╭ changed ──────────────╮ │src/ui/Repo/Commits.zig│ ╰───────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 {│ ╰──────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │224 - return self.scroll.getFocus(); │ │690 + return self.box.getFocus(); │ │691 } │ │692 │ │227 - pub fn getSelectedIndex(self: View) ?usize { │ │228 - const box = &self.scroll.child.box; │ │229 - const child_id = box.focus.child_id orelse return null; │ │230 - return box.children.getIndex(child_id); │ │693 + // for the parent's "scroll up at the top jumps to the header" check. in the │ │694 + // diff pane that means the first hunk is focused and the diff can't scroll up │ │695 + // any further; otherwise up-arrow stays in the pane (scrolling or moving │ │696 + // between hunks). when the list holds focus, report its selection directly. │ │697 + pub fn getSelectedIndex(self: *View) ?usize { │ │698 + if (self.diffActive()) { │ │699 + const inner = self.diffInner(); │ │700 + const cid = inner.getFocus().child_id orelse return 1; │ │701 + const first_focused = inner.children.count() > 0 and cid == inner.children.keys()[0];│ │702 + return if (first_focused and self.diffScroll().y == 0) 0 else 1; │ │703 + } │ │704 + const lb = self.listBox(); │ │705 + const cid = lb.getFocus().child_id orelse return null; │ │706 + return lb.children.getIndex(cid); │ │707 } │ │708 }; │ │709 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯