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