╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Commits.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
496 .page_down => try self.moveSelection(root_focus, page_rows), 497 .page_up => try self.moveSelection(root_focus, -page_rows), 498 .mouse => |mouse| switch (mouse.action) { 499 .scroll => |dir| try self.moveSelection(root_focus, if (dir == .up) -1 else 1), 500 else => {}, 501 }, 502 else => {}, 503 } 504 } 505 506 fn diffInput(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 507 const sc = self.diffScroll(); 508 switch (key) { 509 // left scrolls horizontally, then leaves for the list once flush left. 510 .arrow_left => { 511 if (sc.x > 0) { 512 sc.x -= 1; 513 self.clampDiffScroll(); 514 } else try self.focusList(root_focus); 515 }, 516 .arrow_right => { 517 sc.x += 1; 518 self.clampDiffScroll(); 519 }, 520 .arrow_up => try self.moveDiff(root_focus, -1), 521 .arrow_down => try self.moveDiff(root_focus, 1), 522 .page_up => try self.pageDiff(root_focus, -page_rows), 523 .page_down => try self.pageDiff(root_focus, page_rows), 524 .enter => if (self.focusedIsLoadMore(root_focus)) try self.loadMore(allocator, root_focus), 525 .mouse => |mouse| switch (mouse.action) { 526 .scroll => |dir| try self.moveDiff(root_focus, if (dir == .up) -1 else 1), 527 .press => |btn| if (btn == .left and self.focusedIsLoadMore(root_focus)) try self.loadMore(allocator, root_focus), 528 else => {}, 529 }, 530 else => {}, 531 } 532 } 533 534 // move focus one hunk in `dir` (+1 down, -1 up). if the adjacent hunk is 535 // already visible, just move focus to it — don't scroll. only when it isn't 536 // visible yet do we scroll one line toward it (and focus it once it appears), 537 // so scrolling never skips past an on-screen hunk. when nothing fits to 538 // scroll (e.g. the web, where the browser scrolls the page), the clamp keeps 539 // the offset put and focus simply steps to the adjacent hunk. 540 fn moveDiff(self: *View, root_focus: *Focus, dir: isize) !void { 541 const inner = self.diffInner(); 542 const keys = inner.children.keys(); 543 if (keys.len == 0) return; 544 const cur: usize = if (root_focus.grandchild_id) |g| (inner.children.getIndex(g) orelse 0) else 0; 545 const target = @as(isize, @intCast(cur)) + dir; 546 const in_range = target >= 0 and target < @as(isize, @intCast(keys.len)); 547 548 if (in_range and self.hunkVisible(@intCast(target))) { 549 try root_focus.setFocus(keys[@intCast(target)]); 550 return; 551 } 552 553 const sc = self.diffScroll(); 554 sc.y += dir; 555 self.clampDiffScroll(); 556 if (in_range and self.hunkVisible(@intCast(target))) 557 try root_focus.setFocus(keys[@intCast(target)]); 558 } 559 560 // page a fixed number of lines, then focus the leading visible hunk 561 // (bottom-most when paging down, top-most when paging up). 562 fn pageDiff(self: *View, root_focus: *Focus, delta: isize) !void { 563 const sc = self.diffScroll(); 564 sc.y += delta; 565 self.clampDiffScroll(); 566 try self.focusVisible(root_focus, delta > 0); 567 } 568 569 // whether hunk `index` is at least partly within the diff viewport, per the 570 // last build's layout rects (content space) and the current scroll offset. 571 fn hunkVisible(self: *View, index: usize) bool { 572 const inner = self.diffInner(); 573 const sc = self.diffScroll(); 574 const vp = sc.grid orelse return false; 575 const r = inner.children.values()[index].rect orelse return false; 576 const top = sc.y; 577 const bottom = sc.y + @as(isize, @intCast(vp.size.height)); 578 return (r.y + @as(isize, @intCast(r.size.height))) > top and r.y < bottom; 579 } 580 581 // focus the visible hunk at the scroll's leading edge. `prefer_last` picks 582 // the bottom-most visible (for downward motion), else the top-most. 583 fn focusVisible(self: *View, root_focus: *Focus, prefer_last: bool) !void { 584 const inner = self.diffInner(); 585 const sc = self.diffScroll(); 586 const vp = sc.grid orelse return; 587 const top = sc.y; 588 const bottom = sc.y + @as(isize, @intCast(vp.size.height)); 589 var chosen: ?usize = null; 590 for (inner.children.keys(), inner.children.values()) |id, *child| { 591 const r = child.rect orelse continue; // content-space layout rect 592 const r_top = r.y; 593 const r_bot = r.y + @as(isize, @intCast(r.size.height)); 594 if (r_bot <= top or r_top >= bottom) continue; // not visible 595 chosen = id; 596 if (!prefer_last) break; // first visible 597 } 598 if (chosen) |id| try root_focus.setFocus(id); 599 } 600 601 fn focusedIsLoadMore(self: *View, root_focus: *Focus) bool { 602 const id = self.load_more_id orelse return false; 603 return root_focus.grandchild_id == id; 604 } 605 606 // keep the diff scroll within its content, using the last build's grids. 607 fn clampDiffScroll(self: *View) void { 608 const sc = self.diffScroll(); 609 const vp = sc.grid orelse return; 610 const content = sc.child.box.grid orelse return; 611 const max_y: isize = if (content.size.height > vp.size.height) @intCast(content.size.height - vp.size.height) else 0; 612 const max_x: isize = if (content.size.width > vp.size.width) @intCast(content.size.width - vp.size.width) else 0; 613 sc.y = std.math.clamp(sc.y, 0, max_y); 614 sc.x = std.math.clamp(sc.x, 0, max_x); 615 } 616 617 // enter the diff pane by focusing the hunk that was focused last (the box's 618 // child_id remembers it across the trip to the commit list); fall back to the 619 // first hunk on a fresh diff. the host arrives here on right-arrow or Enter 620 // from the list. a diff with no hunks can't be entered. 621 fn focusDiff(self: *View, root_focus: *Focus) !void { 622 const inner = self.diffInner(); 623 if (inner.children.count() == 0) return; 624 const target = inner.getFocus().child_id orelse inner.children.keys()[0]; 625 try root_focus.setFocus(target); 626 if (root_focus.grandchild_id == target) return; 627 // the diff pane wasn't laid out last build (too narrow to show beside 628 // the list), so its hunks aren't in the focus tree yet. select the pane 629 // at the box level, and the hunk will be focused after the next build. 630 self.box.getFocus().child_id = self.diffOuter().getFocus().id; 631 } 632 633 fn focusList(self: *View, root_focus: *Focus) !void { 634 const lb = self.listBox(); 635 const id = lb.getFocus().child_id orelse (if (lb.children.count() > 0) lb.children.keys()[0] else return); 636 try root_focus.setFocus(id); 637 } 638 639 fn moveSelection(self: *View, root_focus: *Focus, delta: isize) !void { 640 const lb = self.listBox(); 641 const keys = lb.children.keys(); 642 if (keys.len == 0) return; 643 const cur_id = lb.getFocus().child_id orelse return; 644 const cur: isize = @intCast(lb.children.getIndex(cur_id) orelse return); 645 const last: isize = @intCast(keys.len - 1); 646 const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); 647 if (next == @as(usize, @intCast(cur))) return; 648 try root_focus.setFocus(keys[next]); 649 if (lb.children.values()[next].rect) |rect| self.listScroll().scrollToRect(rect); 650 } 651 652 // render the next budget of hunks for the displayed commit and append them. 653 // the TUI reopens the repo here; the wasm path has no repo and the web host 654 // drives "load more" via the GET endpoint instead, so the repo code is 655 // comptime-elided from the wasm build. 656 fn loadMore(self: *View, allocator: std.mem.Allocator, root_focus: *Focus) !void { 657 // the wasm build has no repo; it drives "load more" via the GET endpoint. 658 if (!builtin.cpu.arch.isWasm()) try self.loadMoreNative(allocator, root_focus); 659 } 660 661 fn loadMoreNative(self: *View, allocator: std.mem.Allocator, root_focus: *Focus) !void { 662 const io = self.session.io orelse return; 663 const repos_dir = self.session.repos_dir orelse return; 664 const sel = self.diffed_index orelse return; 665 const commit = self.data.commits[sel]; 666 if (commit.oid.len != xit.hash.hexLen(.sha1)) return; 667 668 const aa = self.session.page_arena.allocator(); 669 const gpa = self.session.page_arena.child_allocator; 670 const repo_path = try std.fs.path.join(aa, &.{ repos_dir, self.data.repo_dir_hex }); 671 var repo = rp.Repo(.xit, .{}).open(io, gpa, .{ .path = repo_path }) catch return; 672 defer repo.deinit(io, gpa); 673 674 var oid: [xit.hash.hexLen(.sha1)]u8 = undefined; 675 @memcpy(&oid, commit.oid); 676 const rendered = renderCommitDiff(io, gpa, aa, &repo, oid, self.shown_hunks, diff_budget) catch return; 677 678 const inner = self.diffInner(); 679 if (self.load_more_id) |id| { 680 if (inner.children.fetchOrderedRemove(id)) |kv| { 681 var w = kv.value.widget; 682 w.deinit(allocator); 683 } 684 self.load_more_id = null; 685 } 686 // focus the last already-shown hunk (the one just above where "load 687 // more" was). it's already in the focus tree from the last build, so the 688 // focus lands; the freshly-appended hunks aren't in the tree until the 689 // next build, so focusing one here would silently no-op. the user scrolls 690 // down into the new hunks from here. 691 const shown = inner.children.count(); // old hunk count ("load more" removed) 692 for (rendered.hunks) |hunk| try self.addHunkBox(allocator, inner, hunk.lines); 693 self.shown_hunks = rendered.shown_hunks; 694 self.has_more = rendered.has_more; 695 if (rendered.has_more) try self.addLoadMore(allocator, inner, commit.oid, rendered.shown_hunks); 696 if (shown > 0) try root_focus.setFocus(inner.children.keys()[shown - 1]); 697 } 698 699 pub fn clearGrid(self: *View) void { 700 self.box.clearGrid(); 701 } 702 703 pub fn getGrid(self: View) ?Grid { 704 return self.box.getGrid(); 705 } 706 707 pub fn getFocus(self: *View) *Focus { 708 return self.box.getFocus(); 709 } 710 711 // for the parent's "scroll up at the top jumps to the header" check. in the 712 // diff pane that means the first hunk is focused and the diff can't scroll up 713 // any further; otherwise up-arrow stays in the pane (scrolling or moving 714 // between hunks). when the list holds focus, report its selection directly. 715 pub fn getSelectedIndex(self: *View) ?usize { 716 if (self.diffActive()) { 717 const inner = self.diffInner(); 718 const cid = inner.getFocus().child_id orelse return 1; 719 const first_focused = inner.children.count() > 0 and cid == inner.children.keys()[0]; 720 return if (first_focused and self.diffScroll().y == 0) 0 else 1; 721 } 722 const lb = self.listBox(); 723 const cid = lb.getFocus().child_id orelse return null; 724 return lb.children.getIndex(cid); 725 } 726 }; 727 728 // the "a:" link for the commits page starting at `start_oid` within `identity`. 729 fn commitsPageLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, start_oid: []const u8) ![]const u8 { 730 const route = ui.RoutablePage.repoCommitsRoute(identity, start_oid) orelse return error.RouteTooLong; 731 const url = try route.urlAlloc(page_arena); 732 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 733 } 734