From ff72dc2805b73122a75a3148c7e34daa582bd528 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 25 Sep 2026 15:46:38 -0700 Subject: [PATCH] Fix Windows workspace lifecycle safety and isolation Preserve accepted native dialog values, guard workspace ownership, and isolate new child daemon endpoints. Add production-helper, allocation-failure, disposable-filesystem, and hidden-native regressions. Keep lifecycle parity Partial pending authentic multi-instance, UIA, keyboard, and backend evidence. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 6 +- graphcode-windows/README.md | 46 + graphcode-windows/src/App.zig | 992 +++++++++++++++--- graphcode-windows/src/MainWindow.zig | 231 +++- .../src/WindowsNativeDialogs.zig | 199 +++- graphcode-windows/src/WorkspaceLifecycle.zig | 240 ++++- graphcode-windows/src/main.zig | 5 +- investigation/ui-parity-matrix.md | 2 +- 8 files changed, 1516 insertions(+), 205 deletions(-) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index d6ab215a..c2c1a7e5 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -392,7 +392,7 @@ Invoke-Native "Context menu and gate fixture message executable tests" { try { & $zig test src\GraphContextMenu.zig -target x86_64-windows-msvc -lc -luser32 "-I$include" if ($LASTEXITCODE -ne 0) { return } - & $zig test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include" + & $zig test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$include" } finally { Pop-Location } } Invoke-Native "Jump palette executable tests" { @@ -685,7 +685,7 @@ Invoke-Native "Native dialog field contract executable tests" { $include = Join-Path $winghosttyRoot "include" Push-Location $shellRoot try { - & $zig test src\WindowsNativeDialogs.zig -target x86_64-windows-msvc -lc -luser32 "-I$include" + & $zig test src\WindowsNativeDialogs.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include" } finally { Pop-Location } } Invoke-Native "App shell executable tests" { @@ -698,7 +698,7 @@ Invoke-Native "App shell executable tests" { Push-Location $shellRoot try { & $zig test src\App.zig src\AccessibilityProvider.cpp ` - -target x86_64-windows-msvc -lc -luser32 -lgdi32 -loleaut32 -luiautomationcore -lwinhttp "-I$include" + -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 -loleaut32 -luiautomationcore -lwinhttp "-I$include" } finally { Pop-Location } } diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index e72b0ca5..99df9406 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -45,6 +45,52 @@ project/node identity, `Ctrl+Tab` advances attention, and `Ctrl+Shift+R`, activity settings. `Ctrl+Q` creates a daemon-owned Quick Chat; `Ctrl+Shift+Q` renames the selected chat and `Ctrl+Shift+X` deletes it. +## Workspace lifecycle + +The Workspace menu lists `Default` and `.graphcode-*` directories under +`USERPROFILE`, with the current workspace marked by the native checked state. +New Workspace creates a normalized sibling directory and requests one separate +app instance using a child-only `GRAPHCODE_SUPPORT_DIR` environment. The child +does not inherit `GRAPHCODE_DAEMON_PIPE`: it derives its daemon endpoint from +the new support directory rather than reusing the parent's explicit override. +The parent's environment and other inherited variables remain unchanged. +Selecting the current workspace does nothing; selecting an identified running +workspace restores its exact window rather than the first GraphCode window. +`Ctrl+Alt+PageUp` / `Ctrl+Alt+PageDown` cycles the discovered list, including +workspaces not yet open. + +Instance reservations, selected identity, and mutation guards share lexical +Windows path normalization: drive/ASCII case, separators, dot segments, and +trailing separators. Non-ASCII bytes are preserved; this does not establish +Unicode case, symlink, junction, or hard-link equivalence. Rename and Delete +refuse Default, the current workspace, and a workspace reserved by another +instance. Reservations cover the destructive confirmation and final recheck. +Compatibility checks include the older raw-path mutex; a same-user GraphCode +window without identifiable workspace metadata, or an uncertain owner lookup, +blocks mutations instead of being treated as a closed workspace. + +Advanced connection Settings can reconnect to another support directory, but +that does not migrate every workspace store or layout. If the effective support +identity differs from the instance's reserved identity, or cannot be verified, +the shell removes its workspace attribution and blocks lifecycle mutations and +switching with a persistent restart-required status. It retains the original +reservation until exit. Restoring the original support directory revalidates +attribution; pipe-only changes and equivalent lexical paths keep lifecycle +actions available. No data-store migration is implied by a connection change. + +Delete remains permanent. Cancel in the name form and every confirmation result +other than explicit Yes preserve the workspace; No is the warning's default. +Accepted form text is captured before native controls are destroyed, with +allocation/read failures rejecting the result. Rename does not overwrite an +existing destination and preserves the directory's saved files. + +Executable coverage includes production helpers, allocation failures, disposable +filesystem mutations, exact launch/restore routing, and never-shown native +controls/windows/menus. This is not a live multi-instance, keyboard, UIA, or +real-daemon walkthrough. The lifecycle parity row remains Partial: macOS also +provides a structured Manage view, content summaries, creation-order/running-only +cycling, and recoverable deletion with daemon/session teardown. + ## Build From a fresh checkout, bootstrap the exact Zig toolchains, Swift 6.3.3, and diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index bacd6be9..26b5b639 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -42,13 +42,218 @@ const Win32 = @import("Win32.zig"); const c = Win32.c; const title = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode Windows"); -const instance_prefix = "Local\\graphcode-windows-"; +const workspace_restart_message = "Workspace identity changed or could not be verified. Restart GraphCode before managing workspaces."; const tray_test_hook_environment = "GRAPHCODE_TRAY_TEST_HOOK"; const daemon_supervisor_test_hook_environment = "GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK"; const daemon_supervisor_test_property = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.Windows.DaemonSupervisorState"); extern fn graphcode_pick_folder(owner: c.HWND, buffer: [*]u16, capacity: c.DWORD) callconv(.c) c_int; +fn workspaceUser(allocator: std.mem.Allocator) ![]u8 { + return std.process.getEnvVarOwned(allocator, "USERNAME") catch |err| switch (err) { + error.EnvironmentVariableNotFound => std.process.getEnvVarOwned(allocator, "USER"), + else => err, + }; +} + +fn workspaceInstanceKey(allocator: std.mem.Allocator, path: []const u8) ![:0]u16 { + const user = try workspaceUser(allocator); + defer allocator.free(user); + const name = try WorkspaceLifecycle.instanceName(allocator, user, path); + defer allocator.free(name); + return std.unicode.utf8ToUtf16LeAllocZ(allocator, name); +} + +pub fn restoreCurrentWorkspace(allocator: std.mem.Allocator) !void { + const path = try WorkspaceLifecycle.currentPath(allocator); + defer allocator.free(path); + const key = try workspaceInstanceKey(allocator, path); + defer allocator.free(key); + try MainWindow.restoreExistingInstance(key); +} + +const WorkspaceReservation = struct { + handles: [2]c.HANDLE = .{ null, null }, + + fn acquire(allocator: std.mem.Allocator, path: []const u8) !WorkspaceReservation { + const user = try workspaceUser(allocator); + defer allocator.free(user); + return acquireForUser(allocator, user, path); + } + + fn acquireForUser(allocator: std.mem.Allocator, user: []const u8, path: []const u8) !WorkspaceReservation { + const canonical = try WorkspaceLifecycle.instanceName(allocator, user, path); + defer allocator.free(canonical); + const legacy = try WorkspaceLifecycle.legacyInstanceName(allocator, user, path); + defer allocator.free(legacy); + var result = WorkspaceReservation{}; + errdefer result.deinit(); + const names = [_][]const u8{ canonical, legacy }; + for (names, 0..) |name, index| { + if (index == 1 and std.mem.eql(u8, canonical, legacy)) break; + const wide = try std.unicode.utf8ToUtf16LeAllocZ(allocator, name); + defer allocator.free(wide); + const handle = c.CreateMutexW(null, 1, wide.ptr) orelse return error.WorkspaceReservationFailed; + const last_error = c.GetLastError(); + if (last_error == c.ERROR_ALREADY_EXISTS) { + _ = c.CloseHandle(handle); + return error.WorkspaceInUse; + } + result.handles[index] = handle; + } + return result; + } + + fn deinit(self: *WorkspaceReservation) void { + for (&self.handles) |*handle| { + if (handle.* != null) { + _ = c.ReleaseMutex(handle.*); + _ = c.CloseHandle(handle.*); + handle.* = null; + } + } + } +}; + +const WorkspaceProcess = struct { + fn windows(key: [:0]const u16) !MainWindow.WorkspaceWindows { + return MainWindow.workspaceWindows(key); + } + + fn restore(key: [:0]const u16) !void { + try MainWindow.restoreExistingInstance(key); + } + + fn launch(allocator: std.mem.Allocator, path: []const u8) !void { + const block = try workspaceEnvironment(allocator, path); + defer allocator.free(block); + var executable: [32768]u16 = undefined; + const length = c.GetModuleFileNameW(null, &executable, executable.len); + if (length == 0 or length >= executable.len) return error.WorkspaceExecutablePathFailed; + executable[length] = 0; + var startup: c.STARTUPINFOW = std.mem.zeroes(c.STARTUPINFOW); + startup.cb = @sizeOf(c.STARTUPINFOW); + var process: c.PROCESS_INFORMATION = undefined; + if (c.CreateProcessW(executable[0..length :0].ptr, null, null, null, 0, c.CREATE_UNICODE_ENVIRONMENT, block.ptr, null, &startup, &process) == 0) + return error.WorkspaceLaunchFailed; + _ = c.CloseHandle(process.hThread); + _ = c.CloseHandle(process.hProcess); + } +}; + +fn workspaceEnvironment(allocator: std.mem.Allocator, path: []const u8) ![]u16 { + var environment = try std.process.getEnvMap(allocator); + defer environment.deinit(); + try environment.put("GRAPHCODE_SUPPORT_DIR", path); + environment.remove("GRAPHCODE_DAEMON_PIPE"); + return std.process.createWindowsEnvBlock(allocator, &environment); +} + +const WorkspaceOpenResult = enum { current, restored, launched }; + +fn openWorkspaceWith(comptime Api: type, allocator: std.mem.Allocator, current_identity: []const u8, path: []const u8) !WorkspaceOpenResult { + const identity = try WorkspaceLifecycle.pathIdentity(allocator, path); + defer allocator.free(identity); + if (std.mem.eql(u8, current_identity, identity)) return .current; + const key = try workspaceInstanceKey(allocator, path); + defer allocator.free(key); + const windows = try Api.windows(key); + if (windows.target != null) { + try Api.restore(key); + return .restored; + } + if (windows.unidentified) return error.UnidentifiedWorkspaceWindow; + try Api.launch(allocator, path); + return .launched; +} + +const WorkspaceMutation = union(enum) { rename: []const u8, delete }; +const WorkspaceMutationResult = enum { renamed, deleted, cancelled }; +const workspace_delete_confirmation_flags = c.MB_YESNO | c.MB_ICONWARNING | c.MB_DEFBUTTON2; + +const WorkspaceMutationApi = struct { + const reserve = WorkspaceReservation.acquire; + const windows = WorkspaceProcess.windows; + + 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, + workspace_delete_confirmation_flags, + ); + } + + fn rename(allocator: std.mem.Allocator, source: []const u8, destination: []const u8) !void { + const from = try std.unicode.utf8ToUtf16LeAllocZ(allocator, source); + defer allocator.free(from); + const to = try std.unicode.utf8ToUtf16LeAllocZ(allocator, destination); + defer allocator.free(to); + if (c.MoveFileW(from.ptr, to.ptr) == 0) return error.WorkspaceRenameFailed; + } + + fn delete(path: []const u8) !void { + try std.fs.deleteTreeAbsolute(path); + } +}; + +fn requireIdentifiedClosedWorkspace(comptime Api: type, key: [:0]const u16) !void { + const windows = try Api.windows(key); + if (windows.unidentified) return error.UnidentifiedWorkspaceWindow; + if (windows.target != null) return error.WorkspaceInUse; +} + +fn mutateWorkspaceWith( + comptime Api: type, + allocator: std.mem.Allocator, + owner: c.HWND, + current_identity: []const u8, + workspace: WorkspaceLifecycle.Workspace, + mutation: WorkspaceMutation, +) !WorkspaceMutationResult { + if (workspace.is_default) return error.DefaultWorkspace; + // The confirmation pumps messages that can refresh and free workspace_list. + const source = try allocator.dupe(u8, workspace.path); + defer allocator.free(source); + const identity = try WorkspaceLifecycle.pathIdentity(allocator, source); + defer allocator.free(identity); + if (std.mem.eql(u8, identity, current_identity)) return error.CurrentWorkspace; + var reservation = try Api.reserve(allocator, source); + defer reservation.deinit(); + const key = try workspaceInstanceKey(allocator, source); + defer allocator.free(key); + try requireIdentifiedClosedWorkspace(Api, key); + switch (mutation) { + .rename => |destination| { + var destination_reservation = try Api.reserve(allocator, destination); + defer destination_reservation.deinit(); + const destination_key = try workspaceInstanceKey(allocator, destination); + defer allocator.free(destination_key); + try requireIdentifiedClosedWorkspace(Api, destination_key); + try Api.rename(allocator, source, destination); + return .renamed; + }, + .delete => { + if (Api.confirm(owner) != c.IDYES) return .cancelled; + try requireIdentifiedClosedWorkspace(Api, key); + var directory = try std.fs.openDirAbsolute(source, .{}); + directory.close(); + try Api.delete(source); + return .deleted; + }, + } +} + +fn workspaceMutationFailure(err: anyerror) []const u8 { + return switch (err) { + error.DefaultWorkspace => "The default workspace cannot be renamed or deleted", + error.CurrentWorkspace => "The current workspace cannot be renamed or deleted", + error.WorkspaceInUse => "That workspace is open in another window; quit it first", + error.UnidentifiedWorkspaceWindow => "Close older GraphCode windows before changing a workspace", + else => "Workspace could not be changed safely", + }; +} + /// Deterministic targets and screen position used only by the live UIA gate's /// context-menu hook (`MainWindow.wm_uia_context_menu`). const uia_context_menu_project_path = "C:\\GraphCode\\fixture"; @@ -255,9 +460,12 @@ pub const App = struct { canvas_layout_store: ?CanvasLayoutStore.Store = null, quick_chats_requested: bool = false, selected_quick_chat: ?usize = null, - instance_mutex: c.HANDLE = null, + workspace_reservation: WorkspaceReservation = .{}, workspace_list: ?WorkspaceLifecycle.List = null, workspace_path: []u8 = &.{}, + workspace_identity: []u8 = &.{}, + workspace_identity_valid: bool = false, + workspace_identity_blocked: bool = false, sync_requested: bool = false, restore_requested: bool = false, open_project_pending: bool = false, @@ -392,9 +600,10 @@ pub const App = struct { if (self.selected_edge_project_path.len != 0) self.allocator.free(self.selected_edge_project_path); if (self.selected_edge_id.len != 0) self.allocator.free(self.selected_edge_id); if (self.edge_drag_source_id.len != 0) self.allocator.free(self.edge_drag_source_id); - if (self.instance_mutex != null) _ = c.CloseHandle(self.instance_mutex); + self.workspace_reservation.deinit(); if (self.workspace_list) |*list| list.deinit(self.allocator); if (self.workspace_path.len != 0) self.allocator.free(self.workspace_path); + if (self.workspace_identity.len != 0) self.allocator.free(self.workspace_identity); if (self.last_project_opened.len != 0) self.allocator.free(self.last_project_opened); if (self.accepted_subscription.len != 0) self.allocator.free(self.accepted_subscription); if (self.pending_project_path.len != 0) self.allocator.free(self.pending_project_path); @@ -440,6 +649,7 @@ pub const App = struct { // for both explicit automation hooks. if (!daemon_supervisor_test_hook and !uia_gate_hook) GdiplusAA.init(); try self.window.create(self, &onWindowMessage, title.ptr); + try self.revalidateWorkspaceIdentity(); if (!self.window.gesture_config_registered) { // Non-fatal: the canvas simply falls back to wheel-only zoom (no // pinch input) rather than the app failing to start. The @@ -521,7 +731,7 @@ pub const App = struct { } } self.createEmptyStateControls(); - self.refreshWorkspaceList(); + _ = self.refreshWorkspaceList(); self.updateNativeChrome(); if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_FIXTURE_ROWS")) |fixture| { defer self.allocator.free(fixture); @@ -1529,10 +1739,24 @@ pub const App = struct { } orelse return; defer self.allocator.free(draft.daemon_pipe); defer self.allocator.free(draft.support_directory); - self.client.applySettings(draft.daemon_pipe, draft.support_directory) catch { + self.applyWorkspaceConnectionSettings(draft.daemon_pipe, draft.support_directory) catch { self.setStatus("Invalid daemon settings"); + self.updateNativeChrome(); return; }; + self.syncAccessibility(); + self.updateNativeChrome(); + } + + fn applyWorkspaceConnectionSettings(self: *App, pipe: []const u8, support: []const u8) !void { + self.workspace_identity_valid = false; + self.workspace_identity_blocked = true; + try MainWindow.invalidateWorkspaceIdentity(self.window.hwnd); + self.client.applySettings(pipe, support) catch |err| { + try self.revalidateWorkspaceIdentity(); + return err; + }; + try self.revalidateWorkspaceIdentity(); } fn openProductSettings(self: *App) void { @@ -3853,6 +4077,7 @@ pub const App = struct { } fn status(self: *const App) []const u8 { + if (self.workspace_identity_blocked) return workspace_restart_message; if (self.status_override.len != 0) return self.status_override; return self.client.statusText(); } @@ -3986,7 +4211,7 @@ pub const App = struct { for (list.items, 0..) |workspace, index| { workspace_items[index] = .{ .name = workspace.name, - .is_current = WorkspaceLifecycle.isSamePath(workspace.path, self.workspace_path), + .is_current = self.workspace_identity_valid and std.mem.eql(u8, workspace.identity, self.workspace_identity), }; } } @@ -4427,9 +4652,9 @@ pub const App = struct { .identity = identity, .name = workspace.name, .parent = 21, - .selected = WorkspaceLifecycle.isSamePath(workspace.path, self.workspace_path), - .eligible = true, - .invokable = true, + .selected = self.workspace_identity_valid and std.mem.eql(u8, workspace.identity, self.workspace_identity), + .eligible = self.workspace_identity_valid, + .invokable = self.workspace_identity_valid, .left = 250, .top = row_top, .right = 500, @@ -4794,46 +5019,58 @@ pub const App = struct { } fn acquireSingleInstance(self: *App) !void { - const user = std.process.getEnvVarOwned(self.allocator, "USERNAME") catch - try std.process.getEnvVarOwned(self.allocator, "USER"); - defer self.allocator.free(user); const path = try WorkspaceLifecycle.currentPath(self.allocator); - defer self.allocator.free(path); - var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; - std.crypto.hash.sha2.Sha256.hash(path, &digest, .{}); - const digest_text = std.fmt.bytesToHex(digest, .lower); - const name = try std.fmt.allocPrint(self.allocator, "{s}{s}-{s}", .{ instance_prefix, user, digest_text[0..20] }); - defer self.allocator.free(name); - const raw_wide = try std.unicode.utf8ToUtf16LeAlloc(self.allocator, name); - defer self.allocator.free(raw_wide); - const wide = try self.allocator.alloc(u16, raw_wide.len + 1); - defer self.allocator.free(wide); - @memcpy(wide[0..raw_wide.len], raw_wide); - wide[raw_wide.len] = 0; - self.instance_mutex = c.CreateMutexW(null, 1, wide.ptr); - if (self.instance_mutex == null) return error.SingleInstanceMutexFailed; - if (c.GetLastError() == c.ERROR_ALREADY_EXISTS) { - _ = c.CloseHandle(self.instance_mutex); - self.instance_mutex = null; - return error.InstanceAlreadyRunning; - } + errdefer self.allocator.free(path); + const identity = try WorkspaceLifecycle.pathIdentity(self.allocator, path); + errdefer self.allocator.free(identity); + const reservation = WorkspaceReservation.acquire(self.allocator, path) catch |err| switch (err) { + error.WorkspaceInUse => return error.InstanceAlreadyRunning, + else => return err, + }; + self.workspace_reservation = reservation; + self.workspace_path = path; + self.workspace_identity = identity; + } + + fn revalidateWorkspaceIdentity(self: *App) !void { + self.workspace_identity_valid = false; + self.workspace_identity_blocked = true; + try MainWindow.invalidateWorkspaceIdentity(self.window.hwnd); + if (self.workspace_reservation.handles[0] == null) return error.WorkspaceReservationMissing; + const current = try WorkspaceLifecycle.currentPath(self.allocator); + defer self.allocator.free(current); + const identity = try WorkspaceLifecycle.pathIdentity(self.allocator, current); + defer self.allocator.free(identity); + if (!std.mem.eql(u8, self.workspace_identity, identity)) return error.WorkspaceRestartRequired; + const key = try workspaceInstanceKey(self.allocator, self.workspace_path); + defer self.allocator.free(key); + try MainWindow.publishWorkspaceIdentity(self.window.hwnd, key); + self.workspace_identity_valid = true; + self.workspace_identity_blocked = false; + } + + fn ensureWorkspaceIdentity(self: *App) bool { + self.revalidateWorkspaceIdentity() catch { + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return false; + }; + return true; } - fn refreshWorkspaceList(self: *App) void { - const current = WorkspaceLifecycle.currentPath(self.allocator) catch { - self.setStatus("Workspace location could not be resolved"); - return; - }; - if (self.workspace_path.len != 0) self.allocator.free(self.workspace_path); - self.workspace_path = current; - if (self.workspace_list) |*list| list.deinit(self.allocator); - self.workspace_list = WorkspaceLifecycle.list(self.allocator) catch blk: { + fn refreshWorkspaceList(self: *App) bool { + if (!self.ensureWorkspaceIdentity()) return false; + const refreshed = WorkspaceLifecycle.list(self.allocator) catch { self.setStatus("Workspace list could not be loaded"); - break :blk null; + return false; }; + if (self.workspace_list) |*list| list.deinit(self.allocator); + self.workspace_list = refreshed; + return true; } fn showWorkspaceText(self: *App, dialog_title: []const u8, labels: []const []const u8, initial: []const []const u8) ?NativeDialogs.Result { + if (!self.ensureWorkspaceIdentity()) return null; return NativeDialogs.textWithDescription( self.window.hwnd, self.allocator, @@ -4842,7 +5079,7 @@ pub const App = struct { labels, initial, ) catch { - self.setStatus("Workspace dialog could not be opened"); + self.setStatus("Workspace dialog could not be completed"); return null; }; } @@ -4853,84 +5090,52 @@ pub const App = struct { var owned = result; owned.deinit(self.allocator); } + if (!self.ensureWorkspaceIdentity()) return; const home = std.process.getEnvVarOwned(self.allocator, "USERPROFILE") catch { self.setStatus("User profile could not be resolved"); return; }; defer self.allocator.free(home); - const name = WorkspaceLifecycle.validateName(self.allocator, result.values[0], home) catch |err| { + var workspace = WorkspaceLifecycle.create(self.allocator, result.values[0], home) catch |err| { self.setStatus(switch (err) { error.EmptyName => "Workspace name is required", error.NameTooLong => "Workspace name is too long", - error.NameTaken => "That workspace already exists", - else => "Workspace name is invalid", + error.NameTaken, error.PathAlreadyExists => "That workspace already exists", + else => "Workspace could not be created", }); return; }; - defer self.allocator.free(name); - const path = WorkspaceLifecycle.workspacePath(self.allocator, name, home) catch { - self.setStatus("Workspace path could not be prepared"); - return; - }; - defer self.allocator.free(path); - std.fs.makeDirAbsolute(path) catch |err| { - self.setStatus(if (err == error.PathAlreadyExists) "That workspace already exists" else "Workspace could not be created"); - return; - }; - self.refreshWorkspaceList(); - self.launchWorkspace(path); + defer workspace.deinit(self.allocator); + if (!self.refreshWorkspaceList()) return; + self.launchWorkspace(workspace.path); } fn launchWorkspace(self: *App, path: []const u8) void { - const old = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SUPPORT_DIR") catch null; - defer if (old) |value| self.allocator.free(value); - const key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_SUPPORT_DIR"); - const value = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, path) catch { - self.setStatus("Workspace path could not be encoded"); + if (!self.ensureWorkspaceIdentity()) return; + const opened = openWorkspaceWith(WorkspaceProcess, self.allocator, self.workspace_identity, path) catch |err| { + self.setStatus(if (err == error.UnidentifiedWorkspaceWindow) + "Close older GraphCode windows before opening another workspace" + else + "Workspace could not be opened or activated"); return; }; - defer self.allocator.free(value); - if (c.SetEnvironmentVariableW(key.ptr, value.ptr) == 0) { - self.setStatus("Workspace launch environment could not be set"); - return; - } - defer { - if (old) |previous| { - if (std.unicode.utf8ToUtf16LeAllocZ(self.allocator, previous)) |previous_wide| { - defer self.allocator.free(previous_wide); - _ = c.SetEnvironmentVariableW(key.ptr, previous_wide.ptr); - } else |_| { - _ = c.SetEnvironmentVariableW(key.ptr, null); - } - } else { - _ = c.SetEnvironmentVariableW(key.ptr, null); - } - } - var executable: [32768]u16 = undefined; - const length = c.GetModuleFileNameW(null, &executable, executable.len); - if (length == 0 or length >= executable.len) { - self.setStatus("GraphCode executable path could not be resolved"); - return; - } - executable[length] = 0; - var startup: c.STARTUPINFOW = std.mem.zeroes(c.STARTUPINFOW); - startup.cb = @sizeOf(c.STARTUPINFOW); - var process: c.PROCESS_INFORMATION = undefined; - if (c.CreateProcessW(executable[0..length :0].ptr, null, null, null, 0, 0, null, null, &startup, &process) == 0) { - self.setStatus("Workspace could not be opened"); - return; - } - _ = c.CloseHandle(process.hThread); - _ = c.CloseHandle(process.hProcess); - self.setStatus("Workspace opened"); + self.setStatus(switch (opened) { + .current => "This workspace is already open", + .restored => "Workspace activated", + .launched => "Workspace launch requested", + }); } fn workspaceByName(self: *App, name: []const u8) ?WorkspaceLifecycle.Workspace { const list = self.workspace_list orelse return null; + var found: ?WorkspaceLifecycle.Workspace = null; for (list.items) |workspace| { - if (std.ascii.eqlIgnoreCase(workspace.name, name)) return workspace; + if (std.ascii.eqlIgnoreCase(workspace.name, name)) { + if (found != null) return null; + found = workspace; + } } - return null; + return found; } fn renameWorkspace(self: *App) void { @@ -4943,28 +5148,31 @@ pub const App = struct { var owned = result; owned.deinit(self.allocator); } + if (!self.refreshWorkspaceList()) return; const workspace = self.workspaceByName(result.values[0]) orelse { self.setStatus("Workspace was not found"); return; }; - if (workspace.is_default or WorkspaceLifecycle.isSamePath(workspace.path, self.workspace_path)) { - self.setStatus("Switch to another workspace before renaming this one"); + const home = std.process.getEnvVarOwned(self.allocator, "USERPROFILE") catch { + self.setStatus("User profile could not be resolved"); return; - } - const home = std.process.getEnvVarOwned(self.allocator, "USERPROFILE") catch return; + }; defer self.allocator.free(home); const name = WorkspaceLifecycle.validateName(self.allocator, result.values[1], home) catch { self.setStatus("Workspace name is invalid or already exists"); return; }; defer self.allocator.free(name); - const destination = WorkspaceLifecycle.workspacePath(self.allocator, name, home) catch return; + const destination = WorkspaceLifecycle.workspacePath(self.allocator, name, home) catch { + self.setStatus("Workspace path could not be prepared"); + return; + }; defer self.allocator.free(destination); - std.fs.renameAbsolute(workspace.path, destination) catch { - self.setStatus("Workspace could not be renamed"); + _ = mutateWorkspaceWith(WorkspaceMutationApi, self.allocator, self.window.hwnd, self.workspace_identity, workspace, .{ .rename = destination }) catch |err| { + self.setStatus(workspaceMutationFailure(err)); return; }; - self.refreshWorkspaceList(); + if (!self.refreshWorkspaceList()) return; self.setStatus("Workspace renamed"); } @@ -4974,46 +5182,42 @@ pub const App = struct { var owned = result; owned.deinit(self.allocator); } + if (!self.refreshWorkspaceList()) return; const workspace = self.workspaceByName(result.values[0]) orelse { self.setStatus("Workspace was not found"); return; }; - if (workspace.is_default or WorkspaceLifecycle.isSamePath(workspace.path, self.workspace_path)) { - self.setStatus("The default or current workspace cannot be deleted"); - return; - } - const dialog_title = std.unicode.utf8ToUtf16LeStringLiteral("Delete Workspace"); - const message = std.unicode.utf8ToUtf16LeStringLiteral( - "This permanently deletes the workspace folder and all of its projects and loops. Continue?", - ); - if (c.MessageBoxW(self.window.hwnd, message.ptr, dialog_title.ptr, c.MB_YESNO | c.MB_ICONWARNING | c.MB_DEFBUTTON2) != c.IDYES) { - self.setStatus("Workspace deletion cancelled"); - return; - } - std.fs.deleteTreeAbsolute(workspace.path) catch { - self.setStatus("Workspace could not be deleted"); + const outcome = mutateWorkspaceWith(WorkspaceMutationApi, self.allocator, self.window.hwnd, self.workspace_identity, workspace, .delete) catch |err| { + self.setStatus(workspaceMutationFailure(err)); return; }; - self.refreshWorkspaceList(); - self.setStatus("Workspace deleted"); + if (!self.refreshWorkspaceList()) return; + self.setStatus(if (outcome == .deleted) "Workspace deleted" else "Workspace deletion cancelled"); } fn cycleWorkspace(self: *App, direction: isize) void { + if (!self.refreshWorkspaceList()) return; const list = self.workspace_list orelse return; if (list.items.len < 2) return; - var index: usize = 0; - for (list.items, 0..) |workspace, i| { - if (WorkspaceLifecycle.isSamePath(workspace.path, self.workspace_path)) { - index = i; - break; - } - } - const count = @as(isize, @intCast(list.items.len)); - const next = @mod(@as(isize, @intCast(index)) + direction + count, count); - self.launchWorkspace(list.items[@intCast(next)].path); + const next = workspaceCycleTarget(list.items, self.workspace_identity, direction) orelse { + self.setStatus("The current workspace is not in the workspace list"); + return; + }; + self.launchWorkspace(list.items[next].path); } }; +fn workspaceCycleTarget(items: []const WorkspaceLifecycle.Workspace, current_identity: []const u8, direction: isize) ?usize { + if (items.len < 2) return null; + for (items, 0..) |workspace, index| { + if (std.mem.eql(u8, workspace.identity, current_identity)) { + const count: isize = @intCast(items.len); + return @intCast(@mod(@as(isize, @intCast(index)) + @mod(direction, count), count)); + } + } + return null; +} + fn onDaemonFrame( context: ?*anyopaque, frame: [*]const u8, @@ -5045,7 +5249,7 @@ fn onWindowMessage( return true; } if (MainWindow.restore_message != 0 and message == MainWindow.restore_message) { - restoreShellWindow(hwnd); + if (app.ensureWorkspaceIdentity()) restoreShellWindow(hwnd); result.* = 0; return true; } @@ -5097,6 +5301,9 @@ fn onWindowMessage( } }, c.WM_INITMENUPOPUP => { + if (@intFromPtr(c.GetSubMenu(c.GetMenu(hwnd), 3)) == wparam) { + if (app.refreshWorkspaceList()) app.syncAccessibility(); + } app.updateNativeChrome(); result.* = 0; return true; @@ -5558,7 +5765,11 @@ fn onWindowMessage( return true; }, c.WM_ACTIVATEAPP => { - if (wparam == 0) app.cancelCanvasInteraction(); + if (wparam == 0) { + app.cancelCanvasInteraction(); + } else { + if (app.refreshWorkspaceList()) app.syncAccessibility(); + } result.* = 0; return true; }, @@ -6413,6 +6624,519 @@ fn restoreShellWindow(hwnd: c.HWND) void { _ = c.SetFocus(hwnd); } +fn setWorkspaceTestEnvironment(name: [*:0]const u16, value: ?[]const u8) !void { + const wide = if (value) |text| try std.unicode.utf8ToUtf16LeAllocZ(std.testing.allocator, text) else null; + defer if (wide) |text| std.testing.allocator.free(text); + if (c.SetEnvironmentVariableW(name, if (wide) |text| text.ptr else null) == 0) + return error.TestEnvironmentUpdateFailed; +} + +test "connection settings invalidate lifecycle attribution until the reserved support is revalidated" { + const allocator = std.testing.allocator; + const support_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_SUPPORT_DIR"); + const pipe_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_DAEMON_PIPE"); + var original_environment = try std.process.getEnvMap(allocator); + defer original_environment.deinit(); + defer setWorkspaceTestEnvironment(support_key, original_environment.get("GRAPHCODE_SUPPORT_DIR")) catch @panic("support environment restore failed"); + defer setWorkspaceTestEnvironment(pipe_key, original_environment.get("GRAPHCODE_DAEMON_PIPE")) catch @panic("pipe environment restore failed"); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + for ([_][]const u8{ ".graphcode-alpha", ".graphcode-beta" }) |name| { + var directory = try temporary.dir.makeOpenPath(name, .{}); + defer directory.close(); + try directory.writeFile(.{ .sub_path = ".graphcode-rendezvous.secret", .data = "workspace-fixture-not-a-secret!!" }); + try directory.writeFile(.{ .sub_path = "saved-state", .data = name }); + } + const alpha = try temporary.dir.realpathAlloc(allocator, ".graphcode-alpha"); + defer allocator.free(alpha); + const beta = try temporary.dir.realpathAlloc(allocator, ".graphcode-beta"); + defer allocator.free(beta); + const pipe = try std.fmt.allocPrint(allocator, "\\\\.\\pipe\\graphcode-lifecycle-{x:0>32}", .{std.crypto.random.int(u128)}); + defer allocator.free(pipe); + const second_pipe = try std.fmt.allocPrint(allocator, "{s}-changed", .{pipe}); + defer allocator.free(second_pipe); + try setWorkspaceTestEnvironment(support_key, alpha); + try setWorkspaceTestEnvironment(pipe_key, pipe); + const hwnd = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, + std.unicode.utf8ToUtf16LeStringLiteral("Hidden workspace identity fixture").ptr, + 0, + 0, + 0, + 0, + 0, + null, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.TestWindowCreationFailed; + defer _ = c.DestroyWindow(hwnd); + var app: App = .{ + .allocator = allocator, + .window = .{ .hwnd = hwnd }, + .client = try DaemonClient.init(allocator), + .daemon = undefined, + .model = GraphModel.Model.init(allocator), + .sidebar_state = Sidebar.State.init(allocator), + .declared_entry_ids = std.array_list.Managed([]u8).init(allocator), + .kept_worktree_paths = std.array_list.Managed([]u8).init(allocator), + }; + defer app.client.deinit(); + defer app.model.deinit(); + defer app.sidebar_state.deinit(); + defer app.declared_entry_ids.deinit(); + defer app.kept_worktree_paths.deinit(); + defer if (app.status_override.len != 0) allocator.free(app.status_override); + try app.acquireSingleInstance(); + defer app.workspace_reservation.deinit(); + defer allocator.free(app.workspace_path); + defer allocator.free(app.workspace_identity); + const published_key = try workspaceInstanceKey(allocator, alpha); + defer allocator.free(published_key); + try app.revalidateWorkspaceIdentity(); + try std.testing.expect(MainWindow.workspaceIdentityMatches(hwnd, published_key)); + try app.applyWorkspaceConnectionSettings(second_pipe, alpha); + try std.testing.expect(app.workspace_identity_valid); + const alias = try std.fmt.allocPrint(allocator, "{s}\\ignored\\..\\", .{alpha}); + defer allocator.free(alias); + try app.applyWorkspaceConnectionSettings(pipe, alias); + try std.testing.expect(app.workspace_identity_valid); + try std.testing.expectError(error.InvalidDaemonPipe, app.applyWorkspaceConnectionSettings("invalid-pipe", alpha)); + try std.testing.expect(app.workspace_identity_valid); + try std.testing.expect(MainWindow.workspaceIdentityMatches(hwnd, published_key)); + + try std.testing.expectError(error.WorkspaceRestartRequired, app.applyWorkspaceConnectionSettings(pipe, beta)); + try std.testing.expect(!app.workspace_identity_valid); + try std.testing.expect(!MainWindow.workspaceIdentityMatches(hwnd, published_key)); + try std.testing.expectEqualStrings(workspace_restart_message, app.status()); + try std.testing.expectError(error.WorkspaceInUse, WorkspaceReservation.acquire(allocator, alpha)); + try std.testing.expectError(error.WorkspaceRestartRequired, app.applyWorkspaceConnectionSettings("invalid-pipe", alpha)); + app.createWorkspace(); + app.renameWorkspace(); + app.deleteWorkspace(); + app.launchWorkspace(beta); + app.cycleWorkspace(1); + try std.testing.expect(!app.workspace_identity_valid); + try std.testing.expect(app.workspace_list == null); + try std.testing.expectEqualStrings(alpha, app.workspace_path); + for ([_][]const u8{ ".graphcode-alpha", ".graphcode-beta" }) |name| { + var directory = try temporary.dir.openDir(name, .{}); + defer directory.close(); + const saved = try directory.readFileAlloc(allocator, "saved-state", 100); + defer allocator.free(saved); + try std.testing.expectEqualStrings(name, saved); + } + + try app.applyWorkspaceConnectionSettings(pipe, alpha); + try std.testing.expect(app.workspace_identity_valid); + try std.testing.expect(!app.workspace_identity_blocked); + try std.testing.expect(MainWindow.workspaceIdentityMatches(hwnd, published_key)); + try setWorkspaceTestEnvironment(support_key, "C:relative"); + try std.testing.expectError(error.InvalidWorkspacePath, app.revalidateWorkspaceIdentity()); + try std.testing.expect(!MainWindow.workspaceIdentityMatches(hwnd, published_key)); + try std.testing.expectEqualStrings(workspace_restart_message, app.status()); + try setWorkspaceTestEnvironment(support_key, alpha); + + const Probe = struct { + fn run(failing: std.mem.Allocator, target: *App, key: [:0]const u16) !void { + const previous = target.allocator; + target.allocator = failing; + defer target.allocator = previous; + target.revalidateWorkspaceIdentity() catch |err| { + try std.testing.expect(!target.workspace_identity_valid); + try std.testing.expect(target.workspace_identity_blocked); + try std.testing.expect(!MainWindow.workspaceIdentityMatches(target.window.hwnd, key)); + try std.testing.expectEqualStrings(workspace_restart_message, target.status()); + return err; + }; + try std.testing.expect(target.workspace_identity_valid); + try std.testing.expect(MainWindow.workspaceIdentityMatches(target.window.hwnd, key)); + } + }; + try std.testing.checkAllAllocationFailures(allocator, Probe.run, .{ &app, published_key }); + try app.revalidateWorkspaceIdentity(); + try std.testing.expectError(error.WorkspaceInUse, WorkspaceReservation.acquire(allocator, alpha)); +} + +test "workspace open routes current restore and cold launch exactly once" { + const Probe = struct { + var lookup: MainWindow.WorkspaceWindows = .{}; + var lookups: usize = 0; + var restores: usize = 0; + var launches: usize = 0; + var fail_lookup = false; + var fail_launch = false; + var expected_key: [:0]const u16 = undefined; + + fn windows(key: [:0]const u16) !MainWindow.WorkspaceWindows { + lookups += 1; + try std.testing.expectEqualSlices(u16, expected_key, key); + if (fail_lookup) return error.WorkspaceWindowLookupFailed; + return lookup; + } + fn restore(key: [:0]const u16) !void { + restores += 1; + try std.testing.expectEqualSlices(u16, expected_key, key); + } + fn launch(_: std.mem.Allocator, path: []const u8) !void { + launches += 1; + try std.testing.expectEqualStrings("C:\\fixture\\.graphcode-beta", path); + if (fail_launch) return error.WorkspaceLaunchFailed; + } + }; + const allocator = std.testing.allocator; + const path = "C:\\fixture\\.graphcode-beta"; + const identity = try WorkspaceLifecycle.pathIdentity(allocator, path); + defer allocator.free(identity); + const key = try workspaceInstanceKey(allocator, path); + defer allocator.free(key); + Probe.expected_key = key; + Probe.lookup = .{}; + Probe.lookups = 0; + Probe.restores = 0; + Probe.launches = 0; + Probe.fail_lookup = false; + Probe.fail_launch = false; + try std.testing.expectEqual(WorkspaceOpenResult.current, try openWorkspaceWith(Probe, allocator, identity, "c:/FIXTURE/./.graphcode-beta/")); + try std.testing.expectEqual(@as(usize, 0), Probe.lookups + Probe.restores + Probe.launches); + try std.testing.expectEqual(WorkspaceOpenResult.launched, try openWorkspaceWith(Probe, allocator, "c:/fixture/.graphcode-alpha", path)); + try std.testing.expectEqual(@as(usize, 1), Probe.launches); + Probe.lookup.target = Win32.opaquePointerFromInt(c.HWND, 1); + try std.testing.expectEqual(WorkspaceOpenResult.restored, try openWorkspaceWith(Probe, allocator, "c:/fixture/.graphcode-alpha", path)); + try std.testing.expectEqual(@as(usize, 1), Probe.restores); + try std.testing.expectEqual(@as(usize, 1), Probe.launches); + Probe.lookup = .{ .unidentified = true }; + try std.testing.expectError(error.UnidentifiedWorkspaceWindow, openWorkspaceWith(Probe, allocator, "c:/fixture/.graphcode-alpha", path)); + Probe.fail_lookup = true; + try std.testing.expectError(error.WorkspaceWindowLookupFailed, openWorkspaceWith(Probe, allocator, "c:/fixture/.graphcode-alpha", path)); + try std.testing.expectEqual(@as(usize, 1), Probe.launches); + Probe.fail_lookup = false; + Probe.lookup = .{}; + Probe.fail_launch = true; + try std.testing.expectError(error.WorkspaceLaunchFailed, openWorkspaceWith(Probe, allocator, "c:/fixture/.graphcode-alpha", path)); + try std.testing.expectEqual(@as(usize, 2), Probe.launches); +} + +test "workspace child environment isolates its endpoint and leaves parent and restore environments unchanged" { + const allocator = std.testing.allocator; + const support_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_SUPPORT_DIR"); + const pipe_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_DAEMON_PIPE"); + var original = try std.process.getEnvMap(allocator); + defer original.deinit(); + defer setWorkspaceTestEnvironment(support_key, original.get("GRAPHCODE_SUPPORT_DIR")) catch @panic("support environment restore failed"); + defer setWorkspaceTestEnvironment(pipe_key, original.get("GRAPHCODE_DAEMON_PIPE")) catch @panic("pipe environment restore failed"); + const parent_support = "C:\\fixture\\.graphcode-parent"; + const child_support = "C:\\fixture\\.graphcode-child"; + const parent_pipe = try std.fmt.allocPrint(allocator, "\\\\.\\pipe\\graphcode-lifecycle-{x:0>32}", .{std.crypto.random.int(u128)}); + defer allocator.free(parent_pipe); + try setWorkspaceTestEnvironment(support_key, parent_support); + try setWorkspaceTestEnvironment(pipe_key, parent_pipe); + var before = try std.process.getEnvMap(allocator); + defer before.deinit(); + const block = try workspaceEnvironment(allocator, child_support); + defer allocator.free(block); + try std.testing.expect(block.len >= 2 and block[block.len - 1] == 0 and block[block.len - 2] == 0); + var decoded = std.process.EnvMap.init(allocator); + defer decoded.deinit(); + var entries = std.mem.splitScalar(u16, block, 0); + while (entries.next()) |entry| { + if (entry.len == 0) continue; + const text = try std.unicode.utf16LeToUtf8Alloc(allocator, entry); + defer allocator.free(text); + const separator = std.mem.indexOfScalarPos(u8, text, 1, '=') orelse return error.InvalidEnvironmentEntry; + try decoded.put(text[0..separator], text[separator + 1 ..]); + } + try std.testing.expectEqualStrings(child_support, decoded.get("GRAPHCODE_SUPPORT_DIR") orelse return error.MissingChildSupport); + try std.testing.expect(decoded.get("GRAPHCODE_DAEMON_PIPE") == null); + var inherited = before.iterator(); + while (inherited.next()) |entry| { + if (std.ascii.eqlIgnoreCase(entry.key_ptr.*, "GRAPHCODE_SUPPORT_DIR") or + std.ascii.eqlIgnoreCase(entry.key_ptr.*, "GRAPHCODE_DAEMON_PIPE")) continue; + try std.testing.expectEqualStrings(entry.value_ptr.*, decoded.get(entry.key_ptr.*) orelse return error.MissingInheritedVariable); + } + + const RestoreOnly = struct { + var lookups: usize = 0; + var restores: usize = 0; + + fn windows(_: [:0]const u16) !MainWindow.WorkspaceWindows { + lookups += 1; + return .{ .target = Win32.opaquePointerFromInt(c.HWND, 1) }; + } + fn restore(_: [:0]const u16) !void { + restores += 1; + } + fn launch(_: std.mem.Allocator, _: []const u8) !void { + return error.UnexpectedWorkspaceLaunch; + } + }; + RestoreOnly.lookups = 0; + RestoreOnly.restores = 0; + const parent_identity = try WorkspaceLifecycle.pathIdentity(allocator, parent_support); + defer allocator.free(parent_identity); + try std.testing.expectEqual(WorkspaceOpenResult.current, try openWorkspaceWith(RestoreOnly, allocator, parent_identity, parent_support)); + try std.testing.expectEqual(@as(usize, 0), RestoreOnly.lookups + RestoreOnly.restores); + try std.testing.expectEqual(WorkspaceOpenResult.restored, try openWorkspaceWith(RestoreOnly, allocator, parent_identity, child_support)); + try std.testing.expectEqual(@as(usize, 1), RestoreOnly.lookups); + try std.testing.expectEqual(@as(usize, 1), RestoreOnly.restores); + var after = try std.process.getEnvMap(allocator); + defer after.deinit(); + try std.testing.expectEqualStrings(parent_support, after.get("GRAPHCODE_SUPPORT_DIR").?); + try std.testing.expectEqualStrings(parent_pipe, after.get("GRAPHCODE_DAEMON_PIPE").?); + var original_entries = before.iterator(); + while (original_entries.next()) |entry| { + try std.testing.expectEqualStrings(entry.value_ptr.*, after.get(entry.key_ptr.*) orelse return error.ParentEnvironmentChanged); + } + var final_entries = after.iterator(); + while (final_entries.next()) |entry| { + try std.testing.expect(before.get(entry.key_ptr.*) != null); + } +} + +test "workspace reservations exclude lexical aliases and old raw path mutexes" { + const allocator = std.testing.allocator; + const user = try std.fmt.allocPrint(allocator, "workspace-test-{d}-{d}", .{ c.GetCurrentProcessId(), std.crypto.random.int(u64) }); + defer allocator.free(user); + const path = "C:\\fixture\\.graphcode-alpha"; + { + var held = try WorkspaceReservation.acquireForUser(allocator, user, path); + defer held.deinit(); + try std.testing.expectError(error.WorkspaceInUse, WorkspaceReservation.acquireForUser(allocator, user, "c:/FIXTURE/./.graphcode-alpha/")); + var distinct = try WorkspaceReservation.acquireForUser(allocator, user, "C:\\fixture\\.graphcode-beta"); + defer distinct.deinit(); + } + const legacy_name = try WorkspaceLifecycle.legacyInstanceName(allocator, user, path); + defer allocator.free(legacy_name); + const wide = try std.unicode.utf8ToUtf16LeAllocZ(allocator, legacy_name); + defer allocator.free(wide); + { + const legacy = c.CreateMutexW(null, 1, wide.ptr) orelse return error.TestMutexCreationFailed; + defer { + _ = c.ReleaseMutex(legacy); + _ = c.CloseHandle(legacy); + } + try std.testing.expect(c.GetLastError() != c.ERROR_ALREADY_EXISTS); + try std.testing.expectError(error.WorkspaceInUse, WorkspaceReservation.acquireForUser(allocator, user, path)); + } + var reacquired = try WorkspaceReservation.acquireForUser(allocator, user, path); + defer reacquired.deinit(); +} + +const WorkspaceMutationFixture = struct { + const reserve = WorkspaceReservation.acquire; + const rename = WorkspaceMutationApi.rename; + var response: c.INT = c.IDNO; + var confirmations: usize = 0; + var deletions: usize = 0; + var lookups: usize = 0; + var unidentified = false; + var unidentified_after_confirmation = false; + var fail_lookup = false; + var held_during_confirmation = false; + var expected_path: []const u8 = ""; + var skip_delete = false; + var list_to_release: ?*WorkspaceLifecycle.List = null; + var list_released = false; + + fn reset(path: []const u8) void { + response = c.IDNO; + confirmations = 0; + deletions = 0; + lookups = 0; + unidentified = false; + unidentified_after_confirmation = false; + fail_lookup = false; + held_during_confirmation = false; + expected_path = path; + skip_delete = false; + list_to_release = null; + list_released = false; + } + fn windows(_: [:0]const u16) !MainWindow.WorkspaceWindows { + lookups += 1; + if (fail_lookup) return error.WorkspaceWindowLookupFailed; + return .{ .unidentified = unidentified or (unidentified_after_confirmation and confirmations != 0) }; + } + fn confirm(_: c.HWND) c.INT { + confirmations += 1; + if (WorkspaceReservation.acquire(std.testing.allocator, expected_path)) |value| { + var unexpected = value; + unexpected.deinit(); + } else |err| { + held_during_confirmation = err == error.WorkspaceInUse; + } + if (list_to_release) |list| { + list.deinit(std.testing.allocator); + list_to_release = null; + list_released = true; + } + return response; + } + fn delete(path: []const u8) !void { + deletions += 1; + try std.testing.expectEqualStrings(expected_path, path); + if (!skip_delete) try WorkspaceMutationApi.delete(path); + } +}; + +fn namedWorkspaceFixture(list: WorkspaceLifecycle.List, name: []const u8) !WorkspaceLifecycle.Workspace { + for (list.items) |workspace| { + if (std.mem.eql(u8, name, workspace.name)) return workspace; + } + return error.MissingFixtureWorkspace; +} + +test "workspace deletion guards default current open legacy and every non Yes response" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + try temporary.dir.makeDir(".graphcode-alpha"); + try temporary.dir.makeDir(".graphcode-beta"); + try temporary.dir.writeFile(.{ .sub_path = ".graphcode-alpha\\saved-state", .data = "alpha-state" }); + try temporary.dir.writeFile(.{ .sub_path = ".graphcode-beta\\saved-state", .data = "beta-state" }); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var list = try WorkspaceLifecycle.listFromHome(allocator, home); + defer if (!WorkspaceMutationFixture.list_released) list.deinit(allocator); + const alpha = try namedWorkspaceFixture(list, "alpha"); + const default = try namedWorkspaceFixture(list, "Default"); + const saved_alpha_path = try allocator.dupe(u8, alpha.path); + defer allocator.free(saved_alpha_path); + WorkspaceMutationFixture.reset(alpha.path); + try std.testing.expectError(error.DefaultWorkspace, mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, alpha.identity, default, .delete)); + try std.testing.expectError(error.CurrentWorkspace, mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, alpha.identity, alpha, .delete)); + { + var open = try WorkspaceReservation.acquire(allocator, alpha.path); + defer open.deinit(); + try std.testing.expectError(error.WorkspaceInUse, mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .delete)); + } + WorkspaceMutationFixture.unidentified = true; + try std.testing.expectError(error.UnidentifiedWorkspaceWindow, mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .delete)); + WorkspaceMutationFixture.unidentified = false; + WorkspaceMutationFixture.fail_lookup = true; + try std.testing.expectError(error.WorkspaceWindowLookupFailed, mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .delete)); + try std.testing.expectEqual(@as(usize, 0), WorkspaceMutationFixture.confirmations); + try std.testing.expectEqual(@as(usize, 0), WorkspaceMutationFixture.deletions); + for ([_]c.INT{ c.IDNO, c.IDCANCEL, c.IDCLOSE, c.IDOK, 0, -1 }) |response| { + WorkspaceMutationFixture.reset(alpha.path); + WorkspaceMutationFixture.response = response; + try std.testing.expectEqual(WorkspaceMutationResult.cancelled, try mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .delete)); + try std.testing.expect(WorkspaceMutationFixture.held_during_confirmation); + try std.testing.expectEqual(@as(usize, 1), WorkspaceMutationFixture.confirmations); + try std.testing.expectEqual(@as(usize, 0), WorkspaceMutationFixture.deletions); + const saved = try temporary.dir.readFileAlloc(allocator, ".graphcode-alpha\\saved-state", 100); + defer allocator.free(saved); + try std.testing.expectEqualStrings("alpha-state", saved); + } + WorkspaceMutationFixture.reset(alpha.path); + WorkspaceMutationFixture.response = c.IDYES; + WorkspaceMutationFixture.unidentified_after_confirmation = true; + try std.testing.expectError(error.UnidentifiedWorkspaceWindow, mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .delete)); + try std.testing.expectEqual(@as(usize, 0), WorkspaceMutationFixture.deletions); + WorkspaceMutationFixture.reset(alpha.path); + WorkspaceMutationFixture.response = c.IDYES; + WorkspaceMutationFixture.skip_delete = true; + _ = try mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .delete); + try std.testing.expectError(error.WorkspaceStillExists, requireDeletedWorkspace(alpha.path)); + 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)); + try std.testing.expect(WorkspaceMutationFixture.held_during_confirmation); + try std.testing.expectEqual(@as(usize, 1), WorkspaceMutationFixture.deletions); + try requireDeletedWorkspace(saved_alpha_path); + const untouched = try temporary.dir.readFileAlloc(allocator, ".graphcode-beta\\saved-state", 100); + defer allocator.free(untouched); + try std.testing.expectEqualStrings("beta-state", untouched); +} + +test "workspace delete confirmation makes No the default and uses a warning" { + try std.testing.expectEqual(c.MB_YESNO, workspace_delete_confirmation_flags & c.MB_TYPEMASK); + try std.testing.expectEqual(c.MB_DEFBUTTON2, workspace_delete_confirmation_flags & c.MB_DEFMASK); + try std.testing.expectEqual(c.MB_ICONWARNING, workspace_delete_confirmation_flags & c.MB_ICONMASK); +} + +test "workspace cancelled mutation releases every partial allocation and reservation" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + try temporary.dir.makeDir(".graphcode-alpha"); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var list = try WorkspaceLifecycle.listFromHome(allocator, home); + defer list.deinit(allocator); + const alpha = try namedWorkspaceFixture(list, "alpha"); + const default = try namedWorkspaceFixture(list, "Default"); + const Probe = struct { + fn run(failing: std.mem.Allocator, current_identity: []const u8, workspace: WorkspaceLifecycle.Workspace) !void { + WorkspaceMutationFixture.reset(workspace.path); + try std.testing.expectEqual(WorkspaceMutationResult.cancelled, try mutateWorkspaceWith( + WorkspaceMutationFixture, + failing, + null, + current_identity, + workspace, + .delete, + )); + } + }; + try std.testing.checkAllAllocationFailures(allocator, Probe.run, .{ default.identity, alpha }); + var reacquired = try WorkspaceReservation.acquire(allocator, alpha.path); + defer reacquired.deinit(); +} + +fn requireDeletedWorkspace(path: []const u8) !void { + var directory = std.fs.openDirAbsolute(path, .{}) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + directory.close(); + return error.WorkspaceStillExists; +} + +test "workspace rename preserves saved bytes and never replaces a colliding workspace" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + try temporary.dir.makeDir(".graphcode-alpha"); + try temporary.dir.makeDir(".graphcode-beta"); + try temporary.dir.writeFile(.{ .sub_path = ".graphcode-alpha\\saved-state", .data = "alpha-state" }); + try temporary.dir.writeFile(.{ .sub_path = ".graphcode-beta\\saved-state", .data = "beta-state" }); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var list = try WorkspaceLifecycle.listFromHome(allocator, home); + defer list.deinit(allocator); + const alpha = try namedWorkspaceFixture(list, "alpha"); + const beta = try namedWorkspaceFixture(list, "beta"); + const default = try namedWorkspaceFixture(list, "Default"); + WorkspaceMutationFixture.reset(alpha.path); + try std.testing.expectError(error.WorkspaceRenameFailed, mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .{ .rename = beta.path })); + const destination = try WorkspaceLifecycle.workspacePath(allocator, "renamed", home); + defer allocator.free(destination); + try std.testing.expectEqual(WorkspaceMutationResult.renamed, try mutateWorkspaceWith(WorkspaceMutationFixture, allocator, null, default.identity, alpha, .{ .rename = destination })); + try requireDeletedWorkspace(alpha.path); + const saved = try temporary.dir.readFileAlloc(allocator, ".graphcode-renamed\\saved-state", 100); + defer allocator.free(saved); + const untouched = try temporary.dir.readFileAlloc(allocator, ".graphcode-beta\\saved-state", 100); + defer allocator.free(untouched); + try std.testing.expectEqualStrings("alpha-state", saved); + try std.testing.expectEqualStrings("beta-state", untouched); + try std.testing.expectEqual(@as(usize, 0), WorkspaceMutationFixture.confirmations + WorkspaceMutationFixture.deletions); +} + +test "workspace cycling wraps from the current identity and never invents a current target" { + const items = [_]WorkspaceLifecycle.Workspace{ + .{ .name = "alpha", .path = "C:\\fixture\\.graphcode-alpha", .identity = "c:/fixture/.graphcode-alpha", .is_default = false }, + .{ .name = "beta", .path = "C:\\fixture\\.graphcode-beta", .identity = "c:/fixture/.graphcode-beta", .is_default = false }, + }; + try std.testing.expectEqual(@as(?usize, 1), workspaceCycleTarget(&items, items[0].identity, 1)); + try std.testing.expectEqual(@as(?usize, 1), workspaceCycleTarget(&items, items[0].identity, -1)); + try std.testing.expectEqual(@as(?usize, 0), workspaceCycleTarget(&items, items[1].identity, 1)); + try std.testing.expect(workspaceCycleTarget(&items, "c:/missing", 1) == null); + try std.testing.expect(workspaceCycleTarget(items[0..1], items[0].identity, 1) == null); +} + test "input routing bounds follow hidden workspace panel and rail" { const shown = inputBounds(1200, 900, .{}); try std.testing.expectEqual(@as(i32, Tokens.sidebar_width), shown.rail_left); diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index af0ec6ee..b068dcc6 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -228,19 +228,119 @@ pub const menu_watchdog_interval_ms: c.UINT = 10000; const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeWindowsShell"); -pub fn restoreExistingInstance() void { - const hwnd = c.FindWindowW(class_name.ptr, null); +const workspace_identity_property = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.Windows.WorkspaceIdentityV1"); + +pub const WorkspaceWindows = struct { + target: c.HWND = null, + unidentified: bool = false, +}; + +pub fn publishWorkspaceIdentity(hwnd: c.HWND, key: [:0]const u16) !void { + if (key.len == 0) return error.InvalidWorkspaceIdentity; + const marker = Win32.opaquePointerFromInt(c.HANDLE, 1); + if (c.SetPropW(hwnd, key.ptr, marker) == 0) return error.WorkspaceIdentityPublishFailed; + errdefer _ = c.RemovePropW(hwnd, key.ptr); + if (c.SetPropW(hwnd, workspace_identity_property.ptr, marker) == 0) + return error.WorkspaceIdentityPublishFailed; +} + +pub fn invalidateWorkspaceIdentity(hwnd: c.HWND) !void { + if (c.IsWindow(hwnd) == 0) return error.WorkspaceIdentityWindowMissing; + _ = c.RemovePropW(hwnd, workspace_identity_property.ptr); + if (c.GetPropW(hwnd, workspace_identity_property.ptr) != null) + return error.WorkspaceIdentityInvalidationFailed; +} + +pub fn workspaceIdentityMatches(hwnd: c.HWND, key: [:0]const u16) bool { + return c.GetPropW(hwnd, workspace_identity_property.ptr) != null and c.GetPropW(hwnd, key.ptr) != null; +} + +pub fn workspaceWindows(key: [:0]const u16) !WorkspaceWindows { + return workspaceWindowsForClass(key, class_name); +} + +fn workspaceWindowsForClass(key: [:0]const u16, window_class: []const u16) !WorkspaceWindows { + if (key.len == 0) return error.InvalidWorkspaceIdentity; + const Lookup = struct { + key: [:0]const u16, + window_class: []const u16, + found: WorkspaceWindows = .{}, + failure: ?anyerror = null, + + fn visit(hwnd: c.HWND, parameter: c.LPARAM) callconv(.winapi) c.BOOL { + const self = Win32.messagePointer(*@This(), parameter); + var buffer: [256]u16 = undefined; + const length = c.GetClassNameW(hwnd, &buffer, buffer.len); + if (length <= 0 or !std.mem.eql(u16, self.window_class, buffer[0..@intCast(length)])) return 1; + const same_user = sameWindowUser(hwnd) catch |err| { + self.failure = err; + return 0; + }; + if (!same_user) return 1; + if (c.GetPropW(hwnd, workspace_identity_property.ptr) == null) { + self.found.unidentified = true; + } else if (workspaceIdentityMatches(hwnd, self.key)) { + if (self.found.target != null) { + self.failure = error.AmbiguousWorkspaceWindow; + return 0; + } + self.found.target = hwnd; + } + return 1; + } + }; + var lookup = Lookup{ .key = key, .window_class = window_class }; + const enumerated = c.EnumWindows(Lookup.visit, @bitCast(@intFromPtr(&lookup))); + if (lookup.failure) |err| return err; + if (enumerated == 0) return error.WorkspaceWindowLookupFailed; + return lookup.found; +} + +fn sameWindowUser(hwnd: c.HWND) !bool { + var pid: c.DWORD = 0; + if (c.GetWindowThreadProcessId(hwnd, &pid) == 0) return error.WorkspaceWindowOwnerUnknown; + if (pid == c.GetCurrentProcessId()) return true; + var own_session: c.DWORD = 0; + var target_session: c.DWORD = 0; + if (c.ProcessIdToSessionId(c.GetCurrentProcessId(), &own_session) == 0 or + c.ProcessIdToSessionId(pid, &target_session) == 0) return error.WorkspaceWindowOwnerUnknown; + if (own_session != target_session) return false; + const process = c.OpenProcess(c.PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) orelse + return error.WorkspaceWindowOwnerUnknown; + defer _ = c.CloseHandle(process); + var own_token: c.HANDLE = null; + if (c.OpenProcessToken(c.GetCurrentProcess(), c.TOKEN_QUERY, &own_token) == 0) + return error.WorkspaceWindowOwnerUnknown; + defer _ = c.CloseHandle(own_token); + var target_token: c.HANDLE = null; + if (c.OpenProcessToken(process, c.TOKEN_QUERY, &target_token) == 0) + return error.WorkspaceWindowOwnerUnknown; + defer _ = c.CloseHandle(target_token); + var own_info: [512]u8 align(@alignOf(c.TOKEN_USER)) = undefined; + var target_info: [512]u8 align(@alignOf(c.TOKEN_USER)) = undefined; + var required: c.DWORD = 0; + if (c.GetTokenInformation(own_token, c.TokenUser, &own_info, own_info.len, &required) == 0 or + c.GetTokenInformation(target_token, c.TokenUser, &target_info, target_info.len, &required) == 0) + return error.WorkspaceWindowOwnerUnknown; + const own_user: *const c.TOKEN_USER = @ptrCast(&own_info); + const target_user: *const c.TOKEN_USER = @ptrCast(&target_info); + return c.EqualSid(own_user.User.Sid, target_user.User.Sid) != 0; +} + +pub fn restoreExistingInstance(key: [:0]const u16) !void { + const hwnd = (try workspaceWindows(key)).target orelse return error.WorkspaceWindowNotFound; const message = c.RegisterWindowMessageW(std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.Windows.Restore").ptr); - if (hwnd != null and message != 0) { - var process_id: c.DWORD = 0; - _ = c.GetWindowThreadProcessId(hwnd, &process_id); - if (process_id != 0) _ = c.AllowSetForegroundWindow(process_id); - _ = c.ShowWindow(hwnd, c.SW_RESTORE); - _ = c.ShowWindow(hwnd, c.SW_SHOW); - _ = c.BringWindowToTop(hwnd); - _ = c.SetForegroundWindow(hwnd); - _ = c.PostMessageW(hwnd, message, 0, 0); - } + if (message == 0) return error.WorkspaceRestoreFailed; + var process_id: c.DWORD = 0; + if (c.GetWindowThreadProcessId(hwnd, &process_id) == 0) return error.WorkspaceWindowOwnerUnknown; + if (!workspaceIdentityMatches(hwnd, key)) return error.WorkspaceWindowNotFound; + _ = c.AllowSetForegroundWindow(process_id); + _ = c.ShowWindow(hwnd, c.SW_RESTORE); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.BringWindowToTop(hwnd); + if (c.PostMessageW(hwnd, message, 0, 0) == 0) return error.WorkspaceRestoreFailed; + if (c.SetForegroundWindow(hwnd) == 0 and c.GetForegroundWindow() != hwnd) + return error.WorkspaceActivationFailed; } pub fn installMenu(hwnd: c.HWND) !void { @@ -390,12 +490,10 @@ fn updateWorkspaceMenu(hwnd: c.HWND, workspaces: []const WorkspaceItem) void { appendEnabled(menu, "Previous Workspace\tCtrl+Alt+PageUp", @intFromEnum(Command.workspace_previous), workspaces.len > 1); separator(menu); for (workspaces[0..@min(workspaces.len, workspace_command_limit - workspace_command_base + 1)], 0..) |item, index| { - const label = if (item.is_current) - std.fmt.allocPrint(std.heap.c_allocator, "✓ {s}", .{item.name}) catch continue - else - std.heap.c_allocator.dupe(u8, item.name) catch continue; - defer std.heap.c_allocator.free(label); - append(menu, label, workspace_command_base + index); + const command: c.UINT = @intCast(workspace_command_base + index); + append(menu, item.name, command); + const flags: c.UINT = if (item.is_current) c.MF_BYCOMMAND | c.MF_CHECKED else c.MF_BYCOMMAND | c.MF_UNCHECKED; + _ = c.CheckMenuItem(menu, command, flags); } } @@ -521,6 +619,103 @@ fn testWindowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPA return c.DefWindowProcW(hwnd, message, wparam, lparam); } +const workspace_test_class = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeWorkspaceIdentityTest"); + +fn hiddenWorkspaceTestWindow() !c.HWND { + var wc = std.mem.zeroes(c.WNDCLASSW); + wc.lpfnWndProc = testWindowProc; + wc.hInstance = c.GetModuleHandleW(null); + wc.lpszClassName = workspace_test_class.ptr; + if (c.RegisterClassW(&wc) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.TestWindowClassFailed; + return c.CreateWindowExW( + 0, + workspace_test_class.ptr, + std.unicode.utf8ToUtf16LeStringLiteral("Hidden workspace fixture").ptr, + c.WS_OVERLAPPEDWINDOW, + 0, + 0, + 0, + 0, + null, + null, + wc.hInstance, + null, + ) orelse error.TestWindowCreationFailed; +} + +test "workspace lookup selects only the exact identified window and flags legacy ambiguity" { + const alpha = try hiddenWorkspaceTestWindow(); + defer _ = c.DestroyWindow(alpha); + const beta = try hiddenWorkspaceTestWindow(); + defer _ = c.DestroyWindow(beta); + const key_alpha = std.unicode.utf8ToUtf16LeStringLiteral("workspace-fixture-alpha"); + const key_beta = std.unicode.utf8ToUtf16LeStringLiteral("workspace-fixture-beta"); + const missing = std.unicode.utf8ToUtf16LeStringLiteral("workspace-fixture-missing"); + try publishWorkspaceIdentity(alpha, key_alpha); + try publishWorkspaceIdentity(beta, key_beta); + const found = try workspaceWindowsForClass(key_beta, workspace_test_class); + try std.testing.expectEqual(beta, found.target); + try std.testing.expect(!found.unidentified); + try invalidateWorkspaceIdentity(beta); + const invalidated = try workspaceWindowsForClass(key_beta, workspace_test_class); + try std.testing.expect(invalidated.unidentified); + try std.testing.expect(invalidated.target == null); + try std.testing.expect(!workspaceIdentityMatches(beta, key_beta)); + try publishWorkspaceIdentity(beta, key_beta); + try std.testing.expectEqual(beta, (try workspaceWindowsForClass(key_beta, workspace_test_class)).target); + try std.testing.expect((try workspaceWindowsForClass(missing, workspace_test_class)).target == null); + const legacy = try hiddenWorkspaceTestWindow(); + defer _ = c.DestroyWindow(legacy); + const uncertain = try workspaceWindowsForClass(missing, workspace_test_class); + try std.testing.expect(uncertain.unidentified); + try std.testing.expect(uncertain.target == null); + try publishWorkspaceIdentity(alpha, key_beta); + try std.testing.expectError(error.AmbiguousWorkspaceWindow, workspaceWindowsForClass(key_beta, workspace_test_class)); +} + +fn expectDisabledWorkspaceCommand(menu: c.HMENU, command: Command) !void { + const state = c.GetMenuState(menu, @intFromEnum(command), c.MF_BYCOMMAND); + try std.testing.expect(state != std.math.maxInt(c.UINT)); + try std.testing.expect(state & c.MF_GRAYED != 0); +} + +test "workspace menu checks the exact command and retains target labels and enablement" { + const hwnd = try hiddenWorkspaceTestWindow(); + defer _ = c.DestroyWindow(hwnd); + try installMenu(hwnd); + const menu = c.GetSubMenu(c.GetMenu(hwnd), 3); + var items = [_]WorkspaceItem{ + .{ .name = "Default", .is_current = false }, + .{ .name = "alpha", .is_current = true }, + .{ .name = "beta", .is_current = false }, + }; + for ([_]usize{ 1, 2 }) |selected| { + for (&items, 0..) |*item, index| item.is_current = index == selected; + updateWorkspaceMenu(hwnd, &items); + for (items, 0..) |item, index| { + const command: c.UINT = @intCast(workspace_command_base + index); + const state = c.GetMenuState(menu, command, c.MF_BYCOMMAND); + try std.testing.expect(state != std.math.maxInt(c.UINT)); + try std.testing.expectEqual(index == selected, state & c.MF_CHECKED != 0); + var label: [128]u16 = undefined; + const length = c.GetMenuStringW(menu, command, &label, label.len, c.MF_BYCOMMAND); + const actual = try std.unicode.utf16LeToUtf8Alloc(std.testing.allocator, label[0..@intCast(length)]); + defer std.testing.allocator.free(actual); + try std.testing.expectEqualStrings(item.name, actual); + } + try std.testing.expect(c.GetMenuState(menu, @intFromEnum(Command.workspace_next), c.MF_BYCOMMAND) & c.MF_GRAYED == 0); + } + updateWorkspaceMenu(hwnd, items[0..1]); + try expectDisabledWorkspaceCommand(menu, .workspace_next); + try std.testing.expect(c.DeleteMenu(menu, @intFromEnum(Command.workspace_next), c.MF_BYCOMMAND) != 0); + try std.testing.expectError(error.TestUnexpectedResult, expectDisabledWorkspaceCommand(menu, .workspace_next)); + updateWorkspaceMenu(hwnd, &.{}); + try expectDisabledWorkspaceCommand(menu, .workspace_rename); + try std.testing.expect(c.DeleteMenu(menu, @intFromEnum(Command.workspace_rename), c.MF_BYCOMMAND) != 0); + try std.testing.expectError(error.TestUnexpectedResult, expectDisabledWorkspaceCommand(menu, .workspace_rename)); +} + // Regression test for the Update-command re-enable bug: a real background // update check completes almost instantly, but `finishUpdateCheck` only // refreshed menu state through `updateNativeChrome`, which is gated on diff --git a/graphcode-windows/src/WindowsNativeDialogs.zig b/graphcode-windows/src/WindowsNativeDialogs.zig index 0e6587ac..913aa031 100644 --- a/graphcode-windows/src/WindowsNativeDialogs.zig +++ b/graphcode-windows/src/WindowsNativeDialogs.zig @@ -27,6 +27,7 @@ const State = struct { scroll_offset: i32 = 0, accepted: bool = false, closed: bool = false, + failure: ?anyerror = null, button_y: i32 = 565, }; @@ -54,6 +55,7 @@ pub fn textWithDescription( labels: []const []const u8, initial: []const []const u8, ) !?Result { + if (active) return error.DialogAlreadyOpen; if (labels.len == 0 or labels.len > 16 or labels.len != initial.len) return error.InvalidDialogFields; var state = State{ .allocator = allocator, @@ -121,21 +123,39 @@ pub fn textWithDescription( } ModalTeardown.dismiss(hwnd, parent); active = false; - if (!active_state.accepted) { - freeStateValues(&active_state); + return finishText(&active_state); +} + +fn finishText(state: *State) !?Result { + if (state.failure) |err| { + freeStateValues(state); + return err; + } + if (!state.accepted) { + freeStateValues(state); return null; } - var result = Result{ .values = [_][]u8{&.{}} ** 16, .count = state.count }; - readValues(&active_state); - for (active_state.values[0..state.count], 0..) |value, index| { - result.values[index] = allocator.dupe(u8, value) catch |err| { - result.deinit(allocator); - freeStateValues(&active_state); - return err; + const result = Result{ .values = state.values, .count = state.count }; + state.values = [_][]u8{&.{}} ** 16; + state.count = 0; + return result; +} + +const TextCommand = enum { submit, cancel, close }; + +fn applyTextCommand(state: *State, command: TextCommand) void { + if (command == .submit) { + readValues(state) catch |err| { + state.failure = err; + state.accepted = false; + state.closed = true; + return; }; + state.accepted = true; + } else { + state.accepted = false; } - freeStateValues(&active_state); - return result; + state.closed = true; } fn freeStateValues(state: *State) void { @@ -182,33 +202,26 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) c.WM_COMMAND => { const command: u16 = @truncate(wparam); if (command == ok_id) { - readValues(&active_state); - active_state.accepted = true; - active_state.closed = true; + applyTextCommand(&active_state, .submit); return 0; } if (command == cancel_id) { - active_state.accepted = false; - active_state.closed = true; + applyTextCommand(&active_state, .cancel); return 0; } }, c.WM_KEYDOWN => { if (wparam == c.VK_RETURN) { - readValues(&active_state); - active_state.accepted = true; - active_state.closed = true; + applyTextCommand(&active_state, .submit); return 0; } if (wparam == c.VK_ESCAPE) { - active_state.accepted = false; - active_state.closed = true; + applyTextCommand(&active_state, .cancel); return 0; } }, c.WM_CLOSE => { - active_state.accepted = false; - active_state.closed = true; + applyTextCommand(&active_state, .close); return 0; }, else => {}, @@ -276,11 +289,15 @@ fn createButton(hwnd: c.HWND, label: []const u8, id: usize, x: i32, y: i32) void AppFont.apply(button, AppFont.control_size, false); } -fn readValues(state: *State) void { +fn readValues(state: *State) !void { var buffer: [4096]u16 = undefined; for (0..state.count) |index| { + if (c.IsWindow(state.edits[index]) == 0) return error.DialogReadFailed; + if (c.GetWindowTextLengthW(state.edits[index]) >= buffer.len) return error.DialogTextTooLong; + c.SetLastError(0); const length = c.GetWindowTextW(state.edits[index], &buffer, @intCast(buffer.len)); - const value = std.unicode.utf16LeToUtf8Alloc(state.allocator, buffer[0..@intCast(length)]) catch continue; + if (length == 0 and c.GetLastError() != 0) return error.DialogReadFailed; + const value = try std.unicode.utf16LeToUtf8Alloc(state.allocator, buffer[0..@intCast(length)]); state.allocator.free(state.values[index]); state.values[index] = value; } @@ -300,3 +317,135 @@ test "native dialog field contract preserves Unicode and field count" { try std.testing.expectEqual(labels.len, 2); try std.testing.expect(std.unicode.utf8ValidateSlice("Проекты\\über")); } + +test "accepted workspace text survives native edit teardown" { + const allocator = std.testing.allocator; + var state = State{ .allocator = allocator, .parent = null, .count = 2 }; + defer freeStateValues(&state); + state.values[0] = try allocator.dupe(u8, "old workspace"); + state.values[1] = try allocator.dupe(u8, "old name"); + const first = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, + std.unicode.utf8ToUtf16LeStringLiteral("alpha").ptr, + 0, + 0, + 0, + 100, + 20, + null, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.TestWindowCreationFailed; + defer if (c.IsWindow(first) != 0) { + _ = c.DestroyWindow(first); + }; + const second = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, + std.unicode.utf8ToUtf16LeStringLiteral("Проекты über").ptr, + 0, + 0, + 0, + 100, + 20, + null, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.TestWindowCreationFailed; + defer if (c.IsWindow(second) != 0) { + _ = c.DestroyWindow(second); + }; + state.edits[0] = first; + state.edits[1] = second; + applyTextCommand(&state, .submit); + try std.testing.expect(c.DestroyWindow(first) != 0); + try std.testing.expect(c.DestroyWindow(second) != 0); + var result = (try finishText(&state)) orelse return error.ExpectedAcceptedText; + defer result.deinit(allocator); + try std.testing.expectEqual(@as(usize, 2), result.count); + try std.testing.expectEqualStrings("alpha", result.values[0]); + try std.testing.expectEqualStrings("Проекты über", result.values[1]); + try std.testing.expectEqual(@as(usize, 0), state.count); +} + +test "cancelled or closed workspace text has no accepted result" { + for ([_]TextCommand{ .cancel, .close }) |command| { + var state = State{ + .allocator = std.testing.allocator, + .parent = null, + .count = 1, + }; + defer freeStateValues(&state); + state.values[0] = try state.allocator.dupe(u8, "do-not-delete"); + applyTextCommand(&state, command); + try std.testing.expect((try finishText(&state)) == null); + try std.testing.expect(state.closed); + try std.testing.expectEqual(@as(usize, 0), state.count); + } +} + +test "workspace text read failure never accepts stale values" { + var state = State{ + .allocator = std.testing.allocator, + .parent = null, + .count = 1, + }; + defer freeStateValues(&state); + state.values[0] = try state.allocator.dupe(u8, "stale-name"); + applyTextCommand(&state, .submit); + try std.testing.expect(!state.accepted); + try std.testing.expect(state.closed); + try std.testing.expectError(error.DialogReadFailed, finishText(&state)); + try std.testing.expectEqual(@as(usize, 0), state.count); +} + +test "workspace text rejects reentrant presentation before creating any window" { + const previous = active; + active = true; + defer active = previous; + try std.testing.expectError(error.DialogAlreadyOpen, textWithDescription( + null, + std.testing.allocator, + "New Workspace", + "", + &.{"Name"}, + &.{""}, + )); +} + +test "workspace text capture and transfer release every partial allocation" { + const edit = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, + std.unicode.utf8ToUtf16LeStringLiteral("new workspace").ptr, + 0, + 0, + 0, + 100, + 20, + null, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.TestWindowCreationFailed; + defer _ = c.DestroyWindow(edit); + const Probe = struct { + fn run(allocator: std.mem.Allocator, window: c.HWND) !void { + var state = State{ .allocator = allocator, .parent = null, .count = 2 }; + defer freeStateValues(&state); + state.values[0] = try allocator.dupe(u8, "old first"); + state.values[1] = try allocator.dupe(u8, "old second"); + state.edits[0] = window; + state.edits[1] = window; + applyTextCommand(&state, .submit); + var result = (try finishText(&state)) orelse return error.ExpectedAcceptedText; + defer result.deinit(allocator); + try std.testing.expectEqualStrings("new workspace", result.values[0]); + try std.testing.expectEqualStrings("new workspace", result.values[1]); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Probe.run, .{edit}); +} diff --git a/graphcode-windows/src/WorkspaceLifecycle.zig b/graphcode-windows/src/WorkspaceLifecycle.zig index 47013243..fa20c8fa 100644 --- a/graphcode-windows/src/WorkspaceLifecycle.zig +++ b/graphcode-windows/src/WorkspaceLifecycle.zig @@ -7,11 +7,13 @@ pub const max_name_length: usize = 48; pub const Workspace = struct { name: []const u8, path: []const u8, + identity: []const u8, is_default: bool, pub fn deinit(self: *Workspace, allocator: std.mem.Allocator) void { allocator.free(self.name); allocator.free(self.path); + allocator.free(self.identity); self.* = undefined; } }; @@ -34,8 +36,10 @@ pub const NameError = error{ }; pub fn defaultPath(allocator: std.mem.Allocator) ![]u8 { - const home = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch - return error.UserProfileMissing; + const home = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch |err| switch (err) { + error.EnvironmentVariableNotFound => return error.UserProfileMissing, + else => return err, + }; defer allocator.free(home); return std.fs.path.join(allocator, &.{ home, default_directory_name }); } @@ -44,7 +48,10 @@ pub fn currentPath(allocator: std.mem.Allocator) ![]u8 { if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR")) |value| { defer allocator.free(value); if (value.len != 0) return resolvePath(allocator, value); - } else |_| {} + } else |err| switch (err) { + error.EnvironmentVariableNotFound => {}, + else => return err, + } return defaultPath(allocator); } @@ -78,8 +85,11 @@ pub fn validateName( errdefer allocator.free(name); const path = try workspacePath(allocator, name, home); defer allocator.free(path); - if (directoryExists(path)) return NameError.NameTaken; - return name; + std.fs.cwd().access(path, .{}) catch |err| switch (err) { + error.FileNotFound => return name, + else => return err, + }; + return NameError.NameTaken; } pub fn workspacePath( @@ -90,10 +100,23 @@ pub fn workspacePath( return std.fmt.allocPrint(allocator, "{s}\\{s}{s}", .{ home, directory_prefix, name }); } +pub fn create(allocator: std.mem.Allocator, input: []const u8, home: []const u8) !Workspace { + const name = try validateName(allocator, input, home); + errdefer allocator.free(name); + const path = try workspacePath(allocator, name, home); + errdefer allocator.free(path); + const identity = try pathIdentity(allocator, path); + errdefer allocator.free(identity); + try std.fs.makeDirAbsolute(path); + return .{ .name = name, .path = path, .identity = identity, .is_default = false }; +} + pub fn resolvePath(allocator: std.mem.Allocator, configured: []const u8) ![]u8 { if (isAbsoluteWindowsPath(configured)) return allocator.dupe(u8, configured); - const home = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch - return allocator.dupe(u8, configured); + const home = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch |err| switch (err) { + error.EnvironmentVariableNotFound => return error.UserProfileMissing, + else => return err, + }; defer allocator.free(home); return std.fs.path.join(allocator, &.{ home, configured }); } @@ -111,15 +134,9 @@ pub fn listFromHome(allocator: std.mem.Allocator, home: []const u8) !List { for (values.items) |*workspace| workspace.deinit(allocator); values.deinit(); } - const default_path = try std.fmt.allocPrint(allocator, "{s}\\{s}", .{ home, default_directory_name }); - defer allocator.free(default_path); - try values.append(.{ - .name = try allocator.dupe(u8, "Default"), - .path = try allocator.dupe(u8, default_path), - .is_default = true, - }); + try appendWorkspace(allocator, &values, home, default_directory_name, "Default", true); var directory = std.fs.openDirAbsolute(home, .{ .iterate = true }) catch |err| switch (err) { - error.FileNotFound, error.AccessDenied => return .{ .items = try values.toOwnedSlice() }, + error.FileNotFound => return .{ .items = try values.toOwnedSlice() }, else => return err, }; defer directory.close(); @@ -129,17 +146,29 @@ pub fn listFromHome(allocator: std.mem.Allocator, home: []const u8) !List { continue; const suffix = entry.name[directory_prefix.len..]; if (suffix.len == 0) continue; - const path = try std.fmt.allocPrint(allocator, "{s}\\{s}", .{ home, entry.name }); - try values.append(.{ - .name = try allocator.dupe(u8, suffix), - .path = path, - .is_default = false, - }); + try appendWorkspace(allocator, &values, home, entry.name, suffix, false); } std.sort.block(Workspace, values.items, {}, lessThan); return .{ .items = try values.toOwnedSlice() }; } +fn appendWorkspace( + allocator: std.mem.Allocator, + values: *std.array_list.Managed(Workspace), + home: []const u8, + directory_name: []const u8, + name: []const u8, + is_default: bool, +) !void { + const owned_name = try allocator.dupe(u8, name); + errdefer allocator.free(owned_name); + const path = try std.fs.path.join(allocator, &.{ home, directory_name }); + errdefer allocator.free(path); + const identity = try pathIdentity(allocator, path); + errdefer allocator.free(identity); + try values.append(.{ .name = owned_name, .path = path, .identity = identity, .is_default = is_default }); +} + pub fn directoryExists(path: []const u8) bool { var directory = std.fs.openDirAbsolute(path, .{}) catch return false; directory.close(); @@ -152,8 +181,29 @@ pub fn isAbsoluteWindowsPath(path: []const u8) bool { (path.len >= 2 and path[0] == '/' and path[1] == '/'); } -pub fn isSamePath(left: []const u8, right: []const u8) bool { - return std.ascii.eqlIgnoreCase(left, right); +pub fn pathIdentity(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const parsed = std.fs.path.windowsParsePath(path); + if (!parsed.is_abs or parsed.kind == .None or std.mem.indexOfScalar(u8, path, 0) != null or + !std.unicode.utf8ValidateSlice(path)) return error.InvalidWorkspacePath; + const identity = try std.fs.path.resolveWindows(allocator, &.{path}); + for (identity) |*byte| { + byte.* = if (byte.* == '\\') '/' else std.ascii.toLower(byte.*); + } + return identity; +} + +pub fn instanceName(allocator: std.mem.Allocator, user: []const u8, path: []const u8) ![]u8 { + const identity = try pathIdentity(allocator, path); + defer allocator.free(identity); + return legacyInstanceName(allocator, user, identity); +} + +pub fn legacyInstanceName(allocator: std.mem.Allocator, user: []const u8, path: []const u8) ![]u8 { + if (user.len == 0 or path.len == 0) return error.InvalidWorkspaceIdentity; + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(path, &digest, .{}); + const digest_text = std.fmt.bytesToHex(digest, .lower); + return std.fmt.allocPrint(allocator, "Local\\graphcode-windows-{s}-{s}", .{ user, digest_text[0..20] }); } fn lessThan(_: void, left: Workspace, right: Workspace) bool { @@ -177,3 +227,147 @@ test "workspace paths use the Windows sibling convention" { defer std.testing.allocator.free(path); try std.testing.expectEqualStrings("C:\\Users\\tester\\.graphcode-alpha", path); } + +test "workspace enumeration owns every allocation including partial entries" { + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + try temporary.dir.makeDir(".graphcode-alpha"); + try temporary.dir.makeDir(".graphcode-beta"); + try temporary.dir.makeDir("unrelated"); + try temporary.dir.writeFile(.{ .sub_path = ".graphcode-not-a-directory", .data = "sentinel" }); + const home = try temporary.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + try std.testing.checkAllAllocationFailures(std.testing.allocator, checkWorkspaceList, .{home}); +} + +fn checkWorkspaceList(allocator: std.mem.Allocator, home: []const u8) !void { + var found = try listFromHome(allocator, home); + defer found.deinit(allocator); + try std.testing.expectEqual(@as(usize, 3), found.items.len); + try std.testing.expectEqualStrings("alpha", found.items[0].name); + try std.testing.expectEqualStrings("beta", found.items[1].name); + try std.testing.expectEqualStrings("Default", found.items[2].name); + try std.testing.expect(found.items[2].is_default); + for (found.items[0..2]) |workspace| try std.testing.expect(!workspace.is_default); +} + +test "workspace validation rejects directory and file collisions" { + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + try temporary.dir.makeDir(".graphcode-existing"); + try temporary.dir.writeFile(.{ .sub_path = ".graphcode-file", .data = "do-not-overwrite" }); + const home = try temporary.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + try std.testing.expectError(error.NameTaken, validateName(std.testing.allocator, "Existing", home)); + try std.testing.expectError(error.NameTaken, validateName(std.testing.allocator, "file", home)); + const contents = try temporary.dir.readFileAlloc(std.testing.allocator, ".graphcode-file", 100); + defer std.testing.allocator.free(contents); + try std.testing.expectEqualStrings("do-not-overwrite", contents); +} + +test "workspace normalization cannot turn input into a traversal path" { + const allocator = std.testing.allocator; + for ([_][]const u8{ "", "...", "/\\", " \t\r\n" }) |input| { + try std.testing.expectError(error.EmptyName, normalizeName(allocator, input)); + } + const name = try normalizeName(allocator, "..\\..//Outside workspace"); + defer allocator.free(name); + try std.testing.expectEqualStrings("outside-workspace", name); + const path = try workspacePath(allocator, name, "C:\\fixture"); + defer allocator.free(path); + try std.testing.expectEqualStrings("C:\\fixture\\.graphcode-outside-workspace", path); +} + +test "workspace identity normalizes lexical Windows aliases without changing non ASCII bytes" { + const allocator = std.testing.allocator; + const expected = "c:/users/test/.graphcode-alpha"; + for ([_][]const u8{ + "C:\\Users\\Test\\.graphcode-alpha", + "c:/users/TEST/.graphcode-alpha/", + "C:\\Users\\Test\\ignored\\..\\.\\.graphcode-alpha\\", + }) |path| { + const identity = try pathIdentity(allocator, path); + defer allocator.free(identity); + try std.testing.expectEqualStrings(expected, identity); + const name = try instanceName(allocator, "fixture", path); + defer allocator.free(name); + const canonical_name = try instanceName(allocator, "fixture", expected); + defer allocator.free(canonical_name); + try std.testing.expectEqualStrings(canonical_name, name); + } + const distinct = try pathIdentity(allocator, "C:\\Users\\Test\\.graphcode-beta"); + defer allocator.free(distinct); + try std.testing.expect(!std.mem.eql(u8, expected, distinct)); + const unicode = try pathIdentity(allocator, "C:\\Users\\\xc3\x9cber\\.graphcode-alpha"); + defer allocator.free(unicode); + try std.testing.expectEqualStrings("c:/users/\xc3\x9cber/.graphcode-alpha", unicode); + const lower_unicode = try pathIdentity(allocator, "C:\\Users\\\xc3\xbcber\\.graphcode-alpha"); + defer allocator.free(lower_unicode); + try std.testing.expect(!std.mem.eql(u8, unicode, lower_unicode)); + const network = try pathIdentity(allocator, "\\\\Server\\Share\\nested\\..\\.graphcode-alpha\\"); + defer allocator.free(network); + try std.testing.expectEqualStrings("//server/share/.graphcode-alpha", network); +} + +test "workspace identity rejects absent relative and malformed paths" { + for ([_][]const u8{ "", "relative", "C:relative", "\\relative", "C:\\bad\x00path", "C:\\\xff" }) |path| { + try std.testing.expectError(error.InvalidWorkspacePath, pathIdentity(std.testing.allocator, path)); + } + try std.testing.expectError(error.InvalidWorkspaceIdentity, instanceName(std.testing.allocator, "", "C:\\fixture")); +} + +test "workspace instance identities release partial allocations" { + const Probe = struct { + fn run(allocator: std.mem.Allocator) !void { + const name = try instanceName(allocator, "fixture", "C:\\Users\\Test\\ignored\\..\\.graphcode-alpha\\"); + defer allocator.free(name); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Probe.run, .{}); +} + +test "workspace creation normalizes safely and refuses invalid or colliding targets" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + try temporary.dir.writeFile(.{ .sub_path = "unrelated", .data = "untouched" }); + var created = try create(allocator, "..\\..//My workspace", home); + defer created.deinit(allocator); + try std.testing.expectEqualStrings("my-workspace", created.name); + var directory = try std.fs.openDirAbsolute(created.path, .{}); + directory.close(); + try std.testing.expectError(error.NameTaken, create(allocator, "My workspace", home)); + try std.testing.expectError(error.EmptyName, create(allocator, "...", home)); + const untouched = try temporary.dir.readFileAlloc(allocator, "unrelated", 100); + defer allocator.free(untouched); + try std.testing.expectEqualStrings("untouched", untouched); + var listed = try listFromHome(allocator, home); + defer listed.deinit(allocator); + try std.testing.expectEqual(@as(usize, 2), listed.items.len); +} + +test "workspace creation allocates before creating any directory" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + const destination = try workspacePath(allocator, "created", home); + defer allocator.free(destination); + const Probe = struct { + fn run(failing: std.mem.Allocator, parent: []const u8, path: []const u8) !void { + var created = create(failing, "created", parent) catch |err| { + std.fs.cwd().access(path, .{}) catch |access_error| switch (access_error) { + error.FileNotFound => return err, + else => return access_error, + }; + return error.DirectoryCreatedBeforeAllocationCompleted; + }; + defer created.deinit(failing); + try std.fs.deleteDirAbsolute(created.path); + } + }; + try std.testing.checkAllAllocationFailures(allocator, Probe.run, .{ home, destination }); +} diff --git a/graphcode-windows/src/main.zig b/graphcode-windows/src/main.zig index 7923d015..d63277f7 100644 --- a/graphcode-windows/src/main.zig +++ b/graphcode-windows/src/main.zig @@ -18,7 +18,10 @@ pub fn main() !void { } var app = App.init(allocator) catch |err| { if (err == error.InstanceAlreadyRunning) { - @import("MainWindow.zig").restoreExistingInstance(); + @import("App.zig").restoreCurrentWorkspace(allocator) catch |restore_error| { + std.log.err("Workspace activation failed: {s}", .{@errorName(restore_error)}); + return restore_error; + }; return; } return err; diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 24f86f63..a015156e 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 | The native header now exposes clickable needs-you and reclaimable-worktree chips, a visible Ctrl+P jump affordance, and a contextual loop-panel toggle alongside status, each carrying its own dedicated UIA identity (no longer aliased into the Projects-row or Graph-card automation-id buckets, and correctly parented so they don't pollute either group's exact-child count). The live UIA gate verifies the Graph canvas and Projects grouped-recent-rows assertions still hold with these controls present. Native keyboard focus/tab order for the header controls and macOS visual treatment remain incomplete | 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. Worktree commands are now contextually gated instead of always-enabled: Reclaim/Reveal Selected Worktree require a selected row and Save Worktree Policy requires the Worktrees dialog to be open, so a command that could never succeed is grayed out rather than surfacing a "select a row first" status message; `worktreeRowSelected()` has dedicated unit coverage. This closes part of the contextual-enablement gap, but it is unit-tested only — live UIA gate evidence for the Worktrees dialog itself is still outstanding, and broader project-management parity remains incomplete | Partial | -| Workspace lifecycle | List/switch, create, rename, delete with fail-closed confirmation, and keyboard/UIA reachability | Windows now discovers `Default` plus `.graphcode-*` sibling directories, exposes a native Workspace menu with dynamic checked selection, New/Rename/Delete actions, and Ctrl+Alt paging. Creation and switching launch a support-directory-scoped app/daemon instance; rename/delete reject Default/current workspaces, and delete requires a cancellation-default warning. Focused lifecycle and menu tests pass; live multi-instance/UIA gate evidence remains outstanding this session due to unrelated shared-environment gate instability (see below) | 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. Production-helper and never-shown native-control regressions cover accepted text surviving form teardown, allocation/error cleanup, safe normalized names and file/directory collisions, native checked selection, lexical workspace identity, exact-window lookup, one-launch routing, and Default/current/open-workspace refusal. Decoded child-environment coverage verifies the new support directory and removal of the parent's explicit daemon-pipe override only in the child, leaving parent/current/restore environments unchanged. Canonical plus legacy raw-path reservations span destructive confirmation/final recheck; unidentified older windows and uncertain ownership fail closed. A Settings connection change to a different or unverifiable support identity invalidates workspace attribution and blocks lifecycle actions with a restart-required status while retaining the original reservation; restoring the original identity revalidates it, without claiming data-store migration. Disposable filesystem tests verify non-Yes preservation, exact-target deletion, and non-overwriting rename with saved bytes intact; an action-disabled control proves deletion cannot pass from its return value alone. These are helper/fixture/hidden-native results, not authentic shown multi-instance, UIA, injected-keyboard, app save/reload, or real-daemon evidence. macOS structured Manage/content summaries, creation-order/running-only cycling, and recoverable deletion with session/daemon teardown also remain unmatched. Shared-host menu failures 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 |