╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────────╮ │src/ui/Repo/Files.zig│ ╰─────────────────────╯ ╭───────────────────────────────────────────────────────────────────────╮ │543 - .end => if (self.session.is_terminal) { │ │660 + .end => if (on_content and self.session.is_terminal) {│ │661 sc.y = std.math.maxInt(isize); │ │662 self.clampDetailScroll(); │ │663 }, │ ╰───────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │664 .mouse => |mouse| switch (mouse.action) { │ │548 - .scroll => |dir| if (self.session.is_terminal) { │ │665 + .scroll => |dir| if (on_content and self.session.is_terminal) {│ │666 sc.y += if (dir == .up) -5 else 5; │ │667 self.clampDetailScroll(); │ │668 }, │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │670 }, │ │671 else => {}, │ │672 } │ │673 + } │ │674 + │ │675 + // focus the first (left-most) nav link; a no-op when there are none, leaving │ │676 + // focus on the content. │ │677 + fn focusNav(self: *View, root_focus: *Focus) void { │ │678 + const nav = self.navBox(); │ │679 + if (nav.children.count() == 0) return; │ │680 + root_focus.setFocus(nav.children.keys()[0]); │ │681 + } │ │682 + │ │683 + fn focusContent(self: *View, root_focus: *Focus) void { │ │684 + const inner = self.detailInner(); │ │685 + const id = inner.getFocus().child_id orelse (if (inner.children.count() > 0) inner.children.keys()[0] else return);│ │686 + root_focus.setFocus(id); │ │687 } │ │688 │ │689 + // move focus `delta` rows within the nav box; returns false (without moving) │ │690 + // when that would step off either end. │ │691 + fn moveNav(self: *View, root_focus: *Focus, delta: isize) bool { │ │692 + const nav = self.navBox(); │ │693 + const keys = nav.children.keys(); │ │694 + const cur_id = nav.getFocus().child_id orelse return false; │ │695 + const cur: isize = @intCast(nav.children.getIndex(cur_id) orelse return false); │ │696 + const target = cur + delta; │ │697 + if (target < 0 or target >= @as(isize, @intCast(keys.len))) return false; │ │698 + root_focus.setFocus(keys[@intCast(target)]); │ │699 + return true; │ │700 + } │ │701 + │ │702 // keep the detail scroll within its content, using the last build's grids. │ │703 // the scroll bar's reserved column/row isn't part of the content viewport, │ │704 // so exclude it or the last column/row stays unreachable. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │714 sc.x = std.math.clamp(sc.x, 0, max_x); │ │715 } │ │716 │ │573 - // enter the detail pane. a directory or ".." row has no content, so there's │ │574 - // nothing to enter. setFocus handles the too-narrow case where the pane isn't│ │575 - // laid out yet (it gets selected, then focused after the next build). │ │717 + // enter the detail pane, landing on the "next" link when there is one (so │ │718 + // right-arrow reaches it), else on the content. a directory or ".." row has │ │719 + // neither, so there's nothing to enter. selecting the frame's child (then │ │720 + // focusing the frame) also handles the too-narrow case where the pane isn't │ │721 + // laid out yet — focus lands inside it after the next build. │ │722 fn focusDetail(self: *View, root_focus: *Focus) !void { │ │577 - if (self.detailInner().children.count() == 0) return; │ │578 - root_focus.setFocus(self.detailOuter().getFocus().id); │ │723 + const frame = self.detailOuter(); │ │724 + if (self.navBox().children.count() > 0) { │ │725 + frame.getFocus().child_id = self.navBox().getFocus().id; │ │726 + } else if (self.detailInner().children.count() > 0) { │ │727 + frame.getFocus().child_id = self.detailScroll().getFocus().id; │ │728 + } else return; │ │729 + root_focus.setFocus(frame.getFocus().id); │ │730 } │ │731 │ │732 // return to the list. │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │769 return lb.children.getIndex(cid); │ │770 } │ │771 }; │ │772 + │ │773 +// join `lines` into one string, each prefixed with its 1-based file line number │ │774 +// (right-aligned in a column wide enough for the last number, then a space), │ │775 +// like the diffs view. `start` is the window's 0-based first line, so numbers │ │776 +// continue across paginated windows. │ │777 +fn numberedContent(arena: std.mem.Allocator, lines: []const []const u8, start: usize) ![]const u8 {│ │778 + if (lines.len == 0) return ""; │ │779 + const width = std.fmt.count("{d}", .{start + lines.len}); │ │780 + const indent = try arena.alloc(u8, width); │ │781 + @memset(indent, ' '); │ │782 │ │783 + var out: std.ArrayList(u8) = .empty; │ │784 + for (lines, 0..) |line, i| { │ │785 + if (i != 0) try out.append(arena, '\n'); │ │786 + const num_str = try std.fmt.allocPrint(arena, "{d}", .{start + i + 1}); │ │787 + try out.appendSlice(arena, indent[0 .. width - num_str.len]); │ │788 + try out.appendSlice(arena, num_str); │ │789 + try out.append(arena, ' '); │ │790 + try out.appendSlice(arena, line); │ │791 + } │ │792 + return out.toOwnedSlice(arena); │ │793 +} │ │794 + │ │795 // the index of the "README"/"README.md" file entry (case-insensitive), or null │ │796 // if none. used to pick the default selection at the repo root. │ │797 fn readmeIndex(entries: []const Entry) ?usize { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │814 │ │815 // the "a:" link for the files route at `dir`, pinned to the listing's ref. │ │816 fn dirLink(page_arena: *std.heap.ArenaAllocator, data: *const Self, dir: []const u8) ![]const u8 { │ │644 - const route = ui.RoutablePage.repoFilesRoute(data.identity, data.ref_or_oid, data.ref_or_oid_value, dir) orelse return error.RouteTooLong; │ │817 + const route = ui.RoutablePage.repoFilesRoute(data.identity, data.ref_or_oid, data.ref_or_oid_value, dir, 0) orelse return error.RouteTooLong;│ │818 const url = try route.urlAlloc(page_arena); │ │819 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); │ │820 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Header.zig│ ╰──────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 98 const identity = try std.fmt.allocPrint(aa, "{s}/{s}", .{ data.owner_name, data.name }); │ │ 99 // both tabs link to the ref/oid this page is viewing, so switching tabs │ │100 // keeps the same ref (the files tab opens at its root directory). │ │101 - const files_route = ui.RoutablePage.repoFilesRoute(identity, data.ref_or_oid, data.ref_or_oid_value, "") orelse return error.RouteTooLong; │ │101 + const files_route = ui.RoutablePage.repoFilesRoute(identity, data.ref_or_oid, data.ref_or_oid_value, "", 0) orelse return error.RouteTooLong;│ │102 const files_link = try std.fmt.allocPrint(aa, "a:{s}", .{try files_route.urlAlloc(session.page_arena)}); │ │103 const commits_route = ui.RoutablePage.repoCommitsRoute(identity, data.ref_or_oid, data.ref_or_oid_value, 0) orelse return error.RouteTooLong;│ │104 const commits_link = try std.fmt.allocPrint(aa, "a:{s}", .{try commits_route.urlAlloc(session.page_arena)}); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────────╮ │src/ui/Repo/Refs.zig│ ╰────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │376 .tag => .tag, │ │377 }; │ │378 const value = try ui.ResolvedRefOrOid.urlEncode(aa, name); │ │379 - const route = ui.RoutablePage.repoFilesRoute(identity, ref_or_oid, value, "") orelse return error.RouteTooLong; │ │379 + const route = ui.RoutablePage.repoFilesRoute(identity, ref_or_oid, value, "", 0) orelse return error.RouteTooLong;│ │380 const url = try route.urlAlloc(page_arena); │ │381 return std.fmt.allocPrint(aa, "a:{s}", .{url}); │ │382 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯