From 0e1bb23b9e4f0707d2e02103ce25a85c89e19a0d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 10 Nov 2026 09:31:28 +0100 Subject: [PATCH] feat(app): rename sidebar workspaces in place on wide web layouts At lg and up on web, Rename from a sidebar row's kebab or context menu turns the title into an input instead of opening the modal. The input fills a box exactly one title line tall and its focus outline paints outside that box, so the row never moves. Enter or blur saves, Escape discards. Narrower and native layouts keep the modal; the native-wide device flows drive it. The field stays on screen, read-only, holding the submitted title until the renamed workspace arrives. The daemon answers workspace.title.set before it pushes the workspace update, and queues that update behind any in-flight ones for the workspace, so closing on the answer would show the old title. While editing, the row detaches its press, drag and context-menu handlers rather than becoming disabled: a disabled row sets aria-disabled, which makes the input inside it read as disabled. A ContextMenuTrigger with its menu off now leaves the browser's own menu alone, so copy and paste work in the field. The rename modal moves into the new workspace-rename module, and the unused sidebar-workspace-row.tsx is removed. --- .../app/e2e/browser/branch-switcher.spec.ts | 10 +- .../browser/sidebar-workspace-rename.spec.ts | 135 ++++- .../src/components/sidebar-workspace-list.tsx | 45 +- .../sidebar/sidebar-status-list.tsx | 38 +- .../sidebar/sidebar-workspace-row-content.tsx | 33 +- .../sidebar/sidebar-workspace-row.tsx | 524 ------------------ .../app/src/components/ui/context-menu.tsx | 6 +- .../src/components/workspace-rename-host.tsx | 2 +- .../src/components/workspace-rename-modal.tsx | 70 --- packages/app/src/workspace-rename/index.ts | 6 + .../internal/inline-title-field.tsx | 119 ++++ .../src/workspace-rename/internal/modal.tsx | 46 ++ .../internal/rename-surface.ts | 17 + .../internal/set-workspace-title.ts | 35 ++ .../internal/use-sidebar-workspace-rename.tsx | 110 ++++ 15 files changed, 536 insertions(+), 660 deletions(-) delete mode 100644 packages/app/src/components/sidebar/sidebar-workspace-row.tsx delete mode 100644 packages/app/src/components/workspace-rename-modal.tsx create mode 100644 packages/app/src/workspace-rename/index.ts create mode 100644 packages/app/src/workspace-rename/internal/inline-title-field.tsx create mode 100644 packages/app/src/workspace-rename/internal/modal.tsx create mode 100644 packages/app/src/workspace-rename/internal/rename-surface.ts create mode 100644 packages/app/src/workspace-rename/internal/set-workspace-title.ts create mode 100644 packages/app/src/workspace-rename/internal/use-sidebar-workspace-rename.tsx diff --git a/packages/app/e2e/browser/branch-switcher.spec.ts b/packages/app/e2e/browser/branch-switcher.spec.ts index 0f37f83b10..690713c673 100644 --- a/packages/app/e2e/browser/branch-switcher.spec.ts +++ b/packages/app/e2e/browser/branch-switcher.spec.ts @@ -125,11 +125,13 @@ async function renameWorkspaceViaSidebar( await expect(renameItem).toBeVisible({ timeout: 10_000 }); await renameItem.click(); - const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`; - const renameInput = page.getByTestId(`${modalPrefix}-input`); - await expect(renameInput).toBeVisible({ timeout: 10_000 }); + // Desktop-width sidebars rename in place. + const renameInput = page.getByTestId( + `sidebar-workspace-rename-input-${serverId}:${input.workspaceId}`, + ); + await expect(renameInput).toBeFocused({ timeout: 10_000 }); await renameInput.fill(input.title); - await page.getByTestId(`${modalPrefix}-submit`).click(); + await renameInput.press("Enter"); await expect(renameInput).toHaveCount(0, { timeout: 15_000 }); } diff --git a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts index 6ed14d159a..8a857281e7 100644 --- a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts @@ -3,6 +3,7 @@ import { gotoAppShell } from "../support/helpers/app"; import { seedWorkspace } from "../support/helpers/seed-client"; import { openAgentRoute } from "../support/helpers/mock-agent"; import { getServerId } from "../support/helpers/server-id"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; function workspaceRowTestId(workspaceId: string): string { return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; @@ -12,9 +13,13 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`; } -async function openRenameModal(page: Page, workspaceId: string) { +function workspaceRenameInputTestId(workspaceId: string): string { + return `sidebar-workspace-rename-input-${getServerId()}:${workspaceId}`; +} + +async function chooseRenameFromKebab(page: Page, workspaceId: string): Promise { const serverId = getServerId(); - const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + const row = page.getByTestId(workspaceRowTestId(workspaceId)); await expect(row).toBeVisible({ timeout: 30_000 }); await row.hover(); @@ -25,40 +30,62 @@ async function openRenameModal(page: Page, workspaceId: string) { const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`); await expect(renameItem).toBeVisible({ timeout: 10_000 }); await renameItem.click(); +} - const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input")); - await expect(input).toBeVisible({ timeout: 10_000 }); - return input; +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +async function rowBox(page: Page, workspaceId: string): Promise { + const box = await page.getByTestId(workspaceRowTestId(workspaceId)).boundingBox(); + if (!box) throw new Error(`sidebar row ${workspaceId} has no layout box`); + return box; } // In Model B the workspace title is its identity: renaming sets a custom title // layered over the derived branch/directory name, and reconciliation never // touches it. The sidebar row shows the title verbatim — no branch mutation. test.describe("Sidebar workspace rename", () => { - test("renaming via kebab sets a custom title that survives reload", async ({ page }) => { + test("renaming in place sets a custom title without moving the row, and survives reload", async ({ + page, + }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" }); try { expect(workspace.workspaceName).toBe("main"); + await page.setViewportSize({ width: 1280, height: 800 }); await gotoAppShell(page); await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({ timeout: 30_000, }); + const restingBox = await rowBox(page, workspace.workspaceId); - const input = await openRenameModal(page, workspace.workspaceId); + await chooseRenameFromKebab(page, workspace.workspaceId); + const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId)); + await expect(input).toBeFocused(); + await expect(input).toBeEditable(); await expect(input).toHaveValue("main"); + await expect( + page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input")), + ).toHaveCount(0); + expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox); const customTitle = "Payments Refactor"; - await input.fill(customTitle); - await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click(); + // The whole title is selected, so typing replaces it. + await page.keyboard.type(customTitle); + expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox); + await page.keyboard.press("Enter"); await expect(input).toHaveCount(0, { timeout: 15_000 }); // The title is shown exactly as typed — not slugified into a branch name. await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText( customTitle, - { timeout: 15_000 }, ); + expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox); // The custom title is backing metadata on the workspace: a full reload // re-resolves the descriptor from persistence and must not lose it. This @@ -73,6 +100,94 @@ test.describe("Sidebar workspace rename", () => { await workspace.cleanup(); } }); + + test("Escape discards an in-place rename and clicking away saves it", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-blur-" }); + + try { + await page.setViewportSize({ width: 1280, height: 800 }); + await gotoAppShell(page); + const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId)); + const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId)); + + await chooseRenameFromKebab(page, workspace.workspaceId); + await expect(input).toBeFocused(); + await page.keyboard.type("Discarded title"); + await page.keyboard.press("Escape"); + await expect(input).toHaveCount(0); + await expect(row).toContainText("main"); + await expect(row).not.toContainText("Discarded title"); + + await chooseRenameFromKebab(page, workspace.workspaceId); + await expect(input).toBeFocused(); + // Clicking inside the field places the caret; it does not press the row underneath. + await input.click(); + await expect(input).toBeFocused(); + await input.fill("Saved on blur"); + // Click empty space in the main pane, away from any control. + await page.mouse.click(900, 700); + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(row).toContainText("Saved on blur"); + } finally { + await workspace.cleanup(); + } + }); + + test("the submitted title stays on screen until the renamed workspace arrives", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-hold-" }); + + try { + const gate = await installDaemonWebSocketGate(page); + await page.setViewportSize({ width: 1280, height: 800 }); + await gotoAppShell(page); + const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId)); + const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId)); + + await chooseRenameFromKebab(page, workspace.workspaceId); + await expect(input).toBeFocused(); + await page.keyboard.type("Held title"); + // The daemon answers the rename before it pushes the renamed workspace. + gate.holdNextServerMessage("workspace_update"); + await page.keyboard.press("Enter"); + await gate.waitForHeldServerMessage("workspace_update"); + + await expect(input).toHaveValue("Held title"); + await expect(input).not.toBeEditable(); + await expect(row).not.toContainText("main"); + + gate.releaseHeldServerMessage("workspace_update"); + await expect(input).toHaveCount(0); + await expect(row).toContainText("Held title"); + } finally { + await workspace.cleanup(); + } + }); + + test("below the lg breakpoint renaming opens the modal", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-modal-" }); + + try { + await page.setViewportSize({ width: 800, height: 800 }); + await gotoAppShell(page); + await chooseRenameFromKebab(page, workspace.workspaceId); + + const input = page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input")); + await expect(input).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId))).toHaveCount( + 0, + ); + await input.fill("Modal Title"); + await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click(); + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText( + "Modal Title", + ); + } finally { + await workspace.cleanup(); + } + }); }); async function toggleBackgroundWorkspaces(page: Page): Promise { diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index d6a810e74e..5e41741a5e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -17,6 +17,7 @@ import { useEffect, useRef, type ReactElement, + type ReactNode, type MutableRefObject, type Ref, type ComponentProps, @@ -35,7 +36,7 @@ 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 { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; +import { useSidebarWorkspaceRename } from "@/workspace-rename"; 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"; @@ -287,6 +288,8 @@ interface WorkspaceRowInnerProps { onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; + /** Present while the row is renamed in place: the row stops pressing, dragging and hovering. */ + titleEditor?: ReactNode; onMarkAsRead?: () => void; onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; @@ -1075,6 +1078,7 @@ function WorkspaceRowInner({ onCopyBranchName, onCopyPath, onRename, + titleEditor = null, onMarkAsRead, onMarkAsUnread, archiveShortcutKeys, @@ -1116,9 +1120,14 @@ function WorkspaceRowInner({ }, [interaction]); const accessibilityState = useMemo(() => ({ selected }), [selected]); + const isEditingTitle = titleEditor !== null; return ( - + {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { const isDesktop = !isTouchPlatform; const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null; @@ -1132,7 +1141,7 @@ function WorkspaceRowInner({ return ( } style={styles.workspaceRowContainer} {...hoverHandlers} @@ -1158,15 +1167,16 @@ function WorkspaceRowInner({ onTogglePin={onTogglePin} openInFileManagerPath={workspace.workspaceDirectory} disabled={isArchiving} + enabled={!isEditingTitle} aria-selected={selected} accessibilityRole="button" accessibilityState={accessibilityState} style={workspaceRowStyle} highlightStyle={styles.workspaceRowPressed} - onPressIn={handleWorkspacePressIn} - onTouchMove={interaction.handleTouchMove} - onPressOut={handleWorkspacePressOut} - onPress={handlePress} + onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn} + onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove} + onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut} + onPress={isEditingTitle ? undefined : handlePress} testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > { redirectIfArchivingActiveWorkspace({ @@ -1287,14 +1298,6 @@ function WorkspaceRowWithMenu({ clipboard.copyBranchName(workspace); }, [clipboard, workspace]); - const handleOpenRename = useCallback(() => { - setIsRenameOpen(true); - }, []); - - const handleCloseRename = useCallback(() => { - setIsRenameOpen(false); - }, []); - const isPinned = workspace.pinnedAt != null; const handleTogglePin = useCallback(() => { onToggleWorkspacePin(workspace); @@ -1352,7 +1355,8 @@ function WorkspaceRowWithMenu({ onArchive={handleArchive} onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined} onCopyPath={handleCopyPath} - onRename={handleOpenRename} + onRename={rename.begin} + titleEditor={rename.titleEditor} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} @@ -1360,12 +1364,7 @@ function WorkspaceRowWithMenu({ onTogglePin={onTogglePin} reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace} /> - + {rename.modal} ); } diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index ed19b08857..10814fb28c 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -40,7 +40,7 @@ import { CircleX, } from "lucide-react-native"; import { useToast } from "@/contexts/toast-context"; -import { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; +import { useSidebarWorkspaceRename } from "@/workspace-rename"; import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; @@ -637,7 +637,7 @@ function StatusWorkspaceRowWithMenu({ const { t } = useTranslation(); const toast = useToast(); const [isHidingWorkspace, setIsHidingWorkspace] = useState(false); - const [isRenameOpen, setIsRenameOpen] = useState(false); + const rename = useSidebarWorkspaceRename(workspace); const isArchiving = workspace.archivingAt !== null || isHidingWorkspace; const redirectAfterArchive = useCallback(() => { @@ -674,8 +674,6 @@ function StatusWorkspaceRowWithMenu({ clipboard.copyBranchName(workspace); }, [clipboard, workspace]); - const handleOpenRename = useCallback(() => setIsRenameOpen(true), []); - const handleCloseRename = useCallback(() => setIsRenameOpen(false), []); const isPinned = workspace.pinnedAt != null; const handleTogglePin = useCallback(() => { onToggleWorkspacePin(workspace); @@ -728,7 +726,8 @@ function StatusWorkspaceRowWithMenu({ onArchive={handleArchive} onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined} onCopyPath={handleCopyPath} - onRename={handleOpenRename} + onRename={rename.begin} + titleEditor={rename.titleEditor} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} @@ -740,12 +739,7 @@ function StatusWorkspaceRowWithMenu({ isDragging={isDragging} dragHandleProps={dragHandleProps} /> - + {rename.modal} ); } @@ -767,6 +761,8 @@ interface StatusWorkspaceRowInnerProps { onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; + /** Present while the row is renamed in place: the row stops pressing, dragging and hovering. */ + titleEditor?: ReactNode; onMarkAsRead?: () => void; onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; @@ -814,6 +810,7 @@ function StatusWorkspaceRowInnerContent({ onCopyBranchName, onCopyPath, onRename, + titleEditor = null, onMarkAsRead, onMarkAsUnread, archiveShortcutKeys, @@ -842,6 +839,7 @@ function StatusWorkspaceRowInnerContent({ const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null; const accessibilityState = useMemo(() => ({ selected }), [selected]); + const isEditingTitle = titleEditor !== null; const didLongPressRef = dragInteraction?.didLongPressRef; const startDragPress = dragInteraction?.handlePressIn; const moveDragPress = dragInteraction?.handleTouchMove; @@ -866,7 +864,11 @@ function StatusWorkspaceRowInnerContent({ }, [endDragPress]); return ( - + {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { const showShortcut = showShortcutBadge && shortcutNumber !== null; const { @@ -895,7 +897,7 @@ function StatusWorkspaceRowInnerContent({ return ( } style={styles.workspaceRowContainer} {...hoverHandlers} @@ -922,14 +924,15 @@ function StatusWorkspaceRowInnerContent({ onTogglePin={onTogglePin} openInFileManagerPath={workspace.workspaceDirectory} disabled={isArchiving} + enabled={!isEditingTitle} accessibilityRole="button" accessibilityState={accessibilityState} style={workspaceRowStyle} highlightStyle={styles.workspaceRowPressed} - onPressIn={handlePressIn} - onTouchMove={moveDragPress} - onPressOut={handlePressOut} - onPress={handlePress} + onPressIn={isEditingTitle ? undefined : handlePressIn} + onTouchMove={isEditingTitle ? undefined : moveDragPress} + onPressOut={isEditingTitle ? undefined : handlePressOut} + onPress={isEditingTitle ? undefined : handlePress} testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > {renderSlot ? ( {children({ - isHovered: isHovered && !contextMenuOpen && !isDragging, + isHovered: isHovered && !contextMenuOpen && !isDragging && !isEditingTitle, contextMenuOpen, onContextMenuOpenChange: handleContextMenuOpenChange, hoverHandlers, @@ -102,6 +105,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte shortcutNumber = null, showShortcutBadge = false, reserveIdleStatusIndicatorSpace = true, + titleEditor = null, children, }: { workspace: SidebarWorkspaceEntry; @@ -119,6 +123,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte showShortcutBadge?: boolean; /** Keep the empty leading slot when the workspace has no active status. */ reserveIdleStatusIndicatorSpace?: boolean; + /** Takes the title's place, in a box exactly one title line tall, while renaming in place. */ + titleEditor?: ReactNode; children?: ReactNode; }) { const { @@ -160,13 +166,17 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte )} - - {workspaceLabel} - + {titleEditor ? ( + {titleEditor} + ) : ( + + {workspaceLabel} + + )} {children} ({ flex: 1, minWidth: 0, }, + workspaceTitleEditorSlot: { + height: TITLE_LINE_HEIGHT, + flex: 1, + minWidth: 0, + }, workspaceBranchTextCreating: { opacity: 0.92, }, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx deleted file mode 100644 index d458b390ed..0000000000 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ /dev/null @@ -1,524 +0,0 @@ -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 type { HostBadgeModel } from "@/hosts/appearance"; -import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; -import { density } from "@/styles/density"; -import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; -import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; -import type { ShortcutKey } from "@/utils/format-shortcut"; -import { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; -import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions"; -import { useToast } from "@/contexts/toast-context"; -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 { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; -import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; -import { isNative as platformIsNative } from "@/constants/platform"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; -import { - SidebarWorkspaceContextMenu, - SidebarWorkspaceMenu, -} from "@/components/sidebar/sidebar-workspace-menu"; -import { - SidebarWorkspaceRowFrame, - SidebarWorkspaceRowContent, - resolveTrailingActionVisibility, - SidebarWorkspaceTrailingActionBase, - SidebarWorkspaceTrailingActionOverlay, - SidebarWorkspaceTrailingActionSlot, -} from "@/components/sidebar/sidebar-workspace-row-content"; -import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility"; -import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; -import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row"; -import { - SidebarWorkspaceTrailingContent, - useSidebarWorkspaceTrailing, - type SidebarWorkspaceTrailing, -} from "@/components/sidebar/workspace-trailing"; - -interface SidebarWorkspaceRowProps { - workspace: SidebarWorkspaceEntry; - selected: boolean; - shortcutNumber: number | null; - showShortcutBadge: boolean; - canCopyBranchName: boolean; - onPress: () => void; - /** The host pill after the title. Absent → the sidebar spans one host, or this one is hidden. */ - hostBadge?: HostBadgeModel | null; - /** Project grouping only: shows a transient "creating" affordance. */ - isCreating?: boolean; - /** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */ - drag?: () => void; - isDragging?: boolean; - dragHandleProps?: DraggableListDragHandleProps; -} - -export function SidebarWorkspaceRow({ - workspace, - selected, - shortcutNumber, - showShortcutBadge, - canCopyBranchName, - onPress, - hostBadge, - isCreating = false, - drag, - isDragging = false, - dragHandleProps, -}: SidebarWorkspaceRowProps) { - const { t } = useTranslation(); - const toast = useToast(); - const [isHidingWorkspace, setIsHidingWorkspace] = useState(false); - const [isRenameOpen, setIsRenameOpen] = useState(false); - const isArchiving = workspace.archivingAt !== null || isHidingWorkspace; - - const redirectAfterArchive = useCallback(() => { - redirectIfArchivingActiveWorkspace({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - activeWorkspaceSelection: selected - ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } - : null, - }); - }, [selected, workspace]); - - const archiveController = useWorkspaceArchive({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - workspaceKind: workspace.workspaceKind, - name: workspace.name, - ...toWorktreeArchiveRisk(workspace), - onArchiveStarted: redirectAfterArchive, - onSetHiding: setIsHidingWorkspace, - }); - - const handleArchive = useCallback(() => { - if (isArchiving) { - return; - } - archiveController.archive(); - }, [archiveController, isArchiving]); - - const clipboard = useWorkspaceClipboardActions(); - const handleCopyPath = useCallback(() => { - clipboard.copyPath(workspace); - }, [clipboard, workspace]); - - const handleCopyBranchName = useCallback(() => { - clipboard.copyBranchName(workspace); - }, [clipboard, workspace]); - - const handleOpenRename = useCallback(() => { - setIsRenameOpen(true); - }, []); - - const handleCloseRename = useCallback(() => { - setIsRenameOpen(false); - }, []); - - const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = - useWorkspaceReadState({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); - const handleMarkAsRead = useCallback(() => { - void clearAttention().catch((error) => { - toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); - }); - }, [clearAttention, toast]); - const handleMarkAsUnread = useCallback(() => { - void markUnread().catch((error) => { - toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); - }); - }, [markUnread, toast]); - - useKeyboardActionHandler({ - handlerId: `workspace-archive-${workspace.workspaceKey}`, - actions: ["workspace.archive"], - enabled: selected && !isArchiving, - priority: 0, - handle: () => { - handleArchive(); - return true; - }, - }); - - return ( - <> - - - - ); -} - -interface WorkspaceRowBodyProps { - workspace: SidebarWorkspaceEntry; - selected: boolean; - shortcutNumber: number | null; - showShortcutBadge: boolean; - hostBadge?: HostBadgeModel | null; - isCreating: boolean; - isArchiving: boolean; - onPress: () => void; - drag?: () => void; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; - archiveLabel?: string; - archiveStatus?: "idle" | "pending" | "success"; - archivePendingLabel?: string; - onArchive?: () => void; - onCopyBranchName?: () => void; - onCopyPath?: () => void; - onRename?: () => void; - onMarkAsRead?: () => void; - onMarkAsUnread?: () => void; - archiveShortcutKeys?: ShortcutKey[][] | null; -} - -function WorkspaceRowBody({ - workspace, - selected, - shortcutNumber, - showShortcutBadge, - hostBadge, - isCreating, - isArchiving, - onPress, - drag, - isDragging, - dragHandleProps, - archiveLabel, - archiveStatus = "idle", - archivePendingLabel, - onArchive, - onCopyBranchName, - onCopyPath, - onRename, - onMarkAsRead, - onMarkAsUnread, - archiveShortcutKeys, -}: WorkspaceRowBodyProps) { - const isCompact = useIsCompactFormFactor(); - const isTouchPlatform = platformIsNative || isCompact; - const [isPressed, setIsPressed] = useState(false); - const trailing = useSidebarWorkspaceTrailing(); - const draggable = Boolean(drag); - const interaction = useLongPressDragInteraction({ - drag, - menuController: null, - }); - const { - role: _dragRole, - tabIndex: _dragTabIndex, - "aria-roledescription": _dragRoleDescription, - ...dragAttributes - } = dragHandleProps?.attributes ?? {}; - - const handlePress = useCallback(() => { - if (interaction.didLongPressRef.current) { - interaction.didLongPressRef.current = false; - return; - } - onPress(); - }, [interaction.didLongPressRef, onPress]); - const handleWorkspacePressIn = useCallback( - (event: GestureResponderEvent) => { - setIsPressed(true); - if (draggable) interaction.handlePressIn(event); - }, - [draggable, interaction], - ); - const handleWorkspacePressOut = useCallback(() => { - setIsPressed(false); - if (draggable) interaction.handlePressOut(); - }, [draggable, interaction]); - - const accessibilityState = useMemo(() => ({ selected }), [selected]); - - return ( - - {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { - const isDesktop = !isTouchPlatform; - const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null; - const workspaceRowStyle = getWorkspaceRowStyle({ - isDragging, - isPressed, - selected, - isHovered, - }); - const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered }); - return ( - ) : undefined - } - style={styles.workspaceRowContainer} - {...hoverHandlers} - > - - - - - - - ); - }} - - ); -} - -function WorkspaceRowTrailingActions({ - workspace, - backdrop, - trailing, - isHovered, - isTouchPlatform, - isCreating, - showShortcutBadge, - shortcutNumber, - archiveLabel, - archiveStatus, - archivePendingLabel, - archiveShortcutKeys, - onArchive, - onMarkAsRead, - onMarkAsUnread, - onCopyBranchName, - onCopyPath, - onRename, -}: { - workspace: SidebarWorkspaceEntry; - backdrop: SidebarSurfaceBackdrop; - trailing: SidebarWorkspaceTrailing; - isHovered: boolean; - isTouchPlatform: boolean; - isCreating: boolean; - showShortcutBadge: boolean; - shortcutNumber: number | null; - archiveLabel?: string; - archiveStatus?: "idle" | "pending" | "success"; - archivePendingLabel?: string; - archiveShortcutKeys?: ShortcutKey[][] | null; - onArchive?: () => void; - onMarkAsRead?: () => void; - onMarkAsUnread?: () => void; - onCopyBranchName?: () => void; - onCopyPath?: () => void; - onRename?: () => void; -}) { - const { t } = useTranslation(); - const showShortcut = showShortcutBadge && shortcutNumber !== null; - const { - trailingPresentation, - showKebab: showKebabInSlot, - showArchiveButton, - showScrim, - renderSlot, - reserveSlotWidth, - } = resolveTrailingActionVisibility({ - workspace, - trailing, - hasArchiveAction: Boolean(onArchive), - isHovered, - isTouchPlatform, - showShortcut, - }); - const kebab = useOpenKebabMenuVisibility(showKebabInSlot); - - return ( - <> - {isCreating ? ( - {t("sidebar.workspace.status.creating")} - ) : null} - {renderSlot ? ( - - - - - - {onArchive ? ( - - ) : null} - - - ) : null} - - ); -} - -function getWorkspaceRowStyle({ - isDragging, - isPressed, - selected, - isHovered, -}: { - isDragging: boolean; - isPressed: boolean; - selected: boolean; - isHovered: boolean; -}) { - return [ - styles.workspaceRow, - isHovered && styles.workspaceRowHovered, - selected && styles.sidebarRowSelected, - isDragging && styles.workspaceRowDragging, - isPressed && styles.workspaceRowPressed, - ]; -} - -export const MemoSidebarWorkspaceRow = memo(SidebarWorkspaceRow); - -const styles = StyleSheet.create((theme) => ({ - workspaceRowContainer: { - position: "relative", - }, - workspaceRow: { - minHeight: density.rowHeight, - marginBottom: theme.spacing[1], - paddingVertical: theme.spacing[2], - paddingLeft: theme.spacing[2], - paddingRight: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - flexDirection: "column", - alignItems: "stretch", - justifyContent: "center", - gap: theme.spacing[1], - userSelect: "none", - }, - workspaceRowHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, - workspaceRowPressed: { - backgroundColor: theme.colors.surface2, - }, - workspaceRowDragging: { - backgroundColor: theme.colors.surface2, - borderWidth: 1, - borderColor: theme.colors.border, - transform: [{ scale: 1.02 }], - zIndex: 3, - ...theme.shadow.md, - }, - sidebarRowSelected: { - backgroundColor: theme.colors.surfaceSidebarSelected, - }, - workspaceCreatingText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - flexShrink: 0, - }, -})); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 995caf4a0b..b785f6e8d0 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -165,7 +165,9 @@ export function ContextMenuTrigger({ const handleContextMenu = useCallback( (event: unknown) => { - if (isNative) return; + // A trigger with no menu of its own leaves the browser's menu alone, e.g. copy and paste + // in a field rendered inside it. + if (isNative || !shouldEnableOnThisPlatform) return; if (typeof event === "object" && event !== null) { const preventDefault = Reflect.get(event, "preventDefault"); const stopPropagation = Reflect.get(event, "stopPropagation"); @@ -175,7 +177,7 @@ export function ContextMenuTrigger({ onContextMenu?.(event); openAtEvent(event); }, - [onContextMenu, openAtEvent], + [onContextMenu, openAtEvent, shouldEnableOnThisPlatform], ); const resolveDynamicStyle = useCallback( diff --git a/packages/app/src/components/workspace-rename-host.tsx b/packages/app/src/components/workspace-rename-host.tsx index 8b04616fe3..adc707e413 100644 --- a/packages/app/src/components/workspace-rename-host.tsx +++ b/packages/app/src/components/workspace-rename-host.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; +import { WorkspaceRenameModal } from "@/workspace-rename"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; diff --git a/packages/app/src/components/workspace-rename-modal.tsx b/packages/app/src/components/workspace-rename-modal.tsx deleted file mode 100644 index 2f96534ea0..0000000000 --- a/packages/app/src/components/workspace-rename-modal.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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/workspace-rename/index.ts b/packages/app/src/workspace-rename/index.ts new file mode 100644 index 0000000000..3e78cd62b2 --- /dev/null +++ b/packages/app/src/workspace-rename/index.ts @@ -0,0 +1,6 @@ +export { WorkspaceRenameModal } from "./internal/modal"; +export { + useSidebarWorkspaceRename, + type SidebarRenamableWorkspace, + type SidebarWorkspaceRename, +} from "./internal/use-sidebar-workspace-rename"; diff --git a/packages/app/src/workspace-rename/internal/inline-title-field.tsx b/packages/app/src/workspace-rename/internal/inline-title-field.tsx new file mode 100644 index 0000000000..b821804763 --- /dev/null +++ b/packages/app/src/workspace-rename/internal/inline-title-field.tsx @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input"; + +const ThemedTextInput = withUnistyles(EditingTextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); + +export interface InlineTitleFieldProps { + initialValue: string; + placeholder: string; + /** Holds the submitted text on screen while the daemon accepts it. */ + readOnly: boolean; + onCommit: (text: string) => void; + onCancel: () => void; + testID: string; +} + +/** + * A single-line input that stands in for the row's title text. It reports exactly one outcome: + * Enter and blur commit, Escape cancels. Enter blurs the input too, so without the settled latch + * one Enter would commit twice. + * + * Its box is the slot it is rendered in (100% of the title line), and the outline that marks it + * as editable paints outside that box, so swapping it in for the title never moves the row. + */ +export function InlineTitleField({ + initialValue, + placeholder, + readOnly, + onCommit, + onCancel, + testID, +}: InlineTitleFieldProps) { + const inputRef = useRef(null); + const settledRef = useRef(false); + const focusedRef = useRef(false); + + // Rename starts from a menu item. The menu restores focus to its trigger as it closes, so + // focusing in the same commit would lose to it and the blur would commit an unchanged title. + useEffect(() => { + const frame = requestAnimationFrame(() => { + const input = inputRef.current; + if (!input) return; + input.focus(); + input.setSelection({ start: 0, end: initialValue.length }); + }); + return () => cancelAnimationFrame(frame); + }, [initialValue]); + + const settle = useCallback((outcome: () => void) => { + if (settledRef.current) return; + settledRef.current = true; + outcome(); + }, []); + + const commit = useCallback(() => { + settle(() => onCommit(inputRef.current?.getText() ?? initialValue)); + }, [initialValue, onCommit, settle]); + + const handleFocus = useCallback(() => { + focusedRef.current = true; + }, []); + + const handleBlur = useCallback(() => { + // A blur before the first focus is the menu handing focus back, not the user leaving. + if (!focusedRef.current) return; + commit(); + }, [commit]); + + const handleKeyPress = useCallback( + (event: NativeSyntheticEvent) => { + if (event.nativeEvent.key !== "Escape") return; + settle(onCancel); + }, + [onCancel, settle], + ); + + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Typography matches the sidebar title it replaces; the box comes from the slot. + input: { + width: "100%", + height: "100%", + margin: 0, + padding: 0, + borderWidth: 0, + backgroundColor: "transparent", + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: "400", + includeFontPadding: false, + borderRadius: theme.borderRadius.sm, + outlineStyle: "solid", + outlineWidth: 1, + outlineOffset: 2, + outlineColor: theme.colors.accent, + }, +})); diff --git a/packages/app/src/workspace-rename/internal/modal.tsx b/packages/app/src/workspace-rename/internal/modal.tsx new file mode 100644 index 0000000000..7c4ebb58ef --- /dev/null +++ b/packages/app/src/workspace-rename/internal/modal.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from "react-i18next"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { + type RenamableWorkspace, + useSetWorkspaceTitle, + workspaceRenameInitialValue, +} from "./set-workspace-title"; + +export interface WorkspaceRenameModalProps { + visible: boolean; + workspace: RenamableWorkspace; + onClose: () => void; + /** + * Prefix for the modal's testIDs. Sidebar rows use + * `sidebar-workspace-rename-modal-${workspaceKey}` — the browser and native-wide e2e flows build + * their locators from exactly that prefix. + */ + testID?: string; +} + +/** + * Owns 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 setWorkspaceTitle = useSetWorkspaceTitle(workspace); + + return ( + + ); +} diff --git a/packages/app/src/workspace-rename/internal/rename-surface.ts b/packages/app/src/workspace-rename/internal/rename-surface.ts new file mode 100644 index 0000000000..53f35e90cc --- /dev/null +++ b/packages/app/src/workspace-rename/internal/rename-surface.ts @@ -0,0 +1,17 @@ +import { UnistylesRuntime } from "react-native-unistyles"; +import { isWeb } from "@/constants/platform"; + +export type WorkspaceRenameSurface = "inline" | "modal"; + +/** + * Read when a rename starts rather than subscribed to: the surface is chosen once per rename, and + * a subscription would re-render every sidebar row on each breakpoint change. + * + * Native wide layouts keep the modal. Their sidebar rows are touch targets with a permanent + * kebab, and the iPad overlay-dismissal flows drive the modal. + */ +export function resolveWorkspaceRenameSurface(): WorkspaceRenameSurface { + if (!isWeb) return "modal"; + const breakpoint = UnistylesRuntime.breakpoint; + return breakpoint === "lg" || breakpoint === "xl" ? "inline" : "modal"; +} diff --git a/packages/app/src/workspace-rename/internal/set-workspace-title.ts b/packages/app/src/workspace-rename/internal/set-workspace-title.ts new file mode 100644 index 0000000000..c177a8319e --- /dev/null +++ b/packages/app/src/workspace-rename/internal/set-workspace-title.ts @@ -0,0 +1,35 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; + +// The subset of a workspace a rename 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; +} + +/** What the rename field starts with: the custom title, else the derived name it overrides. */ +export function workspaceRenameInitialValue(workspace: RenamableWorkspace): string { + return workspace.title ?? workspace.name; +} + +/** Resolves once the daemon accepts the title; rejects with a displayable message. */ +export function useSetWorkspaceTitle( + workspace: Pick, +): (title: string) => Promise { + const { t } = useTranslation(); + const mutation = useMutation({ + mutationFn: async (title: string) => { + const client = getHostRuntimeStore().getClient(workspace.serverId); + if (!client) { + throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); + } + const trimmed = title.trim(); + await client.setWorkspaceTitle(workspace.workspaceId, trimmed.length === 0 ? null : trimmed); + }, + }); + return mutation.mutateAsync; +} diff --git a/packages/app/src/workspace-rename/internal/use-sidebar-workspace-rename.tsx b/packages/app/src/workspace-rename/internal/use-sidebar-workspace-rename.tsx new file mode 100644 index 0000000000..88e05d5ce3 --- /dev/null +++ b/packages/app/src/workspace-rename/internal/use-sidebar-workspace-rename.tsx @@ -0,0 +1,110 @@ +import { type ReactNode, useCallback, useState } from "react"; +import { useToast } from "@/contexts/toast-context"; +import { InlineTitleField } from "./inline-title-field"; +import { WorkspaceRenameModal } from "./modal"; +import { resolveWorkspaceRenameSurface } from "./rename-surface"; +import { + type RenamableWorkspace, + useSetWorkspaceTitle, + workspaceRenameInitialValue, +} from "./set-workspace-title"; + +export interface SidebarRenamableWorkspace extends RenamableWorkspace { + workspaceKey: string; +} + +/** + * `saving` and `saved` keep the field on screen holding the submitted text. The daemon answers + * the request before it pushes the updated workspace, so closing on the answer would show the + * old title for a beat. `saved` ends when the row's title moves off the one editing started from. + */ +type RenameSession = + | { kind: "closed" } + | { kind: "modal" } + | { kind: "inline"; phase: "editing" | "saving" | "saved"; titleAtStart: string | null }; + +const CLOSED: RenameSession = { kind: "closed" }; + +export interface SidebarWorkspaceRename { + /** Starts a rename: in place on wide pointer layouts, in the rename modal otherwise. */ + begin: () => void; + /** Rendered in place of the row's title while renaming in place; null otherwise. */ + titleEditor: ReactNode | null; + /** Always render this; it is the modal for layouts that rename there. */ + modal: ReactNode; +} + +export function useSidebarWorkspaceRename( + workspace: SidebarRenamableWorkspace, +): SidebarWorkspaceRename { + const toast = useToast(); + const setWorkspaceTitle = useSetWorkspaceTitle(workspace); + const [session, setSession] = useState(CLOSED); + const currentTitle = workspace.title ?? null; + + if ( + session.kind === "inline" && + session.phase === "saved" && + currentTitle !== session.titleAtStart + ) { + setSession(CLOSED); + } + + const begin = useCallback(() => { + setSession( + resolveWorkspaceRenameSurface() === "inline" + ? { kind: "inline", phase: "editing", titleAtStart: currentTitle } + : { kind: "modal" }, + ); + }, [currentTitle]); + + const close = useCallback(() => setSession(CLOSED), []); + + const initialValue = workspaceRenameInitialValue(workspace); + const commit = useCallback( + (text: string) => { + const title = text.trim(); + if (title.length === 0 || title === initialValue) { + setSession(CLOSED); + return; + } + setSession({ kind: "inline", phase: "saving", titleAtStart: currentTitle }); + setWorkspaceTitle(title).then( + () => + setSession((previous) => + previous.kind === "inline" && previous.phase === "saving" + ? { ...previous, phase: "saved" } + : previous, + ), + (error: unknown) => { + setSession(CLOSED); + toast.error(error instanceof Error ? error.message : String(error)); + }, + ); + }, + [currentTitle, initialValue, setWorkspaceTitle, toast], + ); + + const titleEditor = + session.kind === "inline" ? ( + + ) : null; + + const modal = ( + + ); + + return { begin, titleEditor, modal }; +}