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 }; +}