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