╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Commits.zig│ ╰───────────────────────╯ ╭───────────────────────────────────────────────────────────────────────╮ │31 message_truncated: bool = false, │ │32 author: ui.Author = .unknown, │ │33 stats: ?xit.patch.CommitStats = null, │ │40 - hunks: []const Hunk, │ │41 - // the hunk index this window starts at (0 = the first window).│ │42 - window_start: usize, │ │43 - // whether hunks exist before this window / after it. │ │44 - has_prev: bool, │ │45 - has_more: bool, │ │34 + window: Diff.Window = .{}, │ │35 }; │ │36 │ │37 // where links and clone commands for this repository are rooted. │ ╰───────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │135 .message_truncated = truncated, │ │136 .author = try ui.Author.init(admin_moment, arena, md.author orelse ""), │ │137 .stats = if (repo_kind == .xit) try repo.commitStats(io, gpa, .{ .oid = &commit_object.oid }) else null,│ │149 - .hunks = &.{}, │ │150 - .window_start = 0, │ │151 - .has_prev = false, │ │152 - .has_more = false, │ │138 }; │ │139 count += 1; │ │140 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │149 // the walk root shows the window the url asks for and its filter; │ │150 // the rest show their first window, unfiltered. │ │151 const window_start = if (i == 0) root_start else 0; │ │167 - const rendered = renderCommitDiff(repo_kind, repo_opts, io, gpa, aa, repo, oid, window_start, diff_page, if (i == 0) root_path else "") catch │ │168 - RenderedDiff{ .hunks = &.{}, .has_prev = false, .has_more = false }; │ │169 - commit.hunks = rendered.hunks; │ │170 - commit.window_start = window_start; │ │171 - commit.has_prev = rendered.has_prev; │ │172 - commit.has_more = rendered.has_more; │ │152 + commit.window = renderCommitDiff(repo_kind, repo_opts, io, gpa, aa, repo, oid, window_start, if (i == 0) root_path else "") catch .{ .start = window_start };│ │153 } │ │154 } │ │155 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │190 } │ │191 │ │192 // the page's content for a route's, duped into `aa`. the diff window doesn't │ │213 -// carry over: each commit holds the one its pane shows as `window_start`. │ │193 +// carry over: each commit holds the one its pane shows as `window.start`. │ │194 fn pageContent(aa: std.mem.Allocator, content: ui.RoutablePage.RepoCommitsRoute.Content) !Content {│ │195 return switch (content) { │ │196 .diff => |d| .{ .diff = .{ .path = try aa.dupe(u8, d.path.slice()) } }, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │198 }; │ │199 } │ │200 │ │221 -const RenderedDiff = struct { │ │222 - hunks: []const Hunk, │ │223 - has_prev: bool, │ │224 - has_more: bool, │ │225 -}; │ │226 - │ │201 // render the window [start, start+len) of a commit's diff against its first │ │228 -// parent into `arena`-owned hunks. has_prev/has_more flag adjacent windows. │ │202 +// parent into `arena`-owned hunks. start/has_more identify adjacent windows.│ │203 // a non-empty `path` filters to that file, so the window indexes its hunks. │ │204 fn renderCommitDiff( │ │205 comptime repo_kind: rp.RepoKind, │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────╮ │210 repo: *rp.Repo(repo_kind, repo_opts), │ │211 oid: [xit.hash.hexLen(repo_opts.hash)]u8, │ │212 start: usize, │ │239 - len: usize, │ │213 path: []const u8, │ │241 -) !RenderedDiff { │ │242 - const empty = RenderedDiff{ .hunks = &.{}, .has_prev = false, .has_more = false };│ │214 +) !Diff.Window { │ │215 + const empty = Diff.Window{ .start = start }; │ │216 │ │217 // load the commit so we can diff it against its first parent. │ │218 var start_oids = [_][xit.hash.hexLen(repo_opts.hash)]u8{oid}; │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │222 defer commit_object.deinit(); │ │223 │ │224 const parent_maybe = commit_object.content.commit.metadata.firstParent(); │ │252 - │ │253 - var tree_diff = repo.treeDiff(io, gpa, parent_maybe, &commit_object.oid) catch return empty; │ │254 - defer tree_diff.deinit(); │ │255 - │ │256 - var file_iter = repo.filePairs(io, gpa, .{ .tree = .{ .tree_diff = &tree_diff } }) catch return empty;│ │257 - │ │258 - var hunks: std.ArrayList(Hunk) = .empty; │ │259 - var index: usize = 0; // running hunk index across all files │ │260 - var has_more = false; │ │261 - │ │262 - file_loop: while (file_iter.next() catch null) |pair_val| { │ │263 - var pair = pair_val; │ │264 - defer pair.deinit(); │ │265 - │ │266 - if (path.len != 0 and !std.mem.eql(u8, pair.path, path)) continue; │ │267 - │ │268 - var hunk_iter = df.HunkIterator(repo_kind, repo_opts).init(gpa, &pair.a, &pair.b) catch continue; │ │269 - defer hunk_iter.deinit(gpa); │ │270 - │ │271 - // the path label rides on the first of this file's hunks we actually show. │ │272 - var path_attached = false; │ │273 - while (try hunk_iter.next(gpa)) |hunk_val| { │ │274 - var hunk = hunk_val; │ │275 - defer hunk.deinit(gpa); │ │276 - │ │277 - const i = index; │ │278 - index += 1; │ │279 - if (i < start) continue; // before this window │ │280 - if (hunks.items.len >= len) { │ │281 - has_more = true; │ │282 - break :file_loop; │ │283 - } │ │284 - var lines: std.ArrayList([]const u8) = .empty; │ │285 - try appendHunkLines(repo_kind, repo_opts, arena, &lines, &hunk_iter, &hunk); │ │286 - try hunks.append(arena, .{ │ │287 - .path = if (path_attached) null else try arena.dupe(u8, pair.path), │ │288 - .lines = try lines.toOwnedSlice(arena), │ │289 - }); │ │290 - path_attached = true; │ │291 - } │ │292 - } │ │293 - │ │294 - return .{ .hunks = try hunks.toOwnedSlice(arena), .has_prev = start > 0, .has_more = has_more }; │ │295 -} │ │296 - │ │297 -// append a hunk's header and its edit lines, each prefixed with a right-aligned │ │298 -// line number in a column wide enough for the hunk's largest number, then a space │ │299 -fn appendHunkLines( │ │300 - comptime repo_kind: rp.RepoKind, │ │301 - comptime repo_opts: rp.RepoOpts(repo_kind), │ │302 - arena: std.mem.Allocator, │ │303 - lines: *std.ArrayList([]const u8), │ │304 - hunk_iter: *df.HunkIterator(repo_kind, repo_opts), │ │305 - hunk: *df.Hunk(repo_kind, repo_opts), │ │306 -) !void { │ │307 - var max_num: usize = 1; │ │308 - for (hunk.edits.items) |edit| { │ │309 - const n = editLineNum(edit) + 1; │ │310 - if (n > max_num) max_num = n; │ │311 - } │ │312 - const width = std.fmt.count("{d}", .{max_num}); │ │313 - // a run of spaces sliced to each line's leading pad. │ │314 - const indent = try arena.alloc(u8, width); │ │315 - @memset(indent, ' '); │ │316 - │ │317 - for (hunk.edits.items) |edit| { │ │318 - const text = switch (edit) { │ │319 - .eql => |e| try hunk_iter.line_iter_b.get(e.new_line.num), │ │320 - .ins => |e| try hunk_iter.line_iter_b.get(e.new_line.num), │ │321 - .del => |e| try hunk_iter.line_iter_a.get(e.old_line.num), │ │322 - }; │ │323 - defer switch (edit) { │ │324 - .eql, .ins => hunk_iter.line_iter_b.free(text), │ │325 - .del => hunk_iter.line_iter_a.free(text), │ │326 - }; │ │327 - const prefix: u8 = switch (edit) { │ │328 - .eql => ' ', │ │329 - .ins => '+', │ │330 - .del => '-', │ │331 - }; │ │332 - const num_str = try std.fmt.allocPrint(arena, "{d}", .{editLineNum(edit) + 1}); │ │333 - try lines.append(arena, try std.fmt.allocPrint(arena, "{s}{s} {c}{s}", .{ │ │334 - indent[0 .. width - num_str.len], num_str, prefix, text, │ │335 - })); │ │336 - } │ │337 -} │ │225 │ │339 -// the line number to show for an edit: the new-side number for kept and inserted │ │340 -// lines, the old-side number for deletions (0-based in the diff machinery). │ │341 -fn editLineNum(edit: df.Edit) usize { │ │342 - return switch (edit) { │ │343 - .eql => |e| e.new_line.num, │ │344 - .ins => |e| e.new_line.num, │ │345 - .del => |e| e.old_line.num, │ │346 - }; │ │226 + return Diff.render(repo_kind, repo_opts, io, gpa, arena, repo, parent_maybe, oid, start, path); │ │227 } │ │228 │ │229 // "YYYY-MM-DD" for a unix timestamp. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯