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