╭ changed ────────────╮ │src/ui/Repo/Files.zig│ ╰─────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │553 sc.getFocus().version +%= 1; │ │554 } │ │555 │ │556 + // a focusable "next" link above the content. it's an `a:` link to the │ │557 + // selected file at `target_after`, so activating it (the host follows the │ │558 + // link) reloads the page on the next window. │ │559 + fn addNavLink(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, entry: Entry, target_after: usize) !void { │ │560 + const page_arena = self.session.page_arena; │ │561 + const path = try childDir(page_arena.allocator(), self.data.dir, entry.name); │ │562 + const route = ui.RoutablePage.repoFilesRoute(self.data.identity, self.data.ref_or_oid, self.data.ref_or_oid_value, path, target_after) orelse return error.RouteTooLong;│ │563 + const link = try std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{try route.urlAlloc(page_arena)}); │ │564 + var tb = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); │ │565 + errdefer tb.deinit(allocator); │ │566 + tb.getFocus().focusable = true; │ │567 + tb.getFocus().kind = .{ .custom = link }; │ │568 + try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │569 + } │ │570 + │ │571 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { │ │572 _ = allocator; │ │573 if (self.detailActive()) { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │596 }, │ │597 else => {}, │ │598 } │ │599 + } │ │600 + │ │601 + // whether focus is on the content box (vs. a "previous"/"next" nav link).│ │602 + fn focusOnContent(self: *View, root_focus: *Focus) bool { │ │603 + const g = root_focus.grandchild_id orelse return false; │ │604 + return self.detailInner().children.contains(g); │ │605 } │ │606 │ │607 fn detailInput(self: *View, key: inp.Key, root_focus: *Focus) !void { │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │608 const sc = self.detailScroll(); │ │508 - // on the web each Scroll is a native scrollable element, so vertical │ │509 - // scrolling is handled by the browser; the terminal scrolls the content │ │510 - // by moving the offset. left scrolls horizontally, then returns to the │ │511 - // list once flush left. │ │609 + const on_content = self.focusOnContent(root_focus); │ │610 + // the "next" link sits above the content scroll. on the link, left returns│ │611 + // to the file list and down drops into the content. in the content, the │ │612 + // terminal scrolls by offset and reaching the top then pressing up crosses│ │613 + // back up to the link; on the web vertical scrolling is the browser's job │ │614 + // so up just crosses to the link. │ │615 switch (key) { │ │616 .arrow_left => { │ │514 - if (sc.x > 0) { │ │515 - sc.x -= 1; │ │516 - self.clampDetailScroll(); │ │517 - } else try self.focusList(root_focus); │ │617 + if (on_content) { │ │618 + if (sc.x > 0) { │ │619 + sc.x -= 1; │ │620 + self.clampDetailScroll(); │ │621 + } else try self.focusList(root_focus); │ │622 + } else if (!self.moveNav(root_focus, -1)) { │ │623 + try self.focusList(root_focus); │ │624 + } │ │625 }, │ │626 .arrow_right => { │ │520 - sc.x += 1; │ │521 - self.clampDetailScroll(); │ │627 + if (on_content) { │ │628 + sc.x += 1; │ │629 + self.clampDetailScroll(); │ │630 + } else _ = self.moveNav(root_focus, 1); │ │631 }, │ │523 - .arrow_up => if (self.session.is_terminal) { │ │524 - sc.y -= 1; │ │525 - self.clampDetailScroll(); │ │632 + .arrow_up => { │ │633 + if (on_content) { │ │634 + if (self.session.is_terminal and sc.y > 0) { │ │635 + sc.y -= 1; │ │636 + self.clampDetailScroll(); │ │637 + } else self.focusNav(root_focus); // cross up to the links │ │638 + } │ │639 }, │ │527 - .arrow_down => if (self.session.is_terminal) { │ │528 - sc.y += 1; │ │529 - self.clampDetailScroll(); │ │640 + .arrow_down => { │ │641 + if (on_content) { │ │642 + if (self.session.is_terminal) { │ │643 + sc.y += 1; │ │644 + self.clampDetailScroll(); │ │645 + } │ │646 + } else self.focusContent(root_focus); // links -> content │ │647 }, │ │531 - .page_up => if (self.session.is_terminal) { │ │648 + .page_up => if (on_content and self.session.is_terminal) { │ │649 sc.y -= 10; │ │650 self.clampDetailScroll(); │ │651 }, │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │535 - .page_down => if (self.session.is_terminal) { │ │652 + .page_down => if (on_content and self.session.is_terminal) {│ │653 sc.y += 10; │ │654 self.clampDetailScroll(); │ │655 }, │ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────╮ │539 - .home => if (self.session.is_terminal) { │ │656 + .home => if (on_content and self.session.is_terminal) {│ │657 sc.y = 0; │ │658 self.clampDetailScroll(); │ │659 }, │ ╰────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────╮ │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 { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯