diff --git a/src/renderer/src/components/sidebar/WorktreeCard.activate-on-press.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.activate-on-press.test.tsx new file mode 100644 index 00000000000..e971cde8197 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCard.activate-on-press.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' + +const fetchHostedReviewForBranch = vi.fn() +const fetchIssue = vi.fn() +const fetchLinearIssue = vi.fn() +const openModal = vi.fn() +const updateWorktreeMeta = vi.fn() + +let worktreeCardProperties: WorktreeCardProperty[] = ['status'] +let isSleepingWorktree = false +let root: Root | null = null +let container: HTMLDivElement | null = null + +vi.mock('@/store', () => { + const getState = () => ({ + activeView: 'terminal', + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal, + projectGroups: [], + remoteBranchConflictByWorktreeId: {}, + repos: [{ id: 'repo-1', path: '/repo' }], + settings: { experimentalNewWorktreeCardStyle: true }, + sshConnectionStates: new Map(), + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: { 'repo-1::/repo/worktrees/branch': [{ id: 'tab-1' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + sshTargetLabels: new Map(), + updateWorktreeMeta, + workspacePortScan: null, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} + }) + } +}) + +const activateWorktreeFromSidebar = vi.fn() +vi.mock('@/lib/sidebar-worktree-activation', () => ({ + activateWorktreeFromSidebar: (...args: unknown[]) => activateWorktreeFromSidebar(...args) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownStartedAt: () => null +})) + +vi.mock('./WorktreeCardAgents', () => ({ + default: () => null +})) + +vi.mock('./WorktreeContextMenu', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, + WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu', + WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' +})) + +vi.mock('./use-worktree-activity-status', () => ({ + useWorktreeActivityStatus: () => 'active' +})) + +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false, + selectIsSleepingWorktree: () => isSleepingWorktree +})) + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: '#999999', + addedAt: 1 + } +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo/worktrees/branch', + repoId: 'repo-1', + path: '/repo/worktrees/branch', + displayName: 'feature/branch', + branch: 'feature/branch', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + ...overrides + } +} + +describe('WorktreeCard press activation', () => { + beforeEach(() => { + vi.clearAllMocks() + worktreeCardProperties = ['status'] + isSleepingWorktree = false + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + async function renderCard(props: { activateOnPress?: boolean } = {}): Promise { + const { default: WorktreeCard } = await import('./WorktreeCard') + act(() => { + root?.render( + + ) + }) + const surface = container?.querySelector('[data-worktree-card-surface]') + if (!surface) { + throw new Error('card surface not rendered') + } + return surface + } + + function press(target: HTMLElement, init: PointerEventInit = {}): void { + act(() => { + target.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse', ...init }) + ) + }) + } + + function click(target: HTMLElement, detail: number): void { + act(() => { + target.dispatchEvent(new MouseEvent('click', { bubbles: true, detail })) + }) + } + + it('activates on a plain mouse press and skips the click that ends it', async () => { + const surface = await renderCard({ activateOnPress: true }) + const terminal = document.body.appendChild(document.createElement('textarea')) + terminal.className = 'xterm-helper-textarea' + activateWorktreeFromSidebar.mockImplementationOnce(() => terminal.focus()) + press(surface) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(1) + // The press's native mousedown must not pull focus from the new terminal to the list. + const mousedown = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }) + act(() => { + surface.dispatchEvent(mousedown) + }) + expect(mousedown.defaultPrevented).toBe(true) + click(surface, 1) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(1) + terminal.remove() + }) + + it('keeps click activation for modified, touch, and keyboard-forwarded presses', async () => { + const surface = await renderCard({ activateOnPress: true }) + press(surface, { metaKey: true }) + press(surface, { pointerType: 'touch' }) + expect(activateWorktreeFromSidebar).not.toHaveBeenCalled() + press(surface) + click(surface, 0) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(2) + }) + + it('waits for the click while a text field has focus', async () => { + const surface = await renderCard({ activateOnPress: true }) + const input = document.body.appendChild(document.createElement('input')) + input.focus() + press(surface) + expect(activateWorktreeFromSidebar).not.toHaveBeenCalled() + input.remove() + }) + + it('waits for the click before waking a sleeping workspace', async () => { + isSleepingWorktree = true + const surface = await renderCard({ activateOnPress: true }) + press(surface) + expect(activateWorktreeFromSidebar).not.toHaveBeenCalled() + click(surface, 1) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(1) + }) + + it('does not let a press whose click was swallowed skip a later click', async () => { + const surface = await renderCard({ activateOnPress: true }) + press(surface) + act(() => { + window.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse' })) + }) + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + click(surface, 1) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(2) + }) + + it('leaves cards outside the sidebar lists on click activation', async () => { + const surface = await renderCard() + press(surface) + expect(activateWorktreeFromSidebar).not.toHaveBeenCalled() + click(surface, 1) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index bbcc35e750e..554d6fa2a8a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -18,6 +18,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ revealHighlightTone = 'default', selectedWorktrees, onActivate, + activateOnPress, onWorktreeCardClick, onImmediateActivate, onSelectionGesture, @@ -54,6 +55,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ revealHighlightTone, selectedWorktrees, onActivate, + activateOnPress, onWorktreeCardClick, onImmediateActivate, onSelectionGesture, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts b/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts index 305b96aec3d..33d093f3c1d 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts @@ -3,7 +3,16 @@ import React, { useCallback, useLayoutEffect, useRef } from 'react' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics' import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' -import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events' +import { isEditableTarget } from '@/lib/editable-target' +import { hasVisibleOverlay } from '@/lib/visible-overlay' +import { + afterPointerGestureEnds, + isEventTargetInsideCurrentTarget, + isPlainPrimaryMousePress, + isSelectableTextTarget +} from './worktree-card-dom-events' +import { canActivateWorktreeOnPress } from './worktree-press-activation-eligibility' +import { isSidebarPointerDragBlocked } from './worktree-sidebar-pointer-drag-dom' import type { WorktreeCardProps } from './worktree-card-model' import type { useWorktreeCardFoundation } from './use-worktree-card-foundation' import type { useWorktreeCardLinkedDetails } from './use-worktree-card-linked-details' @@ -19,6 +28,7 @@ export function useWorktreeCardActivationActions({ isActive, activationRowKey, onActivate, + activateOnPress, onWorktreeCardClick, onImmediateActivate, isDeleting, @@ -35,6 +45,7 @@ export function useWorktreeCardActivationActions({ | 'isActive' | 'activationRowKey' | 'onActivate' + | 'activateOnPress' | 'onWorktreeCardClick' | 'onImmediateActivate' > & @@ -47,11 +58,47 @@ export function useWorktreeCardActivationActions({ useLayoutEffect(() => { worktreeRef.current = worktree }, [worktree]) + const pressActivatedRef = useRef(false) + const focusBeforePressRef = useRef(null) + const activate = useCallback(() => { + onWorktreeCardClick?.() + // Use shared activation so sidebar navigation participates in back/forward history. + recordRendererCrashBreadcrumb('sidebar_worktree_activate', { + worktreeId: worktree.id, + repoId: worktree.repoId, + wasActive: isActive, + sshDisconnected: isSshDisconnected + }) + onImmediateActivate?.(worktree.id, activationRowKey) + void activateWorktreeFromSidebar( + worktree.id, + worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined) + ) + onActivate?.() + }, [ + worktree.id, + worktree.repoId, + worktree.hostId, + repo, + isActive, + activationRowKey, + isSshDisconnected, + onActivate, + onWorktreeCardClick, + onImmediateActivate + ]) + const handleClick = useCallback( (event: React.MouseEvent) => { if (!isEventTargetInsideCurrentTarget(event.currentTarget, event.target)) { return } + const pressActivated = pressActivatedRef.current + pressActivatedRef.current = false + // Synthetic option-forwarded clicks report detail 0 and must still activate. + if (pressActivated && event.detail > 0) { + return + } const selection = window.getSelection() // Why: only suppress the click for a selection inside this card; a foreign selection must not block worktree switching. if (selection && selection.toString().length > 0) { @@ -78,38 +125,55 @@ export function useWorktreeCardActivationActions({ event.stopPropagation() return } - onWorktreeCardClick?.() - // Use shared activation so sidebar navigation participates in back/forward history. - recordRendererCrashBreadcrumb('sidebar_worktree_activate', { - worktreeId: worktree.id, - repoId: worktree.repoId, - wasActive: isActive, - sshDisconnected: isSshDisconnected - }) - onImmediateActivate?.(worktree.id, activationRowKey) - void activateWorktreeFromSidebar( - worktree.id, - worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined) - ) - onActivate?.() + activate() }, - [ - affiliateListMode, - worktree.id, - worktree.repoId, - worktree.hostId, - repo, - isActive, - isDeleting, - activationRowKey, - isSshDisconnected, - onActivate, - onWorktreeCardClick, - onImmediateActivate, - onSelectionGesture - ] + [affiliateListMode, isDeleting, onSelectionGesture, activate] ) + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + pressActivatedRef.current = false + if ( + !activateOnPress || + isDeleting || + !isPlainPrimaryMousePress(event) || + !isEventTargetInsideCurrentTarget(event.currentTarget, event.target) || + isSidebarPointerDragBlocked(event.target, event.currentTarget) || + isSelectableTextTarget(event.target) || + // The press prevents mousedown's focus move, so an open edit would keep the keystrokes. + isEditableTarget(document.activeElement) || + // A closing menu or dialog restores focus after the press and would take it from the new workspace. + hasVisibleOverlay() || + !canActivateWorktreeOnPress({ + worktreeId: worktree.id, + rowHostId: worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined) + }) + ) { + return + } + // A plain press replaces the selection exactly as the click would before navigating. + if (onSelectionGesture?.(event, worktreeRef.current)) { + return + } + pressActivatedRef.current = true + focusBeforePressRef.current = document.activeElement + // Drag suppression can swallow this gesture's click; the flag must not outlive it and skip a later click. + afterPointerGestureEnds(() => { + pressActivatedRef.current = false + focusBeforePressRef.current = null + }) + activate() + }, + [activateOnPress, isDeleting, onSelectionGesture, activate, worktree.id, worktree.hostId, repo] + ) + + const handleMouseDown = useCallback((event: React.MouseEvent) => { + // When the press already focused the new workspace, the native default would move focus to the list. + if (pressActivatedRef.current && document.activeElement !== focusBeforePressRef.current) { + event.preventDefault() + } + }, []) + const handleRenameTitle = useCallback( // Store logging/refetch reverts failed optimistic renames; inline rename has no separate error surface. async (displayName: string): Promise => { @@ -162,5 +226,12 @@ export function useWorktreeCardActivationActions({ [worktree.hostId, worktree.id, worktree.isUnread, setWorkspaceUnreadByUser] ) - return { handleClick, handleRenameTitle, handleDoubleClick, handleToggleUnreadQuick } + return { + handleClick, + handlePointerDown, + handleMouseDown, + handleRenameTitle, + handleDoubleClick, + handleToggleUnreadQuick + } } diff --git a/src/renderer/src/components/sidebar/use-worktree-card-controller.ts b/src/renderer/src/components/sidebar/use-worktree-card-controller.ts index c910a2b5c71..09adf9de1c7 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-controller.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-controller.ts @@ -91,6 +91,10 @@ export function useWorktreeCardController(props: ResolvedWorktreeCardProps) { isActive: props.isActive, activationRowKey: props.activationRowKey, onActivate: props.onActivate, + // Why: pressing a row inside a multi-selection starts a group drag; only the click may collapse the selection. + activateOnPress: + props.activateOnPress === true && + !(props.isMultiSelected && (props.selectedWorktrees?.length ?? 0) > 1), onWorktreeCardClick: props.onWorktreeCardClick, onImmediateActivate: props.onImmediateActivate, isDeleting: linked.isDeleting, diff --git a/src/renderer/src/components/sidebar/worktree-card-dom-events.ts b/src/renderer/src/components/sidebar/worktree-card-dom-events.ts index 33ae05c3f0f..7c51608531c 100644 --- a/src/renderer/src/components/sidebar/worktree-card-dom-events.ts +++ b/src/renderer/src/components/sidebar/worktree-card-dom-events.ts @@ -7,3 +7,42 @@ export function isEventTargetInsideCurrentTarget( } return currentTarget.contains(target) } + +// Touch and pen keep click activation so a press can still start a scroll. +export function isPlainPrimaryMousePress(event: { + pointerType: string + button: number + metaKey: boolean + ctrlKey: boolean + shiftKey: boolean + altKey: boolean +}): boolean { + return ( + event.pointerType === 'mouse' && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) +} + +// Selectable text (e.g. port numbers) lets a press start a text selection; it keeps click activation. +export function isSelectableTextTarget(target: EventTarget | null): boolean { + return target instanceof Element && getComputedStyle(target).userSelect === 'text' +} + +// The click that ends a gesture is dispatched right after pointerup, so clear after that task; cancel never clicks. +export function afterPointerGestureEnds(callback: () => void): void { + const finish = (event: Event): void => { + window.removeEventListener('pointerup', finish, true) + window.removeEventListener('pointercancel', finish, true) + if (event.type === 'pointercancel') { + callback() + } else { + setTimeout(callback, 0) + } + } + window.addEventListener('pointerup', finish, true) + window.addEventListener('pointercancel', finish, true) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-model.ts b/src/renderer/src/components/sidebar/worktree-card-model.ts index 43aa0fad75e..638ef68b869 100644 --- a/src/renderer/src/components/sidebar/worktree-card-model.ts +++ b/src/renderer/src/components/sidebar/worktree-card-model.ts @@ -39,6 +39,8 @@ export type WorktreeCardProps = { onLineageToggle?: (event: React.MouseEvent) => void isLineageDropTarget?: boolean onActivate?: () => void + /** Switch on a plain primary mouse press instead of the later click; the hold before release hides the switch cost. */ + activateOnPress?: boolean onWorktreeCardClick?: () => void onImmediateActivate?: (worktreeId: string, rowKey: string | undefined) => void onSelectionGesture?: (event: React.MouseEvent, worktree: Worktree) => boolean diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index e34eb867270..ad6da0b6232 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -33,6 +33,8 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): isQueuedForDeletion, deleteLabel, handleClick, + handlePointerDown, + handleMouseDown, handleDoubleClick, handleDragStart, handleDragEnd, @@ -77,6 +79,8 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): } data-worktree-lineage-drop-target={isLineageDropTarget || undefined} onClick={handleClick} + onPointerDown={handlePointerDown} + onMouseDown={handleMouseDown} onDoubleClick={affiliateListMode ? undefined : handleDoubleClick} draggable={!affiliateListMode && nativeDragEnabled && !isDeleting && !titleRenaming} onDragStart={!affiliateListMode && nativeDragEnabled ? handleDragStart : undefined} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx index 7f11ae21a18..5b95c3c95e2 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx @@ -45,6 +45,8 @@ export type FolderWorkspaceRowContext = { worktree: Worktree ) => readonly Worktree[] onImmediateActivate: (worktreeId: string, rowKey: string | undefined) => void + /** False while the workspace board is open, where a press may start a drag onto the board. */ + activateOnPress: boolean onRowClickCapture: (event: React.MouseEvent) => void onRowPointerDown: ( event: React.PointerEvent, @@ -125,6 +127,15 @@ export function renderFolderWorkspaceVirtualRow(args: { flushSurface nativeDragEnabled={false} onImmediateActivate={activationDisabled ? undefined : ctx.onImmediateActivate} + // A press inside a multi-selection starts a group drag; only the click may collapse it. + activateOnPress={ + ctx.activateOnPress && + !activationDisabled && + !( + ctx.selectedWorktreeIds.has(folderWorktreeIdentity) && + ctx.selectedWorktreeIds.size > 1 + ) + } activationRowKey={folderWorktreeIdentity} onSelectionGesture={(event) => ctx.onSelectionGesture(event, folderWorktree)} onContextMenuSelect={ctx.onContextMenuSelect} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx index 5613d20222f..d3e6425886d 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx @@ -43,6 +43,8 @@ export type WorktreeItemRowContext = { getLineageToggleHandler: (groupKey: string) => LineageToggleHandler onSelectionGesture: (event: React.MouseEvent, worktree: Worktree) => boolean onWorktreeCardClick?: () => void + /** False while the workspace board is open: a press may start a drag onto the board, which activation would close. */ + activateOnPress: boolean onContextMenuSelect: ( event: React.MouseEvent, worktree: Worktree @@ -191,6 +193,7 @@ export function renderWorktreeItemRow( flushSurface activationRowKey={itemRow.rowKey} onImmediateActivate={ctx.onImmediateActivate} + activateOnPress={ctx.activateOnPress} onSelectionGesture={ctx.onSelectionGesture} onWorktreeCardClick={ctx.onWorktreeCardClick} onContextMenuSelect={ctx.onContextMenuSelect} diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts index c607cbd8e1a..addfc2c9457 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts @@ -128,6 +128,7 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual getLineageToggleHandler: args.getLineageToggleHandler, onSelectionGesture: props.onSelectionGesture, onWorktreeCardClick: props.onWorktreeCardClick, + activateOnPress: !props.workspaceBoardOpen, onContextMenuSelect: props.onContextMenuSelect, onImmediateActivate: primaryActive.handleImmediateWorktreeRowActivate, onRowClickCapture: args.onRowClickCapture, @@ -152,6 +153,7 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus, onSelectionGesture: props.onSelectionGesture, onContextMenuSelect: props.onContextMenuSelect, + activateOnPress: !props.workspaceBoardOpen, onImmediateActivate: primaryActive.handleImmediateWorktreeRowActivate, onRowClickCapture: args.onRowClickCapture, onRowPointerDown: args.onRowPointerDown diff --git a/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.test.ts b/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.test.ts new file mode 100644 index 00000000000..17fc76f036d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' +import { canActivateWorktreeOnPress } from './worktree-press-activation-eligibility' + +const WORKTREE_ID = 'repo-1::/repo' +const LOCAL_REPO = { id: 'repo-1', path: '/repo' } +const WSL_TWIN_REPO = { ...LOCAL_REPO, executionHostId: toRuntimeExecutionHostId('env-wsl') } +let state: Record = {} + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => state } +})) + +vi.mock('./use-worktree-sleep-state', () => ({ + selectIsSleepingWorktree: () => false +})) + +describe('press activation hosts', () => { + beforeEach(() => { + state = { + activeView: 'terminal', + sleepingAgentSessionsByPaneKey: {}, + folderWorkspaces: [], + projectGroups: [], + repos: [LOCAL_REPO], + tabsByWorktree: { [WORKTREE_ID]: [{ id: 'tab-1' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } + }) + + it('presses only into local workspaces', () => { + expect(canActivateWorktreeOnPress({ worktreeId: WORKTREE_ID, rowHostId: 'local' })).toBe(true) + for (const rowHostId of [toSshExecutionHostId('devbox'), toRuntimeExecutionHostId('env-wsl')]) { + expect(canActivateWorktreeOnPress({ worktreeId: WORKTREE_ID, rowHostId })).toBe(false) + } + }) + + it('keeps click activation for a local row whose id also names a remote twin', () => { + state = { ...state, repos: [WSL_TWIN_REPO, LOCAL_REPO] } + expect(canActivateWorktreeOnPress({ worktreeId: WORKTREE_ID, rowHostId: 'local' })).toBe(false) + }) + + it('keeps click activation when a terminal would spawn', () => { + for (const terminals of [{ ptyIdsByTabId: { 'tab-1': [] } }, { tabsByWorktree: {} }]) { + state = { ...state, ...terminals } + expect(canActivateWorktreeOnPress({ worktreeId: WORKTREE_ID, rowHostId: 'local' })).toBe( + false + ) + } + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.ts b/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.ts new file mode 100644 index 00000000000..86a8639415e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.ts @@ -0,0 +1,39 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { workspaceHasSleepingAgentSessions } from '@/lib/worktree-agent-activation-gate' +import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { workspaceOwnerHostId } from '@/lib/workspace-row-owner-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import { tabHasLivePty } from '../../../../shared/tab-has-live-pty' +import { selectIsSleepingWorktree } from './use-worktree-sleep-state' + +/** + * A press may only switch to an awake local workspace from the terminal view whose terminals are all + * running. Anything that would connect, boot, spawn or resume waits for the click, since a press can + * still become a drag. + */ +export function canActivateWorktreeOnPress(args: { + worktreeId: string + rowHostId: ExecutionHostId | undefined +}): boolean { + const state = useAppStore.getState() + return ( + state.activeView === 'terminal' && + args.rowHostId === LOCAL_EXECUTION_HOST_ID && + // Host-focus subscribers resolve the workspace by bare id; a remote twin must not be the one they focus. + workspaceOwnerHostId(state, args.worktreeId) === LOCAL_EXECUTION_HOST_ID && + !hasWorktreeSleepIntent(args.worktreeId) && + !workspaceHasSleepingAgentSessions(state, args.worktreeId) && + !selectIsSleepingWorktree(state, args.worktreeId) && + hasOnlyLiveTerminals(state, args.worktreeId) + ) +} + +// Activation spawns a terminal for a dead or missing tab, which can boot a WSL distro on a Windows local project. +function hasOnlyLiveTerminals( + state: Pick, + worktreeId: string +): boolean { + const tabs = state.tabsByWorktree[worktreeId] ?? [] + return tabs.length > 0 && tabs.every((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) +}