╭ 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 df = xit.diff; 8 const xitui = xit.xitui; 9 const wgt = xitui.widget; 10 const layout = xitui.layout; 11 const inp = xitui.input; 12 const Grid = xitui.grid.Grid; 13 const Focus = xitui.focus.Focus; 14 15 const SubHeader = @import("SubHeader.zig"); 16 17 const RefOrOid = ui.RoutablePage.RefOrOid; 18 19 // one entry in the directory currently being viewed. 20 pub const Entry = struct { 21 name: []const u8, 22 is_dir: bool, 23 // a file's contents split into lines (each without its trailing newline); 24 // empty for directories and for binary or empty files. 25 lines: []const []const u8 = &.{}, 26 // true when xit's line iterator flagged the file as binary, so the detail 27 // pane shows a placeholder rather than its bytes. 28 is_binary: bool = false, 29 }; 30 31 // "owner/name", so the view can build /repo/owner/name/files/... links. 32 identity: []const u8, 33 // the resolved ref this listing is read at (the default branch when the route 34 // didn't name one), so the view's directory links stay pinned to it. 35 ref_or_oid: RefOrOid, 36 ref_or_oid_value: []const u8, 37 // the directory being viewed, relative to the repo root ("" at the root). 38 dir: []const u8, 39 // a file within `dir` to start selected (so its contents show and the url 40 // carries it), or null to fall back to a README / the first row. set when the 41 // route's path named a file rather than a directory. 42 selected_file: ?[]const u8 = null, 43 entries: []const Entry, 44 // the "viewing <ref> <value>" banner shown above the listing. 45 sub_header: SubHeader, 46 47 const Self = @This(); 48 49 pub fn init( 50 arena: *std.heap.ArenaAllocator, 51 session: *ui.Session, 52 event_id: *const [evt.event_id_size]u8, 53 identity: []const u8, 54 requested_ref_or_oid: ?RefOrOid, 55 requested_value: []const u8, 56 path: []const u8, 57 ) !Self { 58 const aa = arena.allocator(); 59 60 // no filesystem (wasm) or nowhere to look: empty listing pinned to whatever 61 // ref the route asked for. the wasm path never calls init anyway — it 62 // rebuilds from the serialized snapshot. 63 const io = session.io orelse return emptyResult(aa, identity, requested_ref_or_oid orelse .branch, requested_value, path); 64 const repos_dir = session.repos_dir orelse return emptyResult(aa, identity, requested_ref_or_oid orelse .branch, requested_value, path); 65 66 // the repo's working copy lives at <repos_dir>/<hex event id>. 67 const hex = std.fmt.bytesToHex(event_id.*, .lower); 68 const repo_path = try std.fs.path.join(aa, &.{ repos_dir, &hex }); 69 70 // open + read the committed file list with the arena's backing allocator 71 // (transient; freed before init returns); the listing is built into the 72 // page arena so it outlives them. 73 const gpa = arena.child_allocator; 74 var repo = rp.Repo(.xit, .{}).open(io, gpa, .{ .path = repo_path }) catch return emptyResult(aa, identity, requested_ref_or_oid orelse .branch, requested_value, path); 75 defer repo.deinit(io, gpa); 76 77 // resolve the requested ref (or the default branch) to the commit oid whose 78 // tree we list. a ref the route named explicitly that doesn't resolve is a 79 // bad url (NotFound -> 404); the default-branch path falls through to empty. 80 const resolved = (try ui.ResolvedRefOrOid.init(&repo, io, aa, requested_ref_or_oid, requested_value)) orelse { 81 if (requested_ref_or_oid != null) return error.NotFound; 82 return emptyResult(aa, identity, .branch, requested_value, path); 83 }; 84 85 // read the tree at that commit. building the read-only state mirrors what 86 // repo.status does internally, but for an arbitrary commit rather than HEAD. 87 var moment = repo.core.latestMoment() catch return emptyResult(aa, identity, resolved.ref_or_oid, resolved.value, path); 88 const state = rp.Repo(.xit, .{}).State(.read_only){ .core = &repo.core, .extra = .{ .moment = &moment } }; 89 var tree = tr.Tree(.xit, .{}).init(state, io, gpa, &resolved.oid) catch return emptyResult(aa, identity, resolved.ref_or_oid, resolved.value, path); 90 defer tree.deinit(); 91 92 // `path` names a file when it's an exact tree entry: list its parent 93 // directory and start that file selected. otherwise `path` is the directory. 94 var dir = path; 95 var selected_file: ?[]const u8 = null; 96 if (path.len != 0 and tree.entries.get(path) != null) { 97 const slash = std.mem.lastIndexOfScalar(u8, path, '/'); 98 selected_file = try aa.dupe(u8, if (slash) |s| path[s + 1 ..] else path); 99 dir = if (slash) |s| path[0..s] else ""; 100 } 101 102 // collect the immediate children of `dir`. each committed path is a full 103 // file path; a child is a directory when more path follows its first 104 // segment under `dir`. 105 var children: std.StringArrayHashMapUnmanaged(bool) = .empty; // name -> is_dir 106 defer children.deinit(gpa); 107 const prefix_len = if (dir.len == 0) 0 else dir.len + 1; // skip "dir/" 108 for (tree.entries.keys()) |entry_path| { 109 if (dir.len != 0) { 110 if (!std.mem.startsWith(u8, entry_path, dir) or entry_path.len <= dir.len or entry_path[dir.len] != '/') continue; 111 } 112 const rel = entry_path[prefix_len..]; 113 const slash = std.mem.indexOfScalar(u8, rel, '/'); 114 const name = if (slash) |s| rel[0..s] else rel; 115 const is_dir = slash != null; 116 const gop = try children.getOrPut(gpa, name); 117 if (!gop.found_existing) gop.value_ptr.* = is_dir else if (is_dir) gop.value_ptr.* = true; 118 } 119 120 // trees hold no empty directories, so a non-root `dir` with no children 121 // doesn't exist in this ref — a bad url (404). 122 if (dir.len != 0 and children.count() == 0) return error.NotFound; 123 124 // committed paths come out sorted (tree objects are written in sorted 125 // order), so the deduped children are already in name order. just group 126 // directories before files, keeping that order within each group, and dupe 127 // into the page arena. each file's contents are read here (into the page 128 // arena) so the detail pane can show them without another lookup. 129 const entries = try aa.alloc(Entry, children.count()); 130 var i: usize = 0; 131 for ([_]bool{ true, false }) |want_dir| { 132 for (children.keys(), children.values()) |name, is_dir| { 133 if (is_dir != want_dir) continue; 134 var entry: Entry = .{ .name = try aa.dupe(u8, name), .is_dir = is_dir }; 135 if (!is_dir) { 136 const file_path = try childDir(aa, dir, name); 137 if (tree.entries.get(file_path)) |tree_entry| { 138 const content = readFileContent(state, io, gpa, aa, file_path, tree_entry) catch 139 FileContent{ .lines = &.{}, .is_binary = false }; 140 entry.lines = content.lines; 141 entry.is_binary = content.is_binary; 142 } 143 } 144 entries[i] = entry; 145 i += 1; 146 } 147 } 148 149 return .{ 150 .identity = try aa.dupe(u8, identity), 151 .ref_or_oid = resolved.ref_or_oid, 152 .ref_or_oid_value = resolved.value, 153 .dir = try aa.dupe(u8, dir), 154 .selected_file = selected_file, 155 .entries = entries, 156 .sub_header = try SubHeader.init(aa, resolved.ref_or_oid, resolved.value), 157 }; 158 } 159 160 const FileContent = struct { 161 lines: []const []const u8, 162 is_binary: bool, 163 }; 164 165 // read a committed file's contents at `tree_entry` into `arena`-owned lines. 166 // xit's line iterator flags binary files (its source becomes `.binary`), in 167 // which case we report no lines and let the view show a placeholder. 168 fn readFileContent( 169 state: rp.Repo(.xit, .{}).State(.read_only), 170 io: std.Io, 171 gpa: std.mem.Allocator, 172 arena: std.mem.Allocator, 173 path: []const u8, 174 tree_entry: tr.TreeEntry((rp.RepoOpts(.xit){}).hash), 175 ) !FileContent { 176 var line_iter = try df.LineIterator(.xit, .{}).initFromTree(state, io, gpa, path, tree_entry); 177 defer line_iter.deinit(); 178 if (line_iter.source == .binary) return .{ .lines = &.{}, .is_binary = true }; 179 180 // init reads through the file to validate it (and buffer it in memory), 181 // leaving the cursor at the end, so rewind before reading the lines out. 182 try line_iter.reset(); 183 184 var lines: std.ArrayList([]const u8) = .empty; 185 while (try line_iter.next()) |line| { 186 defer line_iter.free(line); 187 try lines.append(arena, try arena.dupe(u8, line)); 188 } 189 return .{ .lines = try lines.toOwnedSlice(arena), .is_binary = false }; 190 } 191 192 // an empty listing pinned to a ref, for the wasm / no-repo / unresolved paths. 193 fn emptyResult(aa: std.mem.Allocator, identity: []const u8, ref_or_oid: RefOrOid, ref_or_oid_value: []const u8, dir: []const u8) !Self { 194 return .{ 195 .identity = try aa.dupe(u8, identity), 196 .ref_or_oid = ref_or_oid, 197 .ref_or_oid_value = try aa.dupe(u8, ref_or_oid_value), 198 .dir = try aa.dupe(u8, dir), 199 .entries = &.{}, 200 .sub_header = try SubHeader.init(aa, ref_or_oid, ref_or_oid_value), 201 }; 202 } 203 204 pub const View = struct { 205 // a vertical stack: the "viewing <ref>" banner on top, then a horizontal 206 // split with the file/directory list on the left and a detail pane on the 207 // right showing the selected file's contents. each subdirectory row is an 208 // `a:` link to its /repo/.../files/<path> route, so following it (click or 209 // Enter) navigates through the host's link handling; a ".." row at the top 210 // links to the parent. file rows aren't links — selecting one shows its 211 // contents in the detail pane in-page, like the commits view's diff pane. 212 box: wgt.Box(ui.Widget), // vert: [sub_header_index] = banner, [content_index] = split 213 data: *const Self, 214 session: *ui.Session, 215 // the list row whose contents the detail pane currently shows. 216 shown_index: ?usize, 217 218 const sub_header_index: usize = 0; 219 const content_index: usize = 1; 220 // indices within the content box (the horizontal split). 221 const list_index: usize = 0; 222 const detail_index: usize = 1; 223 const list_max_width: usize = 40; 224 const detail_min_width: usize = 40; 225 226 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 227 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 228 errdefer outer.deinit(allocator); 229 230 // the ref banner at the top. 231 { 232 var sub_header = try SubHeader.View.init(allocator, &data.sub_header, session); 233 errdefer sub_header.deinit(allocator); 234 try outer.children.put(allocator, sub_header.getFocus().id, .{ .widget = .{ .repo_sub_header = sub_header }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 235 } 236 237 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 238 errdefer box.deinit(allocator); 239 240 // the directory listing on the left (one focusable row each). 241 { 242 var list_scroll = blk: { 243 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 244 errdefer list_box.deinit(allocator); 245 246 // labels and link kinds are borrowed by the rows, so they live in 247 // the page arena (as long as this page's widget tree). 248 const aa = session.page_arena.allocator(); 249 if (data.dir.len != 0) { 250 try addRow(allocator, &list_box, "..", try dirLink(session.page_arena, data, parentDir(data.dir))); 251 } 252 for (data.entries) |entry| { 253 const label = if (entry.is_dir) try std.fmt.allocPrint(aa, "{s}/", .{entry.name}) else entry.name; 254 const link = if (entry.is_dir) try dirLink(session.page_arena, data, try childDir(aa, data.dir, entry.name)) else ""; 255 try addRow(allocator, &list_box, label, link); 256 } 257 // select the file the route named, else prefer a README so its 258 // contents greet the visitor in the detail pane, else the first 259 // row. a non-root directory has a leading ".." row, so the entry 260 // index is offset by one there. 261 if (list_box.children.count() > 0) { 262 const base: usize = if (data.dir.len != 0) 1 else 0; 263 const entry_idx = selectedFileIndex(data) orelse readmeIndex(data.entries); 264 const sel = if (entry_idx) |i| i + base else 0; 265 list_box.getFocus().child_id = list_box.children.keys()[sel]; 266 } 267 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 268 }; 269 errdefer list_scroll.deinit(allocator); 270 try box.children.put(allocator, list_scroll.getFocus().id, .{ .widget = .{ .scroll = list_scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } }); 271 } 272 273 // the detail pane on the right — a frame around a scroll of the contents. 274 { 275 var detail_outer = blk: { 276 var detail_scroll = blk2: { 277 var detail_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 278 errdefer detail_inner.deinit(allocator); 279 break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .both, .web_native = !session.is_terminal }); 280 }; 281 errdefer detail_scroll.deinit(allocator); 282 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); 283 errdefer frame.deinit(allocator); 284 // the frame's selected child is its scroll, so the focus chain 285 // reaches the content box (populateDetail points the scroll's 286 // inner box at it). this lets focus recovery descend into the 287 // detail pane after it's laid out beside a too-narrow list. 288 frame.getFocus().child_id = detail_scroll.getFocus().id; 289 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); 290 break :blk frame; 291 }; 292 errdefer detail_outer.deinit(allocator); 293 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 294 } 295 296 box.getFocus().child_id = box.children.keys()[list_index]; 297 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 298 299 // focus lives in the split; the banner isn't focusable. 300 outer.getFocus().child_id = outer.children.keys()[content_index]; 301 return .{ .box = outer, .data = data, .session = session, .shown_index = null }; 302 } 303 304 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 305 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 306 errdefer row.deinit(allocator); 307 row.getFocus().focusable = true; 308 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 309 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 310 } 311 312 // the selected file's contents as a single focusable multi-line text box. 313 // its hidden border reserves the space the double border occupies when 314 // focused, so focusing doesn't shift layout. `text` lives in the page arena. 315 fn addContentBox(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8) !void { 316 _ = self; 317 var tb = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 318 errdefer tb.deinit(allocator); 319 tb.getFocus().focusable = true; 320 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 321 } 322 323 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 324 self.box.deinit(allocator); 325 } 326 327 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 328 return &self.box.children.values()[content_index].widget.box; 329 } 330 331 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 332 return &self.contentBox().children.values()[list_index].widget.scroll; 333 } 334 335 fn listBox(self: *View) *wgt.Box(ui.Widget) { 336 return &self.listScroll().child.box; 337 } 338 339 fn detailOuter(self: *View) *wgt.Box(ui.Widget) { 340 return &self.contentBox().children.values()[detail_index].widget.box; 341 } 342 343 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 344 return &self.detailOuter().children.values()[0].widget.scroll; 345 } 346 347 fn detailInner(self: *View) *wgt.Box(ui.Widget) { 348 return &self.detailScroll().child.box; 349 } 350 351 fn detailActive(self: *View) bool { 352 const content = self.contentBox(); 353 const cid = content.getFocus().child_id orelse return false; 354 return content.children.getIndex(cid) == detail_index; 355 } 356 357 // the selected list row's index, or null when nothing is focused. 358 fn selectedRowIndex(self: *View) ?usize { 359 const lb = self.listBox(); 360 const cid = lb.getFocus().child_id orelse return null; 361 return lb.children.getIndex(cid); 362 } 363 364 // the entry the selected row points at, or null for the ".." row (which 365 // isn't an entry). directory rows return their entry too, though they carry 366 // no contents. 367 fn selectedEntry(self: *View) ?Entry { 368 const idx = self.selectedRowIndex() orelse return null; 369 const base: usize = if (self.data.dir.len != 0) 1 else 0; // skip the ".." row 370 if (idx < base) return null; 371 const e = idx - base; 372 if (e >= self.data.entries.len) return null; 373 return self.data.entries[e]; 374 } 375 376 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 377 self.clearGrid(); 378 379 // swap the detail pane to the selected entry when the selection changes. 380 try self.refreshDetail(allocator); 381 382 // mirror the selected file into the url so it updates as the selection 383 // moves, but only while focus is inside this view (so landing on the tab 384 // keeps the page's base route). a file selection carries the file path; a 385 // directory / ".." row (or no selection) stays at the directory. the path 386 // is joined in a stack buffer since build runs every frame on the web. 387 if (root_focus.grandchild_id) |g| { 388 if (self.box.getFocus().children.contains(g)) { 389 var buf: [ui.RoutablePage.repo_route_max_len]u8 = undefined; 390 const sel_path = if (self.selectedEntry()) |entry| blk: { 391 if (entry.is_dir) break :blk self.data.dir; 392 break :blk if (self.data.dir.len == 0) 393 entry.name 394 else 395 std.fmt.bufPrint(&buf, "{s}/{s}", .{ self.data.dir, entry.name }) catch self.data.dir; 396 } else self.data.dir; 397 if (ui.RoutablePage.repoFilesRoute(self.data.identity, self.data.ref_or_oid, self.data.ref_or_oid_value, sel_path)) |route| 398 self.session.data.current_page = route; 399 } 400 } 401 402 // the selected list row shows a border (the focused TextBox upgrades it 403 // to a double border itself); the rest stay borderless. 404 const lb = self.listBox(); 405 for (lb.children.keys(), lb.children.values()) |id, *child| { 406 switch (child.widget) { 407 .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, 408 else => {}, 409 } 410 } 411 412 // the focused content box shows a single border (the focused TextBox 413 // upgrades it to a double border itself, so it stays single when focus is 414 // on the list); the rest keep their space-reserving hidden border. 415 const inner = self.detailInner(); 416 for (inner.children.keys(), inner.children.values()) |id, *child| { 417 switch (child.widget) { 418 .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden, 419 else => {}, 420 } 421 } 422 423 // cap the list at list_max_width only while the detail pane fits beside 424 // it. the box drops the detail pane when the width can't hold both 425 // minimums, so when it's that narrow we lift the cap and let the list 426 // fill the whole width. 427 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 428 self.contentBox().children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 429 430 // crossing panes only re-selects the content box's child (focusList / 431 // focusDetail); when the window is too narrow to show both, the pane that 432 // was holding focus is dropped here. the framework recovers focus after 433 // the top-level build by re-deriving it down the selected-child chain, so 434 // there's nothing to fix up afterward. 435 try self.box.build(allocator, constraint, root_focus); 436 } 437 438 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 439 const cur = self.selectedRowIndex(); 440 if (std.meta.eql(cur, self.shown_index)) return; 441 try self.populateDetail(allocator); 442 self.shown_index = cur; 443 } 444 445 fn populateDetail(self: *View, allocator: std.mem.Allocator) !void { 446 const inner = self.detailInner(); 447 448 for (inner.children.values()) |*child| child.widget.deinit(allocator); 449 inner.children.clearAndFree(allocator); 450 inner.getFocus().child_id = null; 451 452 // only files have contents to show; directories and the ".." row leave 453 // the pane empty. 454 if (self.selectedEntry()) |entry| { 455 if (!entry.is_dir) { 456 if (entry.is_binary) { 457 try self.addContentBox(allocator, inner, "(binary file)"); 458 } else { 459 const text = try std.mem.join(self.session.page_arena.allocator(), "\n", entry.lines); 460 try self.addContentBox(allocator, inner, text); 461 } 462 } 463 } 464 // point the pane at its content box so focus recovery can land here. 465 if (inner.children.count() > 0) inner.getFocus().child_id = inner.children.keys()[0]; 466 467 // reset the scroll to the top for the newly-shown file: directly on the 468 // terminal (the wasm offset), and via a version bump on the web (so the 469 // renderer's scroll id changes and JS drops the preserved position). 470 const sc = self.detailScroll(); 471 sc.x = 0; 472 sc.y = 0; 473 sc.getFocus().version +%= 1; 474 } 475 476 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 477 _ = allocator; 478 if (self.detailActive()) { 479 try self.detailInput(key, root_focus); 480 } else { 481 try self.listInput(key, root_focus); 482 } 483 } 484 485 fn listInput(self: *View, key: inp.Key, root_focus: *Focus) !void { 486 // up/down (and the scroll wheel) move the selection; page up/down jump a 487 // fixed amount. right/Enter cross into the detail pane. Enter/clicks on a 488 // directory (or "..") row are turned into navigation by the host 489 // (crossPageLink) before reaching here. 490 switch (key) { 491 .enter, .arrow_right => try self.focusDetail(root_focus), 492 .arrow_down => try self.moveSelection(root_focus, 1), 493 .arrow_up => try self.moveSelection(root_focus, -1), 494 .page_down => try self.moveSelection(root_focus, 10), 495 .page_up => try self.moveSelection(root_focus, -10), 496 .end => try self.moveSelection(root_focus, @intCast(self.listBox().children.count())), 497 .home => try self.moveSelection(root_focus, -@as(isize, @intCast(self.listBox().children.count()))), 498 .mouse => |mouse| switch (mouse.action) { 499 .scroll => |dir| try self.moveSelection(root_focus, if (dir == .up) -1 else 1), 500 else => {}, 501 }, 502 else => {}, 503 } 504 } 505 506 fn detailInput(self: *View, key: inp.Key, root_focus: *Focus) !void { 507 const sc = self.detailScroll(); 508 // on the web each Scroll is a native scrollable element, so vertical 509 // scrolling is handled by the browser; the terminal scrolls the content 510 // by moving the offset. left scrolls horizontally, then returns to the 511 // list once flush left. 512 switch (key) { 513 .arrow_left => { 514 if (sc.x > 0) { 515 sc.x -= 1; 516 self.clampDetailScroll(); 517 } else try self.focusList(root_focus); 518 }, 519 .arrow_right => { 520 sc.x += 1; 521 self.clampDetailScroll(); 522 }, 523 .arrow_up => if (self.session.is_terminal) { 524 sc.y -= 1; 525 self.clampDetailScroll(); 526 }, 527 .arrow_down => if (self.session.is_terminal) { 528 sc.y += 1; 529 self.clampDetailScroll(); 530 }, 531 .page_up => if (self.session.is_terminal) { 532 sc.y -= 10; 533 self.clampDetailScroll(); 534 }, 535 .page_down => if (self.session.is_terminal) { 536 sc.y += 10; 537 self.clampDetailScroll(); 538 }, 539 .home => if (self.session.is_terminal) { 540 sc.y = 0; 541 self.clampDetailScroll(); 542 }, 543 .end => if (self.session.is_terminal) { 544 sc.y = std.math.maxInt(isize); 545 self.clampDetailScroll(); 546 }, 547 .mouse => |mouse| switch (mouse.action) { 548 .scroll => |dir| if (self.session.is_terminal) { 549 sc.y += if (dir == .up) -5 else 5; 550 self.clampDetailScroll(); 551 }, 552 else => {}, 553 }, 554 else => {}, 555 } 556 } 557 558 // keep the detail scroll within its content, using the last build's grids. 559 // the scroll bar's reserved column/row isn't part of the content viewport, 560 // so exclude it or the last column/row stays unreachable. 561 fn clampDetailScroll(self: *View) void { 562 const sc = self.detailScroll(); 563 const vp = sc.grid orelse return; 564 const content = sc.child.box.grid orelse return; 565 const view_w = vp.size.width - sc.bar_w; 566 const view_h = vp.size.height - sc.bar_h; 567 const max_y: isize = if (content.size.height > view_h) @intCast(content.size.height - view_h) else 0; 568 const max_x: isize = if (content.size.width > view_w) @intCast(content.size.width - view_w) else 0; 569 sc.y = std.math.clamp(sc.y, 0, max_y); 570 sc.x = std.math.clamp(sc.x, 0, max_x); 571 } 572 573 // enter the detail pane. a directory or ".." row has no content, so there's 574 // nothing to enter. setFocus handles the too-narrow case where the pane isn't 575 // laid out yet (it gets selected, then focused after the next build). 576 fn focusDetail(self: *View, root_focus: *Focus) !void { 577 if (self.detailInner().children.count() == 0) return; 578 root_focus.setFocus(self.detailOuter().getFocus().id); 579 } 580 581 // return to the list. 582 fn focusList(self: *View, root_focus: *Focus) !void { 583 root_focus.setFocus(self.listScroll().getFocus().id); 584 } 585 586 fn moveSelection(self: *View, root_focus: *Focus, delta: isize) !void { 587 const lb = self.listBox(); 588 const keys = lb.children.keys(); 589 if (keys.len == 0) return; 590 const cur_id = lb.getFocus().child_id orelse return; 591 const cur: isize = @intCast(lb.children.getIndex(cur_id) orelse return); 592 const last: isize = @intCast(keys.len - 1); 593 const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); 594 if (next == @as(usize, @intCast(cur))) return; 595 root_focus.setFocus(keys[next]); 596 if (lb.children.values()[next].rect) |rect| self.listScroll().scrollToRect(rect); 597 } 598 599 pub fn clearGrid(self: *View) void { 600 self.box.clearGrid(); 601 } 602 603 pub fn getGrid(self: View) ?Grid { 604 return self.box.getGrid(); 605 } 606 607 pub fn getFocus(self: *View) *Focus { 608 return self.box.getFocus(); 609 } 610 611 // for the parent's "scroll up at the top jumps to the header" check. inside 612 // the detail pane up-arrow stays in the pane (scrolling); when the list holds 613 // focus, report its selection directly so up-arrow at the top row ascends. 614 pub fn getSelectedIndex(self: *View) ?usize { 615 if (self.detailActive()) return 1; 616 const lb = self.listBox(); 617 const cid = lb.getFocus().child_id orelse return null; 618 return lb.children.getIndex(cid); 619 } 620 }; 621 622 // the index of the "README"/"README.md" file entry (case-insensitive), or null 623 // if none. used to pick the default selection at the repo root. 624 fn readmeIndex(entries: []const Entry) ?usize { 625 for (entries, 0..) |entry, i| { 626 if (entry.is_dir) continue; 627 if (std.ascii.eqlIgnoreCase(entry.name, "README") or std.ascii.eqlIgnoreCase(entry.name, "README.md")) 628 return i; 629 } 630 return null; 631 } 632 633 // the index of the entry the route named (data.selected_file), or null if none. 634 fn selectedFileIndex(data: *const Self) ?usize { 635 const name = data.selected_file orelse return null; 636 for (data.entries, 0..) |entry, i| { 637 if (!entry.is_dir and std.mem.eql(u8, entry.name, name)) return i; 638 } 639 return null; 640 } 641 642 // the "a:" link for the files route at `dir`, pinned to the listing's ref. 643 fn dirLink(page_arena: *std.heap.ArenaAllocator, data: *const Self, dir: []const u8) ![]const u8 { 644 const route = ui.RoutablePage.repoFilesRoute(data.identity, data.ref_or_oid, data.ref_or_oid_value, dir) orelse return error.RouteTooLong; 645 const url = try route.urlAlloc(page_arena); 646 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 647 } 648 649 // `dir`/`name`, or just `name` at the root. 650 pub fn childDir(arena: std.mem.Allocator, dir: []const u8, name: []const u8) ![]const u8 { 651 return if (dir.len == 0) name else std.fmt.allocPrint(arena, "{s}/{s}", .{ dir, name }); 652 } 653 654 // `dir` with its last segment removed ("" when `dir` has a single segment). 655 fn parentDir(dir: []const u8) []const u8 { 656 const slash = std.mem.lastIndexOfScalar(u8, dir, '/') orelse return ""; 657 return dir[0..slash]; 658 } 659