diff --git a/src/Project.zig b/src/Project.zig index e71c915..946e23a 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,13 @@ 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 + 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; + }; } fn start_walker(self: *Self) void { @@ -2093,6 +2193,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 +2232,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)); } 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 { diff --git a/src/keybind/builtin/helix.json b/src/keybind/builtin/helix.json index 8fb3650..6282250 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", "show_vcs_status"], ["space b", "switch_buffers"], ["space j", "jumplist_picker"], ["space s", "symbol_picker"], diff --git a/src/project_manager.zig b/src/project_manager.zig index 46cd226..c5d380c 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -98,6 +98,20 @@ 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_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 }); @@ -110,6 +124,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 +391,16 @@ 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, .{ "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) })) { 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 +495,16 @@ 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); + } + 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 +535,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; diff --git a/src/tui/mode/overlay/vcs_status.zig b/src/tui/mode/overlay/vcs_status.zig new file mode 100644 index 0000000..2ecfc41 --- /dev/null +++ b/src/tui/mode/overlay/vcs_status.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 = "Changed or untracked files"; +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 = " status"; + 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/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 {}; } diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 8c9551c..2f050d1 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 show_vcs_status(self: *Self, _: Ctx) Result { + return self.enter_overlay_mode(@import("mode/overlay/vcs_status.zig")); + } + 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(); } @@ -1365,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"); } @@ -1679,6 +1688,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 +1760,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| {