╭ changed ──────────────╮ │src/ui/Repo/Commits.zig│ ╰───────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────╮ │1 const std = @import("std"); │ │2 +const builtin = @import("builtin"); │ │3 const evt = @import("../../event.zig");│ │4 const ui = @import("../../ui.zig"); │ │5 const xit = @import("xit"); │ ╰──────────────────────────────────────────╯ ╭────────────────────────────────╮ │ 6 const rp = xit.repo; │ │ 7 +const df = xit.diff; │ │ 8 const xitui = xit.xitui; │ │ 9 const wgt = xitui.widget; │ │10 const layout = xitui.layout;│ ╰────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │12 const Grid = xitui.grid.Grid; │ │13 const Focus = xitui.focus.Focus; │ │14 │ │13 -const Oid = [xit.hash.hexLen(.sha1)]u8; │ │14 - │ │15 // how many commits a page shows before a "next" link appears. │ │16 const page_size = 20; │ │17 +// max diff lines rendered per chunk (the initial render and each "load more")│ │18 +const diff_budget = 100; │ │19 │ │18 -// one commit on the current page. │ │20 +pub const Hunk = struct { │ │21 + lines: []const []const u8, │ │22 +}; │ │23 + │ │24 +// one commit on the current page, with its diff against its first parent │ │25 +// pre-rendered up to the budget so the web client can show it without a repo.│ │26 pub const Commit = struct { │ │27 oid: []const u8, │ │28 date: []const u8, // "YYYY-MM-DD" │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │29 message: []const u8, // first line only │ │30 + hunks: []const Hunk, │ │31 + // how many hunks `hunks` covers; the resume cursor for "load more". │ │32 + shown_hunks: usize, │ │33 + // true when the diff was truncated at the budget and more hunks remain.│ │34 + has_more: bool, │ │35 }; │ │36 │ │37 // "owner/name", so the view can build /repo/owner/name/commits/<oid> links.│ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │39 commits: []const Commit, │ │40 // the first oid of the next page, or null when this is the last page. │ │41 next_start: ?[]const u8, │ │42 +// the on-disk repo directory name (hex event id), for reopening to render more│ │43 +// diff hunks when "load more" is activated in the TUI. │ │44 +repo_dir_hex: []const u8, │ │45 │ │46 const Self = @This(); │ │47 │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │53 start_oid: []const u8, │ │54 ) !Self { │ │55 const aa = arena.allocator(); │ │41 - const empty: Self = .{ .identity = try aa.dupe(u8, identity), .commits = &.{}, .next_start = null };│ │56 + const hex = std.fmt.bytesToHex(event_id.*, .lower); │ │57 + const empty: Self = .{ │ │58 + .identity = try aa.dupe(u8, identity), │ │59 + .commits = &.{}, │ │60 + .next_start = null, │ │61 + .repo_dir_hex = try aa.dupe(u8, &hex), │ │62 + }; │ │63 │ │64 // no filesystem (wasm) or nowhere to look: empty listing. the wasm path │ │65 // never calls init anyway — it rebuilds from the serialized snapshot. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │66 const io = session.io orelse return empty; │ │67 const repos_dir = session.repos_dir orelse return empty; │ │68 │ │48 - // the repo's working copy lives at <repos_dir>/<hex event id>. │ │49 - const hex = std.fmt.bytesToHex(event_id.*, .lower); │ │69 const repo_path = try std.fs.path.join(aa, &.{ repos_dir, &hex }); │ │70 │ │71 // walk the log with the arena's backing allocator (transient; the commits│ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │75 defer repo.deinit(io, gpa); │ │76 │ │77 // page 1 walks from HEAD (null); a later page starts at its first oid. │ │59 - var start_arr: [1]Oid = undefined; │ │60 - const start_oids: ?[]const Oid = if (start_oid.len == 0) null else blk: { │ │78 + var start_arr: [1][xit.hash.hexLen(.sha1)]u8 = undefined; │ │79 + const start_oids: ?[]const [xit.hash.hexLen(.sha1)]u8 = if (start_oid.len == 0) null else blk: {│ │80 if (start_oid.len != start_arr[0].len) return empty; // malformed oid │ │81 @memcpy(&start_arr[0], start_oid); │ │82 break :blk start_arr[0..1]; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 83 }; │ │ 84 │ │ 66 - var iter = repo.log(io, gpa, start_oids) catch return empty; │ │ 67 - defer iter.deinit(); │ │ 68 - │ │ 69 - // collect this page's commits, plus a peek at the one after it (its oid is │ │ 70 - // the next page's start). │ │ 85 + // collect this page's commit metadata, plus a peek at the one after it (its │ │ 86 + // oid is the next page's start). the diff for each is rendered afterward, so │ │ 87 + // the log iterator is closed before opening per-commit diff iterators. │ │ 88 var buf: [page_size]Commit = undefined; │ │ 89 + var oids: [page_size][xit.hash.hexLen(.sha1)]u8 = undefined; │ │ 90 var count: usize = 0; │ │ 91 var next_start: ?[]const u8 = null; │ │ 74 - while (try iter.next(gpa)) |commit_object| { │ │ 75 - defer commit_object.deinit(); │ │ 76 - if (count == page_size) { │ │ 77 - next_start = try aa.dupe(u8, &commit_object.oid); │ │ 78 - break; │ │ 92 + { │ │ 93 + var iter = repo.log(io, gpa, start_oids) catch return empty; │ │ 94 + defer iter.deinit(); │ │ 95 + while (try iter.next(gpa)) |commit_object| { │ │ 96 + defer commit_object.deinit(); │ │ 97 + if (count == page_size) { │ │ 98 + next_start = try aa.dupe(u8, &commit_object.oid); │ │ 99 + break; │ │100 + } │ │101 + const md = commit_object.content.commit.metadata; │ │102 + @memcpy(&oids[count], &commit_object.oid); │ │103 + buf[count] = .{ │ │104 + .oid = try aa.dupe(u8, &commit_object.oid), │ │105 + .date = try formatDate(aa, md.timestamp), │ │106 + .message = try aa.dupe(u8, firstLine(md.message orelse "")), │ │107 + .hunks = &.{}, │ │108 + .shown_hunks = 0, │ │109 + .has_more = false, │ │110 + }; │ │111 + count += 1; │ │112 + } │ │113 + } │ │114 + │ │115 + // render each commit's diff (best effort: a failed diff leaves it empty). │ │116 + // the diff machinery isn't wasm-clean and the wasm client never runs it (it │ │117 + // renders from the snapshot), so gate it out of the wasm build. │ │118 + if (!builtin.cpu.arch.isWasm()) { │ │119 + for (buf[0..count], oids[0..count]) |*commit, oid| { │ │120 + const rendered = renderCommitDiff(io, gpa, aa, &repo, oid, 0, diff_budget) catch│ │121 + RenderedDiff{ .hunks = &.{}, .shown_hunks = 0, .has_more = false }; │ │122 + commit.hunks = rendered.hunks; │ │123 + commit.shown_hunks = rendered.shown_hunks; │ │124 + commit.has_more = rendered.has_more; │ │125 } │ │ 80 - const md = commit_object.content.commit.metadata; │ │ 81 - buf[count] = .{ │ │ 82 - .oid = try aa.dupe(u8, &commit_object.oid), │ │ 83 - .date = try formatDate(aa, md.timestamp), │ │ 84 - .message = try aa.dupe(u8, firstLine(md.message orelse "")), │ │ 85 - }; │ │ 86 - count += 1; │ │126 } │ │127 │ │128 return .{ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯