╭ 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 tr = xit.tree; 7 const xitui = xit.xitui; 8 const wgt = xitui.widget; 9 const layout = xitui.layout; 10 const inp = xitui.input; 11 const Grid = xitui.grid.Grid; 12 const Focus = xitui.focus.Focus; 13 14 const RefOrOid = ui.RoutablePage.RefOrOid; 15 16 // one entry in the directory currently being viewed. 17 pub const Entry = struct { 18 name: []const u8, 19 is_dir: bool, 20 }; 21 22 // "owner/name", so the view can build /repo/owner/name/files/... links. 23 identity: []const u8, 24 // the resolved ref this listing is read at (the default branch when the route 25 // didn't name one), so the view's directory links stay pinned to it. 26 ref_or_oid: RefOrOid, 27 ref_or_oid_value: []const u8, 28 // the directory being viewed, relative to the repo root ("" at the root). 29 dir: []const u8, 30 entries: []const Entry, 31 32 const Self = @This(); 33 34 pub fn init( 35 arena: *std.heap.ArenaAllocator, 36 session: *ui.Session, 37 event_id: *const [evt.event_id_size]u8, 38 identity: []const u8, 39 requested_ref_or_oid: ?RefOrOid, 40 requested_value: []const u8, 41 dir: []const u8, 42 ) !Self { 43 const aa = arena.allocator(); 44 45 // no filesystem (wasm) or nowhere to look: empty listing pinned to whatever 46 // ref the route asked for. the wasm path never calls init anyway — it 47 // rebuilds from the serialized snapshot. 48 const io = session.io orelse return emptyResult(aa, identity, requested_ref_or_oid orelse .branch, requested_value, dir); 49 const repos_dir = session.repos_dir orelse return emptyResult(aa, identity, requested_ref_or_oid orelse .branch, requested_value, dir); 50 51 // the repo's working copy lives at <repos_dir>/<hex event id>. 52 const hex = std.fmt.bytesToHex(event_id.*, .lower); 53 const repo_path = try std.fs.path.join(aa, &.{ repos_dir, &hex }); 54 55 // open + read the committed file list with the arena's backing allocator 56 // (transient; freed before init returns); the listing is built into the 57 // page arena so it outlives them. 58 const gpa = arena.child_allocator; 59 var repo = rp.Repo(.xit, .{}).open(io, gpa, .{ .path = repo_path }) catch return emptyResult(aa, identity, requested_ref_or_oid orelse .branch, requested_value, dir); 60 defer repo.deinit(io, gpa); 61 62 // resolve the requested ref (or the default branch) to the commit oid whose 63 // tree we list. a ref the route named explicitly that doesn't resolve is a 64 // bad url (NotFound -> 404); the default-branch path falls through to empty. 65 const resolved = (try ui.ResolvedRefOrOid.init(&repo, io, aa, requested_ref_or_oid, requested_value)) orelse { 66 if (requested_ref_or_oid != null) return error.NotFound; 67 return emptyResult(aa, identity, .branch, requested_value, dir); 68 }; 69 70 // read the tree at that commit. building the read-only state mirrors what 71 // repo.status does internally, but for an arbitrary commit rather than HEAD. 72 var moment = repo.core.latestMoment() catch return emptyResult(aa, identity, resolved.ref_or_oid, resolved.value, dir); 73 const state = rp.Repo(.xit, .{}).State(.read_only){ .core = &repo.core, .extra = .{ .moment = &moment } }; 74 var tree = tr.Tree(.xit, .{}).init(state, io, gpa, &resolved.oid) catch return emptyResult(aa, identity, resolved.ref_or_oid, resolved.value, dir); 75 defer tree.deinit(); 76 77 // collect the immediate children of `dir`. each committed path is a full 78 // file path; a child is a directory when more path follows its first 79 // segment under `dir`. 80 var children: std.StringArrayHashMapUnmanaged(bool) = .empty; // name -> is_dir 81 defer children.deinit(gpa); 82 const prefix_len = if (dir.len == 0) 0 else dir.len + 1; // skip "dir/" 83 for (tree.entries.keys()) |path| { 84 if (dir.len != 0) { 85 if (!std.mem.startsWith(u8, path, dir) or path.len <= dir.len or path[dir.len] != '/') continue; 86 } 87 const rel = path[prefix_len..]; 88 const slash = std.mem.indexOfScalar(u8, rel, '/'); 89 const name = if (slash) |s| rel[0..s] else rel; 90 const is_dir = slash != null; 91 const gop = try children.getOrPut(gpa, name); 92 if (!gop.found_existing) gop.value_ptr.* = is_dir else if (is_dir) gop.value_ptr.* = true; 93 } 94 95 // trees hold no empty directories, so a non-root `dir` with no children 96 // doesn't exist in this ref — a bad url (404). 97 if (dir.len != 0 and children.count() == 0) return error.NotFound; 98 99 // committed paths come out sorted (tree objects are written in sorted 100 // order), so the deduped children are already in name order. just group 101 // directories before files, keeping that order within each group, and dupe 102 // into the page arena. 103 const entries = try aa.alloc(Entry, children.count()); 104 var i: usize = 0; 105 for ([_]bool{ true, false }) |want_dir| { 106 for (children.keys(), children.values()) |name, is_dir| { 107 if (is_dir == want_dir) { 108 entries[i] = .{ .name = try aa.dupe(u8, name), .is_dir = is_dir }; 109 i += 1; 110 } 111 } 112 } 113 114 return .{ 115 .identity = try aa.dupe(u8, identity), 116 .ref_or_oid = resolved.ref_or_oid, 117 .ref_or_oid_value = resolved.value, 118 .dir = try aa.dupe(u8, dir), 119 .entries = entries, 120 }; 121 } 122 123 // an empty listing pinned to a ref, for the wasm / no-repo / unresolved paths. 124 fn emptyResult(aa: std.mem.Allocator, identity: []const u8, ref_or_oid: RefOrOid, ref_or_oid_value: []const u8, dir: []const u8) !Self { 125 return .{ 126 .identity = try aa.dupe(u8, identity), 127 .ref_or_oid = ref_or_oid, 128 .ref_or_oid_value = try aa.dupe(u8, ref_or_oid_value), 129 .dir = try aa.dupe(u8, dir), 130 .entries = &.{}, 131 }; 132 } 133 134 pub const View = struct { 135 // a vertical list: one focusable row per entry. each subdirectory row is an 136 // `a:` link to its /repo/.../files/<path> route, so following it (click or 137 // Enter) navigates through the host's link handling, like the Users/Repos 138 // lists. files are unlinked. a ".." row at the top links to the parent. 139 scroll: wgt.Scroll(ui.Widget), // wraps a vertical Box of rows 140 data: *const Self, 141 142 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 143 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 144 errdefer box.deinit(allocator); 145 146 // labels and link kinds are borrowed by the rows, so they live in the 147 // page arena (as long as this page's widget tree). 148 const aa = session.page_arena.allocator(); 149 if (data.dir.len != 0) { 150 try addRow(allocator, &box, "..", try dirLink(session.page_arena, data, parentDir(data.dir))); 151 } 152 for (data.entries) |entry| { 153 const label = if (entry.is_dir) try std.fmt.allocPrint(aa, "{s}/", .{entry.name}) else entry.name; 154 const link = if (entry.is_dir) try dirLink(session.page_arena, data, try childDir(aa, data.dir, entry.name)) else ""; 155 try addRow(allocator, &box, label, link); 156 } 157 if (box.children.count() > 0) box.getFocus().child_id = box.children.keys()[0]; 158 159 var scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 160 errdefer scroll.deinit(allocator); 161 return .{ .scroll = scroll, .data = data }; 162 } 163 164 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 165 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 166 errdefer row.deinit(allocator); 167 row.getFocus().focusable = true; 168 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 169 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 170 } 171 172 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 173 self.scroll.deinit(allocator); 174 } 175 176 fn innerBox(self: *View) *wgt.Box(ui.Widget) { 177 return &self.scroll.child.box; 178 } 179 180 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 181 self.clearGrid(); 182 // the selected row shows a border (the focused TextBox upgrades it to a 183 // double border itself); the rest stay borderless. 184 const box = self.innerBox(); 185 for (box.children.keys(), box.children.values()) |id, *child| { 186 switch (child.widget) { 187 .text_box => |*tb| tb.options.border_style = if (box.getFocus().child_id == id) .single else .hidden, 188 else => {}, 189 } 190 } 191 // clear the incoming min_size so rows size to their content rather than 192 // stretching to fill the page height; max_size still bounds the scroll 193 // viewport so a long listing clips and scrolls. 194 try self.scroll.build(allocator, .{ 195 .min_size = .{ .width = null, .height = null }, 196 .max_size = constraint.max_size, 197 }, root_focus); 198 } 199 200 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 201 _ = allocator; 202 // up/down move the selection. Enter and clicks on a link row are turned 203 // into navigation by the host (crossPageLink); the ".." row and the 204 // browser Back button (or Escape on the TUI) handle ascending. 205 switch (key) { 206 .arrow_down => try self.moveSelection(root_focus, 1), 207 .arrow_up => try self.moveSelection(root_focus, -1), 208 else => {}, 209 } 210 } 211 212 fn moveSelection(self: *View, root_focus: *Focus, delta: isize) !void { 213 const box = self.innerBox(); 214 const keys = box.children.keys(); 215 const cur_id = box.getFocus().child_id orelse return; 216 const cur = box.children.getIndex(cur_id) orelse return; 217 if (delta < 0 and cur == 0) return; 218 if (delta > 0 and cur + 1 >= keys.len) return; 219 const next = if (delta < 0) cur - 1 else cur + 1; 220 try root_focus.setFocus(keys[next]); 221 if (box.children.values()[next].rect) |rect| self.scroll.scrollToRect(rect); 222 } 223 224 pub fn clearGrid(self: *View) void { 225 self.scroll.clearGrid(); 226 } 227 228 pub fn getGrid(self: View) ?Grid { 229 return self.scroll.getGrid(); 230 } 231 232 pub fn getFocus(self: *View) *Focus { 233 return self.scroll.getFocus(); 234 } 235 236 pub fn getSelectedIndex(self: View) ?usize { 237 const box = &self.scroll.child.box; 238 const child_id = box.focus.child_id orelse return null; 239 return box.children.getIndex(child_id); 240 } 241 }; 242 243 // the "a:" link for the files route at `dir`, pinned to the listing's ref. 244 fn dirLink(page_arena: *std.heap.ArenaAllocator, data: *const Self, dir: []const u8) ![]const u8 { 245 const route = ui.RoutablePage.repoFilesRoute(data.identity, data.ref_or_oid, data.ref_or_oid_value, dir) orelse return error.RouteTooLong; 246 const url = try route.urlAlloc(page_arena); 247 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 248 } 249 250 // `dir`/`name`, or just `name` at the root. 251 fn childDir(arena: std.mem.Allocator, dir: []const u8, name: []const u8) ![]const u8 { 252 return if (dir.len == 0) name else std.fmt.allocPrint(arena, "{s}/{s}", .{ dir, name }); 253 } 254 255 // `dir` with its last segment removed ("" when `dir` has a single segment). 256 fn parentDir(dir: []const u8) []const u8 { 257 const slash = std.mem.lastIndexOfScalar(u8, dir, '/') orelse return ""; 258 return dir[0..slash]; 259 } 260