From 49569eeed803e3e399bd59831f012c43364b6a16 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 16 Oct 2026 09:37:56 -0500 Subject: [PATCH] chat: a chat can be deleted from its row's menu A chat row's right-click menu has Open and Delete, as a file row's does. Delete works as the file tree's: at once and for good, with no question first. Its tab closes first, which ends its client's session (an agent's process with it) and saves it a last time; then its transcript and its images folder go. A tab that will not close (something holds it, `sdk.Claim`) keeps its chat. The delete runs as the next frame begins, not inside the sidebar's draw. `chat.delete` does the same for a chat in the list, by path (empty: the chat in front); it refuses any path the list does not hold. --- plugin.zig | 1 + src/Chat.zig | 10 +++---- src/Commands.zig | 6 +++++ src/Explorer.zig | 70 ++++++++++++++++++++++++++++++++++++++++++++++++ src/api.zig | 3 +++ 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/plugin.zig b/plugin.zig index e365d5f..0009d97 100644 --- a/plugin.zig +++ b/plugin.zig @@ -95,6 +95,7 @@ fn drawExplorer(_: ?*anyopaque) anyerror!dvui.App.Result { fn beginFrame(_: *anyopaque) void { AcpClient.frame(); + Explorer.frame(sdk.host()); } fn deinit(_: *anyopaque) void { diff --git a/src/Chat.zig b/src/Chat.zig index e3a0840..7f917d8 100644 --- a/src/Chat.zig +++ b/src/Chat.zig @@ -80,7 +80,7 @@ pub fn fromBytes(path: []const u8, bytes: []const u8) !Chat { }; errdefer self.deinitFields(); if (std.mem.trim(u8, bytes, " \t\r\n").len > 0) { - const dir = try self.imagesDir(gpa); + const dir = try imagesDir(gpa, self.path); defer gpa.free(dir); if (self.transcript.load(bytes, dir, dvui.io)) |h| { defer gpa.free(h.client); @@ -122,9 +122,9 @@ pub fn chatIdOf(path: []const u8) chat.ChatId { return h.final() | 1; } -/// Where its images are kept: beside the transcript, in a folder of its name. -pub fn imagesDir(self: *const Chat, gpa: std.mem.Allocator) ![]u8 { - const stem = if (std.mem.endsWith(u8, self.path, ".chat")) self.path[0 .. self.path.len - 5] else self.path; +/// Where the images of the transcript at `path` are kept: beside it, in a folder of its name. +pub fn imagesDir(gpa: std.mem.Allocator, path: []const u8) ![]u8 { + const stem = if (std.mem.endsWith(u8, path, ".chat")) path[0 .. path.len - 5] else path; return std.fmt.allocPrint(gpa, "{s}.images", .{stem}); } @@ -142,7 +142,7 @@ pub fn save(self: *Chat) !void { pub fn write(self: *Chat) !void { var out: std.Io.Writer.Allocating = .init(self.gpa); defer out.deinit(); - const dir = try self.imagesDir(self.gpa); + const dir = try imagesDir(self.gpa, self.path); defer self.gpa.free(dir); try self.transcript.save(&out.writer, .{ .client = self.client_id, .title = self.title.items, .folder = self.folder, .@"resume" = self.resume_state }, dir, dvui.io); if (std.fs.path.dirname(self.path)) |d| std.Io.Dir.cwd().createDirPath(dvui.io, d) catch {}; diff --git a/src/Commands.zig b/src/Commands.zig index e56b778..fc54933 100644 --- a/src/Commands.zig +++ b/src/Commands.zig @@ -134,6 +134,7 @@ pub fn register(host: *sdk.Host, owner: *sdk.Plugin) !void { .{ "focus", api.Focus, cmdFocus }, .{ "set_settings", api.SetSettings, cmdSetSettings }, .{ "attach", api.Attach, cmdAttach }, + .{ "delete", api.Delete, cmdDelete }, }) |entry| { try host.registerCommand(.{ .id = "chat." ++ entry[0], @@ -333,6 +334,11 @@ fn cmdAttach(a: api.Attach, call: *sdk.Command.Call) !void { changed(c); } +fn cmdDelete(a: api.Delete, call: *sdk.Command.Call) !void { + const path = if (a.path.len > 0) a.path else (front() orelse return call.fail("no chat is in front", .{})).path; + if (!Explorer.delete(sdk.host(), path)) return call.fail("no chat {s} is in the list", .{path}); +} + fn cmdFocus(a: api.Focus, call: *sdk.Command.Call) !void { const c = try find(a.chat, call); _ = try sdk.host().openFile(.{ .path = c.path }); diff --git a/src/Explorer.zig b/src/Explorer.zig index 422d4cf..8573076 100644 --- a/src/Explorer.zig +++ b/src/Explorer.zig @@ -15,6 +15,7 @@ const sdk = @import("fizzy_sdk"); const dvui = @import("dvui"); const core = @import("core"); const Commands = @import("Commands.zig"); +const Chat = @import("Chat.zig"); const api = @import("api.zig"); const Row = struct { @@ -31,6 +32,10 @@ var rows: std.ArrayListUnmanaged(Row) = .empty; /// Read the directory again before the next draw. var stale = true; var migrated = false; +/// A chat asked to be deleted (its row's menu, `chat.delete`), deleted as the next frame begins +/// (`frame`) rather than in the middle of this one, which may be drawing its tab. Owned by +/// `host.allocator`. +var doomed: ?[]u8 = null; /// Something on disk changed (a chat saved, made or removed, a client came or went): list again. pub fn changed() void { @@ -46,6 +51,8 @@ pub fn deinit(gpa: std.mem.Allocator) void { } pluses.deinit(gpa); open.deinit(gpa); + if (doomed) |p| gpa.free(p); + doomed = null; } fn clear(gpa: std.mem.Allocator) void { @@ -192,6 +199,51 @@ fn readHead(gpa: std.mem.Allocator, d: std.Io.Dir, name: []const u8) Head { return .{ .title = gpa.dupe(u8, title) catch &.{}, .folder = gpa.dupe(u8, folder) catch &.{} }; } +/// Delete the chat at `path` as the next frame begins: only one the list holds, so nothing else on +/// disk can be deleted this way (`chat.delete` takes any path). False when it is not in the list. +pub fn delete(host: *sdk.Host, path: []const u8) bool { + if (stale) { + stale = false; + scan(host); + } + for (rows.items) |r| { + if (!std.mem.eql(u8, r.path, path)) continue; + const p = host.allocator.dupe(u8, path) catch return false; + if (doomed) |old| host.allocator.free(old); + doomed = p; + host.refresh(); + return true; + } + return false; +} + +/// Delete the chat at `path` as the file tree deletes a file (the workbench's `files.zig`, its +/// row menu's Delete): at once and for good, with no question first. Its tab closes first, which +/// tells its client (an agent's session ends with it) and saves it a last time; a tab that does +/// not close (something is working on it, `sdk.Claim`) keeps its chat. Then its transcript and its +/// images go. +fn deleteChat(host: *sdk.Host, path: []const u8) void { + if (host.docFromPath(path)) |doc| { + host.closeDocById(doc.id) catch |err| return dvui.log.err("chat: could not close {s}: {t}", .{ path, err }); + if (host.docFromPath(path) != null) return dvui.log.warn("chat: {s} is still open, so it stays", .{path}); + } + const cwd = std.Io.Dir.cwd(); + cwd.deleteFile(dvui.io, path) catch |err| return dvui.log.err("chat: could not delete {s}: {t}", .{ path, err }); + if (Chat.imagesDir(host.allocator, path)) |images| { + defer host.allocator.free(images); + cwd.deleteTree(dvui.io, images) catch |err| dvui.log.warn("chat: could not delete {s}: {t}", .{ images, err }); + } else |_| {} + changed(); +} + +/// At the start of a frame, before anything draws: a chat asked to be deleted goes now. +pub fn frame(host: *sdk.Host) void { + const path = doomed orelse return; + doomed = null; + defer host.allocator.free(path); + deleteChat(host, path); +} + pub fn draw(host: *sdk.Host) !dvui.App.Result { if (stale) { stale = false; @@ -451,6 +503,24 @@ fn chatRow(host: *sdk.Host, tree: *core.widgets.TreeWidget, r: Row, selected: bo const branch = tree.branch(@src(), .{ .can_accept_children = false, .draggable = false, .branch_id = id }, rowOptions(id, selected)); defer branch.deinit(); core.anchor.mark(branch.button.data(), "chat.chat:{s}", .{r.path}); + // Its right-click menu, as a file row's is. + { + var context = core.widgets.context(@src(), .{ .rect = branch.button.data().borderRectScale().r }, .{ .id_extra = id }); + defer context.deinit(); + if (context.activePoint()) |point| { + var menu = core.widgets.contextMenu(@src(), point, .{}); + defer menu.deinit(); + if (core.widgets.menuRow(@src(), "Open", .{ .icon = dvui.entypo.chat }) != null) { + _ = host.openFile(.{ .path = r.path }) catch {}; + menu.close(); + } + _ = dvui.separator(@src(), .{ .expand = .horizontal }); + if (core.widgets.menuRow(@src(), "Delete", .{ .icon = dvui.entypo.trash }) != null) { + _ = delete(host, r.path); + menu.close(); + } + } + } { var slot = core.widgets.treeRowGlyph(@src(), .{}); slot.deinit(); diff --git a/src/api.zig b/src/api.zig index a4b9d19..bb8bef2 100644 --- a/src/api.zig +++ b/src/api.zig @@ -156,6 +156,9 @@ pub const Attach = struct { chat: ChatId = 0, path: []const u8 }; /// The settings the chat offers by its message box, replacing any it had. pub const SetSettings = struct { chat: ChatId, settings: []const Setting = &.{} }; pub const Focus = struct { chat: ChatId }; +/// A chat in the list to delete, by its transcript's path (empty: the chat in front), as its +/// row's Delete does: its tab closes, and its transcript and images go for good. +pub const Delete = struct { path: []const u8 = "" }; /// To a client. pub const Send = struct {