Compare commits

...

9 commits

Author SHA1 Message Date
049cecdae6 feat: add commit summary to blame message 2026-01-26 22:31:18 +01:00
ab8d317bbc feat: add toggle_inline_vcs_blame command 2026-01-26 22:31:18 +01:00
0714957357 feat: add config option to toggle inline vcs blame 2026-01-26 22:31:18 +01:00
87b5c7e2a5 fix: git blame causes underline at cursor 2026-01-26 22:31:18 +01:00
ed45b68de8 fix: blame background style with highlight_current_line enabled 2026-01-26 22:31:18 +01:00
c7df7e43d7 refactor: simplify editor.render_blame 2026-01-26 22:31:18 +01:00
7d1809ba57 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.
2026-01-26 22:31:18 +01:00
Miguel Granero
cb73153fd0 feat: fix blame by offsetting current line with changes 2026-01-26 22:31:18 +01:00
Miguel Granero
90a817066a feat: add git blame with inline display (wip) 2026-01-26 22:31:18 +01:00
10 changed files with 310 additions and 2 deletions

View file

@ -418,6 +418,11 @@ pub fn build_exe(
.imports = &.{}, .imports = &.{},
}); });
const VcsBlame_mod = b.createModule(.{
.root_source_file = b.path("src/VcsBlame.zig"),
.imports = &.{},
});
const color_mod = b.createModule(.{ const color_mod = b.createModule(.{
.root_source_file = b.path("src/color.zig"), .root_source_file = b.path("src/color.zig"),
}); });
@ -449,6 +454,7 @@ pub fn build_exe(
.{ .name = "TypedInt", .module = TypedInt_mod }, .{ .name = "TypedInt", .module = TypedInt_mod },
.{ .name = "vaxis", .module = vaxis_mod }, .{ .name = "vaxis", .module = vaxis_mod },
.{ .name = "file_type_config", .module = file_type_config_mod }, .{ .name = "file_type_config", .module = file_type_config_mod },
.{ .name = "VcsBlame", .module = VcsBlame_mod },
}, },
}); });

View file

@ -2861,6 +2861,8 @@ pub fn process_git_response(self: *Self, parent: tp.pid_ref, m: tp.message) (Out
var context: usize = undefined; var context: usize = undefined;
var vcs_id: []const u8 = undefined; var vcs_id: []const u8 = undefined;
var vcs_content: []const u8 = undefined; var vcs_content: []const u8 = undefined;
var blame_output: []const u8 = undefined;
_ = self; _ = self;
if (try m.match(.{ tp.any, tp.extract(&context), "rev_parse", tp.extract(&vcs_id) })) { 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); const request: *VcsContentRequest = @ptrFromInt(context);
defer request.deinit(); defer request.deinit();
parent.send(.{ "PRJ", "vcs_content", request.file_path, request.vcs_id, null }) catch {}; 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);
}
};

146
src/VcsBlame.zig Normal file
View file

@ -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");

View file

@ -2,6 +2,7 @@ const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const cbor = @import("cbor"); const cbor = @import("cbor");
const TypedInt = @import("TypedInt"); const TypedInt = @import("TypedInt");
const VcsBlame = @import("VcsBlame");
const file_type_config = @import("file_type_config"); const file_type_config = @import("file_type_config");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArrayList = std.ArrayList; const ArrayList = std.ArrayList;
@ -31,6 +32,11 @@ pub const Metrics = struct {
pub const egc_last_func = *const fn (self: Metrics, egcs: []const u8) []const u8; 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, arena: std.heap.ArenaAllocator,
allocator: Allocator, allocator: Allocator,
external_allocator: Allocator, external_allocator: Allocator,
@ -50,6 +56,7 @@ meta: ?[]const u8 = null,
lsp_version: usize = 1, lsp_version: usize = 1,
vcs_id: ?[]const u8 = null, vcs_id: ?[]const u8 = null,
vcs_content: ?ArrayList(u8) = null, vcs_content: ?ArrayList(u8) = null,
vcs_blame: VcsBlame = .{},
last_view: ?usize = null, last_view: ?usize = null,
undo_head: ?*UndoNode = null, undo_head: ?*UndoNode = null,
@ -1212,6 +1219,8 @@ pub fn create(allocator: Allocator) error{OutOfMemory}!*Self {
pub fn deinit(self: *Self) void { pub fn deinit(self: *Self) void {
self.clear_vcs_content(); self.clear_vcs_content();
self.clear_vcs_blame();
self.vcs_blame.reset(self.external_allocator);
if (self.vcs_id) |buf| self.external_allocator.free(buf); if (self.vcs_id) |buf| self.external_allocator.free(buf);
if (self.meta) |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); 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.external_allocator.free(old_id);
} }
self.clear_vcs_content(); self.clear_vcs_content();
self.clear_vcs_blame();
self.vcs_id = try self.external_allocator.dupe(u8, vcs_id); self.vcs_id = try self.external_allocator.dupe(u8, vcs_id);
return true; return true;
} }
@ -1287,6 +1297,22 @@ pub fn get_vcs_content(self: *const Self) ?[]const u8 {
return if (self.vcs_content) |*buf| buf.items else null; return if (self.vcs_content) |*buf| buf.items else null;
} }
pub fn clear_vcs_blame(self: *Self) void {
self.vcs_blame.reset(self.external_allocator);
}
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 {
return self.vcs_blame.addContent(self.external_allocator, vcs_blame);
}
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 { pub fn update_last_used_time(self: *Self) void {
self.utime = std.time.milliTimestamp(); self.utime = std.time.milliTimestamp();
} }

View file

@ -19,6 +19,7 @@ highlight_columns_enabled: bool = false,
whitespace_mode: WhitespaceMode = .indent, whitespace_mode: WhitespaceMode = .indent,
inline_diagnostics: bool = true, inline_diagnostics: bool = true,
inline_diagnostics_alignment: Alignment = .right, inline_diagnostics_alignment: Alignment = .right,
inline_vcs_blame: bool = false,
animation_min_lag: usize = 0, //milliseconds animation_min_lag: usize = 0, //milliseconds
animation_max_lag: usize = 50, //milliseconds animation_max_lag: usize = 50, //milliseconds
hover_time_ms: usize = 500, //milliseconds hover_time_ms: usize = 500, //milliseconds

View file

@ -383,4 +383,22 @@ fn get_git() ?[]const u8 {
return path; 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",
"--incremental",
"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()); const module_name = @typeName(@This());

View file

@ -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 }); 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 { pub fn add_task(task: []const u8) (ProjectManagerError || ProjectError)!void {
const project = tp.env.get().str("project"); const project = tp.env.get().str("project");
if (project.len == 0) 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 })) { } else if (try cbor.match(m.buf, .{ "git", tp.extract(&context), "cat_file", tp.more })) {
const request: *Project.VcsContentRequest = @ptrFromInt(context); const request: *Project.VcsContentRequest = @ptrFromInt(context);
request.project.process_git_response(self.parent.ref(), m) catch |e| self.logger.err("git-cat-file", e); 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 })) { } else if (try cbor.match(m.buf, .{ "git", tp.extract(&context), tp.more })) {
const project: *Project = @ptrFromInt(context); const project: *Project = @ptrFromInt(context);
project.process_git(self.parent.ref(), m) catch {}; project.process_git(self.parent.ref(), m) catch {};
@ -523,6 +533,8 @@ const Process = struct {
return; return;
} else if (try cbor.match(m.buf, .{ "exit", "error.LspFailed", tp.more })) { } else if (try cbor.match(m.buf, .{ "exit", "error.LspFailed", tp.more })) {
return; 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 { } else {
self.logger.err("receive", tp.unexpected(m)); self.logger.err("receive", tp.unexpected(m));
} }
@ -677,6 +689,11 @@ const Process = struct {
try project.request_vcs_content(file_path, vcs_id); 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 { 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" }); const frame = tracy.initZone(@src(), .{ .name = module_name ++ ".did_open" });
defer frame.deinit(); defer frame.deinit();

View file

@ -1258,6 +1258,8 @@ pub const Editor = struct {
self.render_whitespace_map(theme, ctx_.cell_map) catch {}; self.render_whitespace_map(theme, ctx_.cell_map) catch {};
if (tui.config().inline_diagnostics) if (tui.config().inline_diagnostics)
self.render_diagnostics(theme, hl_row, ctx_.cell_map) catch {}; self.render_diagnostics(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_column_highlights() catch {};
self.render_cursors(theme, ctx_.cell_map, focused) catch {}; self.render_cursors(theme, ctx_.cell_map, focused) catch {};
} }
@ -1455,6 +1457,40 @@ pub const Editor = struct {
_ = self.plane.putc(&cell) catch {}; _ = self.plane.putc(&cell) catch {};
} }
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;
const commit = buffer.get_vcs_blame(cursor.row) orelse return;
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;
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(.{
.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) {
.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}", .{msg}) catch 0;
},
.left => _ = self.plane.print_aligned_right(@intCast(pos.row), "{s}", .{msg[0..@min(screen_width - space_begin, msg.len)]}) catch {},
}
}
}
inline fn render_selection_cell(_: *const Self, theme: *const Widget.Theme, cell: *Cell) void { 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_opaque(theme.editor);
cell.set_style_bg(theme.editor_selection); cell.set_style_bg(theme.editor_selection);

View file

@ -1005,6 +1005,13 @@ const cmds = struct {
} }
pub const gutter_style_next_meta: Meta = .{ .description = "Next line number style" }; 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 { pub fn toggle_inline_diagnostics(_: *Self, _: Ctx) Result {
const config = tui.config_mut(); const config = tui.config_mut();
config.inline_diagnostics = !config.inline_diagnostics; config.inline_diagnostics = !config.inline_diagnostics;
@ -2129,9 +2136,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) { 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 buffer = self.buffer_manager.get_buffer_for_file(file_path) orelse return;
const need_vcs_content = buffer.set_vcs_id(vcs_id) catch false; const vcs_id_updated = buffer.set_vcs_id(vcs_id) catch false;
if (need_vcs_content) if (vcs_id_updated) {
project_manager.request_vcs_content(file_path, vcs_id) catch {}; project_manager.request_vcs_content(file_path, vcs_id) catch {};
project_manager.request_vcs_blame(file_path) catch {};
}
} }
} }
@ -2150,6 +2159,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_vcs_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 { pub fn trigger_characters_update(self: *Self, m: tp.message) void {
self.lsp_info.add_from_event(m.buf) catch return; self.lsp_info.add_from_event(m.buf) catch return;
self.foreach_editor(ed.Editor.update_completion_triggers); self.foreach_editor(ed.Editor.update_completion_triggers);

View file

@ -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 })) if (try m.match(.{ "PRJ", "vcs_content", tp.more }))
return if (mainview()) |mv| mv.vcs_content_update(m); 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 })) if (try m.match(.{ "PRJ", "triggerCharacters", tp.more }))
return if (mainview()) |mv| mv.trigger_characters_update(m); return if (mainview()) |mv| mv.trigger_characters_update(m);