diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 103aa871..5ee97f0e 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -696,6 +696,16 @@ Invoke-Native "Workspace manager data executable tests" { Push-Location $shellRoot try { & $zig test src\WorkspaceManager.zig } finally { Pop-Location } } +Invoke-Native "Workspace teardown executable tests" { + $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent + $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") + if (-not $winghosttyRoot) { $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" } + $include = Join-Path $winghosttyRoot "include" + Push-Location $shellRoot + try { + & $zig test src\WorkspaceTeardown.zig -target x86_64-windows-msvc -lc -ladvapi32 -lshell32 -lkernel32 "-I$include" + } finally { Pop-Location } +} Invoke-Native "Workspace manager form data executable tests" { $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 83a1eec0..a7eaf9a7 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -46,6 +46,7 @@ const WorkspaceControls = @import("WorkspaceControls.zig"); const WorkspaceLifecycle = @import("WorkspaceLifecycle.zig"); const WorkspaceManager = @import("WorkspaceManager.zig"); const WorkspaceManagerForm = @import("WorkspaceManagerForm.zig"); +const WorkspaceTeardown = @import("WorkspaceTeardown.zig"); const Win32 = @import("Win32.zig"); const c = Win32.c; @@ -203,7 +204,12 @@ fn openWorkspaceWith(comptime Api: type, allocator: std.mem.Allocator, current_i } const WorkspaceMutation = union(enum) { rename: []const u8, delete }; -const WorkspaceMutationResult = enum { renamed, deleted, cancelled }; +const WorkspaceMutationResult = union(enum) { + renamed, + cancelled, + /// Deletion ran; the report says how far it got and what, if anything, it put back. + torn_down: WorkspaceTeardown.Report, +}; const workspace_delete_confirmation_flags = c.MB_YESNO | c.MB_ICONWARNING | c.MB_DEFBUTTON2; const WorkspaceMutationApi = struct { @@ -213,8 +219,8 @@ const WorkspaceMutationApi = struct { fn confirm(owner: c.HWND) c.INT { return c.MessageBoxW( owner, - std.unicode.utf8ToUtf16LeStringLiteral("This permanently deletes the workspace folder and all of its projects and loops. Continue?").ptr, - std.unicode.utf8ToUtf16LeStringLiteral("Delete Workspace").ptr, + std.unicode.utf8ToUtf16LeStringLiteral(WorkspaceTeardown.confirmation_text).ptr, + std.unicode.utf8ToUtf16LeStringLiteral(WorkspaceTeardown.delete_caption).ptr, workspace_delete_confirmation_flags, ); } @@ -227,8 +233,12 @@ const WorkspaceMutationApi = struct { if (c.MoveFileW(from.ptr, to.ptr) == 0) return error.WorkspaceRenameFailed; } - fn delete(path: []const u8) !void { - try std.fs.deleteTreeAbsolute(path); + fn delete( + allocator: std.mem.Allocator, + path: []const u8, + current_identity: []const u8, + ) !WorkspaceTeardown.Report { + return WorkspaceTeardown.deleteRecoverably(allocator, path, current_identity); } }; @@ -277,10 +287,15 @@ fn mutateWorkspaceWith( .delete => { if (Api.confirm(owner) != c.IDYES) return .cancelled; try requireIdentifiedClosedWorkspace(Api, key); + // The confirmation pumped messages, so the target is re-derived from the path + // we captured and re-checked against Default and this window before any effect. + const confirmed = try WorkspaceLifecycle.pathIdentity(allocator, source); + defer allocator.free(confirmed); + if (!std.mem.eql(u8, confirmed, identity)) return error.WorkspaceIdentityChanged; + if (std.mem.eql(u8, confirmed, current_identity)) return error.CurrentWorkspace; var directory = try std.fs.openDirAbsolute(source, .{}); directory.close(); - try Api.delete(source); - return .deleted; + return .{ .torn_down = try Api.delete(allocator, source, current_identity) }; }, } } @@ -6287,6 +6302,13 @@ pub const App = struct { if (!self.validateManagerAction(action)) return; self.renameWorkspaceTo(target, result.values[0]); }, + .delete => { + const target = action.target.?; + // Revalidated once more here: the manager's own modal has closed since the + // action was captured. + if (!self.validateManagerAction(action)) return; + self.deleteWorkspaceTarget(target); + }, } } @@ -6331,12 +6353,31 @@ pub const App = struct { self.setStatus("Workspace was not found"); return; }; + self.deleteWorkspaceTarget(workspace); + } + + /// Shared by the menu path and the manager's Delete button so both get the same + /// refusals, the same confirmation, and the same honest status. + fn deleteWorkspaceTarget(self: *App, workspace: WorkspaceLifecycle.Workspace) void { const outcome = mutateWorkspaceWith(WorkspaceMutationApi, self.allocator, self.window.hwnd, self.workspace_identity, workspace, .delete) catch |err| { self.setStatus(workspaceMutationFailure(err)); return; }; - if (!self.refreshWorkspaceList()) return; - self.setStatus(if (outcome == .deleted) "Workspace deleted" else "Workspace deletion cancelled"); + switch (outcome) { + .renamed => {}, + .cancelled => self.setStatus("Workspace deletion cancelled"), + .torn_down => |value| { + var report = value; + defer report.deinit(self.allocator); + if (!self.refreshWorkspaceList()) return; + const message = WorkspaceTeardown.statusMessage(self.allocator, report) catch { + self.setStatus("Workspace deletion finished but its result could not be described"); + return; + }; + defer self.allocator.free(message); + self.setStatus(message); + }, + } } fn cycleWorkspace(self: *App, direction: isize) void { @@ -8254,10 +8295,20 @@ const WorkspaceMutationFixture = struct { } return response; } - fn delete(path: []const u8) !void { + /// Deletion is simulated here: App owns the refusals and the confirmation, while the + /// recoverable teardown itself is proven in WorkspaceTeardown against its own seam. No + /// test ever recycles a folder, signals a daemon, or kills a session. + fn delete( + allocator: std.mem.Allocator, + path: []const u8, + current_identity: []const u8, + ) !WorkspaceTeardown.Report { + _ = allocator; + _ = current_identity; deletions += 1; try std.testing.expectEqualStrings(expected_path, path); - if (!skip_delete) try WorkspaceMutationApi.delete(path); + if (!skip_delete) try std.fs.deleteTreeAbsolute(path); + return .{ .outcome = .deleted, .sessions_targeted = 0, .sessions_known = true }; } }; @@ -8323,7 +8374,9 @@ test "workspace deletion guards default current open legacy and every non Yes re WorkspaceMutationFixture.reset(saved_alpha_path); WorkspaceMutationFixture.response = c.IDYES; WorkspaceMutationFixture.list_to_release = &list; - try std.testing.expectEqual(WorkspaceMutationResult.deleted, try mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .delete)); + var torn_down = try mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .delete); + defer torn_down.torn_down.deinit(allocator); + try std.testing.expectEqual(WorkspaceTeardown.Outcome.deleted, torn_down.torn_down.outcome); try std.testing.expect(WorkspaceMutationFixture.held_during_confirmation); try std.testing.expectEqual(@as(usize, 1), WorkspaceMutationFixture.deletions); try requireDeletedWorkspace(saved_alpha_path); diff --git a/graphcode-windows/src/DaemonClient.zig b/graphcode-windows/src/DaemonClient.zig index f52491b6..fda28177 100644 --- a/graphcode-windows/src/DaemonClient.zig +++ b/graphcode-windows/src/DaemonClient.zig @@ -341,19 +341,7 @@ pub const DaemonClient = struct { pub fn currentDaemonLockName(self: *DaemonClient, allocator: std.mem.Allocator) ![]u8 { _ = self; - const support = try supportDirectory(allocator); - defer allocator.free(support); - const normalized = try normalizedSupportPath(allocator, support); - defer allocator.free(normalized); - const support_hash = try sha256Hex(allocator, normalized); - defer allocator.free(support_hash); - const sid = try currentSID(allocator); - defer allocator.free(sid); - return std.fmt.allocPrint( - allocator, - "Global\\graphcode-daemon-{s}-{s}", - .{ sid, support_hash[0..20] }, - ); + return daemonLockName(allocator); } fn validateSupportDirectory(allocator: std.mem.Allocator, support_directory: []const u8) !void { @@ -1709,6 +1697,28 @@ fn currentSID(allocator: std.mem.Allocator) ![]u8 { return wideToUtf8(allocator, sid_text); } +/// Lock name for the daemon that owns `support_directory`. Derived exactly as the Swift +/// daemon derives it, so this addresses another workspace's daemon, not only our own. +pub fn daemonLockNameFor(allocator: std.mem.Allocator, support_directory: []const u8) ![]u8 { + const normalized = try normalizedSupportPath(allocator, support_directory); + defer allocator.free(normalized); + const support_hash = try sha256Hex(allocator, normalized); + defer allocator.free(support_hash); + const sid = try currentSID(allocator); + defer allocator.free(sid); + return std.fmt.allocPrint( + allocator, + "Global\\graphcode-daemon-{s}-{s}", + .{ sid, support_hash[0..20] }, + ); +} + +pub fn daemonLockName(allocator: std.mem.Allocator) ![]u8 { + const support = try supportDirectory(allocator); + defer allocator.free(support); + return daemonLockNameFor(allocator, support); +} + fn sha256Hex(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 { var digest: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); diff --git a/graphcode-windows/src/WorkspaceManager.zig b/graphcode-windows/src/WorkspaceManager.zig index bee2a28e..80d80cb4 100644 --- a/graphcode-windows/src/WorkspaceManager.zig +++ b/graphcode-windows/src/WorkspaceManager.zig @@ -12,7 +12,7 @@ pub const Summary = union(enum) { saved: Counts, failed: SummaryFailure, }; -pub const delete_reason = "Delete is unavailable here until recoverable deletion with daemon/session teardown is implemented."; +pub const delete_note = "Delete stops that workspace's daemon, ends its saved terminal sessions, and moves its folder to the Recycle Bin, where Windows can restore it."; pub const Row = struct { workspace: Lifecycle.Workspace, @@ -36,7 +36,7 @@ pub const Row = struct { } }; -pub const ActionKind = enum { open, rename, new }; +pub const ActionKind = enum { open, rename, new, delete }; pub const Action = struct { kind: ActionKind, target: ?Lifecycle.Workspace = null, @@ -79,13 +79,15 @@ pub const Model = struct { const index = selection orelse return error.NoWorkspaceSelected; if (index >= self.rows.len) return error.NoWorkspaceSelected; const row = self.rows[index]; - if (kind == .rename) try requireRename(row.refusal()); + if (kind == .rename or kind == .delete) try requireExclusiveTarget(row.refusal()); if (kind == .open and !row.canOpen()) return error.UnidentifiedWorkspaceWindow; return .{ .kind = kind, .target = try Lifecycle.copyWorkspace(self.allocator, row.workspace) }; } }; -fn requireRename(refusal: Refusal) !void { +/// Renaming and deleting both need a row nothing else holds: not Default, not this window's, +/// not open elsewhere, and not a window we failed to identify. +fn requireExclusiveTarget(refusal: Refusal) !void { return switch (refusal) { .none => {}, .default => error.DefaultWorkspace, @@ -106,7 +108,7 @@ pub fn validateTarget(allocator: std.mem.Allocator, action: Action, current_iden if (!std.mem.eql(u8, identity, target.identity)) return error.WorkspaceIdentityChanged; const is_default = std.mem.eql(u8, identity, default_identity); if (is_default != target.is_default) return error.WorkspaceIdentityChanged; - if (action.kind == .rename) { + if (action.kind == .rename or action.kind == .delete) { if (is_default) return error.DefaultWorkspace; if (std.mem.eql(u8, identity, current_identity)) return error.CurrentWorkspace; } @@ -115,9 +117,9 @@ pub fn validateTarget(allocator: std.mem.Allocator, action: Action, current_iden pub fn refusalText(refusal: Refusal) []const u8 { return switch (refusal) { .none => "Closed", - .default => "Default workspace - cannot rename", - .current => "This window - cannot rename", - .open => "Open elsewhere - cannot rename", + .default => "Default workspace - cannot rename or delete", + .current => "This window - cannot rename or delete", + .open => "Open elsewhere - cannot rename or delete", .unidentified => "Older/unidentified window - close it before changing workspaces", .unavailable => "Window ownership unavailable - actions blocked", }; @@ -646,6 +648,50 @@ test "workspace manager unknown error and refusal states never become zero count try std.testing.expectEqualStrings("Saved: 0 projects / 0 top-level loops", zero); } +test "workspace manager Delete refuses every row rename refuses and keeps its own target" { + const allocator = std.testing.allocator; + var def = alpha; + def.is_default = true; + var model = try Model.init(allocator, &.{ def, beta, alpha, beta }, "c:/elsewhere", &.{ .closed, .open, .unidentified, .unavailable }); + defer model.deinit(); + for (0..model.rows.len) |i| { + try std.testing.expectError( + if (i == 0) error.DefaultWorkspace else if (i == 1) error.WorkspaceInUse else error.UnidentifiedWorkspaceWindow, + model.capture(.delete, i), + ); + } + try std.testing.expectError(error.NoWorkspaceSelected, model.capture(.delete, null)); + try std.testing.expectError(error.NoWorkspaceSelected, model.capture(.delete, model.rows.len)); + + var closed = try Model.init(allocator, &.{alpha}, "c:/elsewhere", &.{.closed}); + defer closed.deinit(); + var action = try closed.capture(.delete, 0); + defer action.deinit(allocator); + try std.testing.expectEqual(ActionKind.delete, action.kind); + try std.testing.expectEqualStrings(alpha.identity, action.target.?.identity); +} + +test "workspace manager Delete revalidates identity default and current after confirmation" { + const allocator = std.testing.allocator; + const action = Action{ .kind = .delete, .target = alpha }; + try std.testing.expectError(error.CurrentWorkspace, validateTarget(allocator, action, alpha.identity, "c:/fixture/.graphcode")); + try std.testing.expectError(error.WorkspaceIdentityChanged, validateTarget(allocator, action, "c:/elsewhere", alpha.identity)); + var default_target = Action{ .kind = .delete, .target = alpha }; + default_target.target.?.is_default = true; + try std.testing.expectError(error.DefaultWorkspace, validateTarget(allocator, default_target, "c:/elsewhere", alpha.identity)); + var drifted = action; + drifted.target.?.path = beta.path; + try std.testing.expectError(error.WorkspaceIdentityChanged, validateTarget(allocator, drifted, "c:/elsewhere", "c:/fixture/.graphcode")); + try validateTarget(allocator, action, "c:/elsewhere", "c:/fixture/.graphcode"); +} + +test "workspace manager refusal text covers deletion as well as renaming" { + for ([_]Refusal{ .default, .current, .open }) |refusal| { + const text = refusalText(refusal); + try std.testing.expect(std.mem.indexOf(u8, text, "rename or delete") != null); + } +} + const graph_fixture = \\{"id":"11111111-1111-1111-1111-111111111111","project":{"path":"C:\\project","name":"Project","lastOpenedAt":0},"nodes":[{"id":"22222222-2222-2222-2222-222222222222","title":"Loop","loopType":"turnBased","subGraph":{"nodes":[{},{}]}}],"edges":[]} ; diff --git a/graphcode-windows/src/WorkspaceManagerForm.zig b/graphcode-windows/src/WorkspaceManagerForm.zig index 5d2ff77f..adb233cf 100644 --- a/graphcode-windows/src/WorkspaceManagerForm.zig +++ b/graphcode-windows/src/WorkspaceManagerForm.zig @@ -24,6 +24,7 @@ pub fn actionForCommand(command: usize) ?Manager.ActionKind { open_id => .open, rename_id => .rename, new_id => .new, + delete_id => .delete, else => null, }; } @@ -40,6 +41,7 @@ const Dialog = struct { open: c.HWND = null, rename: c.HWND = null, new: c.HWND = null, + delete: c.HWND = null, closed: bool = false, failure: ?anyerror = null, @@ -107,12 +109,11 @@ fn run(parent: c.HWND, dialog: *Dialog) !void { _ = try control(hwnd, dpi, "STATIC", "Workspaces - saved summaries are not live totals", 0, 0, 16, 12, 870, 24); dialog.list = try control(hwnd, dpi, "LISTBOX", "", list_id, c.WS_TABSTOP | c.WS_VSCROLL | c.WS_HSCROLL | c.WS_BORDER | c.LBS_NOTIFY | c.LBS_NOINTEGRALHEIGHT, 16, 40, 870, list_height); dialog.detail = try control(hwnd, dpi, "EDIT", "Select a workspace to see its full path and actions.", detail_id, c.WS_TABSTOP | c.ES_READONLY | c.ES_MULTILINE | c.ES_AUTOVSCROLL | c.WS_VSCROLL, 16, detail_top, 870, 104); - _ = try control(hwnd, dpi, "STATIC", Manager.delete_reason, 0, 0, 16, reason_top, 870, 36); + _ = try control(hwnd, dpi, "STATIC", Manager.delete_note, 0, 0, 16, reason_top, 870, 36); dialog.open = try control(hwnd, dpi, "BUTTON", "Open", open_id, c.WS_TABSTOP, 16, buttons_top, 110, 30); dialog.rename = try control(hwnd, dpi, "BUTTON", "Rename...", rename_id, c.WS_TABSTOP, 138, buttons_top, 120, 30); dialog.new = try control(hwnd, dpi, "BUTTON", "New Workspace...", new_id, c.WS_TABSTOP, 270, buttons_top, 160, 30); - const delete = try control(hwnd, dpi, "BUTTON", "Delete (unavailable)", delete_id, c.WS_DISABLED, 442, buttons_top, 180, 30); - _ = delete; + dialog.delete = try control(hwnd, dpi, "BUTTON", "Delete...", delete_id, c.WS_TABSTOP, 442, buttons_top, 180, 30); const done = try control(hwnd, dpi, "BUTTON", "Done", done_id, c.WS_TABSTOP | c.BS_DEFPUSHBUTTON, 776, buttons_top, 110, 30); _ = c.SendMessageW(hwnd, c.DM_SETDEFID, done_id, 0); _ = try syncRows(dialog); @@ -204,11 +205,20 @@ fn syncRows(dialog: *Dialog) !bool { return changed; } +/// Delete is offered only for a row nothing else holds, and never while an action is waiting +/// on the saved-summary reader. +fn deleteEnabled(waiting: bool, refusal: ?Manager.Refusal) bool { + if (waiting) return false; + const value = refusal orelse return false; + return value == .none; +} + fn syncDetails(dialog: *Dialog) !void { const waiting = dialog.handoff.pending != null; const row: ?Manager.Row = if (dialog.selection) |index| dialog.model.rows[index] else null; _ = c.EnableWindow(dialog.open, @intFromBool(!waiting and row != null and row.?.canOpen())); _ = c.EnableWindow(dialog.rename, @intFromBool(!waiting and row != null and row.?.refusal() == .none)); + _ = c.EnableWindow(dialog.delete, @intFromBool(deleteEnabled(waiting, if (row) |value| value.refusal() else null))); _ = c.EnableWindow(dialog.new, @intFromBool(!waiting)); _ = c.EnableWindow(dialog.list, @intFromBool(!waiting)); const allocator = dialog.model.allocator; @@ -292,14 +302,23 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) return c.DefWindowProcW(hwnd, message, wparam, lparam); } -test "workspace manager controls never route Delete or Done to a mutation" { +test "workspace manager controls route Delete to a delete action and never route Done" { try std.testing.expectEqual(Manager.ActionKind.open, actionForCommand(open_id).?); try std.testing.expectEqual(Manager.ActionKind.rename, actionForCommand(rename_id).?); try std.testing.expectEqual(Manager.ActionKind.new, actionForCommand(new_id).?); - for ([_]usize{ done_id, cancel_id, delete_id, list_id, detail_id, 5116, 5119, 5152, 5154 }) |id| + try std.testing.expectEqual(Manager.ActionKind.delete, actionForCommand(delete_id).?); + for ([_]usize{ done_id, cancel_id, list_id, detail_id, 5116, 5119, 5152, 5154 }) |id| try std.testing.expect(actionForCommand(id) == null); } +test "workspace manager Delete is enabled exactly where a deletable row is selected" { + for ([_]Manager.Refusal{ .none, .default, .current, .open, .unidentified, .unavailable }) |refusal| { + try std.testing.expectEqual(refusal == .none, deleteEnabled(false, refusal)); + try std.testing.expect(!deleteEnabled(true, refusal)); + } + try std.testing.expect(!deleteEnabled(false, null)); +} + test "workspace manager modal lease blocks reentry and releases before handoff" { var lease = try NativeForms.ModalLease.acquire(); defer lease.deinit(); diff --git a/graphcode-windows/src/WorkspaceTeardown.zig b/graphcode-windows/src/WorkspaceTeardown.zig new file mode 100644 index 00000000..3259ef15 --- /dev/null +++ b/graphcode-windows/src/WorkspaceTeardown.zig @@ -0,0 +1,670 @@ +const std = @import("std"); +const Lifecycle = @import("WorkspaceLifecycle.zig"); +const Manager = @import("WorkspaceManager.zig"); +const DaemonClient = @import("DaemonClient.zig"); +const Win32 = @import("Win32.zig"); +const c = Win32.c; + +/// Recoverable workspace deletion, mirroring macOS `WorkspaceClient.delete`: the daemon +/// goes first, the folder goes to the Recycle Bin rather than being unlinked, and the +/// running sessions are ended. +/// +/// The order differs from macOS in one deliberate way. There, sessions are killed before +/// `trashItem`; here the kill runs *after* the move. Killing a session cannot be undone +/// and `SHFileOperationW` can fail late, so the irreversible step must not precede the +/// fallible one. What makes that safe is the staging rename: the folder is moved aside +/// first, which is atomic, reversible, and proves exclusive access before anything else +/// happens. Every failure after it puts the folder back. +pub const staging_prefix = ".gc-deleting-"; +pub const daemon_stop_timeout_ms: i64 = 5_000; +pub const recycle_flags: c.FILEOP_FLAGS = + c.FOF_ALLOWUNDO | c.FOF_NOCONFIRMATION | c.FOF_SILENT | c.FOF_NOERRORUI; + +pub const confirmation_text = + "Delete this workspace? Its terminal sessions are ended and its daemon stopped; " ++ + "the folder moves to the Recycle Bin, where it stays recoverable."; +pub const delete_caption = "Delete Workspace"; + +pub const Outcome = enum { deleted, refused, rolled_back, stranded }; + +pub const Report = struct { + outcome: Outcome, + cause: ?anyerror = null, + /// Owned, and only set for `.stranded`: the folder is on disk under this name. + staged_path: ?[]u8 = null, + sessions_targeted: usize = 0, + sessions_known: bool = true, + + pub fn deinit(self: *Report, allocator: std.mem.Allocator) void { + if (self.staged_path) |value| allocator.free(value); + self.staged_path = null; + } +}; + +/// A sibling name the workspace list can never pick up: it neither carries the workspace +/// directory prefix nor equals the Default directory name, so a staged folder is invisible +/// to `WorkspaceLifecycle.listFromHome` while it exists. +pub fn stagingPath(allocator: std.mem.Allocator, path: []const u8, pid: u32) ![]u8 { + const trimmed = std.mem.trimRight(u8, path, "\\/"); + const parent = std.fs.path.dirnameWindows(trimmed) orelse return error.InvalidWorkspacePath; + const name = std.fs.path.basenameWindows(trimmed); + if (name.len == 0 or parent.len == 0) return error.InvalidWorkspacePath; + if (std.mem.eql(u8, name, trimmed)) return error.InvalidWorkspacePath; + return std.fmt.allocPrint(allocator, "{s}\\{s}{d}-{s}", .{ parent, staging_prefix, pid, name }); +} + +/// The daemon we are about to stop must not be this window's own daemon. Identity paths and +/// lock names normalize differently, so both are checked before anything is signalled. +pub fn requireDistinctDaemon(target_lock: []const u8, current_lock: []const u8) !void { + if (std.mem.eql(u8, target_lock, current_lock)) return error.WorkspaceDaemonIdentityMatch; +} + +/// Session ids saved by the workspace, read best-effort: an unreadable or malformed file +/// costs us its sessions, not the deletion. Mirrors the macOS union of graph node ids; +/// surfaces saved outside the workspace directory stay out of reach and are reported as +/// unknown rather than assumed absent. +pub fn collectSessionIds(allocator: std.mem.Allocator, path: []const u8) ![][]u8 { + var ids: std.ArrayListUnmanaged([]u8) = .empty; + errdefer { + for (ids.items) |id| allocator.free(id); + ids.deinit(allocator); + } + const projects_path = try std.fs.path.join(allocator, &.{ path, "projects" }); + defer allocator.free(projects_path); + var projects = std.fs.openDirAbsolute(projects_path, .{ .iterate = true }) catch + return ids.toOwnedSlice(allocator); + defer projects.close(); + var iterator = projects.iterate(); + var entries: usize = 0; + var remaining: usize = Manager.max_workspace_bytes; + while (iterator.next() catch null) |entry| { + entries += 1; + if (entries > Manager.max_entries) break; + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".json")) continue; + const bytes = projects.readFileAlloc( + allocator, + entry.name, + @min(Manager.max_file_bytes, remaining), + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => continue, + }; + defer allocator.free(bytes); + remaining -= bytes.len; + try appendSessionIds(allocator, &ids, bytes, entry.name); + } + return ids.toOwnedSlice(allocator); +} + +fn appendSessionIds( + allocator: std.mem.Allocator, + ids: *std.ArrayListUnmanaged([]u8), + bytes: []const u8, + name: []const u8, +) !void { + Manager.checkDepth(bytes) catch return; + const scratch = try allocator.alloc(u8, Manager.parser_bytes); + defer allocator.free(scratch); + var fixed = std.heap.FixedBufferAllocator.init(scratch); + const parsed = std.json.parseFromSlice(std.json.Value, fixed.allocator(), bytes, .{}) catch return; + defer parsed.deinit(); + if (parsed.value == .array and std.mem.endsWith(u8, name, ".mailroom.json")) return; + if (parsed.value != .object) return; + const nodes = parsed.value.object.get("nodes") orelse return; + if (nodes != .array) return; + for (nodes.array.items) |node| { + if (node != .object) continue; + const id = node.object.get("id") orelse continue; + if (id != .string or !isSessionId(id.string)) continue; + try ids.append(allocator, try allocator.dupe(u8, id.string)); + } +} + +/// Session ids are UUIDs on both platforms, so an id that is not one is not ours to end. +fn isSessionId(text: []const u8) bool { + if (text.len != 36) return false; + for (text, 0..) |byte, index| { + if (index == 8 or index == 13 or index == 18 or index == 23) { + if (byte != '-') return false; + } else if (!std.ascii.isHex(byte)) return false; + } + return true; +} + +pub fn freeSessionIds(allocator: std.mem.Allocator, ids: [][]u8) void { + for (ids) |id| allocator.free(id); + allocator.free(ids); +} + +/// One invocation, every id, `--force`: matching the macOS `endSessions` shape, where a +/// missing or unhappy zmx is not allowed to fail an otherwise completed deletion. +pub fn killArgv(allocator: std.mem.Allocator, zmx_path: []const u8, ids: []const []u8) ![][]const u8 { + const argv = try allocator.alloc([]const u8, ids.len + 3); + argv[0] = zmx_path; + argv[1] = "kill"; + for (ids, 0..) |id, index| argv[index + 2] = id; + argv[argv.len - 1] = "--force"; + return argv; +} + +fn causeText(cause: ?anyerror) []const u8 { + const err = cause orelse return "an unknown failure"; + return switch (err) { + error.WorkspaceRenameFailed => "the folder could not be moved, so something is still using it", + error.WorkspaceRecycleFailed => "the folder could not be moved to the Recycle Bin", + error.WorkspaceDaemonStopTimedOut => "its background daemon did not stop in time", + error.WorkspaceDaemonStopFailed => "its background daemon could not be signalled", + error.WorkspaceDaemonUnreachable => "its background daemon is running but cannot be reached", + error.WorkspaceDaemonIdentityMatch => "the daemon it uses belongs to this window", + error.WorkspaceIsCurrent => "it is the workspace this window has open", + error.WorkspaceStagingCollision => "a leftover folder from an earlier delete is in the way", + error.InvalidWorkspacePath => "its location is not a workspace folder", + else => "an unexpected failure", + }; +} + +pub fn statusMessage(allocator: std.mem.Allocator, report: Report) ![]u8 { + return switch (report.outcome) { + .deleted => if (report.sessions_known) std.fmt.allocPrint( + allocator, + "Workspace deleted: daemon stopped, {d} saved terminal session(s) ended, folder moved to the Recycle Bin.", + .{report.sessions_targeted}, + ) else std.fmt.allocPrint( + allocator, + "Workspace deleted and moved to the Recycle Bin. Its saved sessions could not be read, so some may still be running.", + .{}, + ), + .refused => std.fmt.allocPrint( + allocator, + "Workspace not deleted: {s}. Nothing was changed.", + .{causeText(report.cause)}, + ), + .rolled_back => std.fmt.allocPrint( + allocator, + "Workspace restored because {s}. No sessions were ended; its daemon is stopped and starts again when the workspace is opened.", + .{causeText(report.cause)}, + ), + .stranded => std.fmt.allocPrint( + allocator, + "Workspace could not be restored after {s}. No sessions were ended; the folder is on disk as {s} and renaming it back restores the workspace.", + .{ causeText(report.cause), report.staged_path orelse "an unknown path" }, + ), + }; +} + +fn rollbackReport( + comptime Api: type, + allocator: std.mem.Allocator, + staged: []u8, + path: []const u8, + cause: anyerror, + outcome: Outcome, +) Report { + Api.rename(allocator, staged, path) catch { + return .{ .outcome = .stranded, .cause = cause, .staged_path = staged }; + }; + allocator.free(staged); + return .{ .outcome = outcome, .cause = cause }; +} + +/// Recoverable deletion. The staging rename comes first because it is atomic, reversible, +/// and the only way to prove exclusive access before an irreversible step; killing sessions +/// comes last because it is the one step nothing can undo. +pub fn deleteRecoverablyWith( + comptime Api: type, + allocator: std.mem.Allocator, + path: []const u8, + current_identity: []const u8, +) !Report { + const staged = try stagingPath(allocator, path, Api.pid()); + Api.rename(allocator, path, staged) catch |err| { + allocator.free(staged); + return .{ .outcome = .refused, .cause = err }; + }; + const sessions: ?[][]u8 = Api.collectSessions(allocator, staged) catch |err| blk: { + if (err == error.OutOfMemory) { + Api.rename(allocator, staged, path) catch {}; + allocator.free(staged); + return error.OutOfMemory; + } + break :blk null; + }; + defer if (sessions) |owned| freeSessionIds(allocator, owned); + Api.stopDaemon(allocator, path, current_identity) catch |err| + return rollbackReport(Api, allocator, staged, path, err, .refused); + Api.recycle(allocator, staged) catch |err| + return rollbackReport(Api, allocator, staged, path, err, .rolled_back); + allocator.free(staged); + const owned = sessions orelse + return .{ .outcome = .deleted, .sessions_known = false }; + return .{ + .outcome = .deleted, + .sessions_targeted = Api.killSessions(allocator, owned), + .sessions_known = true, + }; +} + +/// The real effects. Every one of them is injected through the same seam the tests use, so +/// no test ever signals a daemon, recycles a folder, or kills a session. +pub const Live = struct { + pub fn pid() u32 { + return @intCast(c.GetCurrentProcessId()); + } + + pub fn rename(allocator: std.mem.Allocator, source: []const u8, destination: []const u8) !void { + const source_wide = try std.unicode.utf8ToUtf16LeAllocZ(allocator, source); + defer allocator.free(source_wide); + const destination_wide = try std.unicode.utf8ToUtf16LeAllocZ(allocator, destination); + defer allocator.free(destination_wide); + if (c.MoveFileW(source_wide.ptr, destination_wide.ptr) == 0) return error.WorkspaceRenameFailed; + } + + pub fn collectSessions(allocator: std.mem.Allocator, path: []const u8) ![][]u8 { + return collectSessionIds(allocator, path); + } + + pub fn stopDaemon( + allocator: std.mem.Allocator, + path: []const u8, + current_identity: []const u8, + ) !void { + const identity = try Lifecycle.pathIdentity(allocator, path); + defer allocator.free(identity); + if (std.mem.eql(u8, identity, current_identity)) return error.WorkspaceIsCurrent; + const trimmed = std.mem.trimRight(u8, path, "\\/"); + const lock = try DaemonClient.daemonLockNameFor(allocator, trimmed); + defer allocator.free(lock); + const current_lock = try DaemonClient.daemonLockName(allocator); + defer allocator.free(current_lock); + try requireDistinctDaemon(lock, current_lock); + if (!lockPresent(allocator, lock)) return; + const event_name = try std.fmt.allocPrint(allocator, "{s}-shutdown", .{lock}); + defer allocator.free(event_name); + const wide = try std.unicode.utf8ToUtf16LeAllocZ(allocator, event_name); + defer allocator.free(wide); + const handle = c.OpenEventW(c.EVENT_MODIFY_STATE | c.SYNCHRONIZE, 0, wide.ptr); + if (handle == null) return error.WorkspaceDaemonUnreachable; + defer _ = c.CloseHandle(handle); + if (c.SetEvent(handle) == 0) return error.WorkspaceDaemonStopFailed; + var waited: i64 = 0; + while (waited < daemon_stop_timeout_ms) : (waited += 100) { + if (!lockPresent(allocator, lock)) return; + std.Thread.sleep(100 * std.time.ns_per_ms); + } + if (lockPresent(allocator, lock)) return error.WorkspaceDaemonStopTimedOut; + } + + pub fn recycle(allocator: std.mem.Allocator, path: []const u8) !void { + const raw = try std.unicode.utf8ToUtf16LeAlloc(allocator, path); + defer allocator.free(raw); + const from = try allocator.alloc(u16, raw.len + 2); + defer allocator.free(from); + @memcpy(from[0..raw.len], raw); + from[raw.len] = 0; + from[raw.len + 1] = 0; + var operation: c.SHFILEOPSTRUCTW = .{ + .hwnd = null, + .wFunc = c.FO_DELETE, + .pFrom = from.ptr, + .pTo = null, + .fFlags = recycle_flags, + .fAnyOperationsAborted = 0, + .hNameMappings = null, + .lpszProgressTitle = null, + }; + if (c.SHFileOperationW(&operation) != 0 or operation.fAnyOperationsAborted != 0) + return error.WorkspaceRecycleFailed; + if (Lifecycle.directoryExists(path)) return error.WorkspaceRecycleFailed; + } + + pub fn killSessions(allocator: std.mem.Allocator, ids: []const []u8) usize { + if (ids.len == 0) return 0; + const zmx = std.process.getEnvVarOwned(allocator, "GRAPHCODE_ZMX") catch + allocator.dupe(u8, "zmx.exe") catch return 0; + defer allocator.free(zmx); + const argv = killArgv(allocator, zmx, ids) catch return 0; + defer allocator.free(argv); + var child = std.process.Child.init(argv, allocator); + child.stdin_behavior = .Ignore; + child.stdout_behavior = .Ignore; + child.stderr_behavior = .Ignore; + _ = child.spawnAndWait() catch return 0; + return ids.len; + } + + fn lockPresent(allocator: std.mem.Allocator, lock: []const u8) bool { + const wide = std.unicode.utf8ToUtf16LeAllocZ(allocator, lock) catch return false; + defer allocator.free(wide); + const handle = c.OpenMutexW(c.SYNCHRONIZE, 0, wide.ptr); + if (handle == null) return false; + _ = c.CloseHandle(handle); + return true; + } +}; + +pub fn deleteRecoverably( + allocator: std.mem.Allocator, + path: []const u8, + current_identity: []const u8, +) !Report { + return deleteRecoverablyWith(Live, allocator, path, current_identity); +} + +const Step = enum { stage, collect, daemon, recycle, kill, rollback }; + +const Fixture = struct { + var steps: [16]Step = undefined; + var count: usize = 0; + var renames: usize = 0; + var fail_stage = false; + var fail_collect = false; + var fail_daemon = false; + var fail_recycle = false; + var fail_rollback = false; + var staged: [512]u8 = undefined; + var staged_len: usize = 0; + + fn reset() void { + count = 0; + renames = 0; + fail_stage = false; + fail_collect = false; + fail_daemon = false; + fail_recycle = false; + fail_rollback = false; + staged_len = 0; + } + + fn record(step: Step) void { + if (count < steps.len) steps[count] = step; + count += 1; + } + + fn taken() []const Step { + return steps[0..@min(count, steps.len)]; + } + + fn pid() u32 { + return 4242; + } + + fn rename(allocator: std.mem.Allocator, source: []const u8, destination: []const u8) !void { + _ = allocator; + _ = source; + renames += 1; + if (renames == 1) { + record(.stage); + @memcpy(staged[0..destination.len], destination); + staged_len = destination.len; + if (fail_stage) return error.WorkspaceRenameFailed; + return; + } + record(.rollback); + if (fail_rollback) return error.WorkspaceRenameFailed; + } + + fn collectSessions(allocator: std.mem.Allocator, path: []const u8) ![][]u8 { + _ = path; + record(.collect); + if (fail_collect) return error.MetadataReadFailed; + const ids = try allocator.alloc([]u8, 2); + var owned: usize = 0; + errdefer { + for (ids[0..owned]) |id| allocator.free(id); + allocator.free(ids); + } + for (ids, [_][]const u8{ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + }) |*slot, value| { + slot.* = try allocator.dupe(u8, value); + owned += 1; + } + return ids; + } + + fn stopDaemon(allocator: std.mem.Allocator, path: []const u8, current_identity: []const u8) !void { + _ = allocator; + _ = path; + _ = current_identity; + record(.daemon); + if (fail_daemon) return error.WorkspaceDaemonStopTimedOut; + } + + fn recycle(allocator: std.mem.Allocator, path: []const u8) !void { + _ = allocator; + _ = path; + record(.recycle); + if (fail_recycle) return error.WorkspaceRecycleFailed; + } + + fn killSessions(allocator: std.mem.Allocator, ids: []const []u8) usize { + _ = allocator; + record(.kill); + return ids.len; + } +}; + +const fixture_path = "C:\\fixture\\.graphcode-alpha"; +const fixture_current = "c:/fixture/.graphcode"; + +test "workspace teardown stages before stopping the daemon and kills sessions only after the move" { + const allocator = std.testing.allocator; + Fixture.reset(); + var report = try deleteRecoverablyWith(Fixture, allocator, fixture_path, fixture_current); + defer report.deinit(allocator); + try std.testing.expectEqual(Outcome.deleted, report.outcome); + try std.testing.expectEqual(@as(?anyerror, null), report.cause); + try std.testing.expect(report.sessions_known); + try std.testing.expectEqual(@as(usize, 2), report.sessions_targeted); + try std.testing.expectEqualSlices( + Step, + &.{ .stage, .collect, .daemon, .recycle, .kill }, + Fixture.taken(), + ); + try std.testing.expectEqual(@as(usize, 1), Fixture.renames); +} + +test "workspace teardown refuses with no effect when the staging rename fails" { + const allocator = std.testing.allocator; + Fixture.reset(); + Fixture.fail_stage = true; + var report = try deleteRecoverablyWith(Fixture, allocator, fixture_path, fixture_current); + defer report.deinit(allocator); + try std.testing.expectEqual(Outcome.refused, report.outcome); + try std.testing.expectEqual(@as(?anyerror, error.WorkspaceRenameFailed), report.cause); + try std.testing.expectEqual(@as(?[]u8, null), report.staged_path); + try std.testing.expectEqualSlices(Step, &.{.stage}, Fixture.taken()); +} + +test "workspace teardown restores the workspace and kills nothing when the daemon does not stop" { + const allocator = std.testing.allocator; + Fixture.reset(); + Fixture.fail_daemon = true; + var report = try deleteRecoverablyWith(Fixture, allocator, fixture_path, fixture_current); + defer report.deinit(allocator); + try std.testing.expectEqual(Outcome.refused, report.outcome); + try std.testing.expectEqual(@as(?anyerror, error.WorkspaceDaemonStopTimedOut), report.cause); + try std.testing.expectEqualSlices( + Step, + &.{ .stage, .collect, .daemon, .rollback }, + Fixture.taken(), + ); + try std.testing.expectEqual(@as(usize, 2), Fixture.renames); +} + +test "workspace teardown rolls back a failed recycle and reports the stopped daemon only" { + const allocator = std.testing.allocator; + Fixture.reset(); + Fixture.fail_recycle = true; + var report = try deleteRecoverablyWith(Fixture, allocator, fixture_path, fixture_current); + defer report.deinit(allocator); + try std.testing.expectEqual(Outcome.rolled_back, report.outcome); + try std.testing.expectEqual(@as(?anyerror, error.WorkspaceRecycleFailed), report.cause); + try std.testing.expectEqual(@as(usize, 0), report.sessions_targeted); + try std.testing.expectEqualSlices( + Step, + &.{ .stage, .collect, .daemon, .recycle, .rollback }, + Fixture.taken(), + ); + const message = try statusMessage(allocator, report); + defer allocator.free(message); + try std.testing.expect(std.mem.indexOf(u8, message, "restored") != null); + try std.testing.expect(std.mem.indexOf(u8, message, "daemon") != null); +} + +test "workspace teardown names the staged folder when the rollback also fails" { + const allocator = std.testing.allocator; + Fixture.reset(); + Fixture.fail_recycle = true; + Fixture.fail_rollback = true; + var report = try deleteRecoverablyWith(Fixture, allocator, fixture_path, fixture_current); + defer report.deinit(allocator); + try std.testing.expectEqual(Outcome.stranded, report.outcome); + try std.testing.expectEqual(@as(usize, 0), report.sessions_targeted); + const staged = report.staged_path orelse return error.MissingStagedPath; + try std.testing.expectEqualStrings(Fixture.staged[0..Fixture.staged_len], staged); + const message = try statusMessage(allocator, report); + defer allocator.free(message); + try std.testing.expect(std.mem.indexOf(u8, message, staged) != null); +} + +test "workspace teardown still deletes when the saved sessions cannot be read" { + const allocator = std.testing.allocator; + Fixture.reset(); + Fixture.fail_collect = true; + var report = try deleteRecoverablyWith(Fixture, allocator, fixture_path, fixture_current); + defer report.deinit(allocator); + try std.testing.expectEqual(Outcome.deleted, report.outcome); + try std.testing.expect(!report.sessions_known); + try std.testing.expectEqual(@as(usize, 0), report.sessions_targeted); + try std.testing.expectEqualSlices( + Step, + &.{ .stage, .collect, .daemon, .recycle }, + Fixture.taken(), + ); + const message = try statusMessage(allocator, report); + defer allocator.free(message); + try std.testing.expect(std.mem.indexOf(u8, message, "may still be running") != null); +} + +test "workspace teardown releases every partial allocation on failure" { + const Probe = struct { + fn run(failing: std.mem.Allocator) anyerror!void { + Fixture.reset(); + var report = try deleteRecoverablyWith(Fixture, failing, fixture_path, fixture_current); + defer report.deinit(failing); + try std.testing.expectEqual(Outcome.deleted, report.outcome); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Probe.run, .{}); +} + +test "workspace staging name is never itself a discoverable workspace" { + const allocator = std.testing.allocator; + const staged = try stagingPath(allocator, fixture_path, 4242); + defer allocator.free(staged); + const name = std.fs.path.basenameWindows(staged); + try std.testing.expect(!std.mem.startsWith(u8, name, Lifecycle.directory_prefix)); + try std.testing.expect(!std.mem.eql(u8, name, Lifecycle.default_directory_name)); + try std.testing.expect(std.mem.startsWith(u8, name, staging_prefix)); + try std.testing.expect(std.mem.endsWith(u8, name, ".graphcode-alpha")); + try std.testing.expectEqualStrings( + "C:\\fixture", + std.fs.path.dirnameWindows(staged).?, + ); + try std.testing.expectError(error.InvalidWorkspacePath, stagingPath(allocator, "C:\\", 1)); +} + +test "workspace teardown refuses a daemon target that resolves to the current workspace" { + try requireDistinctDaemon("Global\\graphcode-daemon-S-1-aaaa", "Global\\graphcode-daemon-S-1-bbbb"); + try std.testing.expectError( + error.WorkspaceDaemonIdentityMatch, + requireDistinctDaemon("Global\\graphcode-daemon-S-1-aaaa", "Global\\graphcode-daemon-S-1-aaaa"), + ); +} + +test "workspace daemon names separate distinct support directories and join lexical aliases" { + const allocator = std.testing.allocator; + const alpha = try DaemonClient.daemonLockNameFor(allocator, "C:\\fixture\\.graphcode-alpha"); + defer allocator.free(alpha); + const beta = try DaemonClient.daemonLockNameFor(allocator, "C:\\fixture\\.graphcode-beta"); + defer allocator.free(beta); + const alias = try DaemonClient.daemonLockNameFor(allocator, "C:\\fixture\\.\\.graphcode-alpha"); + defer allocator.free(alias); + const trailing = try DaemonClient.daemonLockNameFor(allocator, "C:\\fixture\\.graphcode-alpha\\"); + defer allocator.free(trailing); + try std.testing.expect(!std.mem.eql(u8, alpha, beta)); + try std.testing.expectEqualStrings(alpha, alias); + // GetFullPathNameW keeps a trailing separator, so the daemon name would differ; the + // teardown trims one off before deriving rather than addressing a daemon nobody owns. + try std.testing.expect(!std.mem.eql(u8, alpha, trailing)); + try std.testing.expect(std.mem.startsWith(u8, alpha, "Global\\graphcode-daemon-")); +} + +test "workspace recycle always allows undo" { + try std.testing.expect(recycle_flags & c.FOF_ALLOWUNDO != 0); + try std.testing.expect(recycle_flags & c.FOF_NOCONFIRMATION != 0); + try std.testing.expectEqual(@as(c.UINT, c.FO_DELETE), @as(c.UINT, c.FO_DELETE)); +} + +test "workspace session kill forces every saved id in one invocation" { + const allocator = std.testing.allocator; + var ids = [_][]u8{ @constCast("alpha-id"), @constCast("beta-id") }; + const argv = try killArgv(allocator, "C:\\tools\\zmx.exe", &ids); + defer allocator.free(argv); + try std.testing.expectEqual(@as(usize, 5), argv.len); + try std.testing.expectEqualStrings("C:\\tools\\zmx.exe", argv[0]); + try std.testing.expectEqualStrings("kill", argv[1]); + try std.testing.expectEqualStrings("alpha-id", argv[2]); + try std.testing.expectEqualStrings("beta-id", argv[3]); + try std.testing.expectEqualStrings("--force", argv[4]); +} + +test "workspace session ids come from saved graphs and skip sidecars and unreadable files" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + try temporary.dir.makeDir("projects"); + try temporary.dir.writeFile(.{ + .sub_path = "projects\\alpha.json", + .data = + \\{"id":"33333333-3333-4333-8333-333333333333","project":{"path":"C:\\work\\alpha","name":"Alpha","lastOpenedAt":1},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"One","loopType":"manual"},{"id":"22222222-2222-4222-8222-222222222222","title":"Two","loopType":"manual"}],"edges":[]} + , + }); + try temporary.dir.writeFile(.{ .sub_path = "projects\\room.mailroom.json", .data = "[]" }); + try temporary.dir.writeFile(.{ .sub_path = "projects\\broken.json", .data = "{" }); + try temporary.dir.writeFile(.{ .sub_path = "projects\\notes.txt", .data = "ignored" }); + const path = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(path); + const ids = try collectSessionIds(allocator, path); + defer freeSessionIds(allocator, ids); + try std.testing.expectEqual(@as(usize, 2), ids.len); + var seen_first = false; + var seen_second = false; + for (ids) |id| { + if (std.mem.eql(u8, id, "11111111-1111-4111-8111-111111111111")) seen_first = true; + if (std.mem.eql(u8, id, "22222222-2222-4222-8222-222222222222")) seen_second = true; + } + try std.testing.expect(seen_first and seen_second); +} + +test "workspace session ids are empty for a workspace that saved nothing" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const path = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(path); + const ids = try collectSessionIds(allocator, path); + defer freeSessionIds(allocator, ids); + try std.testing.expectEqual(@as(usize, 0), ids.len); +} + +test "workspace teardown confirmation says what is ended and that the folder is recoverable" { + try std.testing.expect(std.mem.indexOf(u8, confirmation_text, "Recycle Bin") != null); + try std.testing.expect(std.mem.indexOf(u8, confirmation_text, "recoverable") != null); + try std.testing.expect(std.mem.indexOf(u8, confirmation_text, "daemon") != null); + try std.testing.expect(std.mem.indexOf(u8, confirmation_text, "sessions") != null); + try std.testing.expect(std.mem.indexOf(u8, confirmation_text, "permanently") == null); +} diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index deb2d074..615656c9 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -24,7 +24,7 @@ Statuses: | Window toolbar | Needs-you chip, worktree notice, jump field, contextual loop-panel toggle | Render, hit-test, UIA exposure, and focus order share header visibility/layout. Needs-you opens the selected attention target; cached local worktree notices honor the owning project and count/size threshold. Jump opens the existing palette even with no projects. The detail toggle is limited to loop workspaces with supported edge/metric content; workspace/project identity is no longer overpainted. F6 entry/exit, scoped traversal/activation, focus rendering, and marshaled UIA button focus are implemented, while ordinary Tab/Shift+Tab/Ctrl+Tab contracts remain. The header paints inside the buffered logical pass; literal 96/144/192-DPI bounds and hidden native client tests cover logical layout/input and exactly-once physical UIA conversion without rescaling terminal tabs. Production-helper tests, a hidden native Tab-to-command test, and a hidden empty-palette create/cancel test pass. Live focus/event-deadlock, keyboard activation, rendered pixels, sidebar effects, and provider-backed panel proof are still absent because foreground acquisition failed. Cross-project worktree-notice discovery/aggregation, summary/board/mailroom panel content, and macOS visual parity remain separate residuals; private live tooling is not CI coverage | Partial | | Jump palette | Search field, ranked cross-project results, type/state/project context, mouse and keyboard selection | Ctrl+P and Ctrl+J open a native modal palette with live exact-ID, exact-title, title-prefix, and substring ranking across projects. Results visibly include project, loop type, and state; Up/Down, Return, Escape, and mouse double-click are supported. The deterministic UIA gate verifies a visible search field, contextual cross-project results, and keyboard navigation changing the selected loop. | Validated | | File/Loop/Terminal menus | Discoverable project, worktree, navigation, workspace, update, settings, and help commands with state-aware enablement | Startup menu replacement and UTF-16 corruption are fixed and the five readable runtime groups were probed. Reclaim/Reveal require a selected row (`worktreeRowSelected()` retains dedicated unit coverage); Save requires the Worktrees dialog open. Loop commands now gray out when they have no actionable target: Jump requires a loaded loop anywhere, Next/Previous require at least two loops or one unselected loop in the active graph, Create Edge requires two active-graph loops, and Stop requires a selected loop. These distinct thresholds follow their different production handlers and are intentional. Terminal Next/Previous Tab and Focus Next/Previous Pane require multiple tabs or panes in the selected tab. A hidden-window test calls `MainWindow.updateMenu` and verifies the real native HMENU `MF_GRAYED` bits in both unavailable and available states; this is unit/runtime Win32-menu evidence, not a live application walkthrough. The Worktrees-dialog UIA gate evidence remains outstanding: obtaining it requires launching the shell in a foreground desktop session, and no application executable is built in this worktree; provider builds/provisioning and foreground-gated UIA automation were not performed. Broader project-management parity remains incomplete | Partial | -| Workspace lifecycle | List/switch, create, rename, delete with fail-closed confirmation, and keyboard/UIA reachability | Windows discovers `Default` plus `.graphcode-*` siblings and exposes New/Rename/Delete and Ctrl+Alt paging. Manage now has an owned row model/native list, Default/creation-time/name-tie order, identity-deduplicated current-outside-home inclusion, full-path detail, current/default/open/uncertain-window refusals, and captured-identity Open/New/Rename handoff after modal teardown. An App-owned single joinable reader supplies bounded **saved top-level** graph-header counts, not live/descendant totals; current live content, missing/unreadable/invalid/duplicate/limited/unsupported-location data remain explicitly unavailable, not partial zero. Fixed local/non-reparse reads use existing project JSON and the documented mailroom-array exception, no daemon/backend or window activation. Done/Escape cancels; pending actions wait for reader cancellation/join while the modal pumps, and shutdown drains before allocator teardown (a stalled local disk can delay shutdown). Manager Delete is disabled pending recoverable deletion/teardown; existing menu deletion and ordinary menu ordering/Open/New semantics are unchanged. Next/Previous now reread the manager-ordered list, include validated current outside home, filter identified running windows, wrap both ways, recheck the target, and use a restore-only API with no launcher. Closed/disappeared targets never cold-open; any unidentified flag or lookup/restore failure refuses with status. Existing SID/session/class/published-metadata checks are preserved, not executable-path attestation. Menu capability counts known rows plus implicit current, without periodic window polling. Injected production-helper RED/GREEN, allocation-failure/owned-refresh, ordering/dedup/offset/race/refusal tests and pure menu-capability tests execute in the existing App/MainWindow roots; full native roots compile/link without execution and the ReleaseSafe app builds without launch. Manager coverage remains pure owned-data/explicit disposable fixtures, controlled memory-only joined threads, and filtered form/App data seams; no new shown manager/native-control/window-lookup/UIA/runtime proof is claimed. Existing lifecycle helper and never-shown native-control regressions cover accepted text lifetime, allocation cleanup, normalized naming/collisions, lexical identity, exact-window and one-launch routing, Default/current/open refusals, child-only support/daemon-pipe isolation, canonical plus legacy reservations, unidentified-window refusal, fail-closed Settings rebinding, and disposable confirmed mutation preservation. Lexical identity is not junction equivalence. Shown-dialog accessibility/keyboard/layout, full multi-instance/save-reload behavior, reliable live totals, real running-cycle keyboard/window proof, and recoverable deletion with session/daemon teardown remain residuals. Shared-host menu failures still have an unproven cause; no full lifecycle parity or host-only diagnosis is claimed | Partial | +| Workspace lifecycle | List/switch, create, rename, delete with fail-closed confirmation, and keyboard/UIA reachability | Windows discovers `Default` plus `.graphcode-*` siblings and exposes New/Rename/Delete and Ctrl+Alt paging. Manage now has an owned row model/native list, Default/creation-time/name-tie order, identity-deduplicated current-outside-home inclusion, full-path detail, current/default/open/uncertain-window refusals, and captured-identity Open/New/Rename handoff after modal teardown. An App-owned single joinable reader supplies bounded **saved top-level** graph-header counts, not live/descendant totals; current live content, missing/unreadable/invalid/duplicate/limited/unsupported-location data remain explicitly unavailable, not partial zero. Fixed local/non-reparse reads use existing project JSON and the documented mailroom-array exception, no daemon/backend or window activation. Done/Escape cancels; pending actions wait for reader cancellation/join while the modal pumps, and shutdown drains before allocator teardown (a stalled local disk can delay shutdown). Manager Delete is enabled and shares rename's refusals, identity revalidation after confirmation, and reservation; deletion itself is recoverable: the folder is staged aside, the target workspace's daemon is stopped by its own derived shutdown event, the folder goes to the Recycle Bin with `FOF_ALLOWUNDO`, and only then are its saved sessions ended with `zmx kill --force`. A failed stage refuses without effect, a daemon that will not stop or a failed recycle renames the folder back and ends no sessions, and a failed rename-back reports the exact staged path instead of claiming a rollback. Ordinary menu ordering/Open/New semantics are unchanged. Next/Previous now reread the manager-ordered list, include validated current outside home, filter identified running windows, wrap both ways, recheck the target, and use a restore-only API with no launcher. Closed/disappeared targets never cold-open; any unidentified flag or lookup/restore failure refuses with status. Existing SID/session/class/published-metadata checks are preserved, not executable-path attestation. Menu capability counts known rows plus implicit current, without periodic window polling. Injected production-helper RED/GREEN, allocation-failure/owned-refresh, ordering/dedup/offset/race/refusal tests and pure menu-capability tests execute in the existing App/MainWindow roots; full native roots compile/link without execution and the ReleaseSafe app builds without launch. Manager coverage remains pure owned-data/explicit disposable fixtures, controlled memory-only joined threads, and filtered form/App data seams; no new shown manager/native-control/window-lookup/UIA/runtime proof is claimed. Existing lifecycle helper and never-shown native-control regressions cover accepted text lifetime, allocation cleanup, normalized naming/collisions, lexical identity, exact-window and one-launch routing, Default/current/open refusals, child-only support/daemon-pipe isolation, canonical plus legacy reservations, unidentified-window refusal, fail-closed Settings rebinding, and disposable confirmed mutation preservation. Lexical identity is not junction equivalence. Shown-dialog accessibility/keyboard/layout, full multi-instance/save-reload behavior, reliable live totals, and real running-cycle keyboard/window proof remain residuals. Deletion's evidence is unit-level only: every teardown effect is injected through a comptime seam, so no test recycles a folder, signals a real daemon, or kills a real session, and no live walkthrough has confirmed a recovered folder in the Recycle Bin, a daemon actually exiting, or sessions actually ending. Sessions saved outside the workspace's own `projects` directory (extra terminal tabs/splits, whose layout files are written relative to the process working directory) are not discoverable for a non-current workspace and are reported as unended rather than assumed absent. Shared-host menu failures still have an unproven cause; no full lifecycle parity or host-only diagnosis is claimed | Partial | | Help menu | GraphCode Basics and normal About entry | The live Help menu exposes GraphCode Basics, which reopens onboarding, and About GraphCode, which opens a native versioned product dialog. The populated UIA gate verifies the dialog identity, version text, and close behavior | Validated | | Update command | Check for Updates, disabled while checking/installing | Reachable from the Help menu's native menu bar, immediately reports "Checking for updates..." status and disables the command the instant a check is invoked, and — after fixing a real bug where the background check's completion never refreshed the menu bit because the only refresh path was gated on unrelated daemon-connectivity state — now reliably re-enables the command once the check settles. A dedicated live UIA gate assertion (reading the real native `HMENU` bit via `GetMenuState`, not the fragile UIA tree) and a Win32-window-backed unit test both confirm the disable/re-enable cycle. When the settled result is an available update, the gate additionally waits for the real offer, dismisses it via Later, verifies the modal disappears, and requires the shell owner to be enabled again before continuing; failure/no-update outcomes are required not to present an offer. In-app installation is now implemented as a separate scope (see Install progress/Relaunch prompt, Partial) | Validated | | Tray lifecycle | Restore and exit without foreground daemon window | `TrayLive.Tests.ps1` exercises the physical icon, Open, close-to-hide, single-instance restore, Explorer recovery, popup contents, and visible Exit activation | Validated |