╭ changed ────────────╮ │src/ui/Repo/Files.zig│ ╰─────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭────────────────────────────────╮ │ 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;│ ╰────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │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. │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │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│ │106 - // into the page arena. │ │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| { │ │111 - if (is_dir == want_dir) { │ │112 - entries[i] = .{ .name = try aa.dupe(u8, name), .is_dir = is_dir }; │ │113 - i += 1; │ │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 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │187 } │ │188 │ │189 pub const View = struct { │ │141 - // a vertical stack: the "viewing <ref>" banner on top, then a list with one │ │142 - // focusable row per entry. each subdirectory row is an `a:` link to its │ │143 - // /repo/.../files/<path> route, so following it (click or Enter) navigates │ │144 - // through the host's link handling, like the Users/Repos lists. files are │ │145 - // unlinked. a ".." row at the top links to the parent. │ │146 - box: wgt.Box(ui.Widget), // vert: [sub_header_index] = banner, [scroll_index] = list scroll │ │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; │ │150 - const scroll_index: usize = 1; │ │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 });│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 │ │163 - // the directory listing. │ │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 { │ │165 - var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │166 - errdefer box.deinit(allocator); │ │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 │ │168 - // labels and link kinds are borrowed by the rows, so they live in the │ │169 - // page arena (as long as this page's widget tree). │ │170 - const aa = session.page_arena.allocator(); │ │171 - if (data.dir.len != 0) { │ │172 - try addRow(allocator, &box, "..", try dirLink(session.page_arena, data, parentDir(data.dir))); │ │173 - } │ │174 - for (data.entries) |entry| { │ │175 - const label = if (entry.is_dir) try std.fmt.allocPrint(aa, "{s}/", .{entry.name}) else entry.name; │ │176 - const link = if (entry.is_dir) try dirLink(session.page_arena, data, try childDir(aa, data.dir, entry.name)) else ""; │ │177 - try addRow(allocator, &box, label, link); │ │178 - } │ │179 - if (box.children.count() > 0) box.getFocus().child_id = box.children.keys()[0]; │ │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 │ │181 - var scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .vert, .web_native = !session.is_terminal }); │ │182 - errdefer scroll.deinit(allocator); │ │183 - try outer.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); │ │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 │ │186 - // focus lives on the list; the banner isn't focusable. │ │187 - outer.getFocus().child_id = outer.children.keys()[scroll_index]; │ │188 - return .{ .box = outer, .data = data }; │ │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 { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 │ │203 - fn scrollWidget(self: *View) *wgt.Scroll(ui.Widget) { │ │204 - return &self.box.children.values()[scroll_index].widget.scroll; │ │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 │ │207 - fn innerBox(self: *View) *wgt.Box(ui.Widget) { │ │208 - return &self.scrollWidget().child.box; │ │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(); │ │213 - // the selected row shows a border (the focused TextBox upgrades it to a │ │214 - // double border itself); the rest stay borderless. │ │215 - const box = self.innerBox(); │ │216 - for (box.children.keys(), box.children.values()) |id, *child| { │ │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) { │ │218 - .text_box => |*tb| tb.options.border_style = if (box.getFocus().child_id == id) .single else .hidden, │ │377 + .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden,│ │378 else => {}, │ │379 } │ │380 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯