╭ changed ────────────╮ │src/ui/Repo/Files.zig│ ╰─────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │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 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │483 } │ │484 } │ │485 │ │486 + // same for the "next" link above the content. │ │487 + const nav = self.navBox(); │ │488 + for (nav.children.keys(), nav.children.values()) |id, *child| { │ │489 + switch (child.widget) { │ │490 + .text_box => |*tb| tb.options.border_style = if (nav.getFocus().child_id == id) .single else .hidden,│ │491 + else => {}, │ │492 + } │ │493 + } │ │494 + │ │495 // cap the list at list_max_width only while the detail pane fits beside │ │496 // it. the box drops the detail pane when the width can't hold both │ │497 // minimums, so when it's that narrow we lift the cap and let the list │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │515 } │ │516 │ │517 fn populateDetail(self: *View, allocator: std.mem.Allocator) !void { │ │518 + const nav = self.navBox(); │ │519 const inner = self.detailInner(); │ │520 │ │521 + for (nav.children.values()) |*child| child.widget.deinit(allocator); │ │522 + nav.children.clearAndFree(allocator); │ │523 + nav.getFocus().child_id = null; │ │524 for (inner.children.values()) |*child| child.widget.deinit(allocator);│ │525 inner.children.clearAndFree(allocator); │ │526 inner.getFocus().child_id = null; │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │529 // the pane empty. │ │530 if (self.selectedEntry()) |entry| { │ │531 if (!entry.is_dir) { │ │532 + // the "next" window link sits above the scroll, so it stays put │ │533 + // while the content scrolls. │ │534 + if (entry.has_more) try self.addNavLink(allocator, nav, "next lines →", entry, entry.window_start + file_page);│ │535 if (entry.is_binary) { │ │536 try self.addContentBox(allocator, inner, "(binary file)"); │ │537 } else { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │459 - const text = try std.mem.join(self.session.page_arena.allocator(), "\n", entry.lines); │ │538 + const text = try numberedContent(self.session.page_arena.allocator(), entry.lines, entry.window_start);│ │539 try self.addContentBox(allocator, inner, text); │ │540 } │ │541 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │542 } │ │464 - // point the pane at its content box so focus recovery can land here. │ │543 + // point each box at its first child so focus can descend into it. │ │544 + if (nav.children.count() > 0) nav.getFocus().child_id = nav.children.keys()[0]; │ │545 if (inner.children.count() > 0) inner.getFocus().child_id = inner.children.keys()[0];│ │546 │ │547 // reset the scroll to the top for the newly-shown file: directly on the │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯