╭ Files.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const evt = @import("../../event.zig"); 3 const ui = @import("../../ui.zig"); 4 const xit = @import("xit"); 5 const rp = xit.repo; 6 const xitui = xit.xitui; 7 const wgt = xitui.widget; 8 const layout = xitui.layout; 9 const inp = xitui.input; 10 const Grid = xitui.grid.Grid; 11 const Focus = xitui.focus.Focus; 12 13 // one entry in the directory currently being viewed. 14 pub const Entry = struct { 15 name: []const u8, 16 is_dir: bool, 17 }; 18 19 // "owner/name", so the view can build /repo/owner/name/files/... links. 20 identity: []const u8, 21 // the directory being viewed, relative to the repo root ("" at the root). 22 dir: []const u8, 23 entries: []const Entry, 24 25 const Self = @This(); 26 27 pub fn init( 28 arena: *std.heap.ArenaAllocator, 29 session: *ui.Session, 30 event_id: *const [evt.event_id_size]u8, 31 identity: []const u8, 32 dir: []const u8, 33 ) !Self { 34 const aa = arena.allocator(); 35 const empty: Self = .{ .identity = try aa.dupe(u8, identity), .dir = try aa.dupe(u8, dir), .entries = &.{} }; 36 37 // no filesystem (wasm) or nowhere to look: empty listing. the wasm path 38 // never calls init anyway — it rebuilds from the serialized snapshot. 39 const io = session.io orelse return empty; 40 const repos_dir = session.repos_dir orelse return empty; 41 42 // the repo's working copy lives at <repos_dir>/<hex event id>. 43 const hex = std.fmt.bytesToHex(event_id.*, .lower); 44 const repo_path = try std.fs.path.join(aa, &.{ repos_dir, &hex }); 45 46 // open + read the committed file list with the arena's backing allocator 47 // (transient; freed before init returns); the listing is built into the 48 // page arena so it outlives them. 49 const gpa = arena.child_allocator; 50 var repo = rp.Repo(.xit, .{}).open(io, gpa, .{ .path = repo_path }) catch return empty; 51 defer repo.deinit(io, gpa); 52 var status = repo.status(io, gpa) catch return empty; 53 defer status.deinit(gpa); 54 55 // collect the immediate children of `dir`. each committed path is a full 56 // file path; a child is a directory when more path follows its first 57 // segment under `dir`. 58 var children: std.StringArrayHashMapUnmanaged(bool) = .empty; // name -> is_dir 59 defer children.deinit(gpa); 60 const prefix_len = if (dir.len == 0) 0 else dir.len + 1; // skip "dir/" 61 for (status.head_tree.entries.keys()) |path| { 62 if (dir.len != 0) { 63 if (!std.mem.startsWith(u8, path, dir) or path.len <= dir.len or path[dir.len] != '/') continue; 64 } 65 const rel = path[prefix_len..]; 66 const slash = std.mem.indexOfScalar(u8, rel, '/'); 67 const name = if (slash) |s| rel[0..s] else rel; 68 const is_dir = slash != null; 69 const gop = try children.getOrPut(gpa, name); 70 if (!gop.found_existing) gop.value_ptr.* = is_dir else if (is_dir) gop.value_ptr.* = true; 71 } 72 73 // sort directories first, then alphabetically, and dupe into the page arena. 74 const Pair = struct { name: []const u8, is_dir: bool }; 75 var pairs = try gpa.alloc(Pair, children.count()); 76 defer gpa.free(pairs); 77 for (children.keys(), children.values(), 0..) |name, is_dir, i| pairs[i] = .{ .name = name, .is_dir = is_dir }; 78 std.mem.sort(Pair, pairs, {}, struct { 79 fn lessThan(_: void, a: Pair, b: Pair) bool { 80 if (a.is_dir != b.is_dir) return a.is_dir; 81 return std.mem.lessThan(u8, a.name, b.name); 82 } 83 }.lessThan); 84 85 const entries = try aa.alloc(Entry, pairs.len); 86 for (pairs, 0..) |pair, i| entries[i] = .{ .name = try aa.dupe(u8, pair.name), .is_dir = pair.is_dir }; 87 88 return .{ .identity = empty.identity, .dir = empty.dir, .entries = entries }; 89 } 90 91 pub const View = struct { 92 // a vertical list: one focusable row per entry. each subdirectory row is an 93 // `a:` link to its /repo/.../files/<path> route, so following it (click or 94 // Enter) navigates through the host's link handling, like the Users/Repos 95 // lists. files are unlinked. a ".." row at the top links to the parent. 96 scroll: wgt.Scroll(ui.Widget), // wraps a vertical Box of rows 97 data: *const Self, 98 99 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 100 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 101 errdefer box.deinit(allocator); 102 103 // labels and link kinds are borrowed by the rows, so they live in the 104 // page arena (as long as this page's widget tree). 105 const aa = session.page_arena.allocator(); 106 if (data.dir.len != 0) { 107 try addRow(allocator, &box, "..", try dirLink(session.page_arena, data.identity, parentDir(data.dir))); 108 } 109 for (data.entries) |entry| { 110 const label = if (entry.is_dir) try std.fmt.allocPrint(aa, "{s}/", .{entry.name}) else entry.name; 111 const link = if (entry.is_dir) try dirLink(session.page_arena, data.identity, try childDir(aa, data.dir, entry.name)) else ""; 112 try addRow(allocator, &box, label, link); 113 } 114 if (box.children.count() > 0) box.getFocus().child_id = box.children.keys()[0]; 115 116 var scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .vert }); 117 errdefer scroll.deinit(allocator); 118 return .{ .scroll = scroll, .data = data }; 119 } 120 121 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 122 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 123 errdefer row.deinit(allocator); 124 row.getFocus().focusable = true; 125 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 126 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 127 } 128 129 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 130 self.scroll.deinit(allocator); 131 } 132 133 fn innerBox(self: *View) *wgt.Box(ui.Widget) { 134 return &self.scroll.child.box; 135 } 136 137 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 138 self.clearGrid(); 139 // the selected row shows a border (the focused TextBox upgrades it to a 140 // double border itself); the rest stay borderless. 141 const box = self.innerBox(); 142 for (box.children.keys(), box.children.values()) |id, *child| { 143 switch (child.widget) { 144 .text_box => |*tb| tb.options.border_style = if (box.getFocus().child_id == id) .single else .hidden, 145 else => {}, 146 } 147 } 148 // clear the incoming min_size so rows size to their content rather than 149 // stretching to fill the page height; max_size still bounds the scroll 150 // viewport so a long listing clips and scrolls. 151 try self.scroll.build(allocator, .{ 152 .min_size = .{ .width = null, .height = null }, 153 .max_size = constraint.max_size, 154 }, root_focus); 155 } 156 157 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 158 _ = allocator; 159 // up/down move the selection. Enter and clicks on a link row are turned 160 // into navigation by the host (crossPageLink); the ".." row and the 161 // browser Back button (or Escape on the TUI) handle ascending. 162 switch (key) { 163 .arrow_down => try self.moveSelection(root_focus, 1), 164 .arrow_up => try self.moveSelection(root_focus, -1), 165 else => {}, 166 } 167 } 168 169 fn moveSelection(self: *View, root_focus: *Focus, delta: isize) !void { 170 const box = self.innerBox(); 171 const keys = box.children.keys(); 172 const cur_id = box.getFocus().child_id orelse return; 173 const cur = box.children.getIndex(cur_id) orelse return; 174 if (delta < 0 and cur == 0) return; 175 if (delta > 0 and cur + 1 >= keys.len) return; 176 const next = if (delta < 0) cur - 1 else cur + 1; 177 try root_focus.setFocus(keys[next]); 178 if (box.children.values()[next].rect) |rect| self.scroll.scrollToRect(rect); 179 } 180 181 pub fn clearGrid(self: *View) void { 182 self.scroll.clearGrid(); 183 } 184 185 pub fn getGrid(self: View) ?Grid { 186 return self.scroll.getGrid(); 187 } 188 189 pub fn getFocus(self: *View) *Focus { 190 return self.scroll.getFocus(); 191 } 192 193 pub fn getSelectedIndex(self: View) ?usize { 194 const box = &self.scroll.child.box; 195 const child_id = box.focus.child_id orelse return null; 196 return box.children.getIndex(child_id); 197 } 198 }; 199 200 // the "a:" link for the files route at `dir` within `identity` ("owner/name"). 201 fn dirLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, dir: []const u8) ![]const u8 { 202 const route = ui.RoutablePage.repoFilesRoute(identity, dir) orelse return error.RouteTooLong; 203 const url = try route.urlAlloc(page_arena); 204 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 205 } 206 207 // `dir`/`name`, or just `name` at the root. 208 fn childDir(arena: std.mem.Allocator, dir: []const u8, name: []const u8) ![]const u8 { 209 return if (dir.len == 0) name else std.fmt.allocPrint(arena, "{s}/{s}", .{ dir, name }); 210 } 211 212 // `dir` with its last segment removed ("" when `dir` has a single segment). 213 fn parentDir(dir: []const u8) []const u8 { 214 const slash = std.mem.lastIndexOfScalar(u8, dir, '/') orelse return ""; 215 return dir[0..slash]; 216 } 217