╭ 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 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); 269 break :blk frame; 270 }; 271 errdefer detail_outer.deinit(allocator); 272 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 273 } 274 275 box.getFocus().child_id = box.children.keys()[list_index]; 276 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 277 278 // focus lives in the split; the banner isn't focusable. 279 outer.getFocus().child_id = outer.children.keys()[content_index]; 280 return .{ .box = outer, .data = data, .session = session, .shown_index = null }; 281 } 282 283 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 284 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 285 errdefer row.deinit(allocator); 286 row.getFocus().focusable = true; 287 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 288 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 289 } 290 291 // the selected file's contents as a single focusable multi-line text box. 292 // its hidden border reserves the space the double border occupies when 293 // focused, so focusing doesn't shift layout. `text` lives in the page arena. 294 fn addContentBox(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8) !void { 295 _ = self; 296 var tb = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 297 errdefer tb.deinit(allocator); 298 tb.getFocus().focusable = true; 299 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 300 } 301 302 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 303 self.box.deinit(allocator); 304 } 305 306 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 307 return &self.box.children.values()[content_index].widget.box; 308 } 309 310 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 311 return &self.contentBox().children.values()[list_index].widget.scroll; 312 } 313 314 fn listBox(self: *View) *wgt.Box(ui.Widget) { 315 return &self.listScroll().child.box; 316 } 317 318 fn detailOuter(self: *View) *wgt.Box(ui.Widget) { 319 return &self.contentBox().children.values()[detail_index].widget.box; 320 } 321 322 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 323 return &self.detailOuter().children.values()[0].widget.scroll; 324 } 325 326 fn detailInner(self: *View) *wgt.Box(ui.Widget) { 327 return &self.detailScroll().child.box; 328 } 329 330 fn detailActive(self: *View) bool { 331 const content = self.contentBox(); 332 const cid = content.getFocus().child_id orelse return false; 333 return content.children.getIndex(cid) == detail_index; 334 } 335 336 // the selected list row's index, or null when nothing is focused. 337 fn selectedRowIndex(self: *View) ?usize { 338 const lb = self.listBox(); 339 const cid = lb.getFocus().child_id orelse return null; 340 return lb.children.getIndex(cid); 341 } 342 343 // the entry the selected row points at, or null for the ".." row (which 344 // isn't an entry). directory rows return their entry too, though they carry 345 // no contents. 346 fn selectedEntry(self: *View) ?Entry { 347 const idx = self.selectedRowIndex() orelse return null; 348 const base: usize = if (self.data.dir.len != 0) 1 else 0; // skip the ".." row 349 if (idx < base) return null; 350 const e = idx - base; 351 if (e >= self.data.entries.len) return null; 352 return self.data.entries[e]; 353 } 354 355 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 356 self.clearGrid(); 357 358 // swap the detail pane to the selected entry when the selection changes. 359 try self.refreshDetail(allocator); 360 361 // the selected list row shows a border (the focused TextBox upgrades it 362 // to a double border itself); the rest stay borderless. 363 const lb = self.listBox(); 364 for (lb.children.keys(), lb.children.values()) |id, *child| { 365 switch (child.widget) { 366 .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, 367 else => {}, 368 } 369 } 370 371 // the focused content box shows a single border (the focused TextBox 372 // upgrades it to a double border itself, so it stays single when focus is 373 // on the list); the rest keep their space-reserving hidden border. 374 const inner = self.detailInner(); 375 for (inner.children.keys(), inner.children.values()) |id, *child| { 376 switch (child.widget) { 377 .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden, 378 else => {}, 379 } 380 } 381 382 // cap the list at list_max_width only while the detail pane fits beside 383 // it. the box drops the detail pane when the width can't hold both 384 // minimums, so when it's that narrow we lift the cap and let the list 385 // fill the whole width. 386 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 387 self.contentBox().children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 388 389 try self.box.build(allocator, constraint, root_focus); 390 391 // if the detail pane is selected but focus is still elsewhere in this 392 // view, the pane was too narrow to lay out when focus crossed over. it's 393 // laid out now, so focus its content box. 394 if (self.detailActive() and inner.children.count() > 0) { 395 if (root_focus.grandchild_id) |g| { 396 const in_view = self.box.getFocus().children.contains(g); 397 const in_detail = inner.children.contains(g); 398 if (in_view and !in_detail) 399 try root_focus.setFocus(inner.getFocus().child_id orelse inner.children.keys()[0]); 400 } 401 } 402 } 403 404 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 405 const cur = self.selectedRowIndex(); 406 if (std.meta.eql(cur, self.shown_index)) return; 407 try self.populateDetail(allocator); 408 self.shown_index = cur; 409 } 410 411 fn populateDetail(self: *View, allocator: std.mem.Allocator) !void { 412 const inner = self.detailInner(); 413 414 for (inner.children.values()) |*child| child.widget.deinit(allocator); 415 inner.children.clearAndFree(allocator); 416 inner.getFocus().child_id = null; 417 418 // only files have contents to show; directories and the ".." row leave 419 // the pane empty. 420 if (self.selectedEntry()) |entry| { 421 if (!entry.is_dir) { 422 if (entry.is_binary) { 423 try self.addContentBox(allocator, inner, "(binary file)"); 424 } else { 425 const text = try std.mem.join(self.session.page_arena.allocator(), "\n", entry.lines); 426 try self.addContentBox(allocator, inner, text); 427 } 428 } 429 } 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 by focusing its content box. the host arrives here on 538 // right-arrow or Enter from the list. an empty pane (a directory or ".." row 539 // is selected) can't be entered. 540 fn focusDetail(self: *View, root_focus: *Focus) !void { 541 const inner = self.detailInner(); 542 if (inner.children.count() == 0) return; 543 const target = inner.getFocus().child_id orelse inner.children.keys()[0]; 544 try root_focus.setFocus(target); 545 if (root_focus.grandchild_id == target) return; 546 // the detail pane wasn't laid out last build (too narrow to show beside 547 // the list), so its content isn't in the focus tree yet. select the pane 548 // at the box level, and the content will be focused after the next build. 549 self.contentBox().getFocus().child_id = self.detailOuter().getFocus().id; 550 } 551 552 fn focusList(self: *View, root_focus: *Focus) !void { 553 const lb = self.listBox(); 554 const id = lb.getFocus().child_id orelse (if (lb.children.count() > 0) lb.children.keys()[0] else return); 555 try root_focus.setFocus(id); 556 } 557 558 fn moveSelection(self: *View, root_focus: *Focus, delta: isize) !void { 559 const lb = self.listBox(); 560 const keys = lb.children.keys(); 561 if (keys.len == 0) return; 562 const cur_id = lb.getFocus().child_id orelse return; 563 const cur: isize = @intCast(lb.children.getIndex(cur_id) orelse return); 564 const last: isize = @intCast(keys.len - 1); 565 const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); 566 if (next == @as(usize, @intCast(cur))) return; 567 try root_focus.setFocus(keys[next]); 568 if (lb.children.values()[next].rect) |rect| self.listScroll().scrollToRect(rect); 569 } 570 571 pub fn clearGrid(self: *View) void { 572 self.box.clearGrid(); 573 } 574 575 pub fn getGrid(self: View) ?Grid { 576 return self.box.getGrid(); 577 } 578 579 pub fn getFocus(self: *View) *Focus { 580 return self.box.getFocus(); 581 } 582 583 // for the parent's "scroll up at the top jumps to the header" check. inside 584 // the detail pane up-arrow stays in the pane (scrolling); when the list holds 585 // focus, report its selection directly so up-arrow at the top row ascends. 586 pub fn getSelectedIndex(self: *View) ?usize { 587 if (self.detailActive()) return 1; 588 const lb = self.listBox(); 589 const cid = lb.getFocus().child_id orelse return null; 590 return lb.children.getIndex(cid); 591 } 592 }; 593 594 // the index of the "README"/"README.md" file entry (case-insensitive), or null 595 // if none. used to pick the default selection at the repo root. 596 fn readmeIndex(entries: []const Entry) ?usize { 597 for (entries, 0..) |entry, i| { 598 if (entry.is_dir) continue; 599 if (std.ascii.eqlIgnoreCase(entry.name, "README") or std.ascii.eqlIgnoreCase(entry.name, "README.md")) 600 return i; 601 } 602 return null; 603 } 604 605 // the "a:" link for the files route at `dir`, pinned to the listing's ref. 606 fn dirLink(page_arena: *std.heap.ArenaAllocator, data: *const Self, dir: []const u8) ![]const u8 { 607 const route = ui.RoutablePage.repoFilesRoute(data.identity, data.ref_or_oid, data.ref_or_oid_value, dir) orelse return error.RouteTooLong; 608 const url = try route.urlAlloc(page_arena); 609 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 610 } 611 612 // `dir`/`name`, or just `name` at the root. 613 fn childDir(arena: std.mem.Allocator, dir: []const u8, name: []const u8) ![]const u8 { 614 return if (dir.len == 0) name else std.fmt.allocPrint(arena, "{s}/{s}", .{ dir, name }); 615 } 616 617 // `dir` with its last segment removed ("" when `dir` has a single segment). 618 fn parentDir(dir: []const u8) []const u8 { 619 const slash = std.mem.lastIndexOfScalar(u8, dir, '/') orelse return ""; 620 return dir[0..slash]; 621 } 622