mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
perf(sidebar): switch workspaces on mouse press instead of click (#34594)
* perf(sidebar): switch workspaces on mouse press instead of click A plain primary mouse press on a sidebar workspace row now activates it; the click that ends the same press is skipped. The ~80-120 ms a press is held before release hides the switch, so the new workspace is usually painted before the button comes up. Modified presses, touch and pen, nested controls, rows inside a multi-selection, kanban cards and keyboard-forwarded clicks keep click activation. * test(sidebar): drop wall-clock cache stub from press activation test * fix(sidebar): keep press activation off drags that wake, resume or close the board A press-activated switch now keeps focus in the new terminal: the press's native mousedown no longer moves it to the workspace list. Presses fall back to click activation when the target would be woken (sleeping workspace or agent session, slept VM, disconnected SSH), outside the terminal view, on selectable text, and while the workspace board is open, so a row dragged onto the board no longer closes it. The skip-the-next-click flag now ends with its gesture instead of surviving a drag-suppressed click. * fix(sidebar): keep press activation off hosts a focus could boot Switching workspaces focuses the row's host: a stopped WSL distro boots on focus and a recipe VM resumes on activation, both before a press is known to be a drag. A press now activates only on a proven-ready host (local, an SSH target, a WSL host reporting ready, a connected paired runtime), resolved from the row's own host so folder workspaces and host twins match; recipe-VM targets and unknown hosts keep click activation. * fix(sidebar): activate on press only for local workspaces Remote hosts can connect, boot or resume when their workspace gains focus, and the focus subscribers resolve a workspace by bare id, so a twin or an SSH folder row could still reach a remote host from a press that becomes a drag. Press activation now needs the row's own host and the id's owning host to both be local; every SSH, WSL, runtime and VM workspace keeps click activation. * fix(sidebar): keep click activation while a text field has focus or a terminal would respawn * fix(sidebar): keep click activation for workspaces without a running terminal; leave focus to the list when the press moved none * fix(sidebar): keep a multi-selected folder row's press for the group drag * fix(sidebar): drop the pre-press focus reference when the gesture ends * fix(sidebar): keep click activation while a menu or dialog is open --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -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> = {}): 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<HTMLElement> {
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
act(() => {
|
||||
root?.render(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree()}
|
||||
repo={makeRepo()}
|
||||
isActive={false}
|
||||
activateOnPress={props.activateOnPress}
|
||||
/>
|
||||
)
|
||||
})
|
||||
const surface = container?.querySelector<HTMLElement>('[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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Element | null>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
// 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<void> => {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ export type WorktreeCardProps = {
|
||||
onLineageToggle?: (event: React.MouseEvent<HTMLButtonElement>) => 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<HTMLElement>, worktree: Worktree) => boolean
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<HTMLDivElement>) => void
|
||||
onRowPointerDown: (
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
@@ -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}
|
||||
|
||||
@@ -43,6 +43,8 @@ export type WorktreeItemRowContext = {
|
||||
getLineageToggleHandler: (groupKey: string) => LineageToggleHandler
|
||||
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, 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<HTMLElement>,
|
||||
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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> = {}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<AppState, 'tabsByWorktree' | 'ptyIdsByTabId'>,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
const tabs = state.tabsByWorktree[worktreeId] ?? []
|
||||
return tabs.length > 0 && tabs.every((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id))
|
||||
}
|
||||
Reference in New Issue
Block a user