diff --git a/graphcode-windows/src/Accessibility.zig b/graphcode-windows/src/Accessibility.zig index 04b1c37d..8911203e 100644 --- a/graphcode-windows/src/Accessibility.zig +++ b/graphcode-windows/src/Accessibility.zig @@ -8,10 +8,18 @@ const c = if (builtin.os.tag == .windows and builtin.link_libc) @import("Win32.z pub const LPARAM = isize; pub const LRESULT = isize; pub const HRESULT = i32; - pub fn SetPropW(_: HWND, _: [*:0]const u16, _: HANDLE) c_int { return 0; } - pub fn RemovePropW(_: HWND, _: [*:0]const u16) HANDLE { return null; } - pub fn GetPropW(_: HWND, _: [*:0]const u16) HANDLE { return null; } - pub fn GetDesktopWindow() HWND { return null; } + pub fn SetPropW(_: HWND, _: [*:0]const u16, _: HANDLE) c_int { + return 0; + } + pub fn RemovePropW(_: HWND, _: [*:0]const u16) HANDLE { + return null; + } + pub fn GetPropW(_: HWND, _: [*:0]const u16) HANDLE { + return null; + } + pub fn GetDesktopWindow() HWND { + return null; + } }; const provider_property = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.AccessibilityProvider"); const NativeProvider = opaque {}; @@ -102,7 +110,9 @@ pub const Provider = struct { } pub fn deinit(self: *Provider) void { self.detach(); - self.elements.deinit(); self.focus_order.deinit(); self.notifications.deinit(); + self.elements.deinit(); + self.focus_order.deinit(); + self.notifications.deinit(); } pub fn attach(self: *Provider, hwnd: c.HWND) bool { if (!builtin.link_libc) return false; @@ -273,15 +283,44 @@ pub fn defaultContract(allocator: std.mem.Allocator) !Provider { _ = try provider.add(.{ .id = "actual-size", .name = "Actual size", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "zoom-in", .name = "Zoom in", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "fit-canvas", .name = "Fit canvas", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); - const menu = try provider.add(.{ .id = "actions", .name = "Actions", .role = .menu, .parent = window, .focusable = true, .patterns = &.{ .expand_collapse } }); - _ = try provider.add(.{ .id = "inspect-worktrees", .name = "Inspect worktrees", .role = .menu_item, .parent = menu, .patterns = &.{ .invoke } }); - _ = try provider.add(.{ .id = "reclaim-worktrees", .name = "Reclaim selected worktrees", .role = .menu_item, .parent = menu, .patterns = &.{ .invoke } }); - _ = try provider.add(.{ .id = "reveal-worktree", .name = "Reveal in Explorer", .role = .menu_item, .parent = menu, .patterns = &.{ .invoke } }); + const menu = try provider.add(.{ .id = "actions", .name = "Actions", .role = .menu, .parent = window, .focusable = true, .patterns = &.{.expand_collapse} }); + _ = try provider.add(.{ .id = "inspect-worktrees", .name = "Inspect worktrees", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "reclaim-worktrees", .name = "Reclaim selected worktrees", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "reveal-worktree", .name = "Reveal in Explorer", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "terminal-a", .name = "Terminal A", .role = .terminal, .parent = window, .focusable = true, .patterns = &.{ .text, .scroll } }); _ = try provider.add(.{ .id = "terminal-b", .name = "Terminal B", .role = .terminal, .parent = window, .focusable = true, .patterns = &.{ .text, .scroll } }); _ = try provider.add(.{ .id = "status", .name = "Status", .role = .status, .parent = window }); _ = try provider.add(.{ .id = "errors", .name = "Errors", .role = .status, .parent = window }); - _ = try provider.add(.{ .id = "move-project-unavailable", .name = "Move project unavailable: daemon support required", .role = .menu_item, .parent = menu, .patterns = &.{ .text } }); + _ = try provider.add(.{ .id = "move-project-unavailable", .name = "Move project unavailable: daemon support required", .role = .menu_item, .parent = menu, .patterns = &.{.text} }); + const workspaces = try provider.add(.{ + .id = "workspaces", + .name = "Workspaces", + .role = .menu, + .parent = window, + .focusable = true, + .patterns = &.{ .selection, .expand_collapse }, + }); + _ = try provider.add(.{ + .id = "workspace-new", + .name = "New Workspace", + .role = .menu_item, + .parent = workspaces, + .patterns = &.{.invoke}, + }); + _ = try provider.add(.{ + .id = "workspace-rename", + .name = "Rename Workspace", + .role = .menu_item, + .parent = workspaces, + .patterns = &.{.invoke}, + }); + _ = try provider.add(.{ + .id = "workspace-delete", + .name = "Delete Workspace", + .role = .menu_item, + .parent = workspaces, + .patterns = &.{.invoke}, + }); return provider; } @@ -309,10 +348,20 @@ test "UIA contract exposes named roles patterns and deterministic focus order" { try std.testing.expect(provider.hasPattern(22, .text)); try std.testing.expect(!provider.hasPattern(22, .invoke)); try std.testing.expectEqual(Role.menu_item, provider.elements.items[22].role); + try std.testing.expectEqualStrings("move-project-unavailable", provider.elements.items[22].id); try std.testing.expectEqualStrings( "Move project unavailable: daemon support required", provider.elements.items[22].name, ); + try std.testing.expectEqualStrings("workspaces", provider.elements.items[23].id); + try std.testing.expect(provider.hasPattern(23, .selection)); + try std.testing.expect(provider.hasPattern(23, .expand_collapse)); + try std.testing.expectEqualStrings("workspace-new", provider.elements.items[24].id); + try std.testing.expect(provider.hasPattern(24, .invoke)); + try std.testing.expectEqualStrings("workspace-rename", provider.elements.items[25].id); + try std.testing.expect(provider.hasPattern(25, .invoke)); + try std.testing.expectEqualStrings("workspace-delete", provider.elements.items[26].id); + try std.testing.expect(provider.hasPattern(26, .invoke)); try std.testing.expectEqual(@as(?usize, 3), provider.nextFocus(2)); try std.testing.expectEqual(@as(?usize, 4), provider.nextFocus(3)); } diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 5614f784..25039716 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -33,6 +33,7 @@ const WorktreeDialog = @import("WorktreeDialog.zig"); const Accessibility = @import("Accessibility.zig"); const Navigation = @import("Navigation.zig"); const WorkspaceControls = @import("WorkspaceControls.zig"); +const WorkspaceLifecycle = @import("WorkspaceLifecycle.zig"); const Win32 = @import("Win32.zig"); const c = Win32.c; @@ -173,6 +174,7 @@ const UiaDynamicTarget = union(enum) { workspace_toggle_panel, workspace_tab: usize, workspace_tab_close: usize, + workspace_switch: usize, }; pub const App = struct { @@ -212,6 +214,8 @@ pub const App = struct { quick_chats_requested: bool = false, selected_quick_chat: ?usize = null, instance_mutex: c.HANDLE = null, + workspace_list: ?WorkspaceLifecycle.List = null, + workspace_path: []u8 = &.{}, sync_requested: bool = false, restore_requested: bool = false, open_project_pending: bool = false, @@ -343,6 +347,8 @@ pub const App = struct { 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); + if (self.workspace_list) |*list| list.deinit(self.allocator); + if (self.workspace_path.len != 0) self.allocator.free(self.workspace_path); 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); @@ -445,6 +451,7 @@ pub const App = struct { } } self.createEmptyStateControls(); + self.refreshWorkspaceList(); self.updateNativeChrome(); if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_FIXTURE_ROWS")) |fixture| { defer self.allocator.free(fixture); @@ -3350,6 +3357,19 @@ pub const App = struct { recent_menu[index] = .{ .path = project.path, .name = project.name }; } } + var workspace_items: []MainWindow.WorkspaceItem = &.{}; + if (self.workspace_list) |list| { + workspace_items = self.allocator.alloc(MainWindow.WorkspaceItem, list.items.len) catch &.{}; + if (workspace_items.len != 0) { + for (list.items, 0..) |workspace, index| { + workspace_items[index] = .{ + .name = workspace.name, + .is_current = WorkspaceLifecycle.isSamePath(workspace.path, self.workspace_path), + }; + } + } + } + defer if (workspace_items.len != 0) self.allocator.free(workspace_items); MainWindow.updateMenu(self.window.hwnd, .{ .has_project = self.model.graph != null, .can_worktrees = if (self.model.graph) |graph| graph.project.isLocalFilesystem() else false, @@ -3361,6 +3381,7 @@ pub const App = struct { .activity_visible = self.workspace_controls.activity_enabled, .update_checking = update_checking, .recent_folders = recent_menu, + .workspaces = workspace_items, }); self.layoutEmptyStateControls(); } @@ -3736,6 +3757,28 @@ pub const App = struct { .bottom = bounds.bottom, }) catch return; } + if (self.workspace_list) |list| { + for (list.items, 0..) |workspace, index| { + const identity = std.fmt.allocPrint(self.allocator, "workspace-switch:{s}", .{workspace.path}) catch return; + owned_identities.append(identity) catch { + self.allocator.free(identity); + return; + }; + const row_top: i32 = 34 + @as(i32, @intCast(index * 30)); + elements.append(.{ + .identity = identity, + .name = workspace.name, + .parent = 5, + .selected = WorkspaceLifecycle.isSamePath(workspace.path, self.workspace_path), + .eligible = true, + .invokable = true, + .left = 250, + .top = row_top, + .right = 500, + .bottom = row_top + 28, + }) catch return; + } + } const policy = if (self.worktree_dialog) |dialog| dialog.policy else WorktreeStatus.Policy{}; provider.syncElements(self.status(), elements.items, policy); } @@ -3948,6 +3991,16 @@ pub const App = struct { target = .{ .activity = index }; } } + if (self.workspace_list) |list| { + for (list.items, 0..) |workspace, workspace_index| { + const workspace_identity = std.fmt.allocPrint(self.allocator, "workspace-switch:{s}", .{workspace.path}) catch return false; + defer self.allocator.free(workspace_identity); + if (Accessibility.worktreeIdentityPayload(workspace_identity) == payload) { + if (target != null) return false; + target = .{ .workspace_switch = workspace_index }; + } + } + } const resolved = target orelse return false; switch (resolved) { .local_section => self.sidebar_state.local_collapsed = !self.sidebar_state.local_collapsed, @@ -4031,6 +4084,11 @@ pub const App = struct { .workspace_toggle_panel => self.toggleWorkspaceDetailPanel(), .workspace_tab => |index| if (self.workspace) |workspace| workspace.selectTab(index) catch return false, .workspace_tab_close => |index| if (self.workspace) |workspace| workspace.closeTab(index) catch return false, + .workspace_switch => |index| { + const list = self.workspace_list orelse return false; + if (index >= list.items.len) return false; + self.launchWorkspace(list.items[index].path); + }, } self.clampSidebarScroll(); self.syncAccessibility(); @@ -4073,7 +4131,12 @@ pub const App = struct { const user = std.process.getEnvVarOwned(self.allocator, "USERNAME") catch try std.process.getEnvVarOwned(self.allocator, "USER"); defer self.allocator.free(user); - const name = try std.fmt.allocPrint(self.allocator, "{s}{s}", .{ instance_prefix, 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); @@ -4089,6 +4152,200 @@ pub const App = struct { return error.InstanceAlreadyRunning; } } + + 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: { + self.setStatus("Workspace list could not be loaded"); + break :blk null; + }; + } + + fn showWorkspaceText(self: *App, dialog_title: []const u8, labels: []const []const u8, initial: []const []const u8) ?NativeDialogs.Result { + return NativeDialogs.textWithDescription( + self.window.hwnd, + self.allocator, + dialog_title, + "Workspace names are normalized to lowercase letters, numbers, and hyphens.", + labels, + initial, + ) catch { + self.setStatus("Workspace dialog could not be opened"); + return null; + }; + } + + fn createWorkspace(self: *App) void { + const result = self.showWorkspaceText("New Workspace", &.{"Name"}, &.{""}) orelse return; + defer { + var owned = result; + owned.deinit(self.allocator); + } + 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| { + 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", + }); + 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); + } + + 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"); + 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"); + } + + fn workspaceByName(self: *App, name: []const u8) ?WorkspaceLifecycle.Workspace { + const list = self.workspace_list orelse return null; + for (list.items) |workspace| { + if (std.ascii.eqlIgnoreCase(workspace.name, name)) return workspace; + } + return null; + } + + fn renameWorkspace(self: *App) void { + const result = self.showWorkspaceText( + "Rename Workspace", + &.{ "Workspace", "New name" }, + &.{ "", "" }, + ) orelse return; + defer { + var owned = result; + owned.deinit(self.allocator); + } + 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"); + 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; + defer self.allocator.free(destination); + std.fs.renameAbsolute(workspace.path, destination) catch { + self.setStatus("Workspace could not be renamed"); + return; + }; + self.refreshWorkspaceList(); + self.setStatus("Workspace renamed"); + } + + fn deleteWorkspace(self: *App) void { + const result = self.showWorkspaceText("Delete Workspace", &.{"Workspace"}, &.{""}) orelse return; + defer { + var owned = result; + owned.deinit(self.allocator); + } + 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"); + return; + }; + self.refreshWorkspaceList(); + self.setStatus("Workspace deleted"); + } + + fn cycleWorkspace(self: *App, direction: isize) void { + 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); + } }; fn onDaemonFrame( @@ -4288,6 +4545,11 @@ fn onWindowMessage( if (recent_index < app.model.recent_projects.items.len) { app.openProject(app.model.recent_projects.items[recent_index].path); } + } else if (id >= MainWindow.workspace_command_base and id <= MainWindow.workspace_command_limit) { + const workspace_index = id - MainWindow.workspace_command_base; + if (app.workspace_list) |list| { + if (workspace_index < list.items.len) app.launchWorkspace(list.items[workspace_index].path); + } } else if (MainWindow.commandFromId(id)) |command| { switch (command) { .open_folder => app.openFolder(), @@ -4336,6 +4598,12 @@ fn onWindowMessage( .onboarding => app.handleAction(.onboarding), .check_updates => app.checkForUpdates(), .about => app.showAbout(), + .workspace_new => app.createWorkspace(), + .workspace_manage => app.setStatus("Use Rename Workspace or Delete Workspace from the Workspace menu"), + .workspace_rename => app.renameWorkspace(), + .workspace_delete => app.deleteWorkspace(), + .workspace_next => app.cycleWorkspace(1), + .workspace_previous => app.cycleWorkspace(-1), } } app.updateNativeChrome(); diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index a2fbbe3b..7f56a1e0 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -53,18 +53,31 @@ pub const Command = enum(u16) { reveal_worktree = 4504, edit_worktree_policy = 4505, save_worktree_policy = 4506, + workspace_new = 4800, + workspace_manage = 4801, + workspace_rename = 4802, + workspace_delete = 4803, + workspace_next = 4804, + workspace_previous = 4805, }; pub const empty_open_folder_id: usize = 4601; pub const empty_new_loop_id: usize = 4602; pub const recent_folder_command_base: usize = 4700; pub const recent_folder_command_limit: usize = 4799; +pub const workspace_command_base: usize = 4850; +pub const workspace_command_limit: usize = 4899; pub const RecentFolderItem = struct { path: []const u8, name: []const u8, }; +pub const WorkspaceItem = struct { + name: []const u8, + is_current: bool, +}; + pub const MenuState = struct { has_project: bool, can_worktrees: bool, @@ -76,6 +89,7 @@ pub const MenuState = struct { activity_visible: bool, update_checking: bool, recent_folders: []const RecentFolderItem = &.{}, + workspaces: []const WorkspaceItem = &.{}, }; pub fn commandFromId(id: usize) ?Command { @@ -182,6 +196,7 @@ pub fn installMenu(hwnd: c.HWND) !void { const terminal = c.CreatePopupMenu() orelse return error.MenuCreationFailed; const view = c.CreatePopupMenu() orelse return error.MenuCreationFailed; const help = c.CreatePopupMenu() orelse return error.MenuCreationFailed; + const workspace = c.CreatePopupMenu() orelse return error.MenuCreationFailed; append(add_folder, "Open Folder...\tCtrl+O", @intFromEnum(Command.open_folder)); append(add_folder, "Clone Repository...\tCtrl+Shift+C", @intFromEnum(Command.clone_repository)); @@ -242,9 +257,18 @@ pub fn installMenu(hwnd: c.HWND) !void { separator(help); append(help, "About GraphCode", @intFromEnum(Command.about)); + append(workspace, "New Workspace...", @intFromEnum(Command.workspace_new)); + append(workspace, "Manage Workspaces...", @intFromEnum(Command.workspace_manage)); + append(workspace, "Rename Workspace...", @intFromEnum(Command.workspace_rename)); + append(workspace, "Delete Workspace...", @intFromEnum(Command.workspace_delete)); + separator(workspace); + append(workspace, "Next Workspace\tCtrl+Alt+PageDown", @intFromEnum(Command.workspace_next)); + append(workspace, "Previous Workspace\tCtrl+Alt+PageUp", @intFromEnum(Command.workspace_previous)); + appendPopup(menu, "File", file); appendPopup(menu, "Loop", loop); appendPopup(menu, "Terminal", terminal); + appendPopup(menu, "Workspace", workspace); appendPopup(menu, "View", view); appendPopup(menu, "Help", help); if (c.SetMenu(hwnd, menu) == 0) return error.MenuInstallFailed; @@ -253,6 +277,7 @@ pub fn installMenu(hwnd: c.HWND) !void { pub fn updateMenu(hwnd: c.HWND, state: MenuState) void { updateRecentFolderMenu(hwnd, state.recent_folders); + updateWorkspaceMenu(hwnd, state.workspaces); setEnabled(hwnd, .open_global_overview, true); setEnabled(hwnd, .worktrees, state.can_worktrees); setEnabled(hwnd, .reclaim_worktrees, state.can_worktrees); @@ -279,12 +304,41 @@ pub fn updateMenu(hwnd: c.HWND, state: MenuState) void { setEnabled(hwnd, .product_settings, true); setEnabled(hwnd, .reconnect, true); setEnabled(hwnd, .check_updates, !state.update_checking); + setEnabled(hwnd, .workspace_manage, state.workspaces.len > 1); + setEnabled(hwnd, .workspace_next, state.workspaces.len > 1); + setEnabled(hwnd, .workspace_previous, state.workspaces.len > 1); setChecked(hwnd, .toggle_sidebar, state.sidebar_visible); setChecked(hwnd, .toggle_workspace, state.workspace_visible); setChecked(hwnd, .toggle_activity, state.activity_visible); _ = c.DrawMenuBar(hwnd); } +fn updateWorkspaceMenu(hwnd: c.HWND, workspaces: []const WorkspaceItem) void { + const root = c.GetMenu(hwnd); + if (root == null) return; + const menu = c.GetSubMenu(root, 3); + if (menu == null) return; + while (c.GetMenuItemCount(menu) > 0) { + _ = c.DeleteMenu(menu, 0, c.MF_BYPOSITION); + } + append(menu, "New Workspace...", @intFromEnum(Command.workspace_new)); + appendEnabled(menu, "Manage Workspaces...", @intFromEnum(Command.workspace_manage), workspaces.len > 1); + appendEnabled(menu, "Rename Workspace...", @intFromEnum(Command.workspace_rename), workspaces.len > 1); + appendEnabled(menu, "Delete Workspace...", @intFromEnum(Command.workspace_delete), workspaces.len > 1); + separator(menu); + appendEnabled(menu, "Next Workspace\tCtrl+Alt+PageDown", @intFromEnum(Command.workspace_next), workspaces.len > 1); + 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); + } +} + pub fn isRecentFolderCommand(id: usize) bool { return id >= recent_folder_command_base and id <= recent_folder_command_limit; } @@ -399,6 +453,11 @@ test "recent folder commands use a dedicated command range" { try std.testing.expect(!isRecentFolderCommand(recent_folder_command_limit + 1)); } +test "workspace commands use a dedicated command range" { + try std.testing.expect(workspace_command_base < workspace_command_limit); + try std.testing.expectEqual(Command.workspace_new, commandFromId(4800).?); +} + test "native menu labels are NUL terminated UTF-16" { const wide = try toWideZ(std.testing.allocator, "Clone Repository…"); defer std.testing.allocator.free(wide); @@ -406,7 +465,6 @@ test "native menu labels are NUL terminated UTF-16" { try std.testing.expect(wide.len > "Clone Repository".len); } - fn windowFromHandle(hwnd: c.HWND) ?*Window { const raw = c.GetWindowLongPtrW(hwnd, c.GWLP_USERDATA); if (raw == 0) return null; diff --git a/graphcode-windows/src/WorkspaceLifecycle.zig b/graphcode-windows/src/WorkspaceLifecycle.zig new file mode 100644 index 00000000..47013243 --- /dev/null +++ b/graphcode-windows/src/WorkspaceLifecycle.zig @@ -0,0 +1,179 @@ +const std = @import("std"); + +pub const directory_prefix = ".graphcode-"; +pub const default_directory_name = ".graphcode"; +pub const max_name_length: usize = 48; + +pub const Workspace = struct { + name: []const u8, + path: []const u8, + is_default: bool, + + pub fn deinit(self: *Workspace, allocator: std.mem.Allocator) void { + allocator.free(self.name); + allocator.free(self.path); + self.* = undefined; + } +}; + +pub const List = struct { + items: []Workspace, + + pub fn deinit(self: *List, allocator: std.mem.Allocator) void { + for (self.items) |*workspace| workspace.deinit(allocator); + allocator.free(self.items); + self.* = undefined; + } +}; + +pub const NameError = error{ + EmptyName, + InvalidName, + NameTooLong, + NameTaken, +}; + +pub fn defaultPath(allocator: std.mem.Allocator) ![]u8 { + const home = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch + return error.UserProfileMissing; + defer allocator.free(home); + return std.fs.path.join(allocator, &.{ home, default_directory_name }); +} + +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 |_| {} + return defaultPath(allocator); +} + +pub fn normalizeName(allocator: std.mem.Allocator, input: []const u8) ![]u8 { + var result = std.array_list.Managed(u8).init(allocator); + errdefer result.deinit(); + var previous_dash = false; + for (input) |byte| { + if (std.ascii.isAlphanumeric(byte)) { + try result.append(std.ascii.toLower(byte)); + previous_dash = false; + } else if (!previous_dash and result.items.len != 0) { + try result.append('-'); + previous_dash = true; + } + } + while (result.items.len != 0 and result.items[result.items.len - 1] == '-') { + _ = result.pop(); + } + if (result.items.len == 0) return NameError.EmptyName; + if (result.items.len > max_name_length) return NameError.NameTooLong; + return result.toOwnedSlice(); +} + +pub fn validateName( + allocator: std.mem.Allocator, + input: []const u8, + home: []const u8, +) ![]u8 { + const name = try normalizeName(allocator, input); + errdefer allocator.free(name); + const path = try workspacePath(allocator, name, home); + defer allocator.free(path); + if (directoryExists(path)) return NameError.NameTaken; + return name; +} + +pub fn workspacePath( + allocator: std.mem.Allocator, + name: []const u8, + home: []const u8, +) ![]u8 { + return std.fmt.allocPrint(allocator, "{s}\\{s}{s}", .{ home, directory_prefix, name }); +} + +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); + defer allocator.free(home); + return std.fs.path.join(allocator, &.{ home, configured }); +} + +pub fn list(allocator: std.mem.Allocator) !List { + const home = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch + return error.UserProfileMissing; + defer allocator.free(home); + return listFromHome(allocator, home); +} + +pub fn listFromHome(allocator: std.mem.Allocator, home: []const u8) !List { + var values = std.array_list.Managed(Workspace).init(allocator); + errdefer { + 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, + }); + var directory = std.fs.openDirAbsolute(home, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound, error.AccessDenied => return .{ .items = try values.toOwnedSlice() }, + else => return err, + }; + defer directory.close(); + var iterator = directory.iterate(); + while (try iterator.next()) |entry| { + if (entry.kind != .directory or !std.mem.startsWith(u8, entry.name, directory_prefix)) + 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, + }); + } + std.sort.block(Workspace, values.items, {}, lessThan); + return .{ .items = try values.toOwnedSlice() }; +} + +pub fn directoryExists(path: []const u8) bool { + var directory = std.fs.openDirAbsolute(path, .{}) catch return false; + directory.close(); + return true; +} + +pub fn isAbsoluteWindowsPath(path: []const u8) bool { + return (path.len >= 2 and path[1] == ':') or + (path.len >= 2 and path[0] == '\\' and path[1] == '\\') or + (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); +} + +fn lessThan(_: void, left: Workspace, right: Workspace) bool { + return std.ascii.lessThanIgnoreCase(left.name, right.name); +} + +test "workspace names normalize to safe stable directory suffixes" { + const name = try normalizeName(std.testing.allocator, "Café / Zürich"); + defer std.testing.allocator.free(name); + try std.testing.expectEqualStrings("caf-z-rich", name); +} + +test "workspace validation rejects long names" { + var long_name: [max_name_length + 2]u8 = undefined; + @memset(&long_name, 'x'); + try std.testing.expectError(NameError.NameTooLong, normalizeName(std.testing.allocator, &long_name)); +} + +test "workspace paths use the Windows sibling convention" { + const path = try workspacePath(std.testing.allocator, "alpha", "C:\\Users\\tester"); + defer std.testing.allocator.free(path); + try std.testing.expectEqualStrings("C:\\Users\\tester\\.graphcode-alpha", path); +} diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 74e76e06..96ffcd60 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -24,6 +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. Focused hit-testing and a real populated fixture validate the controls; native focus/UIA semantics 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; project-management and contextual 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 | 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 Help, immediately reports checking state, and is disabled while a check is active. In-app installation remains incomplete | Partial | | 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 |