╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────────╮ │src/ui/Repo/Files.zig│ ╰─────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │161 if (!is_dir) { │ │162 const file_path = try childDir(aa, dir, name); │ │163 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 }; │ │164 + // only the route's selected file paginates; the rest show │ │165 + // their first window (for the in-page detail when selected). │ │166 + const is_selected = if (selected_file) |sf| std.mem.eql(u8, sf, name) else false; │ │167 + const window_start = if (is_selected) after else 0; │ │168 + const content = readFileContent(state, io, gpa, aa, file_path, tree_entry, window_start) catch│ │169 + FileContent{ .lines = &.{} }; │ │170 entry.lines = content.lines; │ │171 entry.is_binary = content.is_binary; │ │172 + entry.window_start = window_start; │ │173 + entry.has_more = content.has_more; │ │174 } │ │175 } │ │176 entries[i] = entry; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │191 │ │192 const FileContent = struct { │ │193 lines: []const []const u8, │ │162 - is_binary: bool, │ │194 + is_binary: bool = false, │ │195 + has_more: bool = false, │ │196 }; │ │197 │ │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. │ │198 +// read the window [start, start+file_page) of a committed file's contents at │ │199 +// `tree_entry` into `arena`-owned lines, flagging whether more follow. xit's │ │200 +// line iterator flags binary files (its source becomes `.binary`), in which case│ │201 +// we report no lines and let the view show a placeholder. │ │202 fn readFileContent( │ │203 state: rp.Repo(.xit, .{}).State(.read_only), │ │204 io: std.Io, │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │206 arena: std.mem.Allocator, │ │207 path: []const u8, │ │208 tree_entry: tr.TreeEntry((rp.RepoOpts(.xit){}).hash), │ │209 + start: usize, │ │210 ) !FileContent { │ │211 var line_iter = try df.LineIterator(.xit, .{}).initFromTree(state, io, gpa, path, tree_entry);│ │212 defer line_iter.deinit(); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │213 if (line_iter.source == .binary) return .{ .lines = &.{}, .is_binary = true };│ │214 │ │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(); │ │215 + // init reads through the file to validate it (and buffer it in memory), so │ │216 + // the line count is known and a window can be sliced out of the buffer. │ │217 + const total = line_iter.count(); │ │218 + const end = @min(start + file_page, total); │ │219 │ │220 var lines: std.ArrayList([]const u8) = .empty; │ │185 - while (try line_iter.next()) |line| { │ │221 + var i = start; │ │222 + while (i < end) : (i += 1) { │ │223 + const line = try line_iter.get(i); │ │224 defer line_iter.free(line); │ │225 try lines.append(arena, try arena.dupe(u8, line)); │ │226 } │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │189 - return .{ .lines = try lines.toOwnedSlice(arena), .is_binary = false }; │ │227 + return .{ │ │228 + .lines = try lines.toOwnedSlice(arena), │ │229 + .has_more = end < total, │ │230 + }; │ │231 } │ │232 │ │233 // an empty listing pinned to a ref, for the wasm / no-repo / unresolved paths.│ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │261 // indices within the content box (the horizontal split). │ │262 const list_index: usize = 0; │ │263 const detail_index: usize = 1; │ │264 + // indices within the detail pane frame (nav box above the content scroll).│ │265 + const detail_nav_index: usize = 0; │ │266 + const detail_scroll_index: usize = 1; │ │267 const list_max_width: usize = 40; │ │268 const detail_min_width: usize = 40; │ │269 │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │314 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 } });│ │315 } │ │316 │ │273 - // the detail pane on the right — a frame around a scroll of the contents. │ │317 + // the detail pane on the right — a "next" nav box above a scroll of the │ │318 + // selected file's content window. both are repopulated per selection by │ │319 + // populateDetail. │ │320 { │ │321 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); │ │322 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); │ │323 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 }); │ │324 + │ │325 + { │ │326 + var nav_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │327 + errdefer nav_box.deinit(allocator); │ │328 + try frame.children.put(allocator, nav_box.getFocus().id, .{ .widget = .{ .box = nav_box }, .rect = null, .min_size = null }); │ │329 + } │ │330 + { │ │331 + var detail_scroll = blk2: { │ │332 + var detail_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │333 + errdefer detail_inner.deinit(allocator); │ │334 + break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .both, .web_native = !session.is_terminal }); │ │335 + }; │ │336 + errdefer detail_scroll.deinit(allocator); │ │337 + // entering the detail pane lands on the content scroll (focus │ │338 + // recovery descends the frame's selected child); populateDetail │ │339 + // points the scroll's inner box at the content. the nav links │ │340 + // above are reached by scrolling to the top, then up. │ │341 + frame.getFocus().child_id = detail_scroll.getFocus().id; │ │342 + try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); │ │343 + } │ │344 break :blk frame; │ │345 }; │ │346 errdefer detail_outer.deinit(allocator); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │394 return &self.contentBox().children.values()[detail_index].widget.box; │ │395 } │ │396 │ │397 + fn navBox(self: *View) *wgt.Box(ui.Widget) { │ │398 + return &self.detailOuter().children.values()[detail_nav_index].widget.box; │ │399 + } │ │400 + │ │401 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { │ │344 - return &self.detailOuter().children.values()[0].widget.scroll; │ │402 + return &self.detailOuter().children.values()[detail_scroll_index].widget.scroll;│ │403 } │ │404 │ │405 fn detailInner(self: *View) *wgt.Box(ui.Widget) { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │439 │ │440 // mirror the selected file into the url so it updates as the selection │ │441 // 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. │ │442 + // keeps the page's base route). a file selection carries the file path and│ │443 + // its content window offset; a directory / ".." row (or no selection) │ │444 + // stays at the directory with no offset. the path is joined in a stack │ │445 + // buffer since build runs every frame on the web. │ │446 if (root_focus.grandchild_id) |g| { │ │447 if (self.box.getFocus().children.contains(g)) { │ │448 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| │ │449 + var sel_path = self.data.dir; │ │450 + var sel_after: usize = 0; │ │451 + if (self.selectedEntry()) |entry| { │ │452 + if (!entry.is_dir) { │ │453 + sel_path = if (self.data.dir.len == 0) │ │454 + entry.name │ │455 + else │ │456 + std.fmt.bufPrint(&buf, "{s}/{s}", .{ self.data.dir, entry.name }) catch self.data.dir; │ │457 + sel_after = entry.window_start; │ │458 + } │ │459 + } │ │460 + if (ui.RoutablePage.repoFilesRoute(self.data.identity, self.data.ref_or_oid, self.data.ref_or_oid_value, sel_path, sel_after)) |route|│ │461 self.session.data.current_page = route; │ │462 } │ │463 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯