mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 16:02:37 +00:00
Resolve sidebar SSH reconnect prompts before commit
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ReactNode } from 'react'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
|
||||
import type WorktreeCardComponent from './WorktreeCard'
|
||||
|
||||
const fetchHostedReviewForBranch = vi.fn()
|
||||
const fetchIssue = vi.fn()
|
||||
const fetchLinearIssue = vi.fn()
|
||||
const openModal = vi.fn()
|
||||
const updateWorktreeMeta = vi.fn()
|
||||
|
||||
let WorktreeCard: typeof WorktreeCardComponent
|
||||
let sshConnectionStates = new Map<string, { status: string }>()
|
||||
let sshTargetLabels = new Map<string, string>()
|
||||
let worktreeCardProperties: WorktreeCardProperty[] = ['status']
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
deleteStateByWorktreeId: {},
|
||||
fetchHostedReviewForBranch,
|
||||
fetchIssue,
|
||||
fetchLinearIssue,
|
||||
gitConflictOperationByWorktree: {},
|
||||
hostedReviewCache: {},
|
||||
issueCache: {},
|
||||
linearIssueCache: {},
|
||||
openModal,
|
||||
remoteBranchConflictByWorktreeId: {},
|
||||
settings: null,
|
||||
sshConnectionStates,
|
||||
sshTargetLabels,
|
||||
updateWorktreeMeta,
|
||||
worktreeCardProperties
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
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('./use-worktree-activity-status', () => ({
|
||||
useWorktreeActivityStatus: () => 'idle'
|
||||
}))
|
||||
|
||||
vi.mock('./SshDisconnectedDialog', () => ({
|
||||
SshDisconnectedDialog: ({
|
||||
open,
|
||||
status,
|
||||
targetLabel
|
||||
}: {
|
||||
open: boolean
|
||||
status: string
|
||||
targetLabel: string
|
||||
}) => (
|
||||
<div
|
||||
data-ssh-disconnected-dialog={open ? 'open' : 'closed'}
|
||||
data-ssh-status={status}
|
||||
data-ssh-target-label={targetLabel}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeContextMenu', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
|
||||
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope',
|
||||
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu'
|
||||
}))
|
||||
|
||||
function makeRepo(): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Remote repo',
|
||||
badgeColor: '#999999',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-target-1'
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(): Worktree {
|
||||
return {
|
||||
id: 'worktree-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/worktrees/one',
|
||||
displayName: 'Remote workspace',
|
||||
branch: 'remote-workspace',
|
||||
head: 'abc123',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('WorktreeCard SSH reconnect prompt', () => {
|
||||
beforeAll(async () => {
|
||||
WorktreeCard = (await import('./WorktreeCard')).default
|
||||
}, 20_000)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
sshConnectionStates = new Map()
|
||||
sshTargetLabels = new Map()
|
||||
worktreeCardProperties = ['status']
|
||||
})
|
||||
|
||||
it('opens the reconnect dialog for an active disconnected SSH worktree during render', () => {
|
||||
sshConnectionStates.set('ssh-target-1', { status: 'disconnected' })
|
||||
sshTargetLabels.set('ssh-target-1', 'Remote target')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={true} />
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-ssh-disconnected-dialog="open"')
|
||||
expect(markup).toContain('data-ssh-status="disconnected"')
|
||||
expect(markup).toContain('data-ssh-target-label="Remote target"')
|
||||
})
|
||||
})
|
||||
@@ -174,16 +174,21 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
})
|
||||
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
|
||||
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
|
||||
const sshDisconnectedPromptKey = isActive && isSshDisconnected ? worktree.id : null
|
||||
const [lastSshDisconnectedPromptKey, setLastSshDisconnectedPromptKey] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [titleRenaming, setTitleRenaming] = useState(false)
|
||||
|
||||
// Why: on restart the previously-active worktree is auto-restored without a
|
||||
// click, so the dialog never opens. Auto-show it for the active card when SSH
|
||||
// is disconnected so the user sees the reconnect prompt immediately.
|
||||
useEffect(() => {
|
||||
if (isActive && isSshDisconnected) {
|
||||
// is disconnected, but keep dismissals sticky until that prompt key changes.
|
||||
if (sshDisconnectedPromptKey !== lastSshDisconnectedPromptKey) {
|
||||
setLastSshDisconnectedPromptKey(sshDisconnectedPromptKey)
|
||||
if (sshDisconnectedPromptKey) {
|
||||
setShowDisconnectedDialog(true)
|
||||
}
|
||||
}, [isActive, isSshDisconnected])
|
||||
}
|
||||
// Why: read the target label from the store (populated during hydration in
|
||||
// useIpcEvents.ts) instead of calling listTargets IPC per card instance.
|
||||
const sshTargetLabel = useAppStore((s) =>
|
||||
|
||||
@@ -93,6 +93,26 @@ vi.mock('./WorktreeContextMenu', () => ({
|
||||
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope'
|
||||
}))
|
||||
|
||||
vi.mock('./SshDisconnectedDialog', () => ({
|
||||
SshDisconnectedDialog: ({
|
||||
open,
|
||||
status,
|
||||
targetId,
|
||||
targetLabel
|
||||
}: {
|
||||
open: boolean
|
||||
status: string
|
||||
targetId: string
|
||||
targetLabel: string
|
||||
}) =>
|
||||
React.createElement('aside', {
|
||||
'data-lineage-ssh-dialog': open ? 'open' : 'closed',
|
||||
'data-ssh-status': status,
|
||||
'data-ssh-target-id': targetId,
|
||||
'data-ssh-target-label': targetLabel
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
@@ -358,6 +378,22 @@ describe('WorktreeList lineage child card renderer', () => {
|
||||
expect(agentRowIndex).toBeLessThan(childToggleIndex)
|
||||
})
|
||||
|
||||
it('opens the reconnect dialog for an active disconnected lineage child during render', async () => {
|
||||
setLineageFixtureState()
|
||||
const repo = (mockStore.state.repos as Repo[])[0]!
|
||||
repo.connectionId = 'ssh-target-1'
|
||||
mockStore.state.activeWorktreeId = 'child'
|
||||
mockStore.state.sshConnectionStates = new Map([['ssh-target-1', { status: 'disconnected' }]])
|
||||
mockStore.state.sshTargetLabels = new Map([['ssh-target-1', 'Remote target']])
|
||||
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('data-lineage-ssh-dialog="open"')
|
||||
expect(markup).toContain('data-ssh-status="disconnected"')
|
||||
expect(markup).toContain('data-ssh-target-id="ssh-target-1"')
|
||||
expect(markup).toContain('data-ssh-target-label="Remote target"')
|
||||
})
|
||||
|
||||
it('does not add group indentation when grouping is disabled', async () => {
|
||||
setLineageFixtureState('none')
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
@@ -919,6 +919,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const activeLineageChildSshDisconnected =
|
||||
activeLineageChildSshStatus !== null && activeLineageChildSshStatus !== 'connected'
|
||||
const lineageReconnectPromptKey =
|
||||
activeLineageChildWorktreeId && activeLineageChildSshDisconnected
|
||||
? activeLineageChildWorktreeId
|
||||
: null
|
||||
const [lastLineageReconnectPromptKey, setLastLineageReconnectPromptKey] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const renderRowsRef = useRef(renderRows)
|
||||
renderRowsRef.current = renderRows
|
||||
const getVirtualItemKey = useCallback(
|
||||
@@ -1388,12 +1395,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}, [activeModal, keybindings, markDirectScrollInput, navigateWorktree])
|
||||
|
||||
// Why: lightweight nested cards do not mount WorktreeCard, so the viewport
|
||||
// owns the SSH reconnect prompt for an active lineage child.
|
||||
useEffect(() => {
|
||||
if (activeLineageChildWorktreeId && activeLineageChildSshDisconnected) {
|
||||
setLineageReconnectWorktreeId(activeLineageChildWorktreeId)
|
||||
// owns the SSH reconnect prompt for an active lineage child. The prompt key
|
||||
// keeps dismissals sticky until the active/disconnected child changes.
|
||||
if (lineageReconnectPromptKey !== lastLineageReconnectPromptKey) {
|
||||
setLastLineageReconnectPromptKey(lineageReconnectPromptKey)
|
||||
if (lineageReconnectPromptKey) {
|
||||
setLineageReconnectWorktreeId(lineageReconnectPromptKey)
|
||||
}
|
||||
}, [activeLineageChildWorktreeId, activeLineageChildSshDisconnected])
|
||||
}
|
||||
|
||||
const handleContainerKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
|
||||
Reference in New Issue
Block a user