╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Commits.zig│ ╰───────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │129 .identity = empty.identity, │ │130 .commits = try aa.dupe(Commit, buf[0..count]), │ │131 .next_start = next_start, │ │132 + .repo_dir_hex = empty.repo_dir_hex, │ │133 }; │ │134 +} │ │135 + │ │136 +const RenderedDiff = struct { │ │137 + hunks: []const Hunk, │ │138 + shown_hunks: usize, │ │139 + has_more: bool, │ │140 +}; │ │141 + │ │142 +// render a commit's diff against its first parent into `arena`-owned hunks, │ │143 +// skipping the first `skip_hunks` hunks and emitting at most `budget` lines │ │144 +fn renderCommitDiff( │ │145 + io: std.Io, │ │146 + gpa: std.mem.Allocator, │ │147 + arena: std.mem.Allocator, │ │148 + repo: *rp.Repo(.xit, .{}), │ │149 + oid: [xit.hash.hexLen(.sha1)]u8, │ │150 + skip_hunks: usize, │ │151 + budget: usize, │ │152 +) !RenderedDiff { │ │153 + const empty = RenderedDiff{ .hunks = &.{}, .shown_hunks = 0, .has_more = false }; │ │154 + │ │155 + // load the commit so we can diff it against its first parent. │ │156 + var start = [_][xit.hash.hexLen(.sha1)]u8{oid}; │ │157 + var commit_iter = repo.log(io, gpa, start[0..1]) catch return empty; │ │158 + defer commit_iter.deinit(); │ │159 + const commit_object = (commit_iter.next(gpa) catch return empty) orelse return empty; │ │160 + defer commit_object.deinit(); │ │161 + │ │162 + const parent_maybe = commit_object.content.commit.metadata.firstParent(); │ │163 + │ │164 + var tree_diff = repo.treeDiff(io, gpa, parent_maybe, &commit_object.oid) catch return empty; │ │165 + defer tree_diff.deinit(); │ │166 + │ │167 + var file_iter = repo.filePairs(io, gpa, .{ .tree = .{ .tree_diff = &tree_diff } }) catch return empty;│ │168 + │ │169 + var hunks: std.ArrayList(Hunk) = .empty; │ │170 + var total_lines: usize = 0; │ │171 + var hunks_seen: usize = 0; │ │172 + var has_more = false; │ │173 + │ │174 + file_loop: while (file_iter.next() catch null) |pair_val| { │ │175 + var pair = pair_val; │ │176 + defer pair.deinit(); │ │177 + │ │178 + var hunk_iter = df.HunkIterator(.xit, .{}).init(gpa, &pair.a, &pair.b) catch continue; │ │179 + defer hunk_iter.deinit(gpa); │ │180 + │ │181 + // the file header rides on the first of this file's hunks we actually show. │ │182 + var file_header_emitted = false; │ │183 + while (try hunk_iter.next(gpa)) |hunk_val| { │ │184 + var hunk = hunk_val; │ │185 + defer hunk.deinit(gpa); │ │186 + │ │187 + if (hunks_seen < skip_hunks) { │ │188 + hunks_seen += 1; │ │189 + continue; │ │190 + } │ │191 + if (total_lines >= budget) { │ │192 + has_more = true; │ │193 + break :file_loop; │ │194 + } │ │195 + var lines: std.ArrayList([]const u8) = .empty; │ │196 + if (!file_header_emitted) { │ │197 + for (hunk_iter.header_lines.items) |hl| { │ │198 + try lines.append(arena, try arena.dupe(u8, hl)); │ │199 + } │ │200 + file_header_emitted = true; │ │201 + } │ │202 + try appendHunkLines(arena, &lines, &hunk_iter, &hunk); │ │203 + total_lines += lines.items.len; │ │204 + try hunks.append(arena, .{ .lines = try lines.toOwnedSlice(arena) }); │ │205 + hunks_seen += 1; │ │206 + } │ │207 + } │ │208 + │ │209 + return .{ .hunks = try hunks.toOwnedSlice(arena), .shown_hunks = hunks_seen, .has_more = has_more }; │ │210 +} │ │211 + │ │212 +// append a hunk's header and its edit lines │ │213 +fn appendHunkLines( │ │214 + arena: std.mem.Allocator, │ │215 + lines: *std.ArrayList([]const u8), │ │216 + hunk_iter: *df.HunkIterator(.xit, .{}), │ │217 + hunk: *df.Hunk(.xit, .{}), │ │218 +) !void { │ │219 + const o = hunk.offsets(); │ │220 + try lines.append(arena, try std.fmt.allocPrint(arena, "@@ -{d},{d} +{d},{d} @@", .{ │ │221 + o.del_start, o.del_count, o.ins_start, o.ins_count, │ │222 + })); │ │223 + for (hunk.edits.items) |edit| { │ │224 + const text = switch (edit) { │ │225 + .eql => |e| try hunk_iter.line_iter_b.get(e.new_line.num), │ │226 + .ins => |e| try hunk_iter.line_iter_b.get(e.new_line.num), │ │227 + .del => |e| try hunk_iter.line_iter_a.get(e.old_line.num), │ │228 + }; │ │229 + defer switch (edit) { │ │230 + .eql, .ins => hunk_iter.line_iter_b.free(text), │ │231 + .del => hunk_iter.line_iter_a.free(text), │ │232 + }; │ │233 + const prefix: u8 = switch (edit) { │ │234 + .eql => ' ', │ │235 + .ins => '+', │ │236 + .del => '-', │ │237 + }; │ │238 + try lines.append(arena, try std.fmt.allocPrint(arena, "{c}{s}", .{ prefix, text })); │ │239 + } │ │240 } │ │241 │ │242 // "YYYY-MM-DD" for a unix timestamp. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │259 } │ │260 │ │261 pub const View = struct { │ │116 - // a vertical list mirroring Files.View: one focusable row per commit │ │117 - // (showing its date and message), and — when there's another page — a │ │118 - // "next" row at the bottom that links to it. │ │119 - scroll: wgt.Scroll(ui.Widget), // wraps a vertical Box of rows │ │262 + // a horizontal split: the commit list on the left and a diff pane on the │ │263 + // right showing the selected commit's diff │ │264 + box: wgt.Box(ui.Widget), // horiz: [list_index] = list scroll, [diff_index] = diff pane │ │265 data: *const Self, │ │266 + session: *ui.Session, │ │267 + // the commit whose diff the pane currently shows (index into data.commits). │ │268 + diffed_index: ?usize, │ │269 + // mutable diff state for the displayed commit (seeded from data, grown by │ │270 + // "load more"). │ │271 + shown_hunks: usize, │ │272 + has_more: bool, │ │273 + // focus id of the "load more" row, when present. │ │274 + load_more_id: ?usize, │ │275 │ │276 + const list_index: usize = 0; │ │277 + const diff_index: usize = 1; │ │278 + // min widths for the two panes │ │279 + const list_width: usize = 40; │ │280 + const diff_width: usize = 40; │ │281 + // how many rows a page up/down jumps, and the scroll-wheel step. │ │282 + const page_rows = 10; │ │283 + │ │284 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { │ │123 - var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │285 + const aa = session.page_arena.allocator(); │ │286 + │ │287 + var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │288 errdefer box.deinit(allocator); │ │289 │ │126 - // labels and link kinds are borrowed by the rows, so they live in the │ │127 - // page arena (as long as this page's widget tree). │ │128 - const aa = session.page_arena.allocator(); │ │129 - for (data.commits) |commit| { │ │130 - const label = try std.fmt.allocPrint(aa, "{s} {s}", .{ commit.date, commit.message }); │ │131 - try addRow(allocator, &box, label, ""); │ │290 + // the commit list (one focusable row each), plus a "next" link │ │291 + { │ │292 + var list_scroll = blk: { │ │293 + var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │294 + errdefer list_box.deinit(allocator); │ │295 + for (data.commits) |commit| { │ │296 + const label = try std.fmt.allocPrint(aa, "{s} {s}", .{ commit.date, commit.message }); │ │297 + try addRow(allocator, &list_box, label, ""); │ │298 + } │ │299 + if (data.next_start) |next| { │ │300 + try addRow(allocator, &list_box, "next", try commitsPageLink(session.page_arena, data.identity, next)); │ │301 + } │ │302 + if (list_box.children.count() > 0) list_box.getFocus().child_id = list_box.children.keys()[0]; │ │303 + break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert }); │ │304 + }; │ │305 + errdefer list_scroll.deinit(allocator); │ │306 + try box.children.put(allocator, list_scroll.getFocus().id, .{ .widget = .{ .scroll = list_scroll }, .rect = null, .min_size = .{ .width = list_width, .height = null } });│ │307 } │ │133 - if (data.next_start) |next| { │ │134 - try addRow(allocator, &box, "next", try commitsPageLink(session.page_arena, data.identity, next)); │ │308 + │ │309 + // the diff pane — a frame around a scroll of the hunks │ │310 + { │ │311 + var diff_outer = blk: { │ │312 + var diff_scroll = blk2: { │ │313 + var diff_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │314 + errdefer diff_inner.deinit(allocator); │ │315 + break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = diff_inner }, .{ .direction = .both }); │ │316 + }; │ │317 + errdefer diff_scroll.deinit(allocator); │ │318 + var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .single, .direction = .vert }); │ │319 + errdefer outer.deinit(allocator); │ │320 + try outer.children.put(allocator, diff_scroll.getFocus().id, .{ .widget = .{ .scroll = diff_scroll }, .rect = null, .min_size = null }); │ │321 + break :blk outer; │ │322 + }; │ │323 + errdefer diff_outer.deinit(allocator); │ │324 + try box.children.put(allocator, diff_outer.getFocus().id, .{ .widget = .{ .box = diff_outer }, .rect = null, .min_size = .{ .width = diff_width, .height = null } }); │ │325 } │ │136 - if (box.children.count() > 0) box.getFocus().child_id = box.children.keys()[0]; │ │326 + │ │327 + box.getFocus().child_id = box.children.keys()[list_index]; │ │328 │ │138 - var scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .vert }); │ │139 - errdefer scroll.deinit(allocator); │ │140 - return .{ .scroll = scroll, .data = data }; │ │329 + return .{ │ │330 + .box = box, │ │331 + .data = data, │ │332 + .session = session, │ │333 + .diffed_index = null, │ │334 + .shown_hunks = 0, │ │335 + .has_more = false, │ │336 + .load_more_id = null, │ │337 + }; │ │338 } │ │339 │ │340 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │343 row.getFocus().focusable = true; │ │344 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; │ │345 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); │ │346 + } │ │347 + │ │348 + // one hunk as a focusable multi-line text box. its lines sit flush (the │ │349 + // inner Text rows have no border); the box's hidden border reserves the │ │350 + // space the double border occupies when focused, so focusing doesn't shift │ │351 + // layout. `lines` are joined into the page arena (text_box borrows it). │ │352 + fn addHunkBox(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), lines: []const []const u8) !void { │ │353 + if (lines.len == 0) return; │ │354 + const text = try std.mem.join(self.session.page_arena.allocator(), "\n", lines); │ │355 + var tb = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); │ │356 + errdefer tb.deinit(allocator); │ │357 + tb.getFocus().focusable = true; │ │358 + try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │359 } │ │360 │ │361 + // the focusable "load more" row; its kind carries the GET endpoint the web │ │362 + // client fetches (the TUI reopens the repo instead). │ │363 + fn addLoadMore(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), oid: []const u8, after_hunks: usize) !void { │ │364 + const link = try std.fmt.allocPrint(self.session.page_arena.allocator(), "fetch:/_diff/{s}/{s}?after={d}", .{ self.data.identity, oid, after_hunks });│ │365 + var tb = try wgt.TextBox(ui.Widget).init(allocator, "load more", .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); │ │366 + errdefer tb.deinit(allocator); │ │367 + tb.getFocus().focusable = true; │ │368 + tb.getFocus().kind = .{ .custom = link }; │ │369 + try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │370 + self.load_more_id = tb.getFocus().id; │ │371 + } │ │372 + │ │373 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { │ │152 - self.scroll.deinit(allocator); │ │374 + self.box.deinit(allocator); │ │375 + } │ │376 + │ │377 + fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { │ │378 + return &self.box.children.values()[list_index].widget.scroll; │ │379 } │ │380 │ │155 - fn innerBox(self: *View) *wgt.Box(ui.Widget) { │ │156 - return &self.scroll.child.box; │ │381 + fn listBox(self: *View) *wgt.Box(ui.Widget) { │ │382 + return &self.listScroll().child.box; │ │383 + } │ │384 + │ │385 + fn diffOuter(self: *View) *wgt.Box(ui.Widget) { │ │386 + return &self.box.children.values()[diff_index].widget.box; │ │387 + } │ │388 + │ │389 + fn diffScroll(self: *View) *wgt.Scroll(ui.Widget) { │ │390 + return &self.diffOuter().children.values()[0].widget.scroll; │ │391 + } │ │392 + │ │393 + fn diffInner(self: *View) *wgt.Box(ui.Widget) { │ │394 + return &self.diffScroll().child.box; │ │395 + } │ │396 + │ │397 + fn diffActive(self: *View) bool { │ │398 + const cid = self.box.getFocus().child_id orelse return false; │ │399 + return self.box.children.getIndex(cid) == diff_index; │ │400 + } │ │401 + │ │402 + // the selected commit's index, or null when the "next" row is selected. │ │403 + fn selectedCommitIndex(self: *View) ?usize { │ │404 + const lb = self.listBox(); │ │405 + const cid = lb.getFocus().child_id orelse return null; │ │406 + const idx = lb.children.getIndex(cid) orelse return null; │ │407 + if (idx >= self.data.commits.len) return null; │ │408 + return idx; │ │409 } │ │410 │ │411 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │412 self.clearGrid(); │ │161 - // the selected row shows a border (the focused TextBox upgrades it to a │ │162 - // double border itself); the rest stay borderless. │ │163 - const box = self.innerBox(); │ │164 - for (box.children.keys(), box.children.values()) |id, *child| { │ │413 + │ │414 + // swap the diff pane to the selected commit when it changes. │ │415 + try self.refreshDiff(allocator); │ │416 + │ │417 + // the selected list row shows a border (the focused TextBox upgrades it │ │418 + // to a double border itself); the rest stay borderless. │ │419 + const lb = self.listBox(); │ │420 + for (lb.children.keys(), lb.children.values()) |id, *child| { │ │421 + switch (child.widget) { │ │422 + .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, │ │423 + else => {}, │ │424 + } │ │425 + } │ │426 + │ │427 + // the selected hunk (or the "load more" row) shows a single border (the │ │428 + // focused TextBox upgrades it to a double border itself, so it stays │ │429 + // single when focus is on the commit list); the rest keep their │ │430 + // space-reserving hidden border. │ │431 + const inner = self.diffInner(); │ │432 + for (inner.children.keys(), inner.children.values()) |id, *child| { │ │433 switch (child.widget) { │ │166 - .text_box => |*tb| tb.options.border_style = if (box.getFocus().child_id == id) .single else .hidden, │ │434 + .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden,│ │435 else => {}, │ │436 } │ │437 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │170 - // clear the incoming min_size so rows size to their content rather than │ │171 - // stretching to fill the page height; max_size still bounds the scroll │ │172 - // viewport so a long listing clips and scrolls. │ │173 - try self.scroll.build(allocator, .{ │ │174 - .min_size = .{ .width = null, .height = null }, │ │175 - .max_size = constraint.max_size, │ │176 - }, root_focus); │ │438 + │ │439 + try self.box.build(allocator, constraint, root_focus); │ │440 + } │ │441 + │ │442 + fn refreshDiff(self: *View, allocator: std.mem.Allocator) !void { │ │443 + const sel = self.selectedCommitIndex() orelse return; │ │444 + if (self.diffed_index) |d| if (d == sel) return; │ │445 + try self.populateDiff(allocator, sel); │ │446 + self.diffed_index = sel; │ │447 } │ │448 │ │179 - // how many rows a page up/down jumps. │ │180 - const page_rows = 10; │ │449 + fn populateDiff(self: *View, allocator: std.mem.Allocator, sel: usize) !void { │ │450 + const commit = self.data.commits[sel]; │ │451 + const inner = self.diffInner(); │ │452 + │ │453 + for (inner.children.values()) |*child| child.widget.deinit(allocator); │ │454 + inner.children.clearAndFree(allocator); │ │455 + inner.getFocus().child_id = null; │ │456 + self.load_more_id = null; │ │457 + │ │458 + for (commit.hunks) |hunk| try self.addHunkBox(allocator, inner, hunk.lines); │ │459 + if (commit.has_more) try self.addLoadMore(allocator, inner, commit.oid, commit.shown_hunks); │ │460 + self.shown_hunks = commit.shown_hunks; │ │461 + self.has_more = commit.has_more; │ │462 + │ │463 + const sc = self.diffScroll(); │ │464 + sc.x = 0; │ │465 + sc.y = 0; │ │466 + } │ │467 │ │468 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void {│ │183 - _ = allocator; │ │469 + if (self.diffActive()) { │ │470 + try self.diffInput(allocator, key, root_focus); │ │471 + } else { │ │472 + try self.listInput(key, root_focus); │ │473 + } │ │474 + } │ │475 + │ │476 + fn listInput(self: *View, key: inp.Key, root_focus: *Focus) !void { │ │477 // up/down (and the scroll wheel) move the selection a row; page up/down │ │185 - // move it a fixed jump. the scroll wheel only reaches here in the TUI — │ │186 - // the web build lets the browser scroll natively and never forwards it. │ │187 - // Enter/clicks on the "next" row are turned into navigation by the host │ │188 - // (crossPageLink). │ │478 + // jump a fixed amount. right/Enter cross into the diff pane. Enter/clicks │ │479 + // on the "next" row become navigation in the host before reaching here. │ │480 switch (key) { │ │481 + .arrow_right, .enter => try self.focusDiff(root_focus), │ │482 .arrow_down => try self.moveSelection(root_focus, 1), │ │483 .arrow_up => try self.moveSelection(root_focus, -1), │ │484 .page_down => try self.moveSelection(root_focus, page_rows), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │485 .page_up => try self.moveSelection(root_focus, -page_rows), │ │486 .mouse => |mouse| switch (mouse.action) { │ │487 .scroll => |dir| try self.moveSelection(root_focus, if (dir == .up) -1 else 1), │ │488 + else => {}, │ │489 + }, │ │490 + else => {}, │ │491 + } │ │492 + } │ │493 + │ │494 + fn diffInput(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { │ │495 + const sc = self.diffScroll(); │ │496 + switch (key) { │ │497 + // left scrolls horizontally, then leaves for the list once flush left. │ │498 + .arrow_left => { │ │499 + if (sc.x > 0) { │ │500 + sc.x -= 1; │ │501 + self.clampDiffScroll(); │ │502 + } else try self.focusList(root_focus); │ │503 + }, │ │504 + .arrow_right => { │ │505 + sc.x += 1; │ │506 + self.clampDiffScroll(); │ │507 + }, │ │508 + .arrow_up => try self.moveDiff(root_focus, -1), │ │509 + .arrow_down => try self.moveDiff(root_focus, 1), │ │510 + .page_up => try self.pageDiff(root_focus, -page_rows), │ │511 + .page_down => try self.pageDiff(root_focus, page_rows), │ │512 + .enter => if (self.focusedIsLoadMore(root_focus)) try self.loadMore(allocator, root_focus), │ │513 + .mouse => |mouse| switch (mouse.action) { │ │514 + .scroll => |dir| try self.moveDiff(root_focus, if (dir == .up) -1 else 1), │ │515 + .press => |btn| if (btn == .left and self.focusedIsLoadMore(root_focus)) try self.loadMore(allocator, root_focus),│ │516 else => {}, │ │517 }, │ │518 else => {}, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │519 + } │ │520 + } │ │521 + │ │522 + // move focus one hunk in `dir` (+1 down, -1 up). if the adjacent hunk is │ │523 + // already visible, just move focus to it — don't scroll. only when it isn't │ │524 + // visible yet do we scroll one line toward it (and focus it once it appears), │ │525 + // so scrolling never skips past an on-screen hunk. when nothing fits to │ │526 + // scroll (e.g. the web, where the browser scrolls the page), the clamp keeps │ │527 + // the offset put and focus simply steps to the adjacent hunk. │ │528 + fn moveDiff(self: *View, root_focus: *Focus, dir: isize) !void { │ │529 + const inner = self.diffInner(); │ │530 + const keys = inner.children.keys(); │ │531 + if (keys.len == 0) return; │ │532 + const cur: usize = if (root_focus.grandchild_id) |g| (inner.children.getIndex(g) orelse 0) else 0; │ │533 + const target = @as(isize, @intCast(cur)) + dir; │ │534 + const in_range = target >= 0 and target < @as(isize, @intCast(keys.len)); │ │535 + │ │536 + if (in_range and self.hunkVisible(@intCast(target))) { │ │537 + try root_focus.setFocus(keys[@intCast(target)]); │ │538 + return; │ │539 + } │ │540 + │ │541 + const sc = self.diffScroll(); │ │542 + sc.y += dir; │ │543 + self.clampDiffScroll(); │ │544 + if (in_range and self.hunkVisible(@intCast(target))) │ │545 + try root_focus.setFocus(keys[@intCast(target)]); │ │546 + } │ │547 + │ │548 + // page a fixed number of lines, then focus the leading visible hunk │ │549 + // (bottom-most when paging down, top-most when paging up). │ │550 + fn pageDiff(self: *View, root_focus: *Focus, delta: isize) !void { │ │551 + const sc = self.diffScroll(); │ │552 + sc.y += delta; │ │553 + self.clampDiffScroll(); │ │554 + try self.focusVisible(root_focus, delta > 0); │ │555 + } │ │556 + │ │557 + // whether hunk `index` is at least partly within the diff viewport, per the │ │558 + // last build's layout rects (content space) and the current scroll offset. │ │559 + fn hunkVisible(self: *View, index: usize) bool { │ │560 + const inner = self.diffInner(); │ │561 + const sc = self.diffScroll(); │ │562 + const vp = sc.grid orelse return false; │ │563 + const r = inner.children.values()[index].rect orelse return false; │ │564 + const top = sc.y; │ │565 + const bottom = sc.y + @as(isize, @intCast(vp.size.height)); │ │566 + return (r.y + @as(isize, @intCast(r.size.height))) > top and r.y < bottom; │ │567 + } │ │568 + │ │569 + // focus the visible hunk at the scroll's leading edge. `prefer_last` picks │ │570 + // the bottom-most visible (for downward motion), else the top-most. │ │571 + fn focusVisible(self: *View, root_focus: *Focus, prefer_last: bool) !void { │ │572 + const inner = self.diffInner(); │ │573 + const sc = self.diffScroll(); │ │574 + const vp = sc.grid orelse return; │ │575 + const top = sc.y; │ │576 + const bottom = sc.y + @as(isize, @intCast(vp.size.height)); │ │577 + var chosen: ?usize = null; │ │578 + for (inner.children.keys(), inner.children.values()) |id, *child| { │ │579 + const r = child.rect orelse continue; // content-space layout rect │ │580 + const r_top = r.y; │ │581 + const r_bot = r.y + @as(isize, @intCast(r.size.height)); │ │582 + if (r_bot <= top or r_top >= bottom) continue; // not visible │ │583 + chosen = id; │ │584 + if (!prefer_last) break; // first visible │ │585 } │ │586 + if (chosen) |id| try root_focus.setFocus(id); │ │587 + } │ │588 + │ │589 + fn focusedIsLoadMore(self: *View, root_focus: *Focus) bool { │ │590 + const id = self.load_more_id orelse return false; │ │591 + return root_focus.grandchild_id == id; │ │592 + } │ │593 + │ │594 + // keep the diff scroll within its content, using the last build's grids. │ │595 + fn clampDiffScroll(self: *View) void { │ │596 + const sc = self.diffScroll(); │ │597 + const vp = sc.grid orelse return; │ │598 + const content = sc.child.box.grid orelse return; │ │599 + const max_y: isize = if (content.size.height > vp.size.height) @intCast(content.size.height - vp.size.height) else 0;│ │600 + const max_x: isize = if (content.size.width > vp.size.width) @intCast(content.size.width - vp.size.width) else 0; │ │601 + sc.y = std.math.clamp(sc.y, 0, max_y); │ │602 + sc.x = std.math.clamp(sc.x, 0, max_x); │ │603 } │ │604 │ │605 + // enter the diff pane by focusing the hunk that was focused last (the box's │ │606 + // child_id remembers it across the trip to the commit list); fall back to the │ │607 + // first hunk on a fresh diff. the host arrives here on right-arrow or Enter │ │608 + // from the list. a diff with no hunks can't be entered. │ │609 + fn focusDiff(self: *View, root_focus: *Focus) !void { │ │610 + const inner = self.diffInner(); │ │611 + if (inner.children.count() == 0) return; │ │612 + try root_focus.setFocus(inner.getFocus().child_id orelse inner.children.keys()[0]); │ │613 + } │ │614 + │ │615 + fn focusList(self: *View, root_focus: *Focus) !void { │ │616 + const lb = self.listBox(); │ │617 + const id = lb.getFocus().child_id orelse (if (lb.children.count() > 0) lb.children.keys()[0] else return); │ │618 + try root_focus.setFocus(id); │ │619 + } │ │620 + │ │621 fn moveSelection(self: *View, root_focus: *Focus, delta: isize) !void { │ │203 - const box = self.innerBox(); │ │204 - const keys = box.children.keys(); │ │622 + const lb = self.listBox(); │ │623 + const keys = lb.children.keys(); │ │624 if (keys.len == 0) return; │ │206 - const cur_id = box.getFocus().child_id orelse return; │ │207 - const cur: isize = @intCast(box.children.getIndex(cur_id) orelse return); │ │625 + const cur_id = lb.getFocus().child_id orelse return; │ │626 + const cur: isize = @intCast(lb.children.getIndex(cur_id) orelse return); │ │627 const last: isize = @intCast(keys.len - 1); │ │628 const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); │ │629 if (next == @as(usize, @intCast(cur))) return; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │630 try root_focus.setFocus(keys[next]); │ │212 - if (box.children.values()[next].rect) |rect| self.scroll.scrollToRect(rect); │ │631 + if (lb.children.values()[next].rect) |rect| self.listScroll().scrollToRect(rect); │ │632 + } │ │633 + │ │634 + // render the next budget of hunks for the displayed commit and append them. │ │635 + // the TUI reopens the repo here; the wasm path has no repo and the web host │ │636 + // drives "load more" via the GET endpoint instead, so the repo code is │ │637 + // comptime-elided from the wasm build. │ │638 + fn loadMore(self: *View, allocator: std.mem.Allocator, root_focus: *Focus) !void { │ │639 + // the wasm build has no repo; it drives "load more" via the GET endpoint. │ │640 + if (!builtin.cpu.arch.isWasm()) try self.loadMoreNative(allocator, root_focus); │ │641 + } │ │642 + │ │643 + fn loadMoreNative(self: *View, allocator: std.mem.Allocator, root_focus: *Focus) !void { │ │644 + const io = self.session.io orelse return; │ │645 + const repos_dir = self.session.repos_dir orelse return; │ │646 + const sel = self.diffed_index orelse return; │ │647 + const commit = self.data.commits[sel]; │ │648 + if (commit.oid.len != xit.hash.hexLen(.sha1)) return; │ │649 + │ │650 + const aa = self.session.page_arena.allocator(); │ │651 + const gpa = self.session.page_arena.child_allocator; │ │652 + const repo_path = try std.fs.path.join(aa, &.{ repos_dir, self.data.repo_dir_hex }); │ │653 + var repo = rp.Repo(.xit, .{}).open(io, gpa, .{ .path = repo_path }) catch return; │ │654 + defer repo.deinit(io, gpa); │ │655 + │ │656 + var oid: [xit.hash.hexLen(.sha1)]u8 = undefined; │ │657 + @memcpy(&oid, commit.oid); │ │658 + const rendered = renderCommitDiff(io, gpa, aa, &repo, oid, self.shown_hunks, diff_budget) catch return;│ │659 + │ │660 + const inner = self.diffInner(); │ │661 + if (self.load_more_id) |id| { │ │662 + if (inner.children.fetchOrderedRemove(id)) |kv| { │ │663 + var w = kv.value.widget; │ │664 + w.deinit(allocator); │ │665 + } │ │666 + self.load_more_id = null; │ │667 + } │ │668 + // focus the last already-shown hunk (the one just above where "load │ │669 + // more" was). it's already in the focus tree from the last build, so the │ │670 + // focus lands; the freshly-appended hunks aren't in the tree until the │ │671 + // next build, so focusing one here would silently no-op. the user scrolls │ │672 + // down into the new hunks from here. │ │673 + const shown = inner.children.count(); // old hunk count ("load more" removed) │ │674 + for (rendered.hunks) |hunk| try self.addHunkBox(allocator, inner, hunk.lines); │ │675 + self.shown_hunks = rendered.shown_hunks; │ │676 + self.has_more = rendered.has_more; │ │677 + if (rendered.has_more) try self.addLoadMore(allocator, inner, commit.oid, rendered.shown_hunks); │ │678 + if (shown > 0) try root_focus.setFocus(inner.children.keys()[shown - 1]); │ │679 } │ │680 │ │681 pub fn clearGrid(self: *View) void { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────╮ │216 - self.scroll.clearGrid(); │ │682 + self.box.clearGrid(); │ │683 } │ │684 │ │685 pub fn getGrid(self: View) ?Grid {│ ╰───────────────────────────────────────────╯ ╭──────────────────────────────────────────────╮ │220 - return self.scroll.getGrid(); │ │686 + return self.box.getGrid(); │ │687 } │ │688 │ │689 pub fn getFocus(self: *View) *Focus {│ ╰──────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯