╭ changed ──────────────╮ │src/ui/Repo/Commits.zig│ ╰───────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │354 // the diff pane — a frame around a scroll of the hunks │ │355 { │ │356 var diff_outer = blk: { │ │477 - var diff_scroll = blk2: { │ │478 - var diff_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │479 - errdefer diff_inner.deinit(allocator); │ │480 - // fill the pane (content top-left, scroll bars pinned to the │ │481 - // edges) rather than shrinking to the diff content. │ │482 - break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = diff_inner }, .{ .direction = .both, .web_native = !session.is_terminal, .fill = true });│ │483 - }; │ │357 + var diff_scroll = try Diff.View.initEmpty(allocator, session); │ │358 errdefer diff_scroll.deinit(allocator); │ │359 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); │ │360 errdefer frame.deinit(allocator); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │363 // one), letting focus recovery descend into the diff pane after │ │364 // it's laid out beside a too-narrow list. │ │365 frame.getFocus().child_id = diff_scroll.getFocus().id; │ │492 - try frame.children.put(allocator, diff_scroll.getFocus().id, .{ .widget = .{ .scroll = diff_scroll }, .rect = null, .min_size = null }); │ │366 + try frame.children.put(allocator, diff_scroll.getFocus().id, .{ .widget = .{ .diff_view = diff_scroll }, .rect = null, .min_size = null });│ │367 break :blk frame; │ │368 }; │ │369 errdefer diff_outer.deinit(allocator); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │391 row.getFocus().mode = .all; │ │392 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; │ │393 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } });│ │520 - } │ │521 - │ │522 - // one hunk as a focusable multi-line text box. its lines sit flush (the │ │523 - // inner Text rows have no border). `lines` are joined into the page arena │ │524 - // (text_box borrows it). │ │525 - fn addHunkBox(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), lines: []const []const u8) !void { │ │526 - if (lines.len == 0) return; │ │527 - const text = try std.mem.join(self.session.page_arena.allocator(), "\n", lines); │ │528 - var tb = try wgt.TextBox.init(allocator, text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │529 - errdefer tb.deinit(allocator); │ │530 - tb.getFocus().mode = .all; │ │531 - try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │394 } │ │395 │ │534 - // a focusable file-path label shown above the first hunk of each file. it │ │535 - // links to this page filtered to that file, so activating it navigates │ │536 - // there. │ │537 - fn addPathBox(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), path: []const u8, oid: []const u8) !void { │ │538 - const link = try commitsLink(self.session.page_arena, self.data.location, oid, 0, path); │ │539 - var tb = try wgt.TextBox.init(allocator, path, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │540 - errdefer tb.deinit(allocator); │ │541 - tb.getFocus().mode = .all; │ │542 - tb.getFocus().kind = .{ .custom = link }; │ │543 - try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │544 - } │ │545 - │ │396 // a focusable window-navigation row ("previous"/"next"). it's a link to this │ │397 // commit's route at `target_start`, so activating it (the host follows the │ │398 // "a:" link) reloads the page on the adjacent window — same on TUI and web. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │461 } │ │462 │ │463 fn diffScroll(self: *View) *wgt.Scroll(ui.Widget) { │ │614 - return &self.diffOuter().children.values()[0].widget.scroll; │ │464 + return &self.diffOuter().children.values()[0].widget.diff_view.scroll;│ │465 } │ │466 │ │467 fn diffInner(self: *View) *wgt.Box(ui.Widget) { │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │572 try self.addMessageBox(allocator, inner, commit, .whole); │ │573 }, │ │574 .diff => |d| { │ │725 - if (d.path.len != 0) { │ │726 - // the file's path, then a row returning to this commit's unfiltered diff. │ │727 - var label = try wgt.TextBox.init(allocator, d.path, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none });│ │728 - errdefer label.deinit(allocator); │ │729 - label.getFocus().mode = .all; │ │730 - try inner.children.put(allocator, label.getFocus().id, .{ .widget = .{ .text_box = label }, .rect = null, .min_size = null }); │ │731 - try self.addNavLink(allocator, inner, "← all files", commit.oid, 0, ""); │ │732 - } else { │ │575 + if (d.path.len == 0) { │ │576 try self.addMessageBox(allocator, inner, commit, .preview); │ │577 if (commit.author != .unknown) { │ │578 var tb = try ui.authorBox(allocator, self.session.page_arena, commit.author); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │582 try self.addViewFilesLink(allocator, inner, commit.oid); │ │583 } │ │584 │ │742 - // a "previous" row and a "next" row at the bottom reload the page │ │743 - // on the adjacent diff window. │ │744 - if (commit.has_prev) try self.addNavLink(allocator, inner, "← previous", commit.oid, commit.window_start -| diff_page, d.path);│ │745 - for (commit.hunks) |hunk| { │ │746 - if (d.path.len == 0) if (hunk.path) |hunk_path| try self.addPathBox(allocator, inner, hunk_path, commit.oid); │ │747 - try self.addHunkBox(allocator, inner, hunk.lines); │ │748 - } │ │749 - if (commit.has_more) try self.addNavLink(allocator, inner, "next →", commit.oid, commit.window_start + diff_page, d.path); │ │585 + try (Diff{ │ │586 + .route = .{ .commit = .{ .location = self.data.location, .oid = commit.oid } }, │ │587 + .path = d.path, │ │588 + .window = commit.window, │ │589 + }).appendWindow(allocator, self.session, inner); │ │590 }, │ │591 } │ │592 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │613 } │ │614 if (key == .arrow_up and self.contentAtTop() and self.header().focusCloneUrl(root_focus)) return; │ │615 if (self.diffActive()) { │ │776 - try self.diffInput(key, root_focus); │ │616 + if (key == .arrow_left and self.diffScroll().x == 0) { │ │617 + self.focusList(root_focus); │ │618 + } else { │ │619 + try self.diffOuter().children.values()[0].widget.diff_view.input(allocator, key, root_focus);│ │620 + } │ │621 } else { │ │622 try self.listInput(key, root_focus); │ │623 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │633 } │ │634 switch (key) { │ │635 .enter => if (self.selectedCommitIndex() != null) │ │792 - try self.focusDiff(root_focus) │ │636 + self.focusDiff(root_focus) │ │637 else if (self.data.next_start) |next| { │ │638 if (self.data.location.commitsRoute(.object, next, 0, "")) |route|│ │639 try self.session.navigate(route); │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │796 - }, │ │797 - .arrow_right => try self.focusDiff(root_focus), │ │798 - else => {}, │ │799 - } │ │800 - } │ │801 - │ │802 - fn diffInput(self: *View, key: Key, root_focus: *Focus) !void { │ │803 - const sc = self.diffScroll(); │ │804 - switch (key) { │ │805 - // left scrolls horizontally, then leaves for the list once flush left. │ │806 - .arrow_left => { │ │807 - if (sc.x > 0) { │ │808 - sc.x -= 1; │ │809 - self.diffScroll().clampToContent(); │ │810 - } else try self.focusList(root_focus); │ │811 - }, │ │812 - .arrow_right => { │ │813 - sc.x += 1; │ │814 - self.diffScroll().clampToContent(); │ │815 - }, │ │816 - .arrow_up => try self.moveDiff(root_focus, -1), │ │817 - .arrow_down => try self.moveDiff(root_focus, 1), │ │818 - .page_up => try self.pageDiff(root_focus, -10), │ │819 - .page_down => try self.pageDiff(root_focus, 10), │ │820 - .home => try self.jumpDiff(root_focus, false), │ │821 - .end => try self.jumpDiff(root_focus, true), │ │822 - // Enter / a click on a "previous"/"next" row follow its "a:" link in │ │823 - // the host (it reloads the adjacent window), so they don't reach here. │ │824 - .mouse => |mouse| switch (mouse.action) { │ │825 - .scroll => |dir| try self.moveDiff(root_focus, if (dir == .up) -1 else 1),│ │826 - else => {}, │ │640 }, │ │641 + .arrow_right => self.focusDiff(root_focus), │ │642 else => {}, │ │643 } │ │644 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │645 │ │832 - // move one step in `delta` (+ down, - up). in the terminal, `delta` is a │ │833 - // line count unless there is a new hunk visible (in which case it is a │ │834 - // hunk count). on the web `delta` is always a hunk count. │ │835 - fn moveDiff(self: *View, root_focus: *Focus, delta: isize) !void { │ │836 - const inner = self.diffInner(); │ │837 - const keys = inner.children.keys(); │ │838 - if (keys.len == 0) return; │ │839 - const cur: usize = if (root_focus.grandchild_id) |g| (inner.children.getIndex(g) orelse 0) else 0; │ │840 - const target = @as(isize, @intCast(cur)) + delta; │ │841 - const in_range = target >= 0 and target < @as(isize, @intCast(keys.len)); │ │842 - │ │843 - if (self.session.is_terminal) { │ │844 - if (in_range and self.hunkVisible(@intCast(target))) { │ │845 - root_focus.setFocus(keys[@intCast(target)]); │ │846 - return; │ │847 - } │ │848 - │ │849 - const sc = self.diffScroll(); │ │850 - sc.y += delta * 5; // magnify because it's a line count │ │851 - self.diffScroll().clampToContent(); │ │852 - if (in_range and self.hunkVisible(@intCast(target))) { │ │853 - root_focus.setFocus(keys[@intCast(target)]); │ │854 - } │ │855 - } else { │ │856 - if (in_range) { │ │857 - root_focus.setFocus(keys[@intCast(target)]); │ │858 - } │ │859 - } │ │860 - } │ │861 - │ │862 - // page a fixed number of lines, then focus the leading visible hunk │ │863 - // (bottom-most when paging down, top-most when paging up). in the │ │864 - // terminal, `delta` is a line count, and on the web it's a hunk count. │ │865 - fn pageDiff(self: *View, root_focus: *Focus, delta: isize) !void { │ │866 - if (self.session.is_terminal) { │ │867 - const sc = self.diffScroll(); │ │868 - sc.y += delta * 5; // magnify because it's a line count │ │869 - self.diffScroll().clampToContent(); │ │870 - try self.focusVisible(root_focus, delta > 0); │ │871 - } else { │ │872 - const inner = self.diffInner(); │ │873 - const keys = inner.children.keys(); │ │874 - if (keys.len == 0) return; │ │875 - const cur: isize = if (root_focus.grandchild_id) |g| @intCast(inner.children.getIndex(g) orelse 0) else 0;│ │876 - const target: usize = @intCast(std.math.clamp(cur + delta, 0, @as(isize, @intCast(keys.len - 1)))); │ │877 - root_focus.setFocus(keys[target]); │ │878 - } │ │879 - } │ │880 - │ │881 - // jump to the first or last hunk. on the web the browser scrolls to the │ │882 - // focused hunk; on the terminal pin the scroll to the top/bottom too. │ │883 - fn jumpDiff(self: *View, root_focus: *Focus, to_end: bool) !void { │ │884 - const inner = self.diffInner(); │ │885 - const keys = inner.children.keys(); │ │886 - if (keys.len == 0) return; │ │887 - if (self.session.is_terminal) { │ │888 - const sc = self.diffScroll(); │ │889 - sc.y = if (to_end) std.math.maxInt(isize) else 0; │ │890 - self.diffScroll().clampToContent(); │ │891 - } │ │892 - root_focus.setFocus(if (to_end) keys[keys.len - 1] else keys[0]); │ │893 - } │ │894 - │ │895 - // whether hunk `index` is at least partly within the diff viewport, per the │ │896 - // last build's layout rects (content space) and the current scroll offset. │ │897 - fn hunkVisible(self: *View, index: usize) bool { │ │898 - const inner = self.diffInner(); │ │899 - const sc = self.diffScroll(); │ │900 - const vp = sc.grid orelse return false; │ │901 - const r = inner.children.values()[index].rect orelse return false; │ │902 - const top = sc.y; │ │903 - const bottom = sc.y + @as(isize, @intCast(vp.size.height - sc.bar_h)); │ │904 - return (r.y + @as(isize, @intCast(r.size.height))) > top and r.y < bottom; │ │905 - } │ │906 - │ │907 - // focus the visible hunk at the scroll's leading edge. `prefer_last` picks │ │908 - // the bottom-most visible (for downward motion), else the top-most. │ │909 - fn focusVisible(self: *View, root_focus: *Focus, prefer_last: bool) !void { │ │910 - const inner = self.diffInner(); │ │911 - const sc = self.diffScroll(); │ │912 - const vp = sc.grid orelse return; │ │913 - const top = sc.y; │ │914 - const bottom = sc.y + @as(isize, @intCast(vp.size.height - sc.bar_h)); │ │915 - var chosen: ?usize = null; │ │916 - for (inner.children.keys(), inner.children.values()) |id, *child| { │ │917 - const r = child.rect orelse continue; // content-space layout rect │ │918 - const r_top = r.y; │ │919 - const r_bot = r.y + @as(isize, @intCast(r.size.height)); │ │920 - if (r_bot <= top or r_top >= bottom) continue; // not visible │ │921 - chosen = id; │ │922 - if (!prefer_last) break; // first visible │ │923 - } │ │924 - if (chosen) |id| root_focus.setFocus(id); │ │925 - } │ │926 - │ │646 // enter the diff pane. the host arrives here on right-arrow or Enter from the │ │647 // list. a diff with no rows can't be entered. setFocus handles the too-narrow │ │648 // case where the pane isn't laid out yet (it gets selected, then focused after │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯