From 90a817066a65c9745eb699e6874d58408c889d3f Mon Sep 17 00:00:00 2001 From: Miguel Granero Date: Fri, 23 Jan 2026 21:36:14 +0100 Subject: [PATCH 1/9] feat: add git blame with inline display (wip) --- src/Project.zig | 31 +++++++++++++ src/buffer/Buffer.zig | 96 +++++++++++++++++++++++++++++++++++++++++ src/git.zig | 18 ++++++++ src/project_manager.zig | 17 ++++++++ src/tui/editor.zig | 39 +++++++++++++++++ src/tui/mainview.zig | 20 ++++++++- src/tui/tui.zig | 4 ++ 7 files changed, 223 insertions(+), 2 deletions(-) diff --git a/src/Project.zig b/src/Project.zig index 3712d6c..178979e 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -2861,6 +2861,8 @@ pub fn process_git_response(self: *Self, parent: tp.pid_ref, m: tp.message) (Out var context: usize = undefined; var vcs_id: []const u8 = undefined; var vcs_content: []const u8 = undefined; + var blame_output: []const u8 = undefined; + _ = self; if (try m.match(.{ tp.any, tp.extract(&context), "rev_parse", tp.extract(&vcs_id) })) { @@ -2876,5 +2878,34 @@ pub fn process_git_response(self: *Self, parent: tp.pid_ref, m: tp.message) (Out const request: *VcsContentRequest = @ptrFromInt(context); defer request.deinit(); parent.send(.{ "PRJ", "vcs_content", request.file_path, request.vcs_id, null }) catch {}; + } else if (try m.match(.{ tp.any, tp.extract(&context), "blame", tp.extract(&blame_output) })) { + const request: *GitBlameRequest = @ptrFromInt(context); + parent.send(.{ "PRJ", "git_blame", request.file_path, blame_output }) catch {}; + } else if (try m.match(.{ tp.any, tp.extract(&context), "blame", tp.null_ })) { + const request: *GitBlameRequest = @ptrFromInt(context); + defer request.deinit(); + parent.send(.{ "PRJ", "git_blame", request.file_path, null }) catch {}; } } + +pub fn request_vcs_blame(self: *Self, file_path: []const u8) error{OutOfMemory}!void { + const request = try self.allocator.create(GitBlameRequest); + request.* = .{ + .allocator = self.allocator, + .project = self, + .file_path = try self.allocator.dupe(u8, file_path), + }; + git.blame(@intFromPtr(request), file_path) catch |e| + self.logger_git.print_err("blame", "failed: {t}", .{e}); +} + +pub const GitBlameRequest = struct { + allocator: std.mem.Allocator, + project: *Self, + file_path: []const u8, + + pub fn deinit(self: *@This()) void { + self.allocator.free(self.file_path); + self.allocator.destroy(self); + } +}; diff --git a/src/buffer/Buffer.zig b/src/buffer/Buffer.zig index a7d547e..bdb2359 100644 --- a/src/buffer/Buffer.zig +++ b/src/buffer/Buffer.zig @@ -31,6 +31,11 @@ pub const Metrics = struct { pub const egc_last_func = *const fn (self: Metrics, egcs: []const u8) []const u8; }; +pub const BlameLine = struct { + author_name: []const u8, + author_stamp: usize, +}; + arena: std.heap.ArenaAllocator, allocator: Allocator, external_allocator: Allocator, @@ -50,6 +55,8 @@ meta: ?[]const u8 = null, lsp_version: usize = 1, vcs_id: ?[]const u8 = null, vcs_content: ?ArrayList(u8) = null, +vcs_blame: ?ArrayList(u8) = null, +vcs_blame_items: ArrayList(BlameLine) = .empty, last_view: ?usize = null, undo_head: ?*UndoNode = null, @@ -1212,6 +1219,8 @@ pub fn create(allocator: Allocator) error{OutOfMemory}!*Self { pub fn deinit(self: *Self) void { self.clear_vcs_content(); + self.clear_vcs_blame(); + self.vcs_blame_items.deinit(self.allocator); if (self.vcs_id) |buf| self.external_allocator.free(buf); if (self.meta) |buf| self.external_allocator.free(buf); if (self.file_buf) |buf| self.external_allocator.free(buf); @@ -1257,6 +1266,7 @@ pub fn set_vcs_id(self: *Self, vcs_id: []const u8) error{OutOfMemory}!bool { self.external_allocator.free(old_id); } self.clear_vcs_content(); + self.clear_vcs_blame(); self.vcs_id = try self.external_allocator.dupe(u8, vcs_id); return true; } @@ -1287,6 +1297,92 @@ pub fn get_vcs_content(self: *const Self) ?[]const u8 { return if (self.vcs_content) |*buf| buf.items else null; } +pub fn clear_vcs_blame(self: *Self) void { + if (self.vcs_blame) |*buf| { + buf.deinit(self.external_allocator); + self.vcs_blame = null; + } +} + +pub fn get_vcs_blame(self: *const Self) ?[]const u8 { + return if (self.vcs_blame) |*buf| buf.items else null; +} + +pub fn set_vcs_blame(self: *Self, vcs_blame: []const u8) error{OutOfMemory}!void { + if (self.vcs_blame) |*al| { + try al.appendSlice(self.external_allocator, vcs_blame); + } else { + var al: ArrayList(u8) = .empty; + try al.appendSlice(self.external_allocator, vcs_blame); + self.vcs_blame = al; + } +} + +/// Can probably be optimized +pub fn parse_git_blame(self: *Self) !void { + self.vcs_blame_items.clearAndFree(self.allocator); + var stream = std.io.fixedBufferStream(self.vcs_blame.?.items); + var reader = stream.reader(); + var chunk_active = false; + var current_committer: []u8 = ""; + var current_stamp: usize = undefined; + var buffer: [200]u8 = undefined; + while (true) { + const line = reader.readUntilDelimiter(&buffer, '\n') catch { + break; + }; + + if (line.len == 0) + continue; + + if (line.len >= 40 and std.ascii.isHex(line[0])) { + if (chunk_active) + break; // error + + chunk_active = true; + var it = std.mem.splitScalar(u8, line, ' '); + + const sha = it.next() orelse continue; + if (sha.len != 40) continue; + + _ = it.next(); // original line (ignored) + const number = try std.fmt.parseInt( + i64, + it.next() orelse break, + 10, + ); + if (number != self.vcs_blame_items.items.len + 1) + break; + } else if (std.mem.startsWith(u8, line, "author ")) { + if (!chunk_active) + break; // error + current_committer = try self.allocator.dupe( + u8, + line["author ".len..], + ); + } else if (std.mem.startsWith(u8, line, "author-time ")) { + if (!chunk_active) + break; // error + current_stamp = try std.fmt.parseInt( + usize, + line["author-time ".len..], + 10, + ); + + const ptr = try self.vcs_blame_items.addOne(self.allocator); + ptr.* = BlameLine{ .author_name = current_committer, .author_stamp = current_stamp }; + chunk_active = false; + } + } +} + +pub fn get_line_blame(self: *const Self, line: usize) ?[]const u8 { + if (line + 1 > self.vcs_blame_items.items.len) + return null; + + return self.vcs_blame_items.items[line].author_name; +} + pub fn update_last_used_time(self: *Self) void { self.utime = std.time.milliTimestamp(); } diff --git a/src/git.zig b/src/git.zig index aee9383..60b08c6 100644 --- a/src/git.zig +++ b/src/git.zig @@ -383,4 +383,22 @@ fn get_git() ?[]const u8 { return path; } +pub fn blame(context_: usize, file_path: []const u8) !void { + const tag = @src().fn_name; + var arg: std.Io.Writer.Allocating = .init(allocator); + defer arg.deinit(); + try arg.writer.print("{s}", .{file_path}); + try git(context_, .{ + "blame", + "--line-porcelain", + "HEAD", + "--", + arg.written(), + }, struct { + fn result(context: usize, parent: tp.pid_ref, output: []const u8) void { + parent.send(.{ module_name, context, tag, output }) catch {}; + } + }.result, exit_null(tag)); +} + const module_name = @typeName(@This()); diff --git a/src/project_manager.zig b/src/project_manager.zig index 834b9c7..4e701a0 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -186,6 +186,13 @@ pub fn request_vcs_content(file_path: []const u8, vcs_id: []const u8) (ProjectMa return send(.{ "request_vcs_content", project, file_path, vcs_id }); } +pub fn request_vcs_blame(file_path: []const u8) (ProjectManagerError || ProjectError)!void { + const project = tp.env.get().str("project"); + if (project.len == 0) + return error.NoProject; + return send(.{ "request_vcs_blame", project, file_path }); +} + pub fn add_task(task: []const u8) (ProjectManagerError || ProjectError)!void { const project = tp.env.get().str("project"); if (project.len == 0) @@ -428,6 +435,9 @@ const Process = struct { } else if (try cbor.match(m.buf, .{ "git", tp.extract(&context), "cat_file", tp.more })) { const request: *Project.VcsContentRequest = @ptrFromInt(context); request.project.process_git_response(self.parent.ref(), m) catch |e| self.logger.err("git-cat-file", e); + } else if (try cbor.match(m.buf, .{ "git", tp.extract(&context), "blame", tp.more })) { + const request: *Project.GitBlameRequest = @ptrFromInt(context); + request.project.process_git_response(self.parent.ref(), m) catch |e| self.logger.err("git-blame", e); } else if (try cbor.match(m.buf, .{ "git", tp.extract(&context), tp.more })) { const project: *Project = @ptrFromInt(context); project.process_git(self.parent.ref(), m) catch {}; @@ -523,6 +533,8 @@ const Process = struct { return; } else if (try cbor.match(m.buf, .{ "exit", "error.LspFailed", tp.more })) { return; + } else if (try cbor.match(m.buf, .{ "request_vcs_blame", tp.extract(&project_directory), tp.extract(&path) })) { + self.request_vcs_blame(from, project_directory, path) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else { self.logger.err("receive", tp.unexpected(m)); } @@ -677,6 +689,11 @@ const Process = struct { try project.request_vcs_content(file_path, vcs_id); } + fn request_vcs_blame(self: *Process, _: tp.pid_ref, project_directory: []const u8, file_path: []const u8) (ProjectError || Project.RequestError)!void { + const project = self.projects.get(project_directory) orelse return error.NoProject; + try project.request_vcs_blame(file_path); + } + fn did_open(self: *Process, project_directory: []const u8, file_path: []const u8, file_type: []const u8, language_server: []const u8, language_server_options: []const u8, version: usize, text: []const u8) (ProjectError || Project.StartLspError || CallError || cbor.Error)!void { const frame = tracy.initZone(@src(), .{ .name = module_name ++ ".did_open" }); defer frame.deinit(); diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 78eaf9e..2de6c5a 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -1258,6 +1258,7 @@ pub const Editor = struct { self.render_whitespace_map(theme, ctx_.cell_map) catch {}; if (tui.config().inline_diagnostics) self.render_diagnostics(theme, hl_row, ctx_.cell_map) catch {}; + self.render_blame(theme, ctx_.cell_map) catch {}; self.render_column_highlights() catch {}; self.render_cursors(theme, ctx_.cell_map, focused) catch {}; } @@ -1455,6 +1456,44 @@ pub const Editor = struct { _ = self.plane.putc(&cell) catch {}; } + fn render_blame(self: *Self, theme: *const Widget.Theme, cell_map: CellMap) !void { + const cursor = self.get_primary().cursor; + const row_min = self.view.row; + const row_max = row_min + self.view.rows; + if (cursor.row < row_min or row_max < cursor.row) + return; + + const row = cursor.row - self.view.row; + const col = cursor.col; + + const screen_width = self.view.cols; + var style = theme.editor_hint; + style = .{ .fg = style.fg, .bg = theme.editor_hint.bg }; + + // @TODO: Get diff rows and edit this line number + const blame_name = self.buffer.?.get_line_blame(row) orelse return; + + self.plane.cursor_move_yx(@intCast(row), @intCast(col)); + self.render_diagnostic_cell(style); + + var space_begin = screen_width; + while (space_begin > 0) : (space_begin -= 1) + if (cell_map.get_yx(row, space_begin).cell_type != .empty) break; + if (screen_width > min_diagnostic_view_len and space_begin < screen_width - min_diagnostic_view_len) { + self.plane.set_style(style); + + // Opposite as diagnostics + switch (tui.config().inline_diagnostics_alignment) { + .right => { + const width = self.plane.window.width; + self.plane.cursor_move_yx(@intCast(row), @intCast(width -| (screen_width - space_begin) + 2)); + _ = self.plane.print("  {s}", .{blame_name}) catch 0; + }, + .left => _ = self.plane.print_aligned_right(@intCast(row), "  {s}", .{blame_name[0..@min(screen_width - space_begin - 3, blame_name.len)]}) catch {}, + } + } + } + inline fn render_selection_cell(_: *const Self, theme: *const Widget.Theme, cell: *Cell) void { cell.set_style_bg_opaque(theme.editor); cell.set_style_bg(theme.editor_selection); diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index c20cc97..c1ffade 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -2129,9 +2129,11 @@ pub fn vcs_id_update(self: *Self, m: tp.message) void { if (m.match(.{ "PRJ", "vcs_id", tp.extract(&file_path), tp.extract(&vcs_id) }) catch return) { const buffer = self.buffer_manager.get_buffer_for_file(file_path) orelse return; - const need_vcs_content = buffer.set_vcs_id(vcs_id) catch false; - if (need_vcs_content) + const vcs_id_updated = buffer.set_vcs_id(vcs_id) catch false; + if (vcs_id_updated) { project_manager.request_vcs_content(file_path, vcs_id) catch {}; + project_manager.request_vcs_blame(file_path) catch {}; + } } } @@ -2150,6 +2152,20 @@ pub fn vcs_content_update(self: *Self, m: tp.message) void { } } +pub fn vcs_blame_update(self: *Self, m: tp.message) void { + var file_path: []const u8 = undefined; + var blame_info: []const u8 = undefined; + if (m.match(.{ "PRJ", "git_blame", tp.extract(&file_path), tp.extract(&blame_info) }) catch return) { + const buffer = self.buffer_manager.get_buffer_for_file(file_path) orelse return; + buffer.set_vcs_blame(blame_info) catch {}; + } else if (m.match(.{ "PRJ", "git_blame", tp.extract(&file_path), tp.null_ }) catch return) { + const buffer = self.buffer_manager.get_buffer_for_file(file_path) orelse return; + buffer.parse_git_blame() catch return; + if (self.get_editor_for_buffer(buffer)) |editor| + editor.vcs_content_update() catch {}; + } +} + pub fn trigger_characters_update(self: *Self, m: tp.message) void { self.lsp_info.add_from_event(m.buf) catch return; self.foreach_editor(ed.Editor.update_completion_triggers); diff --git a/src/tui/tui.zig b/src/tui/tui.zig index dc0344e..fa79a67 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -538,6 +538,10 @@ fn receive_safe(self: *Self, from: tp.pid_ref, m: tp.message) !void { if (try m.match(.{ "PRJ", "vcs_content", tp.more })) return if (mainview()) |mv| mv.vcs_content_update(m); + if (try m.match(.{ "PRJ", "git_blame", tp.more })) { + return if (mainview()) |mv| mv.vcs_blame_update(m); + } + if (try m.match(.{ "PRJ", "triggerCharacters", tp.more })) return if (mainview()) |mv| mv.trigger_characters_update(m); From cb73153fd0b0cf486f2e64041ca01aaa7c9fbe6d Mon Sep 17 00:00:00 2001 From: Miguel Granero Date: Sun, 25 Jan 2026 17:43:24 +0100 Subject: [PATCH 2/9] feat: fix blame by offsetting current line with changes --- src/tui/editor.zig | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 2de6c5a..954656e 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -1456,6 +1456,27 @@ pub const Editor = struct { _ = self.plane.putc(&cell) catch {}; } + inline fn get_delta_lines_until_row(self: *const Self, row: usize) ?i32 { + var delta_lines: i32 = 0; + + for (self.changes.items) |change| { + if (change.line > row) + break; + + if (change.kind == .insert) + if (row >= change.line and row < change.line + change.lines) + return null; + + switch (change.kind) { + .insert => delta_lines -= @intCast(change.lines), + .delete => delta_lines += @intCast(change.lines), + else => {}, + } + } + + return delta_lines; + } + fn render_blame(self: *Self, theme: *const Widget.Theme, cell_map: CellMap) !void { const cursor = self.get_primary().cursor; const row_min = self.view.row; @@ -1470,8 +1491,12 @@ pub const Editor = struct { var style = theme.editor_hint; style = .{ .fg = style.fg, .bg = theme.editor_hint.bg }; - // @TODO: Get diff rows and edit this line number - const blame_name = self.buffer.?.get_line_blame(row) orelse return; + // Get delta line from HEAD version with diffs + const casted_row: i32 = @intCast(row); + const delta = self.get_delta_lines_until_row(row) orelse return; + const head_line: i32 = delta + casted_row; + if (head_line < 0) return; + const blame_name = self.buffer.?.get_line_blame(@intCast(head_line)) orelse return; self.plane.cursor_move_yx(@intCast(row), @intCast(col)); self.render_diagnostic_cell(style); From 7d1809ba57d6743d14dc2b4b95769d41e3197301 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 26 Jan 2026 21:38:15 +0100 Subject: [PATCH 3/9] refactor: switch git blame to incremental output mode Also, move blame parser and related structures to a new module VcsBlame. This includes a full parser re-write to take advantage of the slightly more efficient incremental output format. --- build.zig | 6 ++ src/VcsBlame.zig | 146 ++++++++++++++++++++++++++++++++++++++++++ src/buffer/Buffer.zig | 88 +++---------------------- src/git.zig | 2 +- src/tui/editor.zig | 7 +- src/tui/mainview.zig | 2 +- 6 files changed, 167 insertions(+), 84 deletions(-) create mode 100644 src/VcsBlame.zig diff --git a/build.zig b/build.zig index 88f12a0..4377796 100644 --- a/build.zig +++ b/build.zig @@ -418,6 +418,11 @@ pub fn build_exe( .imports = &.{}, }); + const VcsBlame_mod = b.createModule(.{ + .root_source_file = b.path("src/VcsBlame.zig"), + .imports = &.{}, + }); + const color_mod = b.createModule(.{ .root_source_file = b.path("src/color.zig"), }); @@ -449,6 +454,7 @@ pub fn build_exe( .{ .name = "TypedInt", .module = TypedInt_mod }, .{ .name = "vaxis", .module = vaxis_mod }, .{ .name = "file_type_config", .module = file_type_config_mod }, + .{ .name = "VcsBlame", .module = VcsBlame_mod }, }, }); diff --git a/src/VcsBlame.zig b/src/VcsBlame.zig new file mode 100644 index 0000000..f1cae51 --- /dev/null +++ b/src/VcsBlame.zig @@ -0,0 +1,146 @@ +commits: std.ArrayList(Commit) = .empty, +lines: std.ArrayList(?u16) = .empty, +content: std.ArrayList(u8) = .empty, + +pub const Commit = struct { + // {sha1} {src_line_no} {dst_line_no} {line_count} + id: []const u8 = &.{}, + + // author {text} + author: []const u8 = &.{}, + + // author-mail {email} + @"author-mail": []const u8 = &.{}, + + // author-time {timestamp} + @"author-time": i64 = 0, + + // author-tz {TZ} + @"author-tz": i16 = 0, + + // committer {text} + // committer-mail {email} + // committer-time {timestamp} + // committer-tz {TZ} + + // summary {text} + summary: []const u8 = &.{}, + + // previous {sha1} {filename} + // filename {filename} +}; + +pub fn getLine(self: *const @This(), line: usize) ?*const Commit { + if (line >= self.lines.items.len) return null; + const commit_no = self.lines.items[line] orelse return null; + return &self.commits.items[commit_no]; +} + +pub fn addContent(self: *@This(), allocator: std.mem.Allocator, content: []const u8) error{OutOfMemory}!void { + return self.content.appendSlice(allocator, content); +} + +pub const Error = error{ + OutOfMemory, + InvalidBlameCommitHash, + InvalidBlameCommitSrcLine, + InvalidBlameCommitLine, + InvalidBlameCommitLines, + InvalidBlameHeaderName, + InvalidBlameHeaderValue, + InvalidBlameLineNo, + InvalidBlameLines, + InvalidAuthorTime, + InvalidAuthorTz, +}; + +pub fn parse(self: *@This(), allocator: std.mem.Allocator) Error!void { + self.commits.deinit(allocator); + self.lines.deinit(allocator); + self.commits = .empty; + self.lines = .empty; + + var existing: std.StringHashMapUnmanaged(usize) = .empty; + defer existing.deinit(allocator); + + const headers = enum { author, @"author-mail", @"author-time", @"author-tz", summary, filename }; + + var state: enum { root, commit, headers } = .root; + var commit: Commit = .{}; + var line_no: usize = 0; + var lines: usize = 1; + + var it = std.mem.splitScalar(u8, self.content.items, '\n'); + while (it.next()) |line| { + top: switch (state) { + .root => { + commit = .{}; + line_no = 0; + lines = 0; + state = .commit; + continue :top .commit; + }, + .commit => { // 35be98f95ca999a112ad3aff0932be766f702e13 141 141 1 + var arg = std.mem.splitScalar(u8, line, ' '); + commit.id = arg.next() orelse return error.InvalidBlameCommitHash; + _ = arg.next() orelse return error.InvalidBlameCommitSrcLine; + line_no = std.fmt.parseInt(usize, arg.next() orelse return error.InvalidBlameCommitLine, 10) catch return error.InvalidBlameLineNo; + lines = std.fmt.parseInt(usize, arg.next() orelse return error.InvalidBlameCommitLines, 10) catch return error.InvalidBlameLines; + state = .headers; + }, + .headers => { + var arg = std.mem.splitScalar(u8, line, ' '); + const name = arg.next() orelse return error.InvalidBlameHeaderName; + if (name.len == line.len) return error.InvalidBlameHeaderValue; + const value = line[name.len + 1 ..]; + if (std.meta.stringToEnum(headers, name)) |header| switch (header) { + .author => { + commit.author = value; + }, + .@"author-mail" => { + commit.@"author-mail" = value; + }, + .@"author-time" => { + commit.@"author-time" = std.fmt.parseInt(@TypeOf(commit.@"author-time"), value, 10) catch return error.InvalidAuthorTime; + }, + .@"author-tz" => { + commit.@"author-tz" = std.fmt.parseInt(@TypeOf(commit.@"author-tz"), value, 10) catch return error.InvalidAuthorTz; + }, + .summary => { + commit.summary = value; + }, + .filename => { + line_no -|= 1; + const to_line_no = line_no + lines; + const commit_no: usize = if (existing.get(commit.id)) |n| n else blk: { + const n = self.commits.items.len; + try existing.put(allocator, commit.id, self.commits.items.len); + (try self.commits.addOne(allocator)).* = commit; + break :blk n; + }; + if (self.lines.items.len < to_line_no) { + try self.lines.ensureTotalCapacity(allocator, to_line_no); + while (self.lines.items.len < to_line_no) + (try self.lines.addOne(allocator)).* = null; + } + for (line_no..to_line_no) |ln| + self.lines.items[ln] = @intCast(commit_no); + + state = .root; + }, + }; + }, + } + } +} + +pub fn reset(self: *@This(), allocator: std.mem.Allocator) void { + self.commits.deinit(allocator); + self.lines.deinit(allocator); + self.content.deinit(allocator); + self.commits = .empty; + self.lines = .empty; + self.content = .empty; +} + +const std = @import("std"); diff --git a/src/buffer/Buffer.zig b/src/buffer/Buffer.zig index bdb2359..e43ce96 100644 --- a/src/buffer/Buffer.zig +++ b/src/buffer/Buffer.zig @@ -2,6 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const cbor = @import("cbor"); const TypedInt = @import("TypedInt"); +const VcsBlame = @import("VcsBlame"); const file_type_config = @import("file_type_config"); const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; @@ -55,8 +56,7 @@ meta: ?[]const u8 = null, lsp_version: usize = 1, vcs_id: ?[]const u8 = null, vcs_content: ?ArrayList(u8) = null, -vcs_blame: ?ArrayList(u8) = null, -vcs_blame_items: ArrayList(BlameLine) = .empty, +vcs_blame: VcsBlame = .{}, last_view: ?usize = null, undo_head: ?*UndoNode = null, @@ -1220,7 +1220,7 @@ pub fn create(allocator: Allocator) error{OutOfMemory}!*Self { pub fn deinit(self: *Self) void { self.clear_vcs_content(); self.clear_vcs_blame(); - self.vcs_blame_items.deinit(self.allocator); + self.vcs_blame.reset(self.external_allocator); if (self.vcs_id) |buf| self.external_allocator.free(buf); if (self.meta) |buf| self.external_allocator.free(buf); if (self.file_buf) |buf| self.external_allocator.free(buf); @@ -1298,89 +1298,19 @@ pub fn get_vcs_content(self: *const Self) ?[]const u8 { } pub fn clear_vcs_blame(self: *Self) void { - if (self.vcs_blame) |*buf| { - buf.deinit(self.external_allocator); - self.vcs_blame = null; - } + self.vcs_blame.reset(self.external_allocator); } -pub fn get_vcs_blame(self: *const Self) ?[]const u8 { - return if (self.vcs_blame) |*buf| buf.items else null; +pub fn get_vcs_blame(self: *const Self, line: usize) ?*const VcsBlame.Commit { + return self.vcs_blame.getLine(line); } pub fn set_vcs_blame(self: *Self, vcs_blame: []const u8) error{OutOfMemory}!void { - if (self.vcs_blame) |*al| { - try al.appendSlice(self.external_allocator, vcs_blame); - } else { - var al: ArrayList(u8) = .empty; - try al.appendSlice(self.external_allocator, vcs_blame); - self.vcs_blame = al; - } + return self.vcs_blame.addContent(self.external_allocator, vcs_blame); } -/// Can probably be optimized -pub fn parse_git_blame(self: *Self) !void { - self.vcs_blame_items.clearAndFree(self.allocator); - var stream = std.io.fixedBufferStream(self.vcs_blame.?.items); - var reader = stream.reader(); - var chunk_active = false; - var current_committer: []u8 = ""; - var current_stamp: usize = undefined; - var buffer: [200]u8 = undefined; - while (true) { - const line = reader.readUntilDelimiter(&buffer, '\n') catch { - break; - }; - - if (line.len == 0) - continue; - - if (line.len >= 40 and std.ascii.isHex(line[0])) { - if (chunk_active) - break; // error - - chunk_active = true; - var it = std.mem.splitScalar(u8, line, ' '); - - const sha = it.next() orelse continue; - if (sha.len != 40) continue; - - _ = it.next(); // original line (ignored) - const number = try std.fmt.parseInt( - i64, - it.next() orelse break, - 10, - ); - if (number != self.vcs_blame_items.items.len + 1) - break; - } else if (std.mem.startsWith(u8, line, "author ")) { - if (!chunk_active) - break; // error - current_committer = try self.allocator.dupe( - u8, - line["author ".len..], - ); - } else if (std.mem.startsWith(u8, line, "author-time ")) { - if (!chunk_active) - break; // error - current_stamp = try std.fmt.parseInt( - usize, - line["author-time ".len..], - 10, - ); - - const ptr = try self.vcs_blame_items.addOne(self.allocator); - ptr.* = BlameLine{ .author_name = current_committer, .author_stamp = current_stamp }; - chunk_active = false; - } - } -} - -pub fn get_line_blame(self: *const Self, line: usize) ?[]const u8 { - if (line + 1 > self.vcs_blame_items.items.len) - return null; - - return self.vcs_blame_items.items[line].author_name; +pub fn parse_vcs_blame(self: *Self) VcsBlame.Error!void { + return self.vcs_blame.parse(self.external_allocator); } pub fn update_last_used_time(self: *Self) void { diff --git a/src/git.zig b/src/git.zig index 60b08c6..79c8a3f 100644 --- a/src/git.zig +++ b/src/git.zig @@ -390,7 +390,7 @@ pub fn blame(context_: usize, file_path: []const u8) !void { try arg.writer.print("{s}", .{file_path}); try git(context_, .{ "blame", - "--line-porcelain", + "--incremental", "HEAD", "--", arg.written(), diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 954656e..e5bec74 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -1496,7 +1496,8 @@ pub const Editor = struct { const delta = self.get_delta_lines_until_row(row) orelse return; const head_line: i32 = delta + casted_row; if (head_line < 0) return; - const blame_name = self.buffer.?.get_line_blame(@intCast(head_line)) orelse return; + const commit = self.buffer.?.get_vcs_blame(@intCast(head_line)) orelse return; + const author = commit.author; self.plane.cursor_move_yx(@intCast(row), @intCast(col)); self.render_diagnostic_cell(style); @@ -1512,9 +1513,9 @@ pub const Editor = struct { .right => { const width = self.plane.window.width; self.plane.cursor_move_yx(@intCast(row), @intCast(width -| (screen_width - space_begin) + 2)); - _ = self.plane.print("  {s}", .{blame_name}) catch 0; + _ = self.plane.print("  {s}", .{author}) catch 0; }, - .left => _ = self.plane.print_aligned_right(@intCast(row), "  {s}", .{blame_name[0..@min(screen_width - space_begin - 3, blame_name.len)]}) catch {}, + .left => _ = self.plane.print_aligned_right(@intCast(row), "  {s}", .{author[0..@min(screen_width - space_begin - 3, author.len)]}) catch {}, } } } diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index c1ffade..36869a2 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -2160,7 +2160,7 @@ pub fn vcs_blame_update(self: *Self, m: tp.message) void { buffer.set_vcs_blame(blame_info) catch {}; } else if (m.match(.{ "PRJ", "git_blame", tp.extract(&file_path), tp.null_ }) catch return) { const buffer = self.buffer_manager.get_buffer_for_file(file_path) orelse return; - buffer.parse_git_blame() catch return; + buffer.parse_vcs_blame() catch return; if (self.get_editor_for_buffer(buffer)) |editor| editor.vcs_content_update() catch {}; } From c7df7e43d76315b05c9a86d45dcdea0332b5dd48 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 26 Jan 2026 21:54:34 +0100 Subject: [PATCH 4/9] refactor: simplify editor.render_blame --- src/tui/editor.zig | 50 +++++++++------------------------------------- 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index e5bec74..2f807a9 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -1456,55 +1456,23 @@ pub const Editor = struct { _ = self.plane.putc(&cell) catch {}; } - inline fn get_delta_lines_until_row(self: *const Self, row: usize) ?i32 { - var delta_lines: i32 = 0; - - for (self.changes.items) |change| { - if (change.line > row) - break; - - if (change.kind == .insert) - if (row >= change.line and row < change.line + change.lines) - return null; - - switch (change.kind) { - .insert => delta_lines -= @intCast(change.lines), - .delete => delta_lines += @intCast(change.lines), - else => {}, - } - } - - return delta_lines; - } - fn render_blame(self: *Self, theme: *const Widget.Theme, cell_map: CellMap) !void { const cursor = self.get_primary().cursor; - const row_min = self.view.row; - const row_max = row_min + self.view.rows; - if (cursor.row < row_min or row_max < cursor.row) - return; + const pos = self.screen_cursor(&cursor) orelse return; + const buffer = self.buffer orelse return; + const commit = buffer.get_vcs_blame(cursor.row) orelse return; + const author = commit.author; - const row = cursor.row - self.view.row; - const col = cursor.col; - - const screen_width = self.view.cols; var style = theme.editor_hint; style = .{ .fg = style.fg, .bg = theme.editor_hint.bg }; - // Get delta line from HEAD version with diffs - const casted_row: i32 = @intCast(row); - const delta = self.get_delta_lines_until_row(row) orelse return; - const head_line: i32 = delta + casted_row; - if (head_line < 0) return; - const commit = self.buffer.?.get_vcs_blame(@intCast(head_line)) orelse return; - const author = commit.author; - - self.plane.cursor_move_yx(@intCast(row), @intCast(col)); + self.plane.cursor_move_yx(@intCast(pos.row), @intCast(pos.col)); self.render_diagnostic_cell(style); + const screen_width = self.view.cols; var space_begin = screen_width; while (space_begin > 0) : (space_begin -= 1) - if (cell_map.get_yx(row, space_begin).cell_type != .empty) break; + if (cell_map.get_yx(pos.row, space_begin).cell_type != .empty) break; if (screen_width > min_diagnostic_view_len and space_begin < screen_width - min_diagnostic_view_len) { self.plane.set_style(style); @@ -1512,10 +1480,10 @@ pub const Editor = struct { switch (tui.config().inline_diagnostics_alignment) { .right => { const width = self.plane.window.width; - self.plane.cursor_move_yx(@intCast(row), @intCast(width -| (screen_width - space_begin) + 2)); + self.plane.cursor_move_yx(@intCast(pos.row), @intCast(width -| (screen_width - space_begin) + 2)); _ = self.plane.print("  {s}", .{author}) catch 0; }, - .left => _ = self.plane.print_aligned_right(@intCast(row), "  {s}", .{author[0..@min(screen_width - space_begin - 3, author.len)]}) catch {}, + .left => _ = self.plane.print_aligned_right(@intCast(pos.row), "  {s}", .{author[0..@min(screen_width - space_begin - 3, author.len)]}) catch {}, } } } From ed45b68de8fbafd0f799ac388dd72fb24f26afe4 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 26 Jan 2026 22:09:59 +0100 Subject: [PATCH 5/9] fix: blame background style with highlight_current_line enabled --- src/tui/editor.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 2f807a9..5743c10 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -1258,7 +1258,7 @@ pub const Editor = struct { self.render_whitespace_map(theme, ctx_.cell_map) catch {}; if (tui.config().inline_diagnostics) self.render_diagnostics(theme, hl_row, ctx_.cell_map) catch {}; - self.render_blame(theme, ctx_.cell_map) catch {}; + self.render_blame(theme, hl_row, ctx_.cell_map) catch {}; self.render_column_highlights() catch {}; self.render_cursors(theme, ctx_.cell_map, focused) catch {}; } @@ -1456,7 +1456,7 @@ pub const Editor = struct { _ = self.plane.putc(&cell) catch {}; } - fn render_blame(self: *Self, theme: *const Widget.Theme, cell_map: CellMap) !void { + fn render_blame(self: *Self, theme: *const Widget.Theme, hl_row: ?usize, cell_map: CellMap) !void { const cursor = self.get_primary().cursor; const pos = self.screen_cursor(&cursor) orelse return; const buffer = self.buffer orelse return; @@ -1464,7 +1464,7 @@ pub const Editor = struct { const author = commit.author; var style = theme.editor_hint; - style = .{ .fg = style.fg, .bg = theme.editor_hint.bg }; + style = .{ .fg = style.fg, .bg = if (hl_row) |_| theme.editor_line_highlight.bg else theme.editor_hint.bg }; self.plane.cursor_move_yx(@intCast(pos.row), @intCast(pos.col)); self.render_diagnostic_cell(style); From 87b5c7e2a568991665b9a15db6322b9a0ee6a85d Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 26 Jan 2026 22:16:14 +0100 Subject: [PATCH 6/9] fix: git blame causes underline at cursor --- src/tui/editor.zig | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 5743c10..e4b9eb2 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -1463,18 +1463,16 @@ pub const Editor = struct { const commit = buffer.get_vcs_blame(cursor.row) orelse return; const author = commit.author; - var style = theme.editor_hint; - style = .{ .fg = style.fg, .bg = if (hl_row) |_| theme.editor_line_highlight.bg else theme.editor_hint.bg }; - - self.plane.cursor_move_yx(@intCast(pos.row), @intCast(pos.col)); - self.render_diagnostic_cell(style); - const screen_width = self.view.cols; var space_begin = screen_width; while (space_begin > 0) : (space_begin -= 1) if (cell_map.get_yx(pos.row, space_begin).cell_type != .empty) break; if (screen_width > min_diagnostic_view_len and space_begin < screen_width - min_diagnostic_view_len) { - self.plane.set_style(style); + self.plane.set_style(.{ + .fg = theme.editor_hint.fg, + .fs = theme.editor_hint.fs, + .bg = if (hl_row) |_| theme.editor_line_highlight.bg else theme.editor_hint.bg, + }); // Opposite as diagnostics switch (tui.config().inline_diagnostics_alignment) { From 071495735766e3a79bb5047df6698830c046c24f Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 26 Jan 2026 22:18:40 +0100 Subject: [PATCH 7/9] feat: add config option to toggle inline vcs blame --- src/config.zig | 1 + src/tui/editor.zig | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config.zig b/src/config.zig index b58dfab..b501336 100644 --- a/src/config.zig +++ b/src/config.zig @@ -19,6 +19,7 @@ highlight_columns_enabled: bool = false, whitespace_mode: WhitespaceMode = .indent, inline_diagnostics: bool = true, inline_diagnostics_alignment: Alignment = .right, +inline_vcs_blame: bool = false, animation_min_lag: usize = 0, //milliseconds animation_max_lag: usize = 50, //milliseconds hover_time_ms: usize = 500, //milliseconds diff --git a/src/tui/editor.zig b/src/tui/editor.zig index e4b9eb2..6a46ec5 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -1258,7 +1258,8 @@ pub const Editor = struct { self.render_whitespace_map(theme, ctx_.cell_map) catch {}; if (tui.config().inline_diagnostics) self.render_diagnostics(theme, hl_row, ctx_.cell_map) catch {}; - self.render_blame(theme, hl_row, ctx_.cell_map) catch {}; + if (tui.config().inline_vcs_blame) + self.render_blame(theme, hl_row, ctx_.cell_map) catch {}; self.render_column_highlights() catch {}; self.render_cursors(theme, ctx_.cell_map, focused) catch {}; } From ab8d317bbc2d5ff7d157a4fbe6530fda0f945829 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 26 Jan 2026 22:21:49 +0100 Subject: [PATCH 8/9] feat: add toggle_inline_vcs_blame command --- src/tui/mainview.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 36869a2..754d243 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1005,6 +1005,13 @@ const cmds = struct { } pub const gutter_style_next_meta: Meta = .{ .description = "Next line number style" }; + pub fn toggle_inline_vcs_blame(_: *Self, _: Ctx) Result { + const config = tui.config_mut(); + config.inline_vcs_blame = !config.inline_vcs_blame; + try tui.save_config(); + } + pub const toggle_inline_vcs_blame_meta: Meta = .{ .description = "Toggle inline VCS blame info" }; + pub fn toggle_inline_diagnostics(_: *Self, _: Ctx) Result { const config = tui.config_mut(); config.inline_diagnostics = !config.inline_diagnostics; From 049cecdae6c469678651e3913cb04e396bdd6b23 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 26 Jan 2026 22:28:57 +0100 Subject: [PATCH 9/9] feat: add commit summary to blame message --- src/tui/editor.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 6a46ec5..1224b13 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -1462,7 +1462,11 @@ pub const Editor = struct { const pos = self.screen_cursor(&cursor) orelse return; const buffer = self.buffer orelse return; const commit = buffer.get_vcs_blame(cursor.row) orelse return; - const author = commit.author; + + var buf: std.Io.Writer.Allocating = .init(self.allocator); + defer buf.deinit(); + _ = buf.writer.print("  {s}, {s}", .{ commit.summary, commit.author }) catch 0; + const msg = buf.written(); const screen_width = self.view.cols; var space_begin = screen_width; @@ -1480,9 +1484,9 @@ pub const Editor = struct { .right => { const width = self.plane.window.width; self.plane.cursor_move_yx(@intCast(pos.row), @intCast(width -| (screen_width - space_begin) + 2)); - _ = self.plane.print("  {s}", .{author}) catch 0; + _ = self.plane.print("{s}", .{msg}) catch 0; }, - .left => _ = self.plane.print_aligned_right(@intCast(pos.row), "  {s}", .{author[0..@min(screen_width - space_begin - 3, author.len)]}) catch {}, + .left => _ = self.plane.print_aligned_right(@intCast(pos.row), "{s}", .{msg[0..@min(screen_width - space_begin, msg.len)]}) catch {}, } } }