Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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 <ada@x> 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));}