// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! What the details and diff pane shows for the selected commit, as lines: //! the details first — who, when, the parents, the branches and tags about //! it, the message — and then each //! file's part of the diff, a line for its name, and its hunks. //! //! It is laid out once, when the change arrives, as a flat list, so that //! drawing it, scrolling it, and finding where a file starts are all a //! matter of line numbers. Tabs are expanded here, to every eighth column, //! since a font has no idea where a tab stop is. const std = @import("std"); const Allocator = std.mem.Allocator; const Change = @import("../history/Change.zig"); const History = @import("../history/History.zig"); const Relations = @import("../history/Relations.zig"); const Clock = @import("../Clock.zig"); const l10n = @import("../l10n.zig"); const Document = @This(); arena: std.heap.ArenaAllocator, lines: []Line, /// The widest line, in columns: how far the pane can scroll sideways. columns: usize, /// The line each file's part starts on, file by file. file_starts: []usize, pub const Style = enum { /// `Author:`, `Parent:` and the like. header, message, blank, /// A file's name, starting its part of the diff. file, /// What git says of a file besides its hunks: a mode, a rename. note, hunk, context, added, removed, no_newline, /// Something said about the diff itself: that it was cut short. remark, }; pub const Line = struct { style: Style, text: []const u8, /// For a line of the diff, the file it is in. file: ?u32 = null, /// For a line of a hunk, its number in the file as the change leaves /// it; for a removed line, the number of the line that follows it /// there. new_line: ?u32 = null, }; pub const tab_width = 8; /// The most names a line of `relations` lists before it just counts them, /// as gitk does. const max_relations = 20; /// Lays out `change`. `history` names the parents, when it has them; /// `relations` are the commit's branches and tags, or null while they are /// still being found, which leaves their lines in place to be filled in. pub fn build(gpa: Allocator, change: *const Change, history: *const History, relations: ?*const Relations, clock: *const Clock) Allocator.Error!Document { var arena: std.heap.ArenaAllocator = .init(gpa); errdefer arena.deinit(); const a = arena.allocator(); var lines: std.ArrayList(Line) = .empty; var starts: std.ArrayList(usize) = .empty; if (change.details) |*d| { try lines.append(a, .{ .style = .header, .text = try personLine(a, "details-author", d.author, clock) }); try lines.append(a, .{ .style = .header, .text = try personLine(a, "details-committer", d.committer, clock) }); { var id_buf: [64]u8 = undefined; const id = std.fmt.bufPrint(&id_buf, "{f}", .{d.id}) catch ""; try lines.append(a, .{ .style = .header, .text = try l10n.format(a, "details-commit", &.{l10n.str("id", id)}) }); } for (d.parents) |p| { const subject = if (history.rowOf(p)) |row| history.text(history.get(row).subject) else ""; var id_buf: [64]u8 = undefined; const id = std.fmt.bufPrint(&id_buf, "{f}", .{p}) catch ""; try lines.append(a, .{ .style = .header, .text = if (subject.len > 0) try l10n.format(a, "details-parent-subject", &.{ l10n.str("id", id), l10n.str("subject", subject) }) else try l10n.format(a, "details-parent", &.{l10n.str("id", id)}) }); } const rel = [_]struct { []const u8, ?[]const []const u8 }{ .{ "details-branches", if (relations) |r| r.branches else null }, .{ "details-follows", if (relations) |r| r.follows else null }, .{ "details-precedes", if (relations) |r| r.precedes else null }, }; for (rel) |r| try lines.append(a, .{ .style = .header, .text = try l10n.format(a, r[0], &.{l10n.str("names", try namesText(a, r[1]))}) }); try lines.append(a, .{ .style = .blank, .text = "" }); var mit = std.mem.splitScalar(u8, d.message, '\n'); while (mit.next()) |m| try lines.append(a, .{ .style = .message, .text = try expand(a, m, 4) }); } else { // Not a commit: what it is, in words. try lines.append(a, .{ .style = .message, .text = change.title }); if (change.files.len == 0) try lines.append(a, .{ .style = .note, .text = l10n.text("diff-no-changes") }); } for (change.files, 0..) |*f, i| { const file: u32 = @intCast(i); try lines.append(a, .{ .style = .blank, .text = "" }); try starts.append(a, lines.items.len); try lines.append(a, .{ .style = .file, .text = try fileLine(a, f), .file = file }); for (f.notes) |n| try lines.append(a, .{ .style = .note, .text = n, .file = file }); if (f.binary) try lines.append(a, .{ .style = .note, .text = l10n.text("diff-binary"), .file = file }); for (f.hunks) |*h| { var n = newStart(h.header); try lines.append(a, .{ .style = .hunk, .text = h.header, .file = file, .new_line = n }); for (h.lines) |l| { try lines.append(a, .{ .style = switch (l.kind) { .context => .context, .added => .added, .removed => .removed, .no_newline => .no_newline, }, .text = if (l.kind == .no_newline) l.text else try diffLine(a, l), .file = file, .new_line = if (l.kind == .no_newline) null else n, }); if (n) |v| if (l.kind == .context or l.kind == .added) { n = v + 1; }; } } } if (change.truncated) { try lines.append(a, .{ .style = .blank, .text = "" }); try lines.append(a, .{ .style = .remark, .text = l10n.text("diff-truncated") }); } var columns: usize = 0; for (lines.items) |l| columns = @max(columns, std.unicode.utf8CountCodepoints(l.text) catch l.text.len); return .{ .arena = arena, .lines = lines.items, .file_starts = starts.items, .columns = columns }; } pub fn deinit(d: *Document) void { d.arena.deinit(); d.* = undefined; } /// The file whose part of the diff `line` is in, or null in the details. pub fn fileAt(d: *const Document, line: usize) ?usize { var found: ?usize = null; for (d.file_starts, 0..) |s, i| { if (s > line) break; found = i; } return found; } /// A place in the document's text: a line, and a byte of it, on a code /// point's boundary. pub const Pos = struct { line: usize, byte: usize, pub fn lessThan(a: Pos, b: Pos) bool { return a.line < b.line or (a.line == b.line and a.byte < b.byte); } pub fn eql(a: Pos, b: Pos) bool { return a.line == b.line and a.byte == b.byte; } }; /// The text from `a` up to `b`, which may be in either order, as it is /// shown: lines joined by line breaks. Owned by the caller. pub fn textBetween(d: *const Document, gpa: Allocator, a: Pos, b: Pos) Allocator.Error![]u8 { const from, const to = if (b.lessThan(a)) .{ b, a } else .{ a, b }; var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); if (from.line >= d.lines.len) return out.toOwnedSlice(gpa); for (from.line..@min(to.line, d.lines.len - 1) + 1) |i| { const t = d.lines[i].text; const start = if (i == from.line) @min(from.byte, t.len) else 0; const end = if (i == to.line) @min(to.byte, t.len) else t.len; if (i != from.line) try out.append(gpa, '\n'); if (end > start) try out.appendSlice(gpa, t[start..end]); } return out.toOwnedSlice(gpa); } /// The word `p` is in, as a double click selects it: a run of letters, /// digits and underscores, or else of other characters that are not /// spaces, or else of spaces. Byte offsets in its line. pub fn wordAt(d: *const Document, p: Pos) struct { start: usize, end: usize } { if (p.line >= d.lines.len) return .{ .start = 0, .end = 0 }; const t = d.lines[p.line].text; const at = @min(p.byte, t.len); if (t.len == 0) return .{ .start = 0, .end = 0 }; // The class of the character at the point, or before it at the end. const probe = if (at < t.len) at else at - 1; const class = classOf(t[probe]); var start = probe; while (start > 0 and classOf(t[start - 1]) == class) start -= 1; var end = probe; while (end < t.len and classOf(t[end]) == class) end += 1; return .{ .start = start, .end = end }; } const Class = enum { word, space, other }; /// By the byte: whatever is not ASCII is part of a word, which keeps a /// code point's bytes together. fn classOf(c: u8) Class { if (c == ' ' or c == '\t') return .space; if (std.ascii.isAlphanumeric(c) or c == '_' or c >= 0x80) return .word; return .other; } /// Where a line of a document is, in terms that survive the change being /// shown again otherwise — with more context, or whitespace ignored — so /// that the same place can be found in the new document. pub const Anchor = union(enum) { /// A line of the details, which do not change. details: usize, file: struct { /// Which file, by its path, owned by the anchor. path: []u8, /// The line's number in the file as the change leaves it, or null /// for a line before the hunks, `offset` lines after the file's /// start. new_line: ?u32, /// The line was removed: it is the one before `new_line`, not /// `new_line` itself. removed: bool, offset: usize, }, pub fn deinit(an: Anchor, gpa: Allocator) void { switch (an) { .details => {}, .file => |f| gpa.free(f.path), } } }; /// Where `line` is, in the document of `change`. pub fn anchorAt(d: *const Document, gpa: Allocator, change: *const Change, line: usize) Allocator.Error!Anchor { if (line >= d.lines.len) return .{ .details = line }; const i = d.fileAt(line) orelse return .{ .details = line }; return .{ .file = .{ .path = try gpa.dupe(u8, change.files[i].path), .new_line = d.lines[line].new_line, .removed = d.lines[line].style == .removed, .offset = line - d.file_starts[i], } }; } /// The line `anchor` is at in this document of `change`: the same line of /// the details, or in the same file the first line at or after the same /// line of it. Null when the file is not in it. pub fn lineOf(d: *const Document, change: *const Change, anchor: Anchor) ?usize { switch (anchor) { .details => |l| return @min(l, d.lines.len -| 1), .file => |f| { const i = for (change.files, 0..) |*file, i| { if (std.mem.eql(u8, file.path, f.path)) break i; } else return null; const start = d.file_starts[i]; const end = if (i + 1 < d.file_starts.len) d.file_starts[i + 1] else d.lines.len; const want = f.new_line orelse return @min(start + f.offset, end -| 1); // The line itself, or else the first thing after it: a later // line, or the header of a hunk that starts later. var last_before: usize = start; for (d.lines[start..end], start..) |l, at| { const n = l.new_line orelse continue; if (n > want) return at; if (n == want and l.style != .hunk and (f.removed or l.style != .removed)) return at; last_before = at; } return last_before; }, } } /// The first line number in the file as the change leaves it, from a /// hunk's header: the `+` range of `@@ -1,3 +1,4 @@`, or of a combined /// diff's `@@@ -1,2 -1,2 +1,3 @@@`. fn newStart(header: []const u8) ?u32 { var it = std.mem.tokenizeScalar(u8, header, ' '); const open = it.next() orelse return null; while (it.next()) |tok| { if (std.mem.eql(u8, tok, open)) return null; if (tok.len > 1 and tok[0] == '+') { const num = tok[1 .. std.mem.findScalar(u8, tok, ',') orelse tok.len]; return std.fmt.parseInt(u32, num, 10) catch null; } } return null; } /// The line naming `p` with the message `id`: who, and when. fn personLine(a: Allocator, id: []const u8, p: Change.Person, clock: *const Clock) Allocator.Error![]const u8 { var buf: [32]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); // Seconds as well, here: the list has room only for minutes. clock.local(p.time).format("YYYY-MM-DD HH:mm:ss", &w) catch {}; return l10n.format(a, id, &.{ l10n.str("name", p.name), l10n.str("email", p.email), l10n.str("date", w.buffered()) }); } fn fileLine(a: Allocator, f: *const Change.File) Allocator.Error![]const u8 { const args = [_]l10n.Argument{ l10n.str("path", f.path), l10n.str("old", f.old_path orelse "") }; return switch (f.status) { .renamed => l10n.format(a, "diff-file-renamed", &args), .copied => l10n.format(a, "diff-file-copied", &args), .added => l10n.format(a, "diff-file-added", &args), .deleted => l10n.format(a, "diff-file-deleted", &args), else => f.path, }; } /// A line of a hunk, its marks and its text, as it is shown. fn diffLine(a: Allocator, l: Change.Line) Allocator.Error![]const u8 { const text = try expand(a, l.text, 0); if (l.marks.len == 0) return text; return std.mem.concat(a, u8, &.{ l.marks, text }); } /// `s` indented by `indent` spaces, with its tabs made spaces to the next /// stop; `s` itself when there is nothing to do. fn expand(a: Allocator, s: []const u8, indent: usize) Allocator.Error![]const u8 { if (indent == 0 and std.mem.findScalar(u8, s, '\t') == null) return s; var out: std.ArrayList(u8) = .empty; try out.appendNTimes(a, ' ', indent); var column: usize = 0; var i: usize = 0; while (i < s.len) { if (s[i] == '\t') { const n = tab_width - column % tab_width; try out.appendNTimes(a, ' ', n); column += n; i += 1; continue; } // A column per code point: the font is monospace, and most of what // is in a diff takes one. const len = std.unicode.utf8ByteSequenceLength(s[i]) catch 1; const end = @min(s.len, i + len); try out.appendSlice(a, s[i..end]); column += 1; i = end; } return out.items; } /// A line of `Relations`, said: the names, or that there are none, or too /// many to list; or that they are not known yet. fn namesText(a: Allocator, names: ?[]const []const u8) Allocator.Error![]const u8 { const list = names orelse return l10n.text("details-relations-finding"); if (list.len == 0) return l10n.text("details-relations-none"); if (list.len > max_relations) return l10n.format(a, "details-relations-many", &.{l10n.num("count", list.len)}); return std.mem.join(a, ", ", list); } // --------------------------------------------------------------------------- const testing = std.testing; const ShowParser = @import("../history/git/ShowParser.zig"); test "a change laid out: details, then each file" { const output = "1111111111111111111111111111111111111111\x002222222222222222222222222222222222222222\x00Ada\x00ada@x\x001790000000 +0000\x00Grace\x00grace@x\x001790000060 +0000\x00Subject\n\nBody\n\x00\x00\n" ++ ":100644 100644 a b M\x00a.zig\x00:100644 100644 c d M\x00b.zig\x00\x00" ++ "diff --git a/a.zig b/a.zig\n@@ -1 +1 @@\n-x\n+\ty\n" ++ "diff --git a/b.zig b/b.zig\n@@ -2 +2 @@\n-p\n+q\n"; var change = try ShowParser.parse(testing.allocator, output, false); defer change.deinit(); var history: History = .init(testing.allocator); defer history.deinit(); const clock: Clock = .{ .zone = .utc }; var d = try build(testing.allocator, &change, &history, null, &clock); defer d.deinit(); try testing.expectEqualStrings("Author: Ada 2026-09-21 14:13:20", d.lines[0].text); try testing.expectEqualStrings("Commit: 1111111111111111111111111111111111111111", d.lines[2].text); try testing.expectEqual(Style.header, d.lines[3].style); // Not found yet, and in their place. try testing.expectEqualStrings("Branches: …", d.lines[4].text); try testing.expectEqualStrings(" Subject", d.lines[8].text); try testing.expectEqual(@as(usize, 2), d.file_starts.len); try testing.expectEqualStrings("a.zig", d.lines[d.file_starts[0]].text); // The tab is expanded after the mark. try testing.expectEqualStrings("+ y", d.lines[d.file_starts[0] + 3].text); try testing.expectEqual(@as(?usize, null), d.fileAt(0)); try testing.expectEqual(@as(?usize, 1), d.fileAt(d.file_starts[1] + 1)); } test "the branches and tags about a commit" { const output = "1111111111111111111111111111111111111111\x00\x00Ada\x00ada@x\x001790000000 +0000\x00Grace\x00grace@x\x001790000060 +0000\x00Subject\n\x00\x00\n"; var change = try ShowParser.parse(testing.allocator, output, false); defer change.deinit(); var history: History = .init(testing.allocator); defer history.deinit(); const clock: Clock = .{ .zone = .utc }; var many: [max_relations + 1][]const u8 = @splat("t"); var rel: Relations = .{ .arena = .init(testing.allocator), .branches = &.{ "main", "topic" }, .precedes = &many }; defer rel.deinit(); var d = try build(testing.allocator, &change, &history, &rel, &clock); defer d.deinit(); try testing.expectEqualStrings("Branches: main, topic", d.lines[3].text); try testing.expectEqualStrings("Follows: none", d.lines[4].text); try testing.expectEqualStrings("Precedes: many (21)", d.lines[5].text); } test "a hunk's header says where its lines are in the new file" { try testing.expectEqual(@as(?u32, 12), newStart("@@ -10,3 +12,4 @@ fn main()")); try testing.expectEqual(@as(?u32, 7), newStart("@@ -7 +7 @@")); try testing.expectEqual(@as(?u32, 3), newStart("@@@ -1,2 -1,2 +3,3 @@@")); try testing.expectEqual(@as(?u32, null), newStart("@@ -1 @@ +2")); } test "the same place, in the change shown with less context" { const head = "1111111111111111111111111111111111111111\x002222222222222222222222222222222222222222\x00Ada\x00ada@x\x001790000000 +0000\x00Grace\x00grace@x\x001790000060 +0000\x00Subject\n\x00\x00\n" ++ ":100644 100644 a b M\x00a.zig\x00:100644 100644 c d M\x00b.zig\x00\x00"; const wide = head ++ "diff --git a/a.zig b/a.zig\n@@ -1 +1 @@\n-x\n+y\n" ++ "diff --git a/b.zig b/b.zig\n@@ -8,5 +8,5 @@\n 8\n 9\n-p\n+q\n 11\n 12\n"; const narrow = head ++ "diff --git a/a.zig b/a.zig\n@@ -1 +1 @@\n-x\n+y\n" ++ "diff --git a/b.zig b/b.zig\n@@ -10 +10 @@\n-p\n+q\n"; var history: History = .init(testing.allocator); defer history.deinit(); const clock: Clock = .{ .zone = .utc }; var c1 = try ShowParser.parse(testing.allocator, wide, false); defer c1.deinit(); var d1 = try build(testing.allocator, &c1, &history, null, &clock); defer d1.deinit(); var c2 = try ShowParser.parse(testing.allocator, narrow, false); defer c2.deinit(); var d2 = try build(testing.allocator, &c2, &history, null, &clock); defer d2.deinit(); // Line 9 of b.zig, context that the narrower diff does not show: the // hunk that starts after it, from its header. const nine = d1.file_starts[1] + 3; try testing.expectEqualStrings(" 9", d1.lines[nine].text); const an = try d1.anchorAt(testing.allocator, &c1, nine); defer an.deinit(testing.allocator); try testing.expectEqualStrings("@@ -10 +10 @@", d2.lines[d2.lineOf(&c2, an).?].text); // The removed line is found as itself, not as its hunk's header. const removed = try d1.anchorAt(testing.allocator, &c1, d1.file_starts[1] + 4); defer removed.deinit(testing.allocator); try testing.expectEqualStrings("-p", d2.lines[d2.lineOf(&c2, removed).?].text); // The added line stays the added line. const added = try d1.anchorAt(testing.allocator, &c1, d1.file_starts[1] + 5); defer added.deinit(testing.allocator); try testing.expectEqualStrings("+q", d2.lines[d2.lineOf(&c2, added).?].text); // The details are where they were; a file named is found by name. try testing.expectEqual(@as(?usize, 2), d2.lineOf(&c2, .{ .details = 2 })); const file = try d1.anchorAt(testing.allocator, &c1, d1.file_starts[1]); defer file.deinit(testing.allocator); try testing.expectEqual(@as(?usize, d2.file_starts[1]), d2.lineOf(&c2, file)); } test "the text between two places, either way round" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var lines = [_]Line{ .{ .style = .message, .text = "first line" }, .{ .style = .blank, .text = "" }, .{ .style = .added, .text = "+third" }, }; const d: Document = .{ .arena = arena, .lines = &lines, .file_starts = &.{}, .columns = 10 }; const t = try d.textBetween(testing.allocator, .{ .line = 2, .byte = 3 }, .{ .line = 0, .byte = 6 }); defer testing.allocator.free(t); try testing.expectEqualStrings("line\n\n+th", t); const one = try d.textBetween(testing.allocator, .{ .line = 0, .byte = 0 }, .{ .line = 0, .byte = 5 }); defer testing.allocator.free(one); try testing.expectEqualStrings("first", one); const none = try d.textBetween(testing.allocator, .{ .line = 0, .byte = 3 }, .{ .line = 0, .byte = 3 }); defer testing.allocator.free(none); try testing.expectEqualStrings("", none); } test "a word, by what it is made of" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var lines = [_]Line{.{ .style = .context, .text = " foo_bar(x, café) " }}; const d: Document = .{ .arena = arena, .lines = &lines, .file_starts = &.{}, .columns = 0 }; const w = d.wordAt(.{ .line = 0, .byte = 3 }); try testing.expectEqualStrings("foo_bar", lines[0].text[w.start..w.end]); const p = d.wordAt(.{ .line = 0, .byte = 8 }); try testing.expectEqualStrings("(", lines[0].text[p.start..p.end]); const c = d.wordAt(.{ .line = 0, .byte = 13 }); try testing.expectEqualStrings("café", lines[0].text[c.start..c.end]); // At the very end, the word before. const e = d.wordAt(.{ .line = 0, .byte = lines[0].text.len }); try testing.expectEqualStrings(" ", lines[0].text[e.start..e.end]); } test "tabs go to the next stop" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); try testing.expectEqualStrings("ab c", try expand(arena.allocator(), "ab\tc", 0)); try testing.expectEqualStrings(" x", try expand(arena.allocator(), "\tx", 0)); try testing.expectEqualStrings(" é x", try expand(arena.allocator(), "é\tx", 2)); }