From ac9517365dd01646813b6f3600c8ac76a08f5446 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 4 Aug 2025 21:06:54 +0200 Subject: [PATCH 01/11] refactor: clean-up open_file.select --- src/tui/mode/mini/open_file.zig | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/tui/mode/mini/open_file.zig b/src/tui/mode/mini/open_file.zig index 3eeb4b6..07f70e4 100644 --- a/src/tui/mode/mini/open_file.zig +++ b/src/tui/mode/mini/open_file.zig @@ -37,12 +37,9 @@ pub fn select(self: *Type) void { var buf = std.ArrayList(u8).init(self.allocator); defer buf.deinit(); const file_path = project_manager.expand_home(&buf, self.file_path.items); - if (root.is_directory(file_path)) { - tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch return; - tp.self_pid().send(.{ "cmd", "change_project", .{file_path} }) catch {}; - return; - } - if (file_path.len > 0) - tp.self_pid().send(.{ "cmd", "navigate", .{ .file = file_path } }) catch {}; command.executeName("exit_mini_mode", .{}) catch {}; + if (root.is_directory(file_path)) + tp.self_pid().send(.{ "cmd", "change_project", .{file_path} }) catch {} + else if (file_path.len > 0) + tp.self_pid().send(.{ "cmd", "navigate", .{ .file = file_path } }) catch {}; } From 261acbc681685ec856b7800864e172f24cd50959 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 5 Aug 2025 08:18:49 +0200 Subject: [PATCH 02/11] feat: use case insenstive matching for file_browser completion --- src/tui/mode/mini/file_browser.zig | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/tui/mode/mini/file_browser.zig b/src/tui/mode/mini/file_browser.zig index ea6fee7..e075a07 100644 --- a/src/tui/mode/mini/file_browser.zig +++ b/src/tui/mode/mini/file_browser.zig @@ -9,6 +9,7 @@ const keybind = @import("keybind"); const project_manager = @import("project_manager"); const command = @import("command"); const EventHandler = @import("EventHandler"); +const Buffer = @import("Buffer"); const tui = @import("../../tui.zig"); const MessageFilter = @import("../../MessageFilter.zig"); @@ -197,9 +198,7 @@ pub fn Create(options: type) type { var last: ?Entry = null; var last_no: usize = 0; for (self.entries.items, 0..) |entry, i| { - if (entry.name.len >= self.match.items.len and - std.mem.eql(u8, self.match.items, entry.name[0..self.match.items.len])) - { + if (try prefix_compare_icase(self.allocator, self.match.items, entry.name)) { matched += 1; if (matched == self.complete_trigger_count) { try self.construct_path(self.query.items, entry, i); @@ -218,6 +217,15 @@ pub fn Create(options: type) type { } } + fn prefix_compare_icase(allocator: std.mem.Allocator, prefix: []const u8, str: []const u8) error{OutOfMemory}!bool { + const icase_prefix = Buffer.unicode.get_letter_casing().toLowerStr(allocator, prefix) catch try allocator.dupe(u8, prefix); + defer allocator.free(icase_prefix); + const icase_str = Buffer.unicode.get_letter_casing().toLowerStr(allocator, str) catch try allocator.dupe(u8, str); + defer allocator.free(icase_str); + if (icase_str.len < icase_prefix.len) return false; + return std.mem.eql(u8, icase_prefix, icase_str[0..icase_prefix.len]); + } + fn delete_to_previous_path_segment(self: *Self) void { self.complete_trigger_count = 0; if (self.file_path.items.len == 0) return; From 1f74ef255998f58a2251640416a5a1dcc6781112 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 5 Aug 2025 08:28:33 +0200 Subject: [PATCH 03/11] feat(file_browser): complete into subdirs when there is only one match --- src/tui/mode/mini/file_browser.zig | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/tui/mode/mini/file_browser.zig b/src/tui/mode/mini/file_browser.zig index e075a07..c47d088 100644 --- a/src/tui/mode/mini/file_browser.zig +++ b/src/tui/mode/mini/file_browser.zig @@ -24,6 +24,7 @@ pub fn Create(options: type) type { match: std.ArrayList(u8), entries: std.ArrayList(Entry), complete_trigger_count: usize = 0, + total_matches: usize = 0, matched_entry: usize = 0, commands: Commands = undefined, @@ -172,14 +173,23 @@ pub fn Create(options: type) type { fn do_complete(self: *Self) !void { self.complete_trigger_count = @min(self.complete_trigger_count, self.entries.items.len); self.file_path.clearRetainingCapacity(); + const match_number = self.complete_trigger_count; if (self.match.items.len > 0) { try self.match_path(); + if (self.total_matches == 1) + self.complete_trigger_count = 0; } else if (self.entries.items.len > 0) { try self.construct_path(self.query.items, self.entries.items[self.complete_trigger_count - 1], self.complete_trigger_count - 1); } else { try self.construct_path(self.query.items, .{ .name = "", .type = .file }, 0); } - message("{d}/{d}", .{ self.matched_entry + 1, self.entries.items.len }); + if (self.match.items.len > 0) + if (self.total_matches > 1) + message("{d}/{d} ({d}/{d} matches)", .{ self.matched_entry + 1, self.entries.items.len, match_number, self.total_matches }) + else + message("{d}/{d} ({d} match)", .{ self.matched_entry + 1, self.entries.items.len, self.total_matches }) + else + message("{d}/{d}", .{ self.matched_entry + 1, self.entries.items.len }); } fn construct_path(self: *Self, path_: []const u8, entry: Entry, entry_no: usize) error{OutOfMemory}!void { @@ -194,6 +204,7 @@ pub fn Create(options: type) type { } fn match_path(self: *Self) !void { + var found_match: ?usize = null; var matched: usize = 0; var last: ?Entry = null; var last_no: usize = 0; @@ -202,12 +213,14 @@ pub fn Create(options: type) type { matched += 1; if (matched == self.complete_trigger_count) { try self.construct_path(self.query.items, entry, i); - return; + found_match = i; } last = entry; last_no = i; } } + self.total_matches = matched; + if (found_match) |_| return; if (last) |entry| { try self.construct_path(self.query.items, entry, last_no); self.complete_trigger_count = matched; From 8ea3356b579df476dc6a13dde25f40a3f31f1a5a Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 5 Aug 2025 09:36:54 +0200 Subject: [PATCH 04/11] fix: use line buffer for stdout and stderr by default in shell module --- src/shell.zig | 57 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/src/shell.zig b/src/shell.zig index 3e316ab..1678776 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -40,6 +40,7 @@ pub const Handlers = struct { err: ?*const OutputHandler = null, exit: *const ExitHandler = log_exit_handler, log_execute: bool = true, + line_buffered: bool = true, }; pub fn execute(allocator: std.mem.Allocator, argv: tp.message, handlers: Handlers) Error!void { @@ -145,6 +146,8 @@ const Process = struct { logger: log.Logger, stdin_behavior: std.process.Child.StdIo, handlers: Handlers, + stdout_line_buffer: std.ArrayListUnmanaged(u8) = .empty, + stderr_line_buffer: std.ArrayListUnmanaged(u8) = .empty, const Receiver = tp.Receiver(*Process); @@ -181,6 +184,8 @@ const Process = struct { self.logger.deinit(); self.allocator.free(self.arg0); self.allocator.free(self.argv.buf); + self.stdout_line_buffer.deinit(self.allocator); + self.stderr_line_buffer.deinit(self.allocator); self.allocator.destroy(self); } @@ -215,9 +220,9 @@ const Process = struct { } else if (try m.match(.{"close"})) { self.close(); } else if (try m.match(.{ module_name, "stdout", tp.extract(&bytes) })) { - self.handlers.out(self.handlers.context, self.parent.ref(), self.arg0, bytes); + self.handle_stdout(bytes) catch |e| return tp.exit_error(e, @errorReturnTrace()); } else if (try m.match(.{ module_name, "stderr", tp.extract(&bytes) })) { - (self.handlers.err orelse self.handlers.out)(self.handlers.context, self.parent.ref(), self.arg0, bytes); + self.handle_stderr(bytes) catch |e| return tp.exit_error(e, @errorReturnTrace()); } else if (try m.match(.{ module_name, "term", tp.more })) { defer self.sp = null; self.handle_terminated(m) catch |e| return tp.exit_error(e, @errorReturnTrace()); @@ -231,9 +236,57 @@ const Process = struct { } } + fn handle_stdout(self: *Process, bytes: []const u8) error{OutOfMemory}!void { + return if (!self.handlers.line_buffered) + self.handlers.out(self.handlers.context, self.parent.ref(), self.arg0, bytes) + else + self.handle_buffered_output(self.handlers.out, &self.stdout_line_buffer, bytes); + } + + fn handle_stderr(self: *Process, bytes: []const u8) error{OutOfMemory}!void { + const handler = self.handlers.err orelse self.handlers.out; + return if (!self.handlers.line_buffered) + handler(self.handlers.context, self.parent.ref(), self.arg0, bytes) + else + self.handle_buffered_output(handler, &self.stderr_line_buffer, bytes); + } + + fn handle_buffered_output(self: *Process, handler: *const OutputHandler, buffer: *std.ArrayListUnmanaged(u8), bytes: []const u8) error{OutOfMemory}!void { + var it = std.mem.splitScalar(u8, bytes, '\n'); + var have_nl = false; + var prev = it.first(); + while (it.next()) |next| { + have_nl = true; + try buffer.appendSlice(self.allocator, prev); + try buffer.append(self.allocator, '\n'); + prev = next; + } + if (have_nl) { + handler(self.handlers.context, self.parent.ref(), self.arg0, buffer.items); + buffer.clearRetainingCapacity(); + } + try buffer.appendSlice(self.allocator, prev); + } + + fn flush_stdout(self: *Process) void { + self.flush_buffer(self.handlers.out, &self.stdout_line_buffer); + } + + fn flush_stderr(self: *Process) void { + self.flush_buffer(self.handlers.err orelse self.handlers.out, &self.stderr_line_buffer); + } + + fn flush_buffer(self: *Process, handler: *const OutputHandler, buffer: *std.ArrayListUnmanaged(u8)) void { + if (!self.handlers.line_buffered) return; + if (buffer.items.len > 0) handler(self.handlers.context, self.parent.ref(), self.arg0, buffer.items); + buffer.clearRetainingCapacity(); + } + fn handle_terminated(self: *Process, m: tp.message) !void { var err_msg: []const u8 = undefined; var exit_code: i64 = undefined; + self.flush_stdout(); + self.flush_stderr(); if (try m.match(.{ tp.any, tp.any, "exited", 0 })) { self.handlers.exit(self.handlers.context, self.parent.ref(), self.arg0, "exited", 0); } else if (try m.match(.{ tp.any, tp.any, "error.FileNotFound", 1 })) { From 4ca455cbba5f6c021da624a579eee0d0c91e1c21 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 5 Aug 2025 09:59:48 +0200 Subject: [PATCH 05/11] fix: ensure palettes are never smaller than their input hint --- src/tui/mode/overlay/open_recent.zig | 6 ++++-- src/tui/mode/overlay/palette.zig | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/tui/mode/overlay/open_recent.zig b/src/tui/mode/overlay/open_recent.zig index 0c5d687..603a201 100644 --- a/src/tui/mode/overlay/open_recent.zig +++ b/src/tui/mode/overlay/open_recent.zig @@ -36,6 +36,8 @@ longest: usize = 0, commands: Commands = undefined, buffer_manager: ?*BufferManager, +const inputbox_label = "Search files by name"; + pub fn create(allocator: std.mem.Allocator) !tui.Mode { const mv = tui.mainview() orelse return error.NotFound; const self = try allocator.create(Self); @@ -51,7 +53,7 @@ pub fn create(allocator: std.mem.Allocator) !tui.Mode { .logger = log.logger(@typeName(Self)), .inputbox = (try self.menu.add_header(try InputBox.create(*Self, self.allocator, self.menu.menu.parent, .{ .ctx = self, - .label = "Search files by name", + .label = inputbox_label, }))).dynamic_cast(InputBox.State(*Self)) orelse unreachable, .buffer_manager = tui.get_buffer_manager(), }; @@ -82,7 +84,7 @@ pub fn deinit(self: *Self) void { } inline fn menu_width(self: *Self) usize { - return @min(self.longest, max_menu_width()) + 2; + return @max(@min(self.longest, max_menu_width()) + 2, inputbox_label.len + 2); } inline fn menu_pos_x(self: *Self) usize { diff --git a/src/tui/mode/overlay/palette.zig b/src/tui/mode/overlay/palette.zig index 926dc65..1e0e18a 100644 --- a/src/tui/mode/overlay/palette.zig +++ b/src/tui/mode/overlay/palette.zig @@ -153,7 +153,7 @@ pub fn Create(options: type) type { fn do_resize(self: *Self) void { const screen = tui.screen(); - const w = @min(self.longest, max_menu_width) + 2 + 1 + self.longest_hint; + const w = @max(@min(self.longest, max_menu_width) + 2 + 1 + self.longest_hint, options.label.len + 2); const x = if (screen.w > w) (screen.w - w) / 2 else 0; self.view_rows = get_view_rows(screen); const h = @min(self.items + self.menu.header_count, self.view_rows + self.menu.header_count); From 67fc1581d3ebef4d69c3c359abfbe5c717f33247 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 5 Aug 2025 10:23:53 +0200 Subject: [PATCH 06/11] feat: refresh open_recent palette when the project is done loading the file list --- src/Project.zig | 18 ++++++++++-------- src/project_manager.zig | 4 ++-- src/tui/mode/overlay/open_recent.zig | 4 ++++ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Project.zig b/src/Project.zig index 8e97119..23eaae1 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -413,11 +413,11 @@ pub fn walk_tree_entry(self: *Self, file_path: []const u8, mtime: i128) OutOfMem (try self.pending.addOne(self.allocator)).* = .{ .path = try self.allocator.dupe(u8, file_path), .mtime = mtime }; } -pub fn walk_tree_done(self: *Self) OutOfMemoryError!void { +pub fn walk_tree_done(self: *Self, parent: tp.pid_ref) OutOfMemoryError!void { self.state.walk_tree = .done; if (self.walker) |pid| pid.deinit(); self.walker = null; - return self.loaded(); + return self.loaded(parent); } fn merge_pending_files(self: *Self) OutOfMemoryError!void { @@ -433,7 +433,7 @@ fn merge_pending_files(self: *Self) OutOfMemoryError!void { } } -fn loaded(self: *Self) OutOfMemoryError!void { +fn loaded(self: *Self, parent: tp.pid_ref) OutOfMemoryError!void { inline for (@typeInfo(@TypeOf(self.state)).@"struct".fields) |f| if (@field(self.state, f.name) == .running) return; @@ -449,6 +449,8 @@ fn loaded(self: *Self) OutOfMemoryError!void { self.files.items.len, std.time.milliTimestamp() - self.open_time, }); + + parent.send(.{ "PRJ", "open_done", self.name, self.longest_file_path, self.files.items.len }) catch {}; } pub fn update_mru(self: *Self, file_path: []const u8, row: usize, col: usize) OutOfMemoryError!void { @@ -1913,13 +1915,13 @@ fn start_walker(self: *Self) void { }; } -pub fn process_git(self: *Self, m: tp.message) (OutOfMemoryError || error{Exit})!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; if (try m.match(.{ tp.any, tp.any, "workspace_path", tp.null_ })) { self.state.workspace_path = .done; self.start_walker(); - try self.loaded(); + try self.loaded(parent); } else if (try m.match(.{ tp.any, tp.any, "workspace_path", tp.extract(&value) })) { if (self.workspace) |p| self.allocator.free(p); self.workspace = try self.allocator.dupe(u8, value); @@ -1930,19 +1932,19 @@ pub fn process_git(self: *Self, m: tp.message) (OutOfMemoryError || error{Exit}) }; } else if (try m.match(.{ tp.any, tp.any, "current_branch", tp.null_ })) { self.state.current_branch = .done; - try self.loaded(); + try self.loaded(parent); } else if (try m.match(.{ tp.any, tp.any, "current_branch", tp.extract(&value) })) { if (self.branch) |p| self.allocator.free(p); self.branch = try self.allocator.dupe(u8, value); self.state.current_branch = .done; - try self.loaded(); + try self.loaded(parent); } else if (try m.match(.{ tp.any, tp.any, "workspace_files", tp.extract(&path) })) { self.longest_file_path = @max(self.longest_file_path, path.len); const stat = std.fs.cwd().statFile(path) catch return; (try self.pending.addOne(self.allocator)).* = .{ .path = try self.allocator.dupe(u8, path), .mtime = stat.mtime }; } else if (try m.match(.{ tp.any, tp.any, "workspace_files", tp.null_ })) { self.state.workspace_files = .done; - try self.loaded(); + try self.loaded(parent); } else { self.logger_git.err("git", tp.unexpected(m)); } diff --git a/src/project_manager.zig b/src/project_manager.zig index a179c1e..c29a78a 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -341,10 +341,10 @@ const Process = struct { project.walk_tree_entry(path, mtime) catch |e| self.logger.err("walk_tree_entry", e); } else if (try cbor.match(m.buf, .{ "walk_tree_done", tp.extract(&project_directory) })) { if (self.projects.get(project_directory)) |project| - project.walk_tree_done() catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; + project.walk_tree_done(self.parent.ref()) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "git", tp.extract(&context), tp.more })) { const project: *Project = @ptrFromInt(context); - project.process_git(m) catch {}; + project.process_git(self.parent.ref(), m) catch {}; } else if (try cbor.match(m.buf, .{ "update_mru", tp.extract(&project_directory), tp.extract(&path), tp.extract(&row), tp.extract(&col) })) { self.update_mru(project_directory, path, row, col) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "child", tp.extract(&project_directory), tp.extract(&language_server), "notify", tp.extract(&method), tp.extract_cbor(¶ms_cb) })) { diff --git a/src/tui/mode/overlay/open_recent.zig b/src/tui/mode/overlay/open_recent.zig index 603a201..b3c05dc 100644 --- a/src/tui/mode/overlay/open_recent.zig +++ b/src/tui/mode/overlay/open_recent.zig @@ -200,6 +200,10 @@ fn process_project_manager(self: *Self, m: tp.message) MessageFilter.Error!void 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)); } From e13295100bbb3a70e08eafce65e5b648ac967d11 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 5 Aug 2025 12:38:12 +0200 Subject: [PATCH 07/11] fix: set outer mode from delayed init if there is already an overlay mode active This fixes starting flow with a command to initally open an overlay and then cancelling or exiting the overlay without loading a new mode. --- src/tui/tui.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 9b82887..5aa13f6 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -196,7 +196,14 @@ fn init_input_namespace(self: *Self) InitError!void { fn init_delayed(self: *Self) command.Result { self.delayed_init_done = true; - if (self.input_mode_) |_| {} else { + if (self.input_mode_) |_| { + if (self.delayed_init_input_mode) |delayed_init_input_mode| { + if (self.input_mode_outer_ == null) { + self.input_mode_outer_ = delayed_init_input_mode; + self.delayed_init_input_mode = null; + } + } + } else { if (self.delayed_init_input_mode) |delayed_init_input_mode| { try enter_input_mode(self, delayed_init_input_mode); self.delayed_init_input_mode = null; From 652db7d9b9be6fe8d74e12bcd2f5bfea3224fe3e Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 5 Aug 2025 13:41:48 +0200 Subject: [PATCH 08/11] refactor: add Buffer.mark_dirty() function --- src/buffer/Buffer.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/buffer/Buffer.zig b/src/buffer/Buffer.zig index 9e8f09b..3e88dcd 100644 --- a/src/buffer/Buffer.zig +++ b/src/buffer/Buffer.zig @@ -1367,6 +1367,10 @@ pub fn mark_clean(self: *Self) void { self.last_save = self.root; } +pub fn mark_dirty(self: *Self) void { + self.last_save = null; +} + pub fn is_hidden(self: *const Self) bool { return self.hidden; } From 3cace58049ca220fd9ceac04b10164b644a2d59c Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 5 Aug 2025 14:59:07 +0200 Subject: [PATCH 09/11] feat: add Buffer.reset_from_string_and_update function --- src/buffer/Buffer.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/buffer/Buffer.zig b/src/buffer/Buffer.zig index 3e88dcd..0a2d752 100644 --- a/src/buffer/Buffer.zig +++ b/src/buffer/Buffer.zig @@ -1184,6 +1184,13 @@ pub fn load_from_string_and_update(self: *Self, file_path: []const u8, s: []cons self.mtime = std.time.milliTimestamp(); } +pub fn reset_from_string_and_update(self: *Self, s: []const u8) LoadFromStringError!void { + self.root = try self.load_from_string(s, &self.file_eol_mode, &self.file_utf8_sanitized); + self.last_save = self.root; + self.last_save_eol_mode = self.file_eol_mode; + self.mtime = std.time.milliTimestamp(); +} + pub const LoadFromFileError = error{ OutOfMemory, Unexpected, From 886a2582a3ce3d87b4b22d31eb552206e0421b05 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 5 Aug 2025 13:42:19 +0200 Subject: [PATCH 10/11] fix: re-write save_as to work properly with multiple buffers --- src/tui/editor.zig | 17 ----------------- src/tui/mainview.zig | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index f6943c7..09e232c 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -722,15 +722,6 @@ pub const Editor = struct { self.update_event() catch {}; } - fn save_as(self: *Self, file_path: []const u8) !void { - if (self.buffer) |b_mut| try b_mut.store_to_file_and_clean(file_path); - if (self.file_path) |old_file_path| self.allocator.free(old_file_path); - self.file_path = try self.allocator.dupe(u8, file_path); - try self.send_editor_save(self.file_path.?); - self.last.dirty = false; - self.update_event() catch {}; - } - pub fn push_cursor(self: *Self) !void { const primary = self.cursels.getLastOrNull() orelse CurSel{} orelse CurSel{}; (try self.cursels.addOne(self.allocator)).* = primary; @@ -4950,14 +4941,6 @@ pub const Editor = struct { } pub const save_file_without_formatting_meta: Meta = .{ .description = "Save file without formatting" }; - pub fn save_file_as(self: *Self, ctx: Context) Result { - var file_path: []const u8 = undefined; - if (ctx.args.match(.{tp.extract(&file_path)}) catch false) { - try self.save_as(file_path); - } else return error.InvalidSafeFileAsArgument; - } - pub const save_file_as_meta: Meta = .{ .arguments = &.{.string} }; - pub fn close_file(self: *Self, _: Context) Result { const buffer_ = self.buffer; if (buffer_) |buffer| if (buffer.is_dirty()) diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index b0c2c03..0b92e31 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -564,6 +564,48 @@ const cmds = struct { } pub const create_new_file_meta: Meta = .{ .description = "New file" }; + pub fn save_file_as(self: *Self, ctx: Ctx) Result { + var file_path: []const u8 = undefined; + if (!(ctx.args.match(.{tp.extract(&file_path)}) catch false)) + return error.InvalidSafeFileAsArgument; + + if (self.get_active_editor()) |editor| { + const buffer = editor.buffer orelse return; + var content = std.ArrayListUnmanaged(u8).empty; + defer content.deinit(self.allocator); + try buffer.root.store(content.writer(self.allocator), buffer.file_eol_mode); + + var existing = false; + if (self.buffer_manager.get_buffer_for_file(file_path)) |new_buffer| { + if (new_buffer.is_dirty()) + return tp.exit("save as would overwrite unsaved changes"); + if (buffer == new_buffer) + return tp.exit("same file"); + existing = true; + } + try self.create_editor(); + try command.executeName("open_scratch_buffer", command.fmt(.{ + file_path, + "", + buffer.file_type_name, + })); + if (self.get_active_editor()) |new_editor| { + const new_buffer = new_editor.buffer orelse return; + if (existing) new_editor.update_buf(new_buffer.root) catch {}; // store an undo point + try new_buffer.reset_from_string_and_update(content.items); + new_buffer.mark_not_ephemeral(); + new_buffer.mark_dirty(); + new_editor.clamp(); + new_editor.update_buf(new_buffer.root) catch {}; + tui.need_render(); + } + try command.executeName("save_file", .{}); + if (buffer.is_ephemeral() and !buffer.is_dirty()) + _ = self.buffer_manager.close_buffer(buffer); + } + } + pub const save_file_as_meta: Meta = .{ .arguments = &.{.string} }; + pub fn delete_buffer(self: *Self, ctx: Ctx) Result { var file_path: []const u8 = undefined; if (!(ctx.args.match(.{tp.extract(&file_path)}) catch false)) From c88e2dd9754faeaa834f464f9614a961a2720111 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 5 Aug 2025 15:24:18 +0200 Subject: [PATCH 11/11] fix: don't leak Buffer.file_path --- src/buffer/Buffer.zig | 22 +++++++++++++++++----- src/buffer/Manager.zig | 8 ++++---- src/tui/mainview.zig | 4 ++-- src/tui/mode/overlay/buffer_palette.zig | 2 +- src/tui/status/tabs.zig | 8 ++++---- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/buffer/Buffer.zig b/src/buffer/Buffer.zig index 0a2d752..c094845 100644 --- a/src/buffer/Buffer.zig +++ b/src/buffer/Buffer.zig @@ -33,7 +33,7 @@ external_allocator: Allocator, root: Root, leaves_buf: ?[]Node = null, file_buf: ?[]const u8 = null, -file_path: []const u8 = "", +file_path_buf: std.ArrayListUnmanaged(u8) = .empty, last_save: ?Root = null, file_exists: bool = true, file_eol_mode: EolMode = .lf, @@ -1084,6 +1084,7 @@ pub fn deinit(self: *Self) void { if (self.meta) |buf| self.external_allocator.free(buf); if (self.file_buf) |buf| self.external_allocator.free(buf); if (self.leaves_buf) |buf| self.external_allocator.free(buf); + self.file_path_buf.deinit(self.external_allocator); self.arena.deinit(); self.external_allocator.destroy(self); } @@ -1098,6 +1099,17 @@ pub fn get_meta(self: *Self) ?[]const u8 { return self.meta; } +pub fn set_file_path(self: *Self, file_path: []const u8) void { + self.file_path_buf.clearRetainingCapacity(); + self.file_path_buf.appendSlice(self.external_allocator, file_path) catch |e| switch (e) { + error.OutOfMemory => @panic("OOM in Buffer.set_file_path"), + }; +} + +pub inline fn get_file_path(self: *const Self) []const u8 { + return self.file_path_buf.items; +} + pub fn update_last_used_time(self: *Self) void { self.utime = std.time.milliTimestamp(); } @@ -1177,7 +1189,7 @@ pub fn load_from_string(self: *const Self, s: []const u8, eol_mode: *EolMode, ut pub fn load_from_string_and_update(self: *Self, file_path: []const u8, s: []const u8) LoadFromStringError!void { self.root = try self.load_from_string(s, &self.file_eol_mode, &self.file_utf8_sanitized); - self.file_path = try self.allocator.dupe(u8, file_path); + self.set_file_path(file_path); self.last_save = self.root; self.last_save_eol_mode = self.file_eol_mode; self.file_exists = false; @@ -1257,7 +1269,7 @@ pub fn load_from_file_and_update(self: *Self, file_path: []const u8) LoadFromFil var eol_mode: EolMode = .lf; var utf8_sanitized: bool = false; self.root = try self.load_from_file(file_path, &file_exists, &eol_mode, &utf8_sanitized); - self.file_path = try self.allocator.dupe(u8, file_path); + self.set_file_path(file_path); self.last_save = self.root; self.file_exists = file_exists; self.file_eol_mode = eol_mode; @@ -1276,7 +1288,7 @@ pub fn reset_to_last_saved(self: *Self) void { } pub fn refresh_from_file(self: *Self) LoadFromFileError!void { - try self.load_from_file_and_update(self.file_path); + try self.load_from_file_and_update(self.get_file_path()); self.update_last_used_time(); } @@ -1366,7 +1378,7 @@ pub fn store_to_file_and_clean(self: *Self, file_path: []const u8) StoreToFileEr self.file_utf8_sanitized = false; if (self.ephemeral) { self.ephemeral = false; - self.file_path = try self.allocator.dupe(u8, file_path); + self.set_file_path(file_path); } } diff --git a/src/buffer/Manager.zig b/src/buffer/Manager.zig index 7bcd881..d2484ee 100644 --- a/src/buffer/Manager.zig +++ b/src/buffer/Manager.zig @@ -64,15 +64,15 @@ pub fn delete_buffer(self: *Self, file_path: []const u8) bool { pub fn retire(_: *Self, buffer: *Buffer, meta: ?[]const u8) void { if (meta) |buf| buffer.set_meta(buf) catch {}; - tp.trace(tp.channel.debug, .{ "buffer", "retire", buffer.file_path, "hidden", buffer.hidden, "ephemeral", buffer.ephemeral }); + tp.trace(tp.channel.debug, .{ "buffer", "retire", buffer.get_file_path(), "hidden", buffer.hidden, "ephemeral", buffer.ephemeral }); if (meta) |buf| tp.trace(tp.channel.debug, tp.message{ .buf = buf }); } pub fn close_buffer(self: *Self, buffer: *Buffer) void { buffer.hidden = true; - tp.trace(tp.channel.debug, .{ "buffer", "close", buffer.file_path, "hidden", buffer.hidden, "ephemeral", buffer.ephemeral }); + tp.trace(tp.channel.debug, .{ "buffer", "close", buffer.get_file_path(), "hidden", buffer.hidden, "ephemeral", buffer.ephemeral }); if (buffer.is_ephemeral()) { - _ = self.buffers.remove(buffer.file_path); + _ = self.buffers.remove(buffer.get_file_path()); buffer.deinit(); } } @@ -116,7 +116,7 @@ pub fn save_all(self: *const Self) Buffer.StoreToFileError!void { if (buffer.is_ephemeral()) buffer.mark_clean() else - try buffer.store_to_file_and_clean(buffer.file_path); + try buffer.store_to_file_and_clean(buffer.get_file_path()); } } diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 0b92e31..c210882 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1316,11 +1316,11 @@ fn get_next_mru_buffer(self: *Self) ?[]const u8 { defer self.allocator.free(buffers); const active_file_path = self.get_active_file_path(); for (buffers) |buffer| { - if (active_file_path) |fp| if (std.mem.eql(u8, fp, buffer.file_path)) + if (active_file_path) |fp| if (std.mem.eql(u8, fp, buffer.get_file_path())) continue; if (buffer.hidden) continue; - return buffer.file_path; + return buffer.get_file_path(); } return null; } diff --git a/src/tui/mode/overlay/buffer_palette.zig b/src/tui/mode/overlay/buffer_palette.zig index 3db56c1..794ca16 100644 --- a/src/tui/mode/overlay/buffer_palette.zig +++ b/src/tui/mode/overlay/buffer_palette.zig @@ -34,7 +34,7 @@ pub fn load_entries(palette: *Type) !usize { else ""; (try palette.entries.addOne()).* = .{ - .label = buffer.file_path, + .label = buffer.get_file_path(), .icon = buffer.file_type_icon orelse "", .color = buffer.file_type_color, .indicator = indicator, diff --git a/src/tui/status/tabs.zig b/src/tui/status/tabs.zig index 58ede12..c2de5b2 100644 --- a/src/tui/status/tabs.zig +++ b/src/tui/status/tabs.zig @@ -265,7 +265,7 @@ const TabBar = struct { fn navigate_to_tab(tab: *const TabBarTab) void { const buffer_manager = tui.get_buffer_manager() orelse @panic("tabs no buffer manager"); if (buffer_manager.buffer_from_ref(tab.buffer_ref)) |buffer| - tp.self_pid().send(.{ "cmd", "navigate", .{ .file = buffer.file_path } }) catch {}; + tp.self_pid().send(.{ "cmd", "navigate", .{ .file = buffer.get_file_path() } }) catch {}; } }; @@ -298,13 +298,13 @@ const Tab = struct { fn on_click(self: *@This(), _: *Button.State(@This())) void { const buffer_manager = tui.get_buffer_manager() orelse @panic("tabs no buffer manager"); if (buffer_manager.buffer_from_ref(self.buffer_ref)) |buffer| - tp.self_pid().send(.{ "cmd", "navigate", .{ .file = buffer.file_path } }) catch {}; + tp.self_pid().send(.{ "cmd", "navigate", .{ .file = buffer.get_file_path() } }) catch {}; } fn on_click2(self: *@This(), _: *Button.State(@This())) void { const buffer_manager = tui.get_buffer_manager() orelse @panic("tabs no buffer manager"); if (buffer_manager.buffer_from_ref(self.buffer_ref)) |buffer| - tp.self_pid().send(.{ "cmd", "close_buffer", .{buffer.file_path} }) catch {}; + tp.self_pid().send(.{ "cmd", "close_buffer", .{buffer.get_file_path()} }) catch {}; } fn render(self: *@This(), btn: *Button.State(@This()), theme: *const Widget.Theme) bool { @@ -450,7 +450,7 @@ const Tab = struct { } fn name_from_buffer(buffer: *Buffer) []const u8 { - const file_path = buffer.file_path; + const file_path = buffer.get_file_path(); if (file_path.len > 0 and file_path[0] == '*') return file_path; const basename_begin = std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep);