From 9c1d1cb5572df4dbc6999551364fef8107699da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20T=C3=A1mara?= Date: Wed, 22 Oct 2025 19:31:28 -0500 Subject: [PATCH 1/8] feat: untracked and modified files are identified by git Git service offers untracked files and modified ones staged or not filtering out any other statuses and marking if new or modified. This includes renamed files. --- src/git.zig | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/git.zig b/src/git.zig index daf28c8..8cd2850 100644 --- a/src/git.zig +++ b/src/git.zig @@ -174,6 +174,105 @@ pub fn status(context_: usize) Error!void { }.result, exit_null(tag)); } +pub fn new_or_modified_files(context_: usize) Error!void { + const tag = @src().fn_name; + try git_err(context_, .{ + "--no-optional-locks", + "status", + "--porcelain=v2", + "--null", + }, struct { + fn result(context: usize, parent: tp.pid_ref, output: []const u8) void { + var it_ = std.mem.splitScalar(u8, output, 0); + var counter: u8 = 0; + + while (it_.next()) |line| { + var it = std.mem.splitScalar(u8, line, ' '); + const rec_type = if (it.next()) |type_tag| + std.meta.stringToEnum(StatusRecordType, type_tag) orelse { + if (type_tag.len > 0) + std.log.debug("found {s}, it happens when a file is renamed and not modified. Check `git --no-optional-locks status --porcelain=v2`", .{type_tag}); + continue; + } + else + return; + switch (rec_type) { + .@"1" => { // ordinary file: + const sub = it.next() orelse return; + const mH = it.next() orelse return; + var vcs_status: u8 = undefined; + if (sub[0] == 'A') { + // New staged file is shown as new + vcs_status = '+'; + } else if (sub[0] == 'M' or sub[1] == 'M') { + if (mH[0] == 'S') { + // We do not handle submodules, yet + continue; + } + vcs_status = '~'; + } else { + // We will not edit deleted files + continue; + } + + for (0..5) |_| { + _ = it.next() orelse return; + } + var path: std.ArrayListUnmanaged(u8) = .empty; + defer path.deinit(allocator); + while (it.next()) |path_part| { + if (path.items.len > 0) path.append(allocator, ' ') catch return; + path.appendSlice(allocator, path_part) catch return; + } + + parent.send(.{ module_name, context, tag, vcs_status, path.items }) catch {}; + counter += 1; + }, + .@"2" => { + const sub = it.next() orelse return; + if (sub[0] != 'R') { + continue; + } + // An staged file is editable + // renamed: + for (0..7) |_| { + _ = it.next() orelse return; + } + var path: std.ArrayListUnmanaged(u8) = .empty; + defer path.deinit(allocator); + while (it.next()) |path_part| { + if (path.items.len > 0) path.append(allocator, ' ') catch return; + path.appendSlice(allocator, path_part) catch return; + } + parent.send(.{ module_name, context, tag, '+', path.items }) catch {}; + counter += 1; + }, + .@"?" => { // untracked file: + var path: std.ArrayListUnmanaged(u8) = .empty; + defer path.deinit(allocator); + while (it.next()) |path_part| { + if (path.items.len > 0) path.append(allocator, ' ') catch return; + path.appendSlice(allocator, path_part) catch return; + } + parent.send(.{ module_name, context, tag, '+', path.items }) catch {}; + counter += 1; + }, + else => { + // Omit showing other statuses + }, + } + } + std.log.info("git: {} changed files", .{counter}); + } + }.result, struct { + fn result(_: usize, _: tp.pid_ref, output: []const u8) void { + var it = std.mem.splitScalar(u8, output, '\n'); + while (it.next()) |line| if (line.len > 0) + std.log.err("{s}: {s}", .{ module_name, line }); + } + }.result, exit_null(tag)); +} + fn git_line_output(context_: usize, comptime tag: []const u8, cmd: anytype) Error!void { try git_err(context_, cmd, struct { fn result(context: usize, parent: tp.pid_ref, output: []const u8) void { From 52609ab198d7a61fc76c3bf8bb4393902e730d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20T=C3=A1mara?= Date: Sat, 25 Oct 2025 07:54:41 -0500 Subject: [PATCH 2/8] feat: Project makes available modified and untracked files Project exposes functions to query and return modified and untracked files informed by git. --- src/Project.zig | 112 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/Project.zig b/src/Project.zig index e71c915..67060a2 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -19,8 +19,10 @@ const walk_tree = @import("walk_tree.zig"); allocator: std.mem.Allocator, name: []const u8, files: std.ArrayListUnmanaged(File) = .empty, +new_or_modified_files: std.ArrayListUnmanaged(FileVcsStatus) = .empty, pending: std.ArrayListUnmanaged(File) = .empty, longest_file_path: usize = 0, +longest_new_or_modified_file_path: usize = 0, open_time: i64, language_servers: std.StringHashMap(*const LSP), file_language_server_name: std.StringHashMap([]const u8), @@ -41,6 +43,7 @@ state: struct { current_branch: State = .none, workspace_files: State = .none, status: State = .none, + vcs_new_or_modified_files: State = .none, } = .{}, status: VcsStatus = .{}, @@ -66,6 +69,14 @@ const File = struct { visited: bool = false, }; +const FileVcsStatus = struct { + path: []const u8, + type: []const u8, + icon: []const u8, + color: u24, + vcs_status: u8, +}; + pub const FilePos = struct { row: usize = 0, col: usize = 0, @@ -105,6 +116,8 @@ pub fn deinit(self: *Self) void { self.allocator.free(p.key_ptr.*); p.value_ptr.*.term(); } + for (self.new_or_modified_files.items) |file| self.allocator.free(file.path); + self.new_or_modified_files.deinit(self.allocator); for (self.files.items) |file| self.allocator.free(file.path); self.files.deinit(self.allocator); self.pending.deinit(self.allocator); @@ -372,6 +385,84 @@ pub fn request_recent_files(self: *Self, from: tp.pid_ref, max: usize) ClientErr } } +fn simple_query_new_or_modified_files(self: *Self, from: tp.pid_ref, max: usize, query: []const u8) ClientError!usize { + var i: usize = 0; + defer from.send(.{ "PRJ", "new_or_modified_files_done", self.longest_file_path, query }) catch {}; + for (self.new_or_modified_files.items) |file| { + if (file.path.len < query.len) continue; + if (std.mem.indexOf(u8, file.path, query)) |idx| { + var matches = try self.allocator.alloc(usize, query.len); + defer self.allocator.free(matches); + var n: usize = 0; + while (n < query.len) : (n += 1) matches[n] = idx + n; + from.send(.{ "PRJ", "new_or_modified_files", self.longest_new_or_modified_file_path, file.path, file.type, file.icon, file.color, file.vcs_status, matches }) catch return error.ClientFailed; + i += 1; + if (i >= max) return i; + } + } + return i; +} + +pub fn query_new_or_modified_files(self: *Self, from: tp.pid_ref, max: usize, query: []const u8) ClientError!usize { + if (query.len < 3) + return self.simple_query_new_or_modified_files(from, max, query); + defer from.send(.{ "PRJ", "new_or_modified_files_done", self.longest_file_path, query }) catch {}; + + var searcher = try fuzzig.Ascii.init( + self.allocator, + 4096, // haystack max size + 4096, // needle max size + .{ .case_sensitive = false }, + ); + defer searcher.deinit(); + + const Match = struct { + path: []const u8, + type: []const u8, + icon: []const u8, + color: u24, + vcs_status: u8, + score: i32, + matches: []const usize, + }; + var matches: std.ArrayList(Match) = .empty; + + for (self.new_or_modified_files.items) |file| { + const match = searcher.scoreMatches(file.path, query); + if (match.score) |score| { + (try matches.addOne(self.allocator)).* = .{ + .path = file.path, + .type = file.type, + .icon = file.icon, + .color = file.color, + .vcs_status = file.vcs_status, + .score = score, + .matches = try self.allocator.dupe(usize, match.matches), + }; + } + } + if (matches.items.len == 0) return 0; + + const less_fn = struct { + fn less_fn(_: void, lhs: Match, rhs: Match) bool { + return lhs.score > rhs.score; + } + }.less_fn; + std.mem.sort(Match, matches.items, {}, less_fn); + + for (matches.items[0..@min(max, matches.items.len)]) |match| + from.send(.{ "PRJ", "new_or_modified_files", self.longest_new_or_modified_file_path, match.path, match.type, match.icon, match.color, match.vcs_status, match.matches }) catch return error.ClientFailed; + return @min(max, matches.items.len); +} + +pub fn request_new_or_modified_files(self: *Self, from: tp.pid_ref, max: usize) ClientError!void { + defer from.send(.{ "PRJ", "new_or_modified_files_done", self.longest_new_or_modified_file_path, "" }) catch {}; + for (self.new_or_modified_files.items, 0..) |file, i| { + from.send(.{ "PRJ", "new_or_modified_files", self.longest_new_or_modified_file_path, file.path, file.type, file.icon, file.color, file.vcs_status }) catch return error.ClientFailed; + if (i >= max) return; + } +} + fn simple_query_recent_files(self: *Self, from: tp.pid_ref, max: usize, query: []const u8) ClientError!usize { var i: usize = 0; defer from.send(.{ "PRJ", "recent_done", self.longest_file_path, query }) catch {}; @@ -544,6 +635,8 @@ fn loaded(self: *Self, parent: tp.pid_ref) OutOfMemoryError!void { std.time.milliTimestamp() - self.open_time, }); + self.logger.print("vcs outstanding files: {d}", .{self.new_or_modified_files.items.len}); + parent.send(.{ "PRJ", "open_done", self.name, self.longest_file_path, self.files.items.len }) catch {}; } @@ -2080,6 +2173,11 @@ pub fn query_git(self: *Self) void { git.status(@intFromPtr(self)) catch { self.state.status = .failed; }; + // TODO: This needs to be invoked when there are identified changes in the fs + self.state.vcs_new_or_modified_files = .running; + git.new_or_modified_files(@intFromPtr(self)) catch { + self.state.vcs_new_or_modified_files = .failed; + }; } fn start_walker(self: *Self) void { @@ -2093,6 +2191,7 @@ fn start_walker(self: *Self) void { pub fn process_git(self: *Self, parent: tp.pid_ref, m: tp.message) (OutOfMemoryError || error{Exit})!void { var value: []const u8 = undefined; var path: []const u8 = undefined; + var vcs_status: u8 = undefined; if (try m.match(.{ tp.any, tp.any, "status", tp.more })) { return self.process_status(m); } else if (try m.match(.{ tp.any, tp.any, "workspace_path", tp.null_ })) { @@ -2131,6 +2230,19 @@ pub fn process_git(self: *Self, parent: tp.pid_ref, m: tp.message) (OutOfMemoryE } else if (try m.match(.{ tp.any, tp.any, "workspace_files", tp.null_ })) { self.state.workspace_files = .done; try self.loaded(parent); + } else if (try m.match(.{ tp.any, tp.any, "new_or_modified_files", tp.null_ })) { + self.state.vcs_new_or_modified_files = .done; + try self.loaded(parent); + } else if (try m.match(.{ tp.any, tp.any, "new_or_modified_files", tp.extract(&vcs_status), tp.extract(&path) })) { + self.longest_new_or_modified_file_path = @max(self.longest_new_or_modified_file_path, path.len); + const file_type: []const u8, const file_icon: []const u8, const file_color: u24 = guess_file_type(path); + (try self.new_or_modified_files.addOne(self.allocator)).* = .{ + .path = try self.allocator.dupe(u8, path), + .type = file_type, + .icon = file_icon, + .color = file_color, + .vcs_status = vcs_status, + }; } else { self.logger_git.err("git", tp.unexpected(m)); } From 81a6fef3b54f0cfbf69e5258a765cbf9fdcfd724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20T=C3=A1mara?= Date: Sat, 25 Oct 2025 08:01:18 -0500 Subject: [PATCH 3/8] feat: Project manager exposes new and modified files for the current project --- src/project_manager.zig | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/project_manager.zig b/src/project_manager.zig index 46cd226..14a3d8e 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -98,6 +98,13 @@ pub fn request_recent_files(max: usize) (ProjectManagerError || ProjectError)!vo return send(.{ "request_recent_files", project, max }); } +pub fn request_new_or_modified_files(max: usize) (ProjectManagerError || ProjectError)!void { + const project = tp.env.get().str("project"); + if (project.len == 0) + return error.NoProject; + return send(.{ "request_new_or_modified_files", project, max }); +} + pub fn request_recent_projects() (ProjectManagerError || ProjectError)!void { const project = tp.env.get().str("project"); return send(.{ "request_recent_projects", project }); @@ -110,6 +117,13 @@ pub fn query_recent_files(max: usize, query: []const u8) (ProjectManagerError || return send(.{ "query_recent_files", project, max, query }); } +pub fn query_new_or_modified_files(max: usize, query: []const u8) (ProjectManagerError || ProjectError)!void { + const project = tp.env.get().str("project"); + if (project.len == 0) + return error.NoProject; + return send(.{ "query_new_or_modified_files", project, max, query }); +} + pub fn request_path_files(max: usize, path: []const u8) (ProjectManagerError || ProjectError)!void { const project = tp.env.get().str("project"); if (project.len == 0) @@ -370,10 +384,14 @@ const Process = struct { self.request_n_most_recent_file(from, project_directory, n) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "request_recent_files", tp.extract(&project_directory), tp.extract(&max) })) { self.request_recent_files(from, project_directory, max) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; + } else if (try cbor.match(m.buf, .{ "request_new_or_modified_files", tp.extract(&project_directory), tp.extract(&max) })) { + self.request_new_or_modified_files(from, project_directory, max) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "request_recent_projects", tp.extract(&project_directory) })) { self.request_recent_projects(from, project_directory) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "query_recent_files", tp.extract(&project_directory), tp.extract(&max), tp.extract(&query) })) { self.query_recent_files(from, project_directory, max, query) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; + } else if (try cbor.match(m.buf, .{ "query_new_or_modified_files", tp.extract(&project_directory), tp.extract(&max), tp.extract(&query) })) { + self.query_new_or_modified_files(from, project_directory, max, query) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "request_path_files", tp.extract(&project_directory), tp.extract(&max), tp.extract(&path) })) { self.request_path_files(from, project_directory, max, path) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "request_tasks", tp.extract(&project_directory) })) { @@ -468,6 +486,11 @@ const Process = struct { return project.request_recent_files(from, max); } + fn request_new_or_modified_files(self: *Process, from: tp.pid_ref, project_directory: []const u8, max: usize) (ProjectError || Project.ClientError)!void { + const project = self.projects.get(project_directory) orelse return error.NoProject; + return project.request_new_or_modified_files(from, max); + } + fn request_recent_projects(self: *Process, from: tp.pid_ref, project_directory: []const u8) (ProjectError || Project.ClientError)!void { var recent_projects: std.ArrayList(RecentProject) = .empty; defer recent_projects.deinit(self.allocator); @@ -498,6 +521,15 @@ const Process = struct { self.logger.print("query \"{s}\" matched {d}/{d} in {d} ms", .{ query, matched, project.files.items.len, query_time }); } + fn query_new_or_modified_files(self: *Process, from: tp.pid_ref, project_directory: []const u8, max: usize, query: []const u8) (ProjectError || Project.ClientError)!void { + const project = self.projects.get(project_directory) orelse return error.NoProject; + const start_time = std.time.milliTimestamp(); + const matched = try project.query_new_or_modified_files(from, max, query); + const query_time = std.time.milliTimestamp() - start_time; + if (query_time > 250) + self.logger.print("query \"{s}\" matched {d}/{d} in {d} ms", .{ query, matched, project.files.items.len, query_time }); + } + fn request_path_files(self: *Process, from: tp.pid_ref, project_directory: []const u8, max: usize, path: []const u8) (ProjectError || SpawnError || std.fs.Dir.OpenError)!void { const project = self.projects.get(project_directory) orelse return error.NoProject; var buf: std.ArrayList(u8) = .empty; From b6010767f26c40c9265fabc1e9f1057e7dc8aad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20T=C3=A1mara?= Date: Sat, 25 Oct 2025 08:12:43 -0500 Subject: [PATCH 4/8] feat: Add open changed files palette to commands The palette shows modified and new files for the current project allowing to open one by one --- src/tui/mode/overlay/open_changed.zig | 391 ++++++++++++++++++++++++++ src/tui/tui.zig | 80 ++++++ 2 files changed, 471 insertions(+) create mode 100644 src/tui/mode/overlay/open_changed.zig diff --git a/src/tui/mode/overlay/open_changed.zig b/src/tui/mode/overlay/open_changed.zig new file mode 100644 index 0000000..3be62ba --- /dev/null +++ b/src/tui/mode/overlay/open_changed.zig @@ -0,0 +1,391 @@ +const std = @import("std"); +const tp = @import("thespian"); +const log = @import("log"); +const cbor = @import("cbor"); +const file_type_config = @import("file_type_config"); + +const Plane = @import("renderer").Plane; +const input = @import("input"); +const keybind = @import("keybind"); +const project_manager = @import("project_manager"); +const command = @import("command"); +const EventHandler = @import("EventHandler"); +const BufferManager = @import("Buffer").Manager; + +const tui = @import("../../tui.zig"); +const MessageFilter = @import("../../MessageFilter.zig"); +const Button = @import("../../Button.zig"); +const InputBox = @import("../../InputBox.zig"); +const Menu = @import("../../Menu.zig"); +const Widget = @import("../../Widget.zig"); +const ModalBackground = @import("../../ModalBackground.zig"); + +const Self = @This(); +const max_recent_files: usize = 25; +const widget_type: Widget.Type = .palette; + +allocator: std.mem.Allocator, +f: usize = 0, +modal: *ModalBackground.State(*Self), +menu: *MenuType, +inputbox: *InputBox.State(*Self), +logger: log.Logger, +query_pending: bool = false, +need_reset: bool = false, +need_select_first: bool = true, +longest: usize, +commands: Commands = undefined, +buffer_manager: ?*BufferManager, + +const inputbox_label = "Open changed files by name"; +const MenuType = Menu.Options(*Self).MenuType; +const ButtonType = MenuType.ButtonType; + +pub fn create(allocator: std.mem.Allocator) !tui.Mode { + const mv = tui.mainview() orelse return error.NotFound; + const self = try allocator.create(Self); + errdefer allocator.destroy(self); + self.* = .{ + .allocator = allocator, + .modal = try ModalBackground.create(*Self, allocator, tui.mainview_widget(), .{ .ctx = self }), + .menu = try Menu.create(*Self, allocator, tui.plane(), .{ + .ctx = self, + .style = widget_type, + .on_render = on_render_menu, + .prepare_resize = prepare_resize_menu, + }), + .logger = log.logger(@typeName(Self)), + .inputbox = (try self.menu.add_header(try InputBox.create(*Self, self.allocator, self.menu.menu.parent, .{ + .ctx = self, + .label = inputbox_label, + .padding = 2, + .icon = "󰈞 ", + }))).dynamic_cast(InputBox.State(*Self)) orelse unreachable, + .buffer_manager = tui.get_buffer_manager(), + .longest = inputbox_label.len, + }; + try self.commands.init(self); + try tui.message_filters().add(MessageFilter.bind(self, receive_project_manager)); + self.query_pending = true; + try project_manager.request_new_or_modified_files(max_recent_files); + self.do_resize(); + try mv.floating_views.add(self.modal.widget()); + try mv.floating_views.add(self.menu.container_widget); + var mode = try keybind.mode("overlay/palette", allocator, .{ + .insert_command = "overlay_insert_bytes", + }); + mode.event_handler = EventHandler.to_owned(self); + mode.name = "󰈞 open changed"; + return mode; +} + +pub fn deinit(self: *Self) void { + self.commands.deinit(); + tui.message_filters().remove_ptr(self); + if (tui.mainview()) |mv| { + mv.floating_views.remove(self.menu.container_widget); + mv.floating_views.remove(self.modal.widget()); + } + self.logger.deinit(); + self.allocator.destroy(self); +} + +inline fn menu_width(self: *Self) usize { + return @max(@min(self.longest + 3, max_menu_width()) + 5, inputbox_label.len + 2); +} + +inline fn menu_pos_x(self: *Self) usize { + const screen_width = tui.screen().w; + const width = self.menu_width(); + return if (screen_width <= width) 0 else (screen_width - width) / 2; +} + +inline fn max_menu_width() usize { + const width = tui.screen().w; + return @max(15, width - (width / 5)); +} + +fn on_render_menu(_: *Self, button: *ButtonType, theme: *const Widget.Theme, selected: bool) bool { + return tui.render_file_vcs_item_cbor(&button.plane, button.opts.label, button.active, selected, button.hover, theme); +} + +fn prepare_resize_menu(self: *Self, _: *MenuType, _: Widget.Box) Widget.Box { + return self.prepare_resize(); +} + +fn prepare_resize(self: *Self) Widget.Box { + const w = self.menu_width(); + const x = self.menu_pos_x(); + const h = self.menu.menu.widgets.items.len; + return .{ .y = 0, .x = x, .w = w, .h = h }; +} + +fn do_resize(self: *Self) void { + self.menu.resize(self.prepare_resize()); +} + +fn menu_action_open_file(menu: **MenuType, button: *ButtonType, _: Widget.Pos) void { + var file_path: []const u8 = undefined; + var iter = button.opts.label; + if (!(cbor.matchString(&iter, &file_path) catch false)) return; + tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err("navigate", e); + tp.self_pid().send(.{ "cmd", "navigate", .{ .file = file_path } }) catch |e| menu.*.opts.ctx.logger.err("navigate", e); +} + +fn add_item( + self: *Self, + file_name: []const u8, + file_icon: []const u8, + file_color: u24, + vcs_status: u8, + indicator: []const u8, + matches: ?[]const u8, +) !void { + var label: std.Io.Writer.Allocating = .init(self.allocator); + defer label.deinit(); + const writer = &label.writer; + try cbor.writeValue(writer, file_name); + try cbor.writeValue(writer, file_icon); + try cbor.writeValue(writer, file_color); + try cbor.writeValue(writer, indicator); + try cbor.writeValue(writer, vcs_status); + if (matches) |cb| _ = try writer.write(cb) else try cbor.writeValue(writer, &[_]usize{}); + try self.menu.add_item_with_handler(label.written(), menu_action_open_file); +} + +fn receive_project_manager(self: *Self, _: tp.pid_ref, m: tp.message) MessageFilter.Error!bool { + if (cbor.match(m.buf, .{ "PRJ", tp.more }) catch false) { + try self.process_project_manager(m); + return true; + } + return false; +} + +fn process_project_manager(self: *Self, m: tp.message) MessageFilter.Error!void { + var file_name: []const u8 = undefined; + var file_type: []const u8 = undefined; + var file_icon: []const u8 = undefined; + var file_color: u24 = undefined; + var vcs_status: u8 = undefined; + var matches: []const u8 = undefined; + var query: []const u8 = undefined; + if (try cbor.match(m.buf, .{ + "PRJ", + "new_or_modified_files", + tp.extract(&self.longest), + tp.extract(&file_name), + tp.extract(&file_type), + tp.extract(&file_icon), + tp.extract(&file_color), + tp.extract(&vcs_status), + tp.extract_cbor(&matches), + })) { + if (self.need_reset) self.reset_results(); + const indicator = if (self.buffer_manager) |bm| tui.get_file_state_indicator(bm, file_name) else ""; + try self.add_item(file_name, file_icon, file_color, vcs_status, indicator, matches); + self.do_resize(); + if (self.need_select_first) { + self.menu.select_down(); + self.need_select_first = false; + } + tui.need_render(); + } else if (try cbor.match(m.buf, .{ + "PRJ", + "new_or_modified_files", + tp.extract(&self.longest), + tp.extract(&file_name), + tp.extract(&file_type), + tp.extract(&file_icon), + tp.extract(&file_color), + tp.extract(&vcs_status), + })) { + if (self.need_reset) self.reset_results(); + const indicator = if (self.buffer_manager) |bm| tui.get_file_state_indicator(bm, file_name) else ""; + try self.add_item(file_name, file_icon, file_color, vcs_status, indicator, null); + self.do_resize(); + if (self.need_select_first) { + self.menu.select_down(); + self.need_select_first = false; + } + tui.need_render(); + } else if (try cbor.match(m.buf, .{ "PRJ", "new_or_modified_files_done", tp.extract(&self.longest), tp.extract(&query) })) { + self.query_pending = false; + self.need_reset = true; + if (!std.mem.eql(u8, self.inputbox.text.items, query)) + try self.start_query(); + } else if (try cbor.match(m.buf, .{ "PRJ", "open_done", tp.string, tp.extract(&self.longest), tp.any })) { + self.query_pending = false; + self.need_reset = true; + try self.start_query(); + } else { + self.logger.err("receive", tp.unexpected(m)); + } +} + +pub fn receive(self: *Self, _: tp.pid_ref, m: tp.message) error{Exit}!bool { + var text: []const u8 = undefined; + + if (try m.match(.{ "system_clipboard", tp.extract(&text) })) { + self.insert_bytes(text) catch |e| return tp.exit_error(e, @errorReturnTrace()); + } + return false; +} + +fn reset_results(self: *Self) void { + self.need_reset = false; + self.menu.reset_items(); + self.menu.selected = null; + self.need_select_first = true; +} + +fn start_query(self: *Self) MessageFilter.Error!void { + if (self.query_pending) return; + self.query_pending = true; + try project_manager.query_new_or_modified_files(max_recent_files, self.inputbox.text.items); +} + +fn delete_word(self: *Self) !void { + if (std.mem.lastIndexOfAny(u8, self.inputbox.text.items, "/\\. -_")) |pos| { + self.inputbox.text.shrinkRetainingCapacity(pos); + } else { + self.inputbox.text.shrinkRetainingCapacity(0); + } + self.inputbox.cursor = tui.egc_chunk_width(self.inputbox.text.items, 0, 8); + return self.start_query(); +} + +fn delete_code_point(self: *Self) !void { + if (self.inputbox.text.items.len > 0) { + self.inputbox.text.shrinkRetainingCapacity(self.inputbox.text.items.len - tui.egc_last(self.inputbox.text.items).len); + self.inputbox.cursor = tui.egc_chunk_width(self.inputbox.text.items, 0, 8); + } + return self.start_query(); +} + +fn insert_code_point(self: *Self, c: u32) !void { + var buf: [6]u8 = undefined; + const bytes = try input.ucs32_to_utf8(&[_]u32{c}, &buf); + try self.inputbox.text.appendSlice(self.allocator, buf[0..bytes]); + self.inputbox.cursor = tui.egc_chunk_width(self.inputbox.text.items, 0, 8); + return self.start_query(); +} + +fn insert_bytes(self: *Self, bytes: []const u8) !void { + try self.inputbox.text.appendSlice(self.allocator, bytes); + self.inputbox.cursor = tui.egc_chunk_width(self.inputbox.text.items, 0, 8); + return self.start_query(); +} + +fn cmd(_: *Self, name_: []const u8, ctx: command.Context) tp.result { + try command.executeName(name_, ctx); +} + +fn msg(_: *Self, text: []const u8) tp.result { + return tp.self_pid().send(.{ "log", "home", text }); +} + +fn cmd_async(_: *Self, name_: []const u8) tp.result { + return tp.self_pid().send(.{ "cmd", name_ }); +} + +const Commands = command.Collection(cmds); +const cmds = struct { + pub const Target = Self; + const Ctx = command.Context; + const Meta = command.Metadata; + const Result = command.Result; + + pub fn palette_menu_down(self: *Self, _: Ctx) Result { + self.menu.select_down(); + } + pub const palette_menu_down_meta: Meta = .{}; + + pub fn palette_menu_up(self: *Self, _: Ctx) Result { + self.menu.select_up(); + } + pub const palette_menu_up_meta: Meta = .{}; + + pub fn palette_menu_pagedown(self: *Self, _: Ctx) Result { + self.menu.select_last(); + } + pub const palette_menu_pagedown_meta: Meta = .{}; + + pub fn palette_menu_pageup(self: *Self, _: Ctx) Result { + self.menu.select_first(); + } + pub const palette_menu_pageup_meta: Meta = .{}; + + pub fn palette_menu_bottom(self: *Self, _: Ctx) Result { + self.menu.select_last(); + } + pub const palette_menu_bottom_meta: Meta = .{}; + + pub fn palette_menu_top(self: *Self, _: Ctx) Result { + self.menu.select_first(); + } + pub const palette_menu_top_meta: Meta = .{}; + + pub fn palette_menu_activate(self: *Self, _: Ctx) Result { + self.menu.activate_selected(); + } + pub const palette_menu_activate_meta: Meta = .{}; + + pub fn palette_menu_activate_quick(self: *Self, _: Ctx) Result { + if (self.menu.selected orelse 0 > 0) self.menu.activate_selected(); + } + pub const palette_menu_activate_quick_meta: Meta = .{}; + + pub fn palette_menu_cancel(self: *Self, _: Ctx) Result { + try self.cmd("exit_overlay_mode", .{}); + } + pub const palette_menu_cancel_meta: Meta = .{}; + + pub fn overlay_delete_word_left(self: *Self, _: Ctx) Result { + self.delete_word() catch |e| return tp.exit_error(e, @errorReturnTrace()); + } + pub const overlay_delete_word_left_meta: Meta = .{ .description = "Delete word to the left" }; + + pub fn overlay_delete_backwards(self: *Self, _: Ctx) Result { + self.delete_code_point() catch |e| return tp.exit_error(e, @errorReturnTrace()); + } + pub const overlay_delete_backwards_meta: Meta = .{ .description = "Delete backwards" }; + + pub fn overlay_insert_code_point(self: *Self, ctx: Ctx) Result { + var egc: u32 = 0; + if (!try ctx.args.match(.{tp.extract(&egc)})) + return error.InvalidOpenRecentInsertCodePointArgument; + self.insert_code_point(egc) catch |e| return tp.exit_error(e, @errorReturnTrace()); + } + pub const overlay_insert_code_point_meta: Meta = .{ .arguments = &.{.integer} }; + + pub fn overlay_insert_bytes(self: *Self, ctx: Ctx) Result { + var bytes: []const u8 = undefined; + if (!try ctx.args.match(.{tp.extract(&bytes)})) + return error.InvalidOpenRecentInsertBytesArgument; + self.insert_bytes(bytes) catch |e| return tp.exit_error(e, @errorReturnTrace()); + } + pub const overlay_insert_bytes_meta: Meta = .{ .arguments = &.{.string} }; + + pub fn overlay_toggle_panel(self: *Self, _: Ctx) Result { + return self.cmd_async("toggle_panel"); + } + pub const overlay_toggle_panel_meta: Meta = .{}; + + pub fn overlay_toggle_inputview(self: *Self, _: Ctx) Result { + return self.cmd_async("toggle_inputview"); + } + pub const overlay_toggle_inputview_meta: Meta = .{}; + + pub fn overlay_next_widget_style(self: *Self, _: Ctx) Result { + tui.set_next_style(widget_type); + self.do_resize(); + tui.need_render(); + try tui.save_config(); + } + pub const overlay_next_widget_style_meta: Meta = .{}; + + pub fn mini_mode_paste(self: *Self, ctx: Ctx) Result { + return overlay_insert_bytes(self, ctx); + } + pub const mini_mode_paste_meta: Meta = .{ .arguments = &.{.string} }; +}; diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 8c9551c..a0dbbff 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -1032,6 +1032,11 @@ const cmds = struct { } pub const open_recent_meta: Meta = .{ .description = "Open recent" }; + pub fn open_changed_files(self: *Self, _: Ctx) Result { + return self.enter_overlay_mode(@import("mode/overlay/open_changed.zig")); + } + pub const open_changed_files_meta: Meta = .{ .description = "Open changed files (vcs)" }; + pub fn open_recent_project(_: *Self, _: Ctx) Result { try project_manager.request_recent_projects(); } @@ -1679,6 +1684,15 @@ pub fn render_pointer(self: *renderer.Plane, selected: bool) void { _ = self.print("{s}", .{pointer}) catch {}; } +pub fn render_pointer_vcs(self: *renderer.Plane, vcs_status: u8, selected: bool) void { + const pointer = "⏵"; + if (selected) { + _ = self.print("{s}{c}", .{ pointer, vcs_status }) catch {}; + } else { + _ = self.print("{c} ", .{vcs_status}) catch {}; + } +} + pub fn render_file_item( self: *renderer.Plane, file_path_: []const u8, @@ -1742,6 +1756,72 @@ pub fn render_file_item_cbor(self: *renderer.Plane, file_item_cbor: []const u8, return render_file_item(self, file_path_, icon, color, indicator, matches_cbor, active, selected, hover, theme_); } +pub fn render_file_vcs_item( + self: *renderer.Plane, + file_path_: []const u8, + icon: []const u8, + color: u24, + indicator: []const u8, + vcs_status: u8, + matches_cbor: []const u8, + active: bool, + selected: bool, + hover: bool, + theme_: *const Widget.Theme, +) bool { + const style_base = theme_.editor_widget; + const style_label = if (active) theme_.editor_cursor else if (hover or selected) theme_.editor_selection else theme_.editor_widget; + const style_hint = if (find_scope_style(theme_, "entity.name")) |sty| sty.style else style_label; + self.set_base_style(style_base); + self.erase(); + self.home(); + self.set_style(style_label); + if (active or hover or selected) { + self.fill(" "); + self.home(); + } + + self.set_style(style_hint); + render_pointer_vcs(self, vcs_status, selected); + + const icon_width = render_file_icon(self, icon, color); + + self.set_style(style_label); + _ = self.print("{s} ", .{file_path_}) catch {}; + + self.set_style(style_hint); + _ = self.print_aligned_right(0, "{s} ", .{indicator}) catch {}; + + var iter = matches_cbor; + var index: usize = 0; + var len = cbor.decodeArrayHeader(&iter) catch return false; + while (len > 0) : (len -= 1) { + if (cbor.matchValue(&iter, cbor.extract(&index)) catch break) { + render_match_cell(self, 0, index + 2 + icon_width, theme_) catch break; + } else break; + } + return false; +} + +pub fn render_file_vcs_item_cbor(self: *renderer.Plane, file_item_cbor: []const u8, active: bool, selected: bool, hover: bool, theme_: *const Widget.Theme) bool { + var iter = file_item_cbor; + var file_path_: []const u8 = undefined; + var icon: []const u8 = undefined; + var color: u24 = undefined; + var indicator: []const u8 = undefined; + var vcs_status: u8 = undefined; + var matches_cbor: []const u8 = undefined; + + if (!(cbor.matchString(&iter, &file_path_) catch false)) @panic("invalid buffer file path"); + if (!(cbor.matchString(&iter, &icon) catch false)) @panic("invalid buffer file type icon"); + if (!(cbor.matchInt(u24, &iter, &color) catch false)) @panic("invalid buffer file type color"); + if (!(cbor.matchString(&iter, &indicator) catch false)) indicator = ""; + if (!(cbor.matchInt(u8, &iter, &vcs_status) catch false)) indicator = " "; + + if (!(cbor.matchValue(&iter, cbor.extract_cbor(&matches_cbor)) catch false)) @panic("invalid matches cbor"); + return render_file_vcs_item(self, file_path_, icon, color, indicator, vcs_status, matches_cbor, active, selected, hover, theme_); +} + fn get_or_create_theme_file(self: *Self, allocator: std.mem.Allocator) ![]const u8 { const theme_name = self.current_theme().name; if (root.read_theme(allocator, theme_name)) |content| { From 8d0fa9a3551a2060c45affb7ad868d7c587300f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20T=C3=A1mara?= Date: Sat, 25 Oct 2025 08:20:58 -0500 Subject: [PATCH 5/8] feat: [hx] space g bound to open changed and new files reported by git --- src/keybind/builtin/helix.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/keybind/builtin/helix.json b/src/keybind/builtin/helix.json index 8fb3650..460b411 100644 --- a/src/keybind/builtin/helix.json +++ b/src/keybind/builtin/helix.json @@ -228,6 +228,7 @@ ["space R", "replace_selections_with_clipboard"], ["space ?", "open_command_palette"], ["space f", "find_file"], + ["space g", "open_changed_files"], ["space b", "switch_buffers"], ["space j", "jumplist_picker"], ["space s", "symbol_picker"], From fdf2b6d3765d155b44a8e04e6a97896d9d7f6f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20T=C3=A1mara?= Date: Sat, 25 Oct 2025 08:31:45 -0500 Subject: [PATCH 6/8] feat: allow to update vcs changed files info for current project --- src/Project.zig | 2 ++ src/project_manager.zig | 14 ++++++++++++++ src/tui/tui.zig | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/src/Project.zig b/src/Project.zig index 67060a2..946e23a 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -2174,6 +2174,8 @@ pub fn query_git(self: *Self) void { self.state.status = .failed; }; // TODO: This needs to be invoked when there are identified changes in the fs + for (self.new_or_modified_files.items) |file| self.allocator.free(file.path); + self.new_or_modified_files.clearRetainingCapacity(); self.state.vcs_new_or_modified_files = .running; git.new_or_modified_files(@intFromPtr(self)) catch { self.state.vcs_new_or_modified_files = .failed; diff --git a/src/project_manager.zig b/src/project_manager.zig index 14a3d8e..c5d380c 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -105,6 +105,13 @@ pub fn request_new_or_modified_files(max: usize) (ProjectManagerError || Project return send(.{ "request_new_or_modified_files", project, max }); } +pub fn request_sync_with_vcs() (ProjectManagerError || ProjectError)!void { + const project = tp.env.get().str("project"); + if (project.len == 0) + return error.NoProject; + return send(.{ "sync_with_vcs", project }); +} + pub fn request_recent_projects() (ProjectManagerError || ProjectError)!void { const project = tp.env.get().str("project"); return send(.{ "request_recent_projects", project }); @@ -386,6 +393,8 @@ const Process = struct { self.request_recent_files(from, project_directory, max) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "request_new_or_modified_files", tp.extract(&project_directory), tp.extract(&max) })) { self.request_new_or_modified_files(from, project_directory, max) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; + } else if (try cbor.match(m.buf, .{ "sync_with_vcs", tp.extract(&project_directory) })) { + self.request_sync_with_vcs(from, project_directory) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "request_recent_projects", tp.extract(&project_directory) })) { self.request_recent_projects(from, project_directory) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "query_recent_files", tp.extract(&project_directory), tp.extract(&max), tp.extract(&query) })) { @@ -486,6 +495,11 @@ const Process = struct { return project.request_recent_files(from, max); } + fn request_sync_with_vcs(self: *Process, _: tp.pid_ref, project_directory: []const u8) (ProjectError || Project.ClientError)!void { + const project = self.projects.get(project_directory) orelse return error.NoProject; + return project.query_git(); + } + fn request_new_or_modified_files(self: *Process, from: tp.pid_ref, project_directory: []const u8, max: usize) (ProjectError || Project.ClientError)!void { const project = self.projects.get(project_directory) orelse return error.NoProject; return project.request_new_or_modified_files(from, max); diff --git a/src/tui/tui.zig b/src/tui/tui.zig index a0dbbff..65c7e3e 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -1370,6 +1370,10 @@ pub fn mainview() ?*MainView { return if (current().mainview_) |*mv| mv.dynamic_cast(MainView) else null; } +pub fn sync_with_vcs() !void { + try project_manager.request_sync_with_vcs(); +} + pub fn mainview_widget() Widget { return current().mainview_ orelse @panic("tui main view not found"); } From 7f7f12a4414e0ebef5da6cd26a81377b3a9764f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20T=C3=A1mara?= Date: Sat, 25 Oct 2025 08:32:41 -0500 Subject: [PATCH 7/8] feat: update vcs changed files info when click on branch status --- src/tui/status/branch.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tui/status/branch.zig b/src/tui/status/branch.zig index 140e090..ec8d7f6 100644 --- a/src/tui/status/branch.zig +++ b/src/tui/status/branch.zig @@ -56,6 +56,7 @@ pub fn ctx_deinit(self: *Self) void { fn on_click(self: *Self, _: *ButtonType, _: Widget.Pos) void { self.refresh_vcs_status(); + tui.sync_with_vcs() catch {}; command.executeName("show_vcs_status", .{}) catch {}; } From 61de5d89d704a1b337cc7a539d473b61e7ddd387 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 28 Oct 2025 22:15:51 +0100 Subject: [PATCH 8/8] refactor: rename open_changed_files to show_vcs_status This fits a bit better as we will add commands to do more than just open the files in the status palette. --- src/keybind/builtin/helix.json | 2 +- src/tui/mode/overlay/{open_changed.zig => vcs_status.zig} | 4 ++-- src/tui/tui.zig | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/tui/mode/overlay/{open_changed.zig => vcs_status.zig} (99%) diff --git a/src/keybind/builtin/helix.json b/src/keybind/builtin/helix.json index 460b411..6282250 100644 --- a/src/keybind/builtin/helix.json +++ b/src/keybind/builtin/helix.json @@ -228,7 +228,7 @@ ["space R", "replace_selections_with_clipboard"], ["space ?", "open_command_palette"], ["space f", "find_file"], - ["space g", "open_changed_files"], + ["space g", "show_vcs_status"], ["space b", "switch_buffers"], ["space j", "jumplist_picker"], ["space s", "symbol_picker"], diff --git a/src/tui/mode/overlay/open_changed.zig b/src/tui/mode/overlay/vcs_status.zig similarity index 99% rename from src/tui/mode/overlay/open_changed.zig rename to src/tui/mode/overlay/vcs_status.zig index 3be62ba..2ecfc41 100644 --- a/src/tui/mode/overlay/open_changed.zig +++ b/src/tui/mode/overlay/vcs_status.zig @@ -37,7 +37,7 @@ longest: usize, commands: Commands = undefined, buffer_manager: ?*BufferManager, -const inputbox_label = "Open changed files by name"; +const inputbox_label = "Changed or untracked files"; const MenuType = Menu.Options(*Self).MenuType; const ButtonType = MenuType.ButtonType; @@ -75,7 +75,7 @@ pub fn create(allocator: std.mem.Allocator) !tui.Mode { .insert_command = "overlay_insert_bytes", }); mode.event_handler = EventHandler.to_owned(self); - mode.name = "󰈞 open changed"; + mode.name = " status"; return mode; } diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 65c7e3e..2f050d1 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -1032,10 +1032,10 @@ const cmds = struct { } pub const open_recent_meta: Meta = .{ .description = "Open recent" }; - pub fn open_changed_files(self: *Self, _: Ctx) Result { - return self.enter_overlay_mode(@import("mode/overlay/open_changed.zig")); + pub fn show_vcs_status(self: *Self, _: Ctx) Result { + return self.enter_overlay_mode(@import("mode/overlay/vcs_status.zig")); } - pub const open_changed_files_meta: Meta = .{ .description = "Open changed files (vcs)" }; + pub const show_vcs_status_meta: Meta = .{ .description = "Show git status" }; pub fn open_recent_project(_: *Self, _: Ctx) Result { try project_manager.request_recent_projects();