diff --git a/packages/app/e2e/browser/command-center-workspace-management.spec.ts b/packages/app/e2e/browser/command-center-workspace-management.spec.ts new file mode 100644 index 0000000000..972386a889 --- /dev/null +++ b/packages/app/e2e/browser/command-center-workspace-management.spec.ts @@ -0,0 +1,270 @@ +import type { Locator } from "@playwright/test"; +import { test, expect, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; + +// These actions used to be reachable only from the sidebar workspace ⋯ menu, the workspace header +// menu, or an unlisted keybind. Rename was the worst of them: its dialog lived inside the sidebar +// row, so it vanished whenever that row was unmounted — a collapsed section, or focus mode. + +function workspaceRow(page: Page, workspaceId: string): Locator { + return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); +} + +function action(panel: Locator, title: string): Locator { + const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return panel.getByRole("button", { name: new RegExp(`^${escaped}(?:\\s|$)`) }); +} + +async function openWorkspace(page: Page, workspaceId: string): Promise { + const row = workspaceRow(page, workspaceId); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); +} + +// Collapsing the project section unmounts every workspace row under it, which is exactly the state +// that used to make the sidebar-owned rename dialog unreachable. +async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise { + const header = page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: project.projectDisplayName }); + await expect(header).toHaveCount(1, { timeout: 30_000 }); + await header.click(); + await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 }); +} + +// The shared helper clicks the sidebar's search button, which is inside the Workspaces section +// header — absent on /settings and once every workspace has moved to Pinned. Cmd-K is the +// surface under test anyway, and it works from every route. +async function openCommandCenter(page: Page): Promise { + await page.keyboard.press("ControlOrMeta+K"); + const panel = page.getByTestId("command-center-panel"); + await expect(panel).toBeVisible({ timeout: 30_000 }); + return panel; +} + +async function runCommand(page: Page, query: string, title: string): Promise { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill(query); + const entry = action(panel, title); + await expect(entry).toBeVisible({ timeout: 15_000 }); + await entry.click(); +} + +async function readWorkspaceLabels(workspace: SeededWorkspace): Promise { + const workspaces = await workspace.client.fetchWorkspaces(); + for (const entry of workspaces.entries) { + if (entry.id === workspace.workspaceId) return entry.labels?.slice().sort(); + } + return undefined; +} + +async function rejectNextLabelAssignment(page: Page): Promise { + let rejectNext = true; + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((message) => { + const raw = typeof message === "string" ? message : message.toString("utf8"); + let request: { type?: string; requestId?: string } | null = null; + try { + const envelope = JSON.parse(raw) as { + type?: string; + message?: { type?: string; requestId?: string }; + }; + request = envelope.type === "session" ? (envelope.message ?? null) : null; + } catch { + server.send(message); + return; + } + if ( + rejectNext && + request?.type === "workspace.label.assignment.set.request" && + request.requestId + ) { + rejectNext = false; + browser.send( + JSON.stringify({ + type: "session", + message: { + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: "Injected label mutation failure.", + code: "handler_error", + }, + }, + }), + ); + return; + } + server.send(message); + }); + server.onMessage((message) => browser.send(message)); + }); +} + +test.describe("Command center workspace management", () => { + test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" }); + + try { + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + await collapseProjectSection(page, workspace); + + await runCommand(page, "rename", "Rename workspace"); + + // Focused, not merely visible. A modal that mounts behind the closing palette, or loses the + // focus race with its focus-restore, still renders — you just cannot type into it. + const input = page.getByTestId("workspace-rename-modal-global-input"); + await expect(input).toBeFocused({ timeout: 15_000 }); + await expect(input).toHaveValue(workspace.workspaceName); + + const customTitle = "Renamed From Palette"; + await input.fill(customTitle); + await page.getByTestId("workspace-rename-modal-global-submit").click(); + await expect(input).toHaveCount(0, { timeout: 15_000 }); + + // Re-expand the section to read the row back. + const header = page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: workspace.projectDisplayName }); + await header.click(); + await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, { + timeout: 15_000, + }); + } finally { + await workspace.cleanup(); + } + }); + + test("copies the workspace path and confirms with a toast", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" }); + + try { + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + + await runCommand(page, "copy path", "Copy workspace path"); + + // toast.copied wraps its label: "Copied {{label}}" with label "Path copied". + await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({ + timeout: 15_000, + }); + } finally { + await workspace.cleanup(); + } + }); + + test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" }); + + try { + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + + await runCommand(page, "pin", "Pin to top"); + await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 }); + + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("pin"); + await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 }); + await expect(action(panel, "Pin to top")).toHaveCount(0); + } finally { + await workspace.cleanup(); + } + }); + + // Regression guard for the registration split. Toggle Explorer sidebar and Toggle focus mode are + // handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them + // off a workspace route would give the user two entries that close the palette and do nothing. + // Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed. + test("lists only the globally-handled toggle off a workspace route", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" }); + + try { + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + + // All three are listed while a workspace route owns the handlers. + const workspacePanel = await openCommandCenter(page); + await workspacePanel.getByTestId("command-center-input").fill("toggle"); + await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 }); + await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible(); + await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("command-center-panel")).toHaveCount(0); + + await page.locator('[data-testid="sidebar-settings"]:visible').first().click(); + await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 }); + + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("toggle"); + + await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 }); + await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0); + await expect(action(panel, "Toggle focus mode")).toHaveCount(0); + } finally { + await workspace.cleanup(); + } + }); + + test("toggles a label on the active workspace from the palette", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "cc-label-" }); + + try { + await workspace.client.setWorkspaceLabel({ + workspaceId: workspace.workspaceId, + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("urgent"); + await panel.getByTestId("command-center-workspace-label-Urgent").click(); + // Unassigning the only label drops the field entirely rather than leaving an empty array. + await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(undefined); + + const reopened = await openCommandCenter(page); + await reopened.getByTestId("command-center-input").fill("urgent"); + await reopened.getByTestId("command-center-workspace-label-Urgent").click(); + await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]); + } finally { + await workspace.cleanup(); + } + }); + + test("shows an error toast when assigning a label fails", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "cc-label-failure-" }); + + try { + await rejectNextLabelAssignment(page); + await workspace.client.setWorkspaceLabel({ + workspaceId: workspace.workspaceId, + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("urgent"); + await panel.getByTestId("command-center-workspace-label-Urgent").click(); + + await expect(page.getByTestId("app-toast-message")).toContainText( + "Injected label mutation failure.", + ); + await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 884bde3558..46e6f05325 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -39,6 +39,7 @@ import { WindowSidebarMenuToggle } from "@/components/headers/menu-header"; import { DesktopWindowControls } from "@/components/desktop/window-controls"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler"; +import { WorkspaceRenameHost } from "@/components/workspace-rename-host"; import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host"; import { ProviderSettingsHost } from "@/components/provider-settings-host"; import { RootErrorBoundary } from "@/components/root-error-boundary"; @@ -595,6 +596,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + diff --git a/packages/app/src/command-center/root-registration.tsx b/packages/app/src/command-center/root-registration.tsx index c20b5060cf..272713486c 100644 --- a/packages/app/src/command-center/root-registration.tsx +++ b/packages/app/src/command-center/root-registration.tsx @@ -9,17 +9,19 @@ import { History, Home, Keyboard, + PanelLeft, Plus, Settings, } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; -import { getIsElectronRuntime } from "@/constants/layout"; +import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { usePanelStore } from "@/stores/panel-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { @@ -54,6 +56,9 @@ const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.for const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({ color: theme.colors.foregroundMuted, })); +const ThemedPanelLeft = withUnistyles(PanelLeft, (theme) => ({ + color: theme.colors.foregroundMuted, +})); function PlusIcon({ size }: CommandCenterIconProps) { return ; @@ -91,6 +96,10 @@ function CircleDashedIcon({ size }: CommandCenterIconProps) { return ; } +function PanelLeftIcon({ size }: CommandCenterIconProps) { + return ; +} + export function CommandCenterRootActions() { const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); @@ -106,6 +115,10 @@ export function CommandCenterRootActions() { // each time host filters are reconciled. const groupMode = useSidebarViewStore((state) => state.groupMode); const setGroupMode = useSidebarViewStore((state) => state.setGroupMode); + const isCompact = useIsCompactFormFactor(); + const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList); + const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList); + const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList; const shortcutPlatform = useMemo( () => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }), [], @@ -227,6 +240,33 @@ export function CommandCenterRootActions() { undefined, }, }, + // Toggle left sidebar is global: it calls the panel store directly and works on every route. + // The right sidebar and focus toggles do NOT belong here — their handlers live in + // workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so registering them globally + // would list entries that silently no-op off a workspace route. They live in + // workspace-contributions.ts instead. That is why the three toggles render in two + // non-adjacent sections; don't "tidy" them back together. + { + id: "toggle-left-sidebar", + group: "actions", + groupRank: 0, + rank: 7, + keywords: ["toggle", "sidebar", "left", "panel", "workspaces"], + visibility: "query", + run: () => { + clearCommandCenterFocusRestoreElement(); + toggleAgentList(); + }, + presentation: { + kind: "action", + title: t("settings.shortcuts.help.toggleLeftSidebar"), + sectionTitle: t("shell.commandCenter.actions"), + icon: PanelLeftIcon, + shortcutKeys: + resolveShortcutKeysForAction("toggle-left-sidebar", overrides, shortcutPlatform) ?? + undefined, + }, + }, ]; if (shortcutsAvailable) { @@ -278,6 +318,7 @@ export function CommandCenterRootActions() { shortcutPlatform, shortcutsAvailable, t, + toggleAgentList, ]); useCommandCenterActions({ sourceId: "root", enabled: true, actions }); diff --git a/packages/app/src/command-center/workspace-contributions.test.ts b/packages/app/src/command-center/workspace-contributions.test.ts index ce027fbf0c..076f054146 100644 --- a/packages/app/src/command-center/workspace-contributions.test.ts +++ b/packages/app/src/command-center/workspace-contributions.test.ts @@ -23,9 +23,14 @@ function source(gitActions: GitActions): { value: WorkspaceCommandCenterSource; runGitActions: GitAction[]; dispatched: KeyboardActionDefinition[]; + copiedPaths: number; + copiedBranchNames: number; + toggledLabels: Array<{ name: string; assigned: boolean }>; } { const runGitActions: GitAction[] = []; const dispatched: KeyboardActionDefinition[] = []; + const toggledLabels: Array<{ name: string; assigned: boolean }> = []; + const counters = { copiedPaths: 0, copiedBranchNames: 0 }; return { value: { gitActions, @@ -62,19 +67,51 @@ function source(gitActions: GitActions): { moveTabDown: "Move tab down", closePane: "Close pane", toggleFocusMode: "Toggle focus mode", - toggleExplorerSidebar: "Toggle side panel", + toggleExplorerSidebar: "Toggle Explorer sidebar", + rename: "Rename workspace", + copyPath: "Copy workspace path", + copyBranchName: "Copy branch name", + pin: "Pin to top", + unpin: "Unpin", + showSetup: "Show setup", + labelsGroup: "Labels", }, icons: {}, shortcuts: {}, - capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false }, + capabilities: { + canSplitPanes: true, + canOpenBrowserTabs: true, + isGit: false, + canPin: false, + canShowSetup: false, + }, activeTabKind: null, activeTabIndex: -1, activeTabCount: 0, + currentBranch: null, + isPinned: false, + labelCatalog: null, dispatch: (action) => dispatched.push(action), runGitAction: (action) => runGitActions.push(action), + copyPath: () => { + counters.copiedPaths += 1; + }, + copyBranchName: () => { + counters.copiedBranchNames += 1; + }, + toggleLabel: (name, assigned) => { + toggledLabels.push({ name, assigned }); + }, }, runGitActions, dispatched, + toggledLabels, + get copiedPaths() { + return counters.copiedPaths; + }, + get copiedBranchNames() { + return counters.copiedBranchNames; + }, }; } @@ -147,6 +184,8 @@ describe("workspace command center contributions", () => { canSplitPanes: false, canOpenBrowserTabs: false, isGit: false, + canPin: false, + canShowSetup: false, }; const contributions = buildWorkspaceCommandCenterContributions(fixture.value); @@ -155,6 +194,8 @@ describe("workspace command center contributions", () => { expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true); expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false); expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false); + expect(contributions.some((item) => item.id === "workspace:rename")).toBe(true); + expect(contributions.some((item) => item.id === "workspace:copy-path")).toBe(true); }); it("dispatches every tab and pane command to the workspace scope", () => { @@ -164,7 +205,14 @@ describe("workspace command center contributions", () => { for (const contribution of contributions) contribution.run(); expect(fixture.dispatched.length).toBeGreaterThan(5); - expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true); + // workspace:rename dispatches to workspace scope + expect( + fixture.dispatched.some((a) => a.id === "workspace.rename" && a.scope === "workspace"), + ).toBe(true); + // workspace:toggle-explorer-sidebar intentionally dispatches to sidebar scope + expect( + fixture.dispatched.some((a) => a.id === "sidebar.toggle.right" && a.scope === "sidebar"), + ).toBe(true); }); it("keeps workspace creation commands available outside Git", () => { @@ -179,4 +227,164 @@ describe("workspace command center contributions", () => { expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true); expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false); }); + + it("dispatches rename to the workspace scope", () => { + const fixture = source({ primary: null, secondary: [], menu: [] }); + const contributions = buildWorkspaceCommandCenterContributions(fixture.value); + + const rename = contributions.find((item) => item.id === "workspace:rename"); + expect(rename?.presentation).toMatchObject({ title: "Rename workspace" }); + rename?.run(); + + expect(fixture.dispatched).toEqual([{ id: "workspace.rename", scope: "workspace" }]); + }); + + it("copies the workspace path without going through the dispatcher", () => { + const fixture = source({ primary: null, secondary: [], menu: [] }); + const contributions = buildWorkspaceCommandCenterContributions(fixture.value); + + contributions.find((item) => item.id === "workspace:copy-path")?.run(); + + expect(fixture.copiedPaths).toBe(1); + expect(fixture.dispatched).toEqual([]); + }); + + it("omits Copy branch name when the workspace has no branch, and copies when it does", () => { + const withoutBranch = source({ primary: null, secondary: [], menu: [] }); + expect( + buildWorkspaceCommandCenterContributions(withoutBranch.value).some( + (item) => item.id === "workspace:copy-branch-name", + ), + ).toBe(false); + + const withBranch = source({ primary: null, secondary: [], menu: [] }); + withBranch.value.currentBranch = "feature/login"; + const contribution = buildWorkspaceCommandCenterContributions(withBranch.value).find( + (item) => item.id === "workspace:copy-branch-name", + ); + + expect(contribution?.keywords).toContain("feature/login"); + contribution?.run(); + expect(withBranch.copiedBranchNames).toBe(1); + }); + + it("flips the pin label on pinned state and omits the entry when the host cannot pin", () => { + const unpinned = source({ primary: null, secondary: [], menu: [] }); + unpinned.value.capabilities.canPin = true; + const pinContribution = buildWorkspaceCommandCenterContributions(unpinned.value).find( + (item) => item.id === "workspace:pin", + ); + expect(pinContribution?.presentation).toMatchObject({ title: "Pin to top" }); + expect(pinContribution?.visibility).toBe("always"); + pinContribution?.run(); + expect(unpinned.dispatched).toEqual([{ id: "workspace.pin", scope: "sidebar" }]); + + const pinned = source({ primary: null, secondary: [], menu: [] }); + pinned.value.capabilities.canPin = true; + pinned.value.isPinned = true; + expect( + buildWorkspaceCommandCenterContributions(pinned.value).find( + (item) => item.id === "workspace:pin", + )?.presentation, + ).toMatchObject({ title: "Unpin" }); + + const unsupported = source({ primary: null, secondary: [], menu: [] }); + expect( + buildWorkspaceCommandCenterContributions(unsupported.value).some( + (item) => item.id === "workspace:pin", + ), + ).toBe(false); + }); + + it("lists Show setup only when the workspace has setup to show", () => { + const withoutSetup = source({ primary: null, secondary: [], menu: [] }); + expect( + buildWorkspaceCommandCenterContributions(withoutSetup.value).some( + (item) => item.id === "workspace:show-setup", + ), + ).toBe(false); + + const withSetup = source({ primary: null, secondary: [], menu: [] }); + withSetup.value.capabilities.canShowSetup = true; + buildWorkspaceCommandCenterContributions(withSetup.value) + .find((item) => item.id === "workspace:show-setup") + ?.run(); + + expect(withSetup.dispatched).toEqual([{ id: "workspace.setup.show", scope: "workspace" }]); + }); + + // Regression guard for the registration split: these two are handled only by workspace-screen.tsx + // behind `enabled: isRouteFocused && ...`, so they must be built here rather than in the global + // root set, where they would silently no-op off a workspace route. + it("builds the Explorer sidebar and focus toggles in the workspace set", () => { + const fixture = source({ primary: null, secondary: [], menu: [] }); + fixture.value.capabilities.canSplitPanes = false; + const contributions = buildWorkspaceCommandCenterContributions(fixture.value); + + contributions.find((item) => item.id === "workspace:toggle-explorer-sidebar")?.run(); + contributions.find((item) => item.id === "workspace:toggle-focus-mode")?.run(); + + expect(fixture.dispatched).toEqual([ + { id: "sidebar.toggle.right", scope: "sidebar" }, + { id: "workspace.focus.toggle", scope: "workspace" }, + ]); + for (const contribution of contributions) { + expect(contribution.group).toBe("workspace"); + } + }); + + // `buildPaneContributions` dispatches this same action as `pane:focus-mode-toggle` once split + // panes are available, so the standalone entry must step aside there — otherwise the palette + // lists "Toggle focus mode" twice for the one `workspace.focus.toggle` action. + it("omits the standalone focus toggle when the pane set already covers it", () => { + const fixture = source({ primary: null, secondary: [], menu: [] }); + fixture.value.capabilities.canSplitPanes = true; + const contributions = buildWorkspaceCommandCenterContributions(fixture.value); + + const focusToggles = contributions.filter( + (item) => + item.presentation.kind === "action" && item.presentation.title === "Toggle focus mode", + ); + expect(focusToggles).toHaveLength(1); + expect(focusToggles[0]?.id).toBe("pane:focus-mode-toggle"); + }); + + it("omits the labels group when the catalog hasn't loaded", () => { + const fixture = source({ primary: null, secondary: [], menu: [] }); + + const contributions = buildWorkspaceCommandCenterContributions(fixture.value); + + expect(contributions.some((item) => item.id.startsWith("workspace:label:"))).toBe(false); + }); + + it("lists a choice per catalog label, ticked when assigned, and toggles the opposite state on run", () => { + const fixture = source({ primary: null, secondary: [], menu: [] }); + fixture.value.labelCatalog = [ + { name: "bug", assigned: true }, + { name: "urgent", assigned: false }, + ]; + + const contributions = buildWorkspaceCommandCenterContributions(fixture.value); + const bug = contributions.find((item) => item.id === "workspace:label:bug"); + const urgent = contributions.find((item) => item.id === "workspace:label:urgent"); + + expect(bug?.presentation).toMatchObject({ + kind: "choice", + path: ["Labels", "bug"], + selected: true, + }); + expect(urgent?.presentation).toMatchObject({ + kind: "choice", + path: ["Labels", "urgent"], + selected: false, + }); + + bug?.run(); + urgent?.run(); + + expect(fixture.toggledLabels).toEqual([ + { name: "bug", assigned: false }, + { name: "urgent", assigned: true }, + ]); + }); }); diff --git a/packages/app/src/command-center/workspace-contributions.ts b/packages/app/src/command-center/workspace-contributions.ts index 2d98ba5e84..afc484de57 100644 --- a/packages/app/src/command-center/workspace-contributions.ts +++ b/packages/app/src/command-center/workspace-contributions.ts @@ -8,6 +8,13 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { ShortcutKey } from "@/utils/format-shortcut"; import type { CommandCenterContribution, CommandCenterIcon } from "./contributions"; +export interface WorkspaceCommandCenterLabelChoice { + name: string; + /** Whether the current workspace already carries this label. */ + assigned: boolean; + icon?: CommandCenterIcon; +} + export interface WorkspaceCommandCenterLabels { section: string; newAgent: string; @@ -42,6 +49,14 @@ export interface WorkspaceCommandCenterLabels { closePane: string; toggleFocusMode: string; toggleExplorerSidebar: string; + // Workspace management actions + rename: string; + copyPath: string; + copyBranchName: string; + pin: string; + unpin: string; + showSetup: string; + labelsGroup: string; } export interface WorkspaceCommandCenterIcons { @@ -63,6 +78,14 @@ export interface WorkspaceCommandCenterIcons { moveTab?: CommandCenterIcon; focusMode?: CommandCenterIcon; explorerSidebar?: CommandCenterIcon; + // Workspace management action icons + copyPath?: CommandCenterIcon; + copyBranchName?: CommandCenterIcon; + pin?: CommandCenterIcon; + unpin?: CommandCenterIcon; + showSetup?: CommandCenterIcon; + toggleFocusMode?: CommandCenterIcon; + label?: CommandCenterIcon; git?(action: GitAction): CommandCenterIcon | undefined; } @@ -78,6 +101,7 @@ export interface WorkspaceCommandCenterShortcuts { closePane?: ShortcutKey[][]; toggleFocusMode?: ShortcutKey[][]; toggleExplorerSidebar?: ShortcutKey[][]; + pinWorkspace?: ShortcutKey[][]; } export interface WorkspaceCommandCenterSource { @@ -89,12 +113,27 @@ export interface WorkspaceCommandCenterSource { canSplitPanes: boolean; canOpenBrowserTabs: boolean; isGit: boolean; + /** Host supports the `workspacePinning` feature. */ + canPin: boolean; + /** The workspace has setup commands or a setup error — same gate as the header menu. */ + canShowSetup: boolean; }; activeTabKind: WorkspaceTabTarget["kind"] | null; activeTabIndex: number; activeTabCount: number; + /** Null on a non-git workspace, or before gitRuntime resolves. Omits Copy branch name. */ + currentBranch: string | null; + isPinned: boolean; + /** + * The host's label catalog, each entry told whether the current workspace carries it. Null + * before the catalog has loaded — omits the whole group rather than showing it empty. + */ + labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null; dispatch(action: KeyboardActionDefinition): void; runGitAction(action: GitAction): void; + copyPath(): void; + copyBranchName(): void; + toggleLabel(name: string, assigned: boolean): void | Promise; } function buildGitContribution( @@ -459,6 +498,35 @@ function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCe return paneActions.map((action) => buildQueryAction(source, action)); } +function buildWorkspaceCallback(input: { + source: WorkspaceCommandCenterSource; + id: string; + rank: number; + title: string; + keywords: readonly string[]; + icon?: CommandCenterIcon; + shortcutKeys?: ShortcutKey[][]; + run: () => void; + visibility: "always" | "query"; +}): CommandCenterContribution { + return { + id: input.id, + group: "workspace", + groupRank: -1, + rank: input.rank, + keywords: input.keywords, + visibility: input.visibility, + run: input.run, + presentation: { + kind: "action", + title: input.title, + sectionTitle: input.source.labels.section, + icon: input.icon, + shortcutKeys: input.shortcutKeys, + }, + }; +} + function buildCreationContributions( source: WorkspaceCommandCenterSource, ): CommandCenterContribution[] { @@ -511,20 +579,150 @@ export function buildWorkspaceCommandCenterContributions( ...buildPanelContributions(source), ...buildActiveTabContributions(source), ...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []), - buildQueryAction(source, { - id: "explorer-sidebar:toggle", - rank: 70, - title: source.labels.toggleExplorerSidebar, - keywords: ["explorer", "sidebar", "toggle", "show", "hide"], - icon: source.icons.explorerSidebar, - shortcutKeys: source.shortcuts.toggleExplorerSidebar, - action: { id: "sidebar.toggle.right", scope: "workspace" }, - }), ]; + const primary = source.gitActions.primary; for (const [index, action] of source.gitActions.secondary.entries()) { if (action.id === primary?.id) continue; contributions.push(buildGitContribution(source, action, 100 + index, "query")); } + + if (source.capabilities.canPin) { + contributions.push( + buildWorkspaceAction({ + source, + id: "workspace:pin", + rank: 20, + title: source.isPinned ? source.labels.unpin : source.labels.pin, + keywords: ["pin", "unpin", "favorite", "sticky"], + icon: source.isPinned ? source.icons.unpin : source.icons.pin, + shortcutKeys: source.shortcuts.pinWorkspace, + action: { id: "workspace.pin", scope: "sidebar" }, + visibility: "always", + }), + ); + } + + contributions.push( + buildWorkspaceAction({ + source, + id: "workspace:rename", + rank: 21, + title: source.labels.rename, + keywords: ["rename", "title", "name", "label"], + icon: source.icons.rename, + action: { id: "workspace.rename", scope: "workspace" }, + visibility: "query", + }), + buildWorkspaceCallback({ + source, + id: "workspace:copy-path", + rank: 22, + title: source.labels.copyPath, + keywords: ["copy", "path", "directory", "folder", "cwd"], + icon: source.icons.copyPath, + run: source.copyPath, + visibility: "query", + }), + ); + + // Omitted rather than present-and-failing: a non-git workspace has nothing to copy. + if (source.currentBranch) { + contributions.push( + buildWorkspaceCallback({ + source, + id: "workspace:copy-branch-name", + rank: 23, + title: source.labels.copyBranchName, + keywords: ["copy", "branch", "git", source.currentBranch], + icon: source.icons.copyBranchName, + run: source.copyBranchName, + visibility: "query", + }), + ); + } + + if (source.capabilities.canShowSetup) { + contributions.push( + buildWorkspaceAction({ + source, + id: "workspace:show-setup", + rank: 24, + title: source.labels.showSetup, + keywords: ["setup", "provision", "install", "bootstrap"], + icon: source.icons.showSetup, + action: { id: "workspace.setup.show", scope: "workspace" }, + visibility: "query", + }), + ); + } + + // Toggle Explorer sidebar and Toggle focus mode belong here, NOT in root-registration.tsx: + // their handlers live in workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so a + // global registration would list two entries that silently no-op on /settings, /sessions, + // /schedules and Home. Toggle sidebar (left) is global and stays in the root set — that is why + // the three toggles render in two non-adjacent sections. Don't "tidy" them back together. + contributions.push( + buildWorkspaceAction({ + source, + id: "workspace:toggle-explorer-sidebar", + rank: 25, + title: source.labels.toggleExplorerSidebar, + keywords: ["toggle", "sidebar", "explorer", "files", "changes"], + icon: source.icons.explorerSidebar, + shortcutKeys: source.shortcuts.toggleExplorerSidebar, + action: { id: "sidebar.toggle.right", scope: "sidebar" }, + visibility: "query", + }), + ); + + // `buildPaneContributions` already dispatches this same `workspace.focus.toggle` action as + // `pane:focus-mode-toggle` once split panes are available, so only add the standalone entry + // where that function is skipped — otherwise the palette lists "Toggle focus mode" twice. + if (!source.capabilities.canSplitPanes) { + contributions.push( + buildWorkspaceAction({ + source, + id: "workspace:toggle-focus-mode", + rank: 26, + title: source.labels.toggleFocusMode, + keywords: ["toggle", "focus", "zen", "distraction", "fullscreen"], + icon: source.icons.toggleFocusMode, + shortcutKeys: source.shortcuts.toggleFocusMode, + action: { id: "workspace.focus.toggle", scope: "workspace" }, + visibility: "query", + }), + ); + } + + contributions.push(...buildLabelContributions(source)); + return contributions; } + +/** + * One choice per catalog label, ticked when the current workspace carries it. Selecting toggles + * assignment rather than gating on `!selected` — unlike the model/mode choice groups this is a + * multi-select, so picking an already-assigned label un-assigns it instead of no-op'ing. + */ +function buildLabelContributions( + source: WorkspaceCommandCenterSource, +): CommandCenterContribution[] { + if (!source.labelCatalog) return []; + return source.labelCatalog.map((choice, index) => ({ + id: `workspace:label:${choice.name}`, + group: "workspace", + groupRank: -1, + rank: 30 + index, + keywords: ["label", "tag", choice.name], + visibility: "query" as const, + run: () => source.toggleLabel(choice.name, !choice.assigned), + presentation: { + kind: "choice" as const, + path: [source.labels.labelsGroup, choice.name] as const, + icon: choice.icon, + selected: choice.assigned, + testId: `command-center-workspace-label-${choice.name}`, + }, + })); +} diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx index 773ada71fc..70789e5353 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -1,4 +1,4 @@ -import { useMemo, type ReactElement } from "react"; +import { useCallback, useMemo, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDownToLine, @@ -8,12 +8,16 @@ import { Copy, Files, Focus, + GitBranch, GitCompareArrows, GitPullRequest, Globe, + ListChecks, Move, PanelRight, Pencil, + Pin, + PinOff, RotateCw, Rows2, SquarePen, @@ -25,23 +29,35 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l import { GIT_ACTION_ICONS } from "@/git/action-icons"; import { useGitActionRunner, useGitActions } from "@/git/use-actions"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; +import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions"; +import { useToast } from "@/contexts/toast-context"; import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; +import { useHostFeature } from "@/runtime/host-features"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks"; import { collectAllTabs, findPaneById, useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; -import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { getShortcutOs } from "@/utils/shortcut-platform"; +import { + buildWorkspaceLabelPickerRows, + useWorkspaceLabelProjection, + workspaceLabelErrorMessage, + workspaceLabels, +} from "@/workspace-labels"; +import { getLabelCommandCenterIcon } from "@/workspace-labels/command-center-icon"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { getCommandCenterIcon } from "./icon"; import type { CommandCenterIcon } from "./contributions"; import { useCommandCenterActions } from "./provider"; import { buildWorkspaceCommandCenterContributions, + type WorkspaceCommandCenterLabelChoice, type WorkspaceCommandCenterShortcuts, } from "./workspace-contributions"; import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts"; @@ -65,6 +81,13 @@ const WORKSPACE_COMMAND_CENTER_ICONS = { moveTab: getCommandCenterIcon(Move), focusMode: getCommandCenterIcon(ArrowDownToLine), explorerSidebar: getCommandCenterIcon(PanelRight), + // Workspace management action icons + copyPath: getCommandCenterIcon(Copy), + copyBranchName: getCommandCenterIcon(GitBranch), + pin: getCommandCenterIcon(Pin), + unpin: getCommandCenterIcon(PinOff), + showSetup: getCommandCenterIcon(ListChecks), + toggleFocusMode: getCommandCenterIcon(Focus), }; const OPEN_PANEL_LABEL_KEYS = { @@ -86,6 +109,58 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma return resolveWorkspaceCommandCenterShortcuts({ overrides, platform }); } +/** + * The label catalog and its toggle callback, pulled out of `useWorkspaceCommandCenterActions` to + * keep that hook under the complexity limit. Null while the host's catalog hasn't loaded, so the + * group is omitted rather than shown empty — the same rule `WorkspaceLabelPickerPage` follows for + * the sidebar's own picker. + */ +function useWorkspaceLabelCatalog( + serverId: string | null, + fields: { id: string; labels: readonly string[] } | null, +): { + labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null; + toggleLabel: (name: string, assigned: boolean) => void; +} { + const { labels: labelDefinitions, targetHost: labelHost } = useWorkspaceLabelProjection( + serverId ?? undefined, + ); + const toast = useToast(); + const assignedLabels = fields?.labels; + const labelCatalog = useMemo( + () => + labelHost?.status === "online" && assignedLabels + ? buildWorkspaceLabelPickerRows({ labels: labelDefinitions, assigned: assignedLabels }).map( + (row) => ({ + name: row.name, + assigned: row.assigned, + icon: getLabelCommandCenterIcon(row.color), + }), + ) + : null, + [assignedLabels, labelDefinitions, labelHost?.status], + ); + const toggleLabel = useCallback( + async (name: string, assigned: boolean) => { + if (!serverId || !fields) return; + const definition = labelDefinitions.find((label) => label.name === name); + if (!definition) return; + try { + await workspaceLabels.setAssignment({ + serverId, + workspaceId: fields.id, + label: definition, + assigned, + }); + } catch (cause) { + toast.error(workspaceLabelErrorMessage(cause)); + } + }, + [fields, labelDefinitions, serverId, toast], + ); + return { labelCatalog, toggleLabel }; +} + export function useWorkspaceCommandCenterActions(): void { const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); @@ -104,8 +179,29 @@ export function useWorkspaceCommandCenterActions(): void { const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId); const activeTabKind = activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null; + // One narrow projection for the workspace management contribution fields. The registry's snapshot + // dedup is unreachable (registry.ts spreads a fresh object per contribution, then compares by + // reference), so the array-identity guard in registry.replace() is the only thing stopping a + // re-render — a churny subscription here rebuilds the list while the user is looking at it. + const fields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({ + id: workspace.id, + workspaceDirectory: workspace.workspaceDirectory ?? null, + currentBranch: workspace.gitRuntime?.currentBranch ?? null, + pinnedAt: workspace.pinnedAt ?? null, + labels: workspace.labels ?? [], + })); const cwd = useWorkspaceDirectory(serverId, workspaceId); + const currentBranch = fields?.currentBranch ?? null; + const isPinned = fields?.pinnedAt != null; const isCompact = useIsCompactFormFactor(); + const canPin = useHostFeature(serverId, "workspacePinning"); + const persistenceKey = + serverId && fields + ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id }) + : null; + const canShowSetup = useWorkspaceSetupStore((state) => + shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null), + ); const { overrides } = useKeyboardShortcutOverrides(); const { gitActions, isGit } = useGitActions({ serverId: serverId ?? "", @@ -113,6 +209,27 @@ export function useWorkspaceCommandCenterActions(): void { icons: GIT_ACTION_ICONS, }); const runGitAction = useGitActionRunner(); + const clipboard = useWorkspaceClipboardActions(); + + const copyPath = useCallback(() => { + if (!fields) return; + clipboard.copyPath({ + workspaceId: fields.id, + workspaceDirectory: fields.workspaceDirectory, + currentBranch: fields.currentBranch, + }); + }, [clipboard, fields]); + + const copyBranchName = useCallback(() => { + if (!fields) return; + clipboard.copyBranchName({ + workspaceId: fields.id, + workspaceDirectory: fields.workspaceDirectory, + currentBranch: fields.currentBranch, + }); + }, [clipboard, fields]); + + const { labelCatalog, toggleLabel } = useWorkspaceLabelCatalog(serverId, fields); const actions = useMemo( () => @@ -152,6 +269,14 @@ export function useWorkspaceCommandCenterActions(): void { closePane: t("settings.shortcuts.help.closePane"), toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"), toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"), + // Workspace management labels + rename: t("sidebar.workspace.actions.rename"), + copyPath: t("workspace.header.actions.copyPath"), + copyBranchName: t("workspace.header.actions.copyBranchName"), + pin: t("sidebar.workspace.actions.pin"), + unpin: t("sidebar.workspace.actions.unpin"), + showSetup: t("workspace.header.actions.showSetup"), + labelsGroup: t("workspaceLabels.title"), }, icons: { ...WORKSPACE_COMMAND_CENTER_ICONS, @@ -162,27 +287,43 @@ export function useWorkspaceCommandCenterActions(): void { canSplitPanes: supportsDesktopPaneSplits() && !isCompact, canOpenBrowserTabs: getIsElectron(), isGit, + canPin, + canShowSetup, }, activeTabKind, activeTabIndex, activeTabCount: focusedTabs.length, + currentBranch, + isPinned, + labelCatalog, dispatch: (action) => { clearCommandCenterFocusRestoreElement(); keyboardActionDispatcher.dispatch(action); }, runGitAction, + copyPath, + copyBranchName, + toggleLabel, }), [ activeTabIndex, activeTabKind, + canPin, + canShowSetup, + copyBranchName, + copyPath, + currentBranch, focusedTabs.length, gitActions, isCompact, isGit, + isPinned, keyboardActionDispatcher, + labelCatalog, overrides, runGitAction, t, + toggleLabel, ], ); diff --git a/packages/app/src/command-center/workspace-shortcuts.ts b/packages/app/src/command-center/workspace-shortcuts.ts index 2bc968af29..c51a1fcc60 100644 --- a/packages/app/src/command-center/workspace-shortcuts.ts +++ b/packages/app/src/command-center/workspace-shortcuts.ts @@ -31,7 +31,10 @@ export function resolveWorkspaceCommandCenterShortcuts({ resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined, closePane: resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined, + toggleFocusMode: resolveShortcutKeysForAction("toggle-focus", overrides, platform) ?? undefined, toggleExplorerSidebar: resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined, + // Workspace management shortcuts + pinWorkspace: resolveShortcutKeysForAction("pin-workspace", overrides, platform) ?? undefined, }; } diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 94a931f700..77f813e602 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -9,8 +9,6 @@ import { type StyleProp, type ViewStyle, } from "react-native"; -import { useMutation } from "@tanstack/react-query"; -import { AdaptiveRenameModal } from "@/components/rename-modal"; import { memo, useCallback, @@ -36,7 +34,8 @@ import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; import { type GestureType } from "react-native-gesture-handler"; -import * as Clipboard from "expo-clipboard"; +import { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; +import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions"; import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; @@ -135,7 +134,6 @@ import { } from "@/utils/sidebar-project-row-model"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { openExternalUrl } from "@/utils/open-external-url"; -import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { getCurrentProjectRemoveReadiness, @@ -1263,42 +1261,14 @@ function WorkspaceRowWithMenu({ archiveController.archive(); }, [archiveController, isArchiving]); + const clipboard = useWorkspaceClipboardActions(); const handleCopyPath = useCallback(() => { - let copyTargetDirectory: string; - try { - copyTargetDirectory = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("sidebar.workspace.toasts.workspacePathUnavailable"), - ); - return; - } - void Clipboard.setStringAsync(copyTargetDirectory); - toast.copied(t("sidebar.workspace.toasts.pathCopied")); - }, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]); + clipboard.copyPath(workspace); + }, [clipboard, workspace]); const handleCopyBranchName = useCallback(() => { - if (!workspace.currentBranch) { - return; - } - void Clipboard.setStringAsync(workspace.currentBranch); - toast.copied(t("sidebar.workspace.toasts.branchNameCopied")); - }, [t, toast, workspace.currentBranch]); - - const renameMutation = useMutation({ - mutationFn: async (title: string) => { - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) { - throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); - } - await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); - }, - }); + clipboard.copyBranchName(workspace); + }, [clipboard, workspace]); const handleOpenRename = useCallback(() => { setIsRenameOpen(true); @@ -1308,13 +1278,6 @@ function WorkspaceRowWithMenu({ setIsRenameOpen(false); }, []); - const handleSubmitRename = useCallback( - async (value: string) => { - await renameMutation.mutateAsync(value.trim()); - }, - [renameMutation], - ); - const isPinned = workspace.pinnedAt != null; const handleTogglePin = useCallback(() => { onToggleWorkspacePin(workspace); @@ -1373,14 +1336,10 @@ function WorkspaceRowWithMenu({ onTogglePin={onTogglePin} reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace} /> - diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 2c37650674..84b5442152 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -40,14 +40,11 @@ import { CircleX, } from "lucide-react-native"; import { useToast } from "@/contexts/toast-context"; -import { useMutation } from "@tanstack/react-query"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { AdaptiveRenameModal } from "@/components/rename-modal"; -import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; +import { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; +import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; -import * as Clipboard from "expo-clipboard"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; @@ -626,42 +623,17 @@ function StatusWorkspaceRowWithMenu({ archiveController.archive(); }, [archiveController, isArchiving]); + const clipboard = useWorkspaceClipboardActions(); const handleCopyPath = useCallback(() => { - let copyTargetDirectory: string; - try { - copyTargetDirectory = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - } catch (error) { - toast.error(error instanceof Error ? error.message : "Workspace path not available"); - return; - } - void Clipboard.setStringAsync(copyTargetDirectory); - toast.copied("Path copied"); - }, [toast, workspace.workspaceDirectory, workspace.workspaceId]); + clipboard.copyPath(workspace); + }, [clipboard, workspace]); const handleCopyBranchName = useCallback(() => { - void Clipboard.setStringAsync(workspace.name); - toast.copied("Branch name copied"); - }, [toast, workspace.name]); - - const renameMutation = useMutation({ - mutationFn: async (title: string) => { - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); - await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); - }, - }); + clipboard.copyBranchName(workspace); + }, [clipboard, workspace]); const handleOpenRename = useCallback(() => setIsRenameOpen(true), []); const handleCloseRename = useCallback(() => setIsRenameOpen(false), []); - const handleSubmitRename = useCallback( - async (value: string) => { - await renameMutation.mutateAsync(value.trim()); - }, - [renameMutation], - ); const isPinned = workspace.pinnedAt != null; const handleTogglePin = useCallback(() => { onToggleWorkspacePin(workspace); @@ -719,14 +691,10 @@ function StatusWorkspaceRowWithMenu({ isDragging={isDragging} dragHandleProps={dragHandleProps} /> - diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index 63355ff7f9..1233365673 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -2,23 +2,20 @@ import { memo, useCallback, useMemo, useState, type Ref } from "react"; import { useTranslation } from "react-i18next"; import { View, Text, type GestureResponderEvent } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { useMutation } from "@tanstack/react-query"; -import * as Clipboard from "expo-clipboard"; import type { HostBadgeModel } from "@/hosts/appearance"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; import type { ShortcutKey } from "@/utils/format-shortcut"; -import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; +import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions"; import { useToast } from "@/contexts/toast-context"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; -import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { isNative as platformIsNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; @@ -108,42 +105,14 @@ export function SidebarWorkspaceRow({ archiveController.archive(); }, [archiveController, isArchiving]); + const clipboard = useWorkspaceClipboardActions(); const handleCopyPath = useCallback(() => { - let copyTargetDirectory: string; - try { - copyTargetDirectory = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("sidebar.workspace.toasts.workspacePathUnavailable"), - ); - return; - } - void Clipboard.setStringAsync(copyTargetDirectory); - toast.copied(t("sidebar.workspace.toasts.pathCopied")); - }, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]); + clipboard.copyPath(workspace); + }, [clipboard, workspace]); const handleCopyBranchName = useCallback(() => { - if (!workspace.currentBranch) { - return; - } - void Clipboard.setStringAsync(workspace.currentBranch); - toast.copied(t("sidebar.workspace.toasts.branchNameCopied")); - }, [t, toast, workspace.currentBranch]); - - const renameMutation = useMutation({ - mutationFn: async (title: string) => { - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) { - throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); - } - await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); - }, - }); + clipboard.copyBranchName(workspace); + }, [clipboard, workspace]); const handleOpenRename = useCallback(() => { setIsRenameOpen(true); @@ -153,13 +122,6 @@ export function SidebarWorkspaceRow({ setIsRenameOpen(false); }, []); - const handleSubmitRename = useCallback( - async (value: string) => { - await renameMutation.mutateAsync(value.trim()); - }, - [renameMutation], - ); - const archiveShortcutKeys = useShortcutKeys("archive-workspace"); const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ serverId: workspace.serverId, @@ -206,14 +168,10 @@ export function SidebarWorkspaceRow({ onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} /> - diff --git a/packages/app/src/components/workspace-rename-host.tsx b/packages/app/src/components/workspace-rename-host.tsx new file mode 100644 index 0000000000..8b04616fe3 --- /dev/null +++ b/packages/app/src/components/workspace-rename-host.tsx @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; +import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; + +const WORKSPACE_RENAME_ACTIONS: readonly KeyboardActionId[] = ["workspace.rename"]; + +/** + * The rename dialog used to live only on the sidebar row, so it was unreachable whenever that row + * was not rendered — a collapsed project or status group, a collapsed Pinned section, or focus + * mode. This is the same fix `use-global-workspace-pin-action.ts` applies to pin: one registration + * keyed on the active route selection. + * + * Sidebar rows keep their own modal instances. They rename *their* workspace, not the active one. + */ +export function WorkspaceRenameHost() { + const selection = useActiveWorkspaceSelection(); + const serverId = selection?.serverId ?? null; + const routeWorkspaceId = selection?.workspaceId ?? null; + // Narrow projection so the dialog doesn't re-render on every gitRuntime/diffStat tick. + // `id` is projected rather than reusing the route id: the route carries an opaque workspace id + // that is not guaranteed to equal the descriptor id, and setWorkspaceTitle needs the descriptor. + const fields = useWorkspaceFields(serverId, routeWorkspaceId, (workspace) => ({ + id: workspace.id, + name: workspace.name, + title: workspace.title ?? null, + })); + const [isOpen, setIsOpen] = useState(false); + const openFrameRef = useRef(null); + + const cancelPendingOpen = useCallback(() => { + if (openFrameRef.current !== null) { + cancelAnimationFrame(openFrameRef.current); + openFrameRef.current = null; + } + }, []); + + useEffect(() => cancelPendingOpen, [cancelPendingOpen]); + + // The command center closes and dispatches in the same React batch, so opening synchronously + // would mount this modal while the palette is still unmounting — on Android the portal stacking + // lands the dialog behind it, and on web the palette's teardown steals focus back from the + // input. Wait a frame. (The caller has already cleared the focus-restore element.) + const handle = useCallback(() => { + if (!fields) return false; + cancelPendingOpen(); + openFrameRef.current = requestAnimationFrame(() => { + openFrameRef.current = null; + setIsOpen(true); + }); + return true; + }, [cancelPendingOpen, fields]); + + const handleClose = useCallback(() => { + cancelPendingOpen(); + setIsOpen(false); + }, [cancelPendingOpen]); + + // Closing on workspace change avoids renaming whatever the user navigated to instead. + useEffect(() => { + cancelPendingOpen(); + setIsOpen(false); + }, [cancelPendingOpen, serverId, routeWorkspaceId]); + + useKeyboardActionHandler({ + handlerId: "workspace-rename-global", + actions: WORKSPACE_RENAME_ACTIONS, + enabled: serverId !== null && fields !== null, + priority: 0, + handle, + }); + + const workspace = useMemo( + () => + serverId && fields + ? { serverId, workspaceId: fields.id, name: fields.name, title: fields.title } + : null, + [fields, serverId], + ); + + if (!workspace) return null; + + return ( + + ); +} diff --git a/packages/app/src/components/workspace-rename-modal.tsx b/packages/app/src/components/workspace-rename-modal.tsx new file mode 100644 index 0000000000..2f96534ea0 --- /dev/null +++ b/packages/app/src/components/workspace-rename-modal.tsx @@ -0,0 +1,70 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation } from "@tanstack/react-query"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; + +// The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the +// command center can build one from the active route selection without a sidebar row. +export interface RenamableWorkspace { + serverId: string; + workspaceId: string; + name: string; + title?: string | null; +} + +export interface WorkspaceRenameModalProps { + visible: boolean; + workspace: RenamableWorkspace; + onClose: () => void; + /** + * Prefix for the modal's testIDs. The sidebar callers must keep passing + * `sidebar-workspace-rename-modal-${workspaceKey}` — e2e/browser/sidebar-workspace-rename.spec.ts + * builds its locators from exactly that prefix. + */ + testID?: string; +} + +/** + * Owns the setWorkspaceTitle mutation and every rename string, so a caller only tracks its own + * open/closed boolean. Errors surface inline inside AdaptiveRenameModal; the dialog stays open. + */ +export function WorkspaceRenameModal({ + visible, + workspace, + onClose, + testID, +}: WorkspaceRenameModalProps) { + const { t } = useTranslation(); + + const renameMutation = useMutation({ + mutationFn: async (title: string) => { + const client = getHostRuntimeStore().getClient(workspace.serverId); + if (!client) { + throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); + } + await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); + }, + }); + const renameAsync = renameMutation.mutateAsync; + + const handleSubmit = useCallback( + async (value: string) => { + await renameAsync(value.trim()); + }, + [renameAsync], + ); + + return ( + + ); +} diff --git a/packages/app/src/hooks/use-workspace-clipboard-actions.ts b/packages/app/src/hooks/use-workspace-clipboard-actions.ts new file mode 100644 index 0000000000..a617d5f1ee --- /dev/null +++ b/packages/app/src/hooks/use-workspace-clipboard-actions.ts @@ -0,0 +1,58 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import * as Clipboard from "expo-clipboard"; +import { useToast } from "@/contexts/toast-context"; +import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; + +// Everything the copy actions actually need. Kept narrower than SidebarWorkspaceEntry so the +// command center can build one from the active route selection without a sidebar row. +export interface CopyableWorkspace { + workspaceId: string; + workspaceDirectory: string | null | undefined; + currentBranch: string | null | undefined; +} + +export interface WorkspaceClipboardActions { + copyPath: (workspace: CopyableWorkspace) => void; + copyBranchName: (workspace: CopyableWorkspace) => void; +} + +export function useWorkspaceClipboardActions(): WorkspaceClipboardActions { + const { t } = useTranslation(); + const toast = useToast(); + + const copyPath = useCallback( + (workspace: CopyableWorkspace) => { + let copyTargetDirectory: string; + try { + copyTargetDirectory = requireWorkspaceDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t("sidebar.workspace.toasts.workspacePathUnavailable"), + ); + return; + } + void Clipboard.setStringAsync(copyTargetDirectory); + toast.copied(t("sidebar.workspace.toasts.pathCopied")); + }, + [t, toast], + ); + + const copyBranchName = useCallback( + (workspace: CopyableWorkspace) => { + if (!workspace.currentBranch) { + return; + } + void Clipboard.setStringAsync(workspace.currentBranch); + toast.copied(t("sidebar.workspace.toasts.branchNameCopied")); + }, + [t, toast], + ); + + return useMemo(() => ({ copyPath, copyBranchName }), [copyBranchName, copyPath]); +} diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index a584db7e02..a3f2b4c515 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -51,7 +51,10 @@ export type KeyboardActionId = | "workspace.project.pick" | "worktree.new" | "workspace.archive" - | "workspace.pin"; + | "workspace.pin" + // Command-center only: no keybind, so these are absent from route-shortcut.ts. + | "workspace.rename" + | "workspace.setup.show"; export type KeyboardActionDefinition = | { id: "agent.interrupt"; scope: KeyboardActionScope } @@ -106,7 +109,9 @@ export type KeyboardActionDefinition = | { id: "workspace.project.pick"; scope: KeyboardActionScope } | { id: "worktree.new"; scope: KeyboardActionScope } | { id: "workspace.archive"; scope: KeyboardActionScope } - | { id: "workspace.pin"; scope: KeyboardActionScope }; + | { id: "workspace.pin"; scope: KeyboardActionScope } + | { id: "workspace.rename"; scope: KeyboardActionScope } + | { id: "workspace.setup.show"; scope: KeyboardActionScope }; export interface KeyboardActionHandler { handlerId: string; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 5d034323ad..436d4e1e02 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3304,7 +3304,8 @@ function WorkspaceScreenContent({ ], ); - const workspaceKeyboardActionsEnabled = Boolean( + // Shared by every handler below: these actions only exist on a focused workspace route. + const workspaceActionsEnabled = Boolean( isRouteFocused && normalizedServerId && normalizedWorkspaceId, ); @@ -3323,7 +3324,7 @@ function WorkspaceScreenContent({ "workspace.browser.new", "workspace.tab.menu.open", ] as const, - enabled: workspaceKeyboardActionsEnabled, + enabled: workspaceActionsEnabled, priority: 100, isActive: () => true, handle: handleWorkspaceTabAction, @@ -3341,7 +3342,7 @@ function WorkspaceScreenContent({ "workspace.tab.target.changes", "workspace.tab.target.files", ] as const, - enabled: workspaceKeyboardActionsEnabled, + enabled: workspaceActionsEnabled, priority: 100, isActive: () => true, handle: handleWorkspaceDirectTargetAction, @@ -3354,7 +3355,7 @@ function WorkspaceScreenContent({ workspaceId: normalizedWorkspaceId, }), actions: ["workspace.tab.open"] as const, - enabled: workspaceKeyboardActionsEnabled, + enabled: workspaceActionsEnabled, priority: 100, isActive: () => true, handle: handleWorkspacePanelOpenAction, @@ -3373,7 +3374,7 @@ function WorkspaceScreenContent({ "workspace.tab.copy-id", "workspace.tab.copy-file-path", ] as const, - enabled: workspaceKeyboardActionsEnabled, + enabled: workspaceActionsEnabled, priority: 100, isActive: () => true, handle: handleWorkspaceCurrentTabMetadataAction, @@ -3390,7 +3391,7 @@ function WorkspaceScreenContent({ "workspace.tab.close-right", "workspace.tab.close-others", ] as const, - enabled: workspaceKeyboardActionsEnabled, + enabled: workspaceActionsEnabled, priority: 100, isActive: () => true, handle: handleWorkspaceCurrentTabCloseAction, @@ -3416,7 +3417,7 @@ function WorkspaceScreenContent({ "workspace.pane.close", "workspace.focus.toggle", ] as const, - enabled: workspaceKeyboardActionsEnabled, + enabled: workspaceActionsEnabled, priority: 100, isActive: () => true, handle: handleWorkspacePaneAction, @@ -3429,12 +3430,28 @@ function WorkspaceScreenContent({ workspaceId: normalizedWorkspaceId, }), actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const, - enabled: workspaceKeyboardActionsEnabled, + enabled: workspaceActionsEnabled, priority: 100, isActive: () => true, handle: handleWorkspaceSidebarAction, }); + // Gated on the same predicate as the header menu item, so the command center never lists a + // Show setup entry the menu would hide. + // Gated by isActive so the handler is only dispatched when the workspace has visible setup; + // the command center contribution is separately gated by canShowSetup in workspace-registration. + useKeyboardActionHandler({ + handlerId: `workspace-setup-show:${normalizedServerId}:${normalizedWorkspaceId}`, + actions: ["workspace.setup.show"] as const, + enabled: workspaceActionsEnabled, + priority: 100, + isActive: () => showWorkspaceSetup, + handle: () => { + handleOpenSetupTab(); + return true; + }, + }); + const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]); const activeFileFields = getWorkspaceFileLocationFields(activeTabDescriptor); const activeFilePath = activeFileFields.path; diff --git a/packages/app/src/workspace-labels/command-center-icon.tsx b/packages/app/src/workspace-labels/command-center-icon.tsx new file mode 100644 index 0000000000..a9def88193 --- /dev/null +++ b/packages/app/src/workspace-labels/command-center-icon.tsx @@ -0,0 +1,19 @@ +import type { WorkspaceLabelColor } from "@getpaseo/protocol/workspace-labels"; +import type { CommandCenterIcon } from "@/command-center/contributions"; +import { WorkspaceLabelDot } from "./swatch"; + +// One stable component per color for the life of the module — `getCommandCenterIcon` in +// `@/command-center/icon` does the same thing keyed by lucide component identity. A label's dot +// has no lucide component to key off, so the cache is keyed by color name instead. +const cache = new Map(); + +/** The color dot a label's row draws everywhere else, adapted to the command center's icon shape. */ +export function getLabelCommandCenterIcon(color: WorkspaceLabelColor): CommandCenterIcon { + const cached = cache.get(color); + if (cached) return cached; + function LabelCommandCenterIcon() { + return ; + } + cache.set(color, LabelCommandCenterIcon); + return LabelCommandCenterIcon; +}