╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Commits.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
621 self.clampDiffScroll(); 622 }, 623 .arrow_up => try self.moveDiff(root_focus, -1), 624 .arrow_down => try self.moveDiff(root_focus, 1), 625 .page_up => try self.pageDiff(root_focus, -10), 626 .page_down => try self.pageDiff(root_focus, 10), 627 .home => try self.jumpDiff(root_focus, false), 628 .end => try self.jumpDiff(root_focus, true), 629 // Enter / a click on a "previous"/"next" row follow its "a:" link in 630 // the host (it reloads the adjacent window), so they don't reach here. 631 .mouse => |mouse| switch (mouse.action) { 632 .scroll => |dir| try self.moveDiff(root_focus, if (dir == .up) -1 else 1), 633 else => {}, 634 }, 635 else => {}, 636 } 637 } 638 639 // move one step in `delta` (+ down, - up). in the terminal, `delta` is a 640 // line count unless there is a new hunk visible (in which case it is a 641 // hunk count). on the web `delta` is always a hunk count. 642 fn moveDiff(self: *View, root_focus: *Focus, delta: isize) !void { 643 const inner = self.diffInner(); 644 const keys = inner.children.keys(); 645 if (keys.len == 0) return; 646 const cur: usize = if (root_focus.grandchild_id) |g| (inner.children.getIndex(g) orelse 0) else 0; 647 const target = @as(isize, @intCast(cur)) + delta; 648 const in_range = target >= 0 and target < @as(isize, @intCast(keys.len)); 649 650 if (self.session.is_terminal) { 651 if (in_range and self.hunkVisible(@intCast(target))) { 652 try root_focus.setFocus(keys[@intCast(target)]); 653 return; 654 } 655 656 const sc = self.diffScroll(); 657 sc.y += delta * 5; // magnify because it's a line count 658 self.clampDiffScroll(); 659 if (in_range and self.hunkVisible(@intCast(target))) { 660 try root_focus.setFocus(keys[@intCast(target)]); 661 } 662 } else { 663 if (in_range) { 664 try root_focus.setFocus(keys[@intCast(target)]); 665 } 666 } 667 } 668 669 // page a fixed number of lines, then focus the leading visible hunk 670 // (bottom-most when paging down, top-most when paging up). in the 671 // terminal, `delta` is a line count, and on the web it's a hunk count. 672 fn pageDiff(self: *View, root_focus: *Focus, delta: isize) !void { 673 if (self.session.is_terminal) { 674 const sc = self.diffScroll(); 675 sc.y += delta * 5; // magnify because it's a line count 676 self.clampDiffScroll(); 677 try self.focusVisible(root_focus, delta > 0); 678 } else { 679 const inner = self.diffInner(); 680 const keys = inner.children.keys(); 681 if (keys.len == 0) return; 682 const cur: isize = if (root_focus.grandchild_id) |g| @intCast(inner.children.getIndex(g) orelse 0) else 0; 683 const target: usize = @intCast(std.math.clamp(cur + delta, 0, @as(isize, @intCast(keys.len - 1)))); 684 try root_focus.setFocus(keys[target]); 685 } 686 } 687 688 // jump to the first or last hunk. on the web the browser scrolls to the 689 // focused hunk; on the terminal pin the scroll to the top/bottom too. 690 fn jumpDiff(self: *View, root_focus: *Focus, to_end: bool) !void { 691 const inner = self.diffInner(); 692 const keys = inner.children.keys(); 693 if (keys.len == 0) return; 694 if (self.session.is_terminal) { 695 const sc = self.diffScroll(); 696 sc.y = if (to_end) std.math.maxInt(isize) else 0; 697 self.clampDiffScroll(); 698 } 699 try root_focus.setFocus(if (to_end) keys[keys.len - 1] else keys[0]); 700 } 701 702 // whether hunk `index` is at least partly within the diff viewport, per the 703 // last build's layout rects (content space) and the current scroll offset. 704 fn hunkVisible(self: *View, index: usize) bool { 705 const inner = self.diffInner(); 706 const sc = self.diffScroll(); 707 const vp = sc.grid orelse return false; 708 const r = inner.children.values()[index].rect orelse return false; 709 const top = sc.y; 710 const bottom = sc.y + @as(isize, @intCast(vp.size.height)); 711 return (r.y + @as(isize, @intCast(r.size.height))) > top and r.y < bottom; 712 } 713 714 // focus the visible hunk at the scroll's leading edge. `prefer_last` picks 715 // the bottom-most visible (for downward motion), else the top-most. 716 fn focusVisible(self: *View, root_focus: *Focus, prefer_last: bool) !void { 717 const inner = self.diffInner(); 718 const sc = self.diffScroll(); 719 const vp = sc.grid orelse return; 720 const top = sc.y; 721 const bottom = sc.y + @as(isize, @intCast(vp.size.height)); 722 var chosen: ?usize = null; 723 for (inner.children.keys(), inner.children.values()) |id, *child| { 724 const r = child.rect orelse continue; // content-space layout rect 725 const r_top = r.y; 726 const r_bot = r.y + @as(isize, @intCast(r.size.height)); 727 if (r_bot <= top or r_top >= bottom) continue; // not visible 728 chosen = id; 729 if (!prefer_last) break; // first visible 730 } 731 if (chosen) |id| try root_focus.setFocus(id); 732 } 733 734 // keep the diff scroll within its content, using the last build's grids. the 735 // scroll bar's reserved column/row isn't part of the content viewport, so 736 // exclude it (as scrollToRect does) or the last column/row stays unreachable. 737 fn clampDiffScroll(self: *View) void { 738 const sc = self.diffScroll(); 739 const vp = sc.grid orelse return; 740 const content = sc.child.box.grid orelse return; 741 const view_w = vp.size.width - sc.bar_w; 742 const view_h = vp.size.height - sc.bar_h; 743 const max_y: isize = if (content.size.height > view_h) @intCast(content.size.height - view_h) else 0; 744 const max_x: isize = if (content.size.width > view_w) @intCast(content.size.width - view_w) else 0; 745 sc.y = std.math.clamp(sc.y, 0, max_y); 746 sc.x = std.math.clamp(sc.x, 0, max_x); 747 } 748 749 // enter the diff pane by focusing the hunk that was focused last (the box's 750 // child_id remembers it across the trip to the commit list); fall back to the 751 // first hunk on a fresh diff. the host arrives here on right-arrow or Enter 752 // from the list. a diff with no hunks can't be entered. 753 fn focusDiff(self: *View, root_focus: *Focus) !void { 754 const inner = self.diffInner(); 755 if (inner.children.count() == 0) return; 756 const target = inner.getFocus().child_id orelse inner.children.keys()[0]; 757 try root_focus.setFocus(target); 758 if (root_focus.grandchild_id == target) return; 759 // the diff pane wasn't laid out last build (too narrow to show beside 760 // the list), so its hunks aren't in the focus tree yet. select the pane 761 // at the box level, and the hunk will be focused after the next build. 762 self.contentBox().getFocus().child_id = self.diffOuter().getFocus().id; 763 } 764 765 fn focusList(self: *View, root_focus: *Focus) !void { 766 const lb = self.listBox(); 767 const id = lb.getFocus().child_id orelse (if (lb.children.count() > 0) lb.children.keys()[0] else return); 768 try root_focus.setFocus(id); 769 } 770 771 fn moveSelection(self: *View, root_focus: *Focus, delta: isize) !void { 772 const lb = self.listBox(); 773 const keys = lb.children.keys(); 774 if (keys.len == 0) return; 775 const cur_id = lb.getFocus().child_id orelse return; 776 const cur: isize = @intCast(lb.children.getIndex(cur_id) orelse return); 777 const last: isize = @intCast(keys.len - 1); 778 const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); 779 if (next == @as(usize, @intCast(cur))) return; 780 try root_focus.setFocus(keys[next]); 781 if (lb.children.values()[next].rect) |rect| self.listScroll().scrollToRect(rect); 782 } 783 784 pub fn clearGrid(self: *View) void { 785 self.box.clearGrid(); 786 } 787 788 pub fn getGrid(self: View) ?Grid { 789 return self.box.getGrid(); 790 } 791 792 pub fn getFocus(self: *View) *Focus { 793 return self.box.getFocus(); 794 } 795 796 // for the parent's "scroll up at the top jumps to the header" check. in the 797 // diff pane that means the first hunk is focused and the diff can't scroll up 798 // any further; otherwise up-arrow stays in the pane (scrolling or moving 799 // between hunks). when the list holds focus, report its selection directly. 800 pub fn getSelectedIndex(self: *View) ?usize { 801 if (self.diffActive()) { 802 const inner = self.diffInner(); 803 const cid = inner.getFocus().child_id orelse return 1; 804 const first_focused = inner.children.count() > 0 and cid == inner.children.keys()[0]; 805 return if (first_focused and self.diffScroll().y == 0) 0 else 1; 806 } 807 const lb = self.listBox(); 808 const cid = lb.getFocus().child_id orelse return null; 809 return lb.children.getIndex(cid); 810 } 811 }; 812 813 // the "a:" navigation link for the commits page walking from commit `oid` within 814 // `identity`, showing the selected commit's first `after` hunks (0 = default). 815 fn commitsLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, oid: []const u8, after: usize) ![]const u8 { 816 const route = ui.RoutablePage.repoCommitsRoute(identity, .object, oid, after) orelse return error.RouteTooLong; 817 const url = try route.urlAlloc(page_arena); 818 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 819 } 820 821 // the in-page "ai:" anchor for selecting `oid` in `identity`'s commit list. it 822 // points at the same route as commitsLink but the "ai:" prefix keeps wasm clicks 823 // in-page (crossPageLink ignores it); the href is only followed with js off. 824 fn commitRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, oid: []const u8) ![]const u8 { 825 const route = ui.RoutablePage.repoCommitsRoute(identity, .object, oid, 0) orelse return error.RouteTooLong; 826 const url = try route.urlAlloc(page_arena); 827 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 828 } 829