mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Restore remote explorer updates after server recovery (#26612)
* Restore remote explorer updates through the recovered app watcher * Keep the remote Explorer watched when it is shown as a fallback tab --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -359,7 +359,8 @@ jobs:
|
||||
. != "tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts" and
|
||||
. != "tests/e2e/ssh-orcad-markdown-live-documents.spec.ts" and
|
||||
. != "tests/e2e/ssh-orcad-open-in-owner.spec.ts" and
|
||||
. != "tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts"
|
||||
. != "tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts" and
|
||||
. != "tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts"
|
||||
)' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs"
|
||||
fi
|
||||
mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs"
|
||||
@@ -593,7 +594,7 @@ jobs:
|
||||
orcad-auto-convert-docker:
|
||||
name: ssh host auto-converts to managed orcad (Docker)
|
||||
needs: [build, prepare-native-cache]
|
||||
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts')
|
||||
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -629,7 +630,7 @@ jobs:
|
||||
ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay
|
||||
run: |
|
||||
export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template"
|
||||
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts --project=electron-headless --workers=1
|
||||
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts --project=electron-headless --workers=1
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
|
||||
@@ -65,6 +65,8 @@ export const ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC =
|
||||
export const ORCAD_OPEN_IN_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-open-in-owner.spec.ts'
|
||||
export const ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC =
|
||||
'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts'
|
||||
export const ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC =
|
||||
'tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts'
|
||||
export const DEDICATED_E2E_SPECS = [
|
||||
...DOCKER_SSH_E2E_SPECS,
|
||||
NODE_NETWORK_E2E_SPEC,
|
||||
@@ -83,7 +85,8 @@ export const DEDICATED_E2E_SPECS = [
|
||||
ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC,
|
||||
ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC,
|
||||
ORCAD_OPEN_IN_OWNER_E2E_SPEC,
|
||||
ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC
|
||||
ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC,
|
||||
ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC
|
||||
]
|
||||
const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS)
|
||||
const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS)
|
||||
@@ -134,7 +137,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') {
|
||||
spec === ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC ||
|
||||
spec === ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC ||
|
||||
spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC ||
|
||||
spec === ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC
|
||||
spec === ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC ||
|
||||
spec === ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,3 +252,17 @@ it.each([
|
||||
spec
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts',
|
||||
'src/renderer/src/hooks/worktree-file-change-event.ts'
|
||||
])('routes %s to the template-building explorer watch recovery lane', (file) => {
|
||||
const spec = 'tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts'
|
||||
expect(selectPrE2eSpecs([file])).toContain(spec)
|
||||
expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true })
|
||||
const job = workflow.jobs['orcad-auto-convert-docker']
|
||||
expect(job.if).toContain(spec)
|
||||
expect(job.steps.find((step) => step.name === 'Convert a relay-era Docker host').run).toContain(
|
||||
spec
|
||||
)
|
||||
})
|
||||
|
||||
@@ -50,6 +50,14 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'ssh.orcad-explorer-watch-recovery',
|
||||
specs: ['tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
(file === 'src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts' ||
|
||||
file === 'src/renderer/src/hooks/worktree-file-change-event.ts')
|
||||
},
|
||||
{
|
||||
id: 'ssh.orcad-editor-watch-recovery',
|
||||
specs: ['tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts'],
|
||||
|
||||
+1
@@ -75,6 +75,7 @@ vi.mock('@/store', async () => {
|
||||
rightSidebarRouteRequestId: mockAppState.rightSidebarRouteRequestId,
|
||||
setRightSidebarTab: mockAppState.setRightSidebarTab,
|
||||
showRightSidebarFiles: mockAppState.showRightSidebarFiles,
|
||||
setRightSidebarEffectiveTab: vi.fn(),
|
||||
toggleRightSidebar: vi.fn(),
|
||||
activeWorktreeId: mockAppState.activeWorktreeId,
|
||||
getKnownWorktreeById: getMockKnownWorktree,
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { ActivityBarItem } from './activity-bar-buttons'
|
||||
import { useRightSidebarTabRouting } from './use-right-sidebar-tab-routing'
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
state: {
|
||||
rightSidebarTab: 'source-control',
|
||||
rightSidebarRouteRequestId: 0,
|
||||
setRightSidebarTab: () => {},
|
||||
showRightSidebarFiles: () => {},
|
||||
setRightSidebarEffectiveTab: (_tab: string | null) => {}
|
||||
}
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) => selector(store.state)
|
||||
}))
|
||||
vi.mock('./use-installed-plugin-route-reconciliation', () => ({
|
||||
useInstalledPluginRouteReconciliation: () => {}
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('publishes Explorer when it renders as the fallback for a hidden stored tab', () => {
|
||||
const published: (string | null)[] = []
|
||||
store.state.setRightSidebarEffectiveTab = (tab) => published.push(tab)
|
||||
// A folder repo hides the git-only Source Control tab.
|
||||
const visibleItems: ActivityBarItem[] = (['explorer', 'vault'] as const).map((id) => ({
|
||||
id,
|
||||
icon: () => null,
|
||||
title: id,
|
||||
shortcut: ''
|
||||
}))
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useRightSidebarTabRouting({
|
||||
visibleItems,
|
||||
activeFolderWorkspaceKey: null,
|
||||
pluginSystemEnabled: false,
|
||||
pluginFetchStatus: 'idle',
|
||||
installedPluginTabKeys: new Set()
|
||||
})
|
||||
)
|
||||
expect(result.current.effectiveTab).toBe('explorer')
|
||||
expect(published).toEqual(['explorer'])
|
||||
unmount()
|
||||
expect(published).toEqual(['explorer', null])
|
||||
})
|
||||
@@ -29,6 +29,7 @@ export function useRightSidebarTabRouting({
|
||||
const rightSidebarRouteRequestId = useAppStore((s) => s.rightSidebarRouteRequestId)
|
||||
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
|
||||
const showRightSidebarFiles = useAppStore((s) => s.showRightSidebarFiles)
|
||||
const setRightSidebarEffectiveTab = useAppStore((s) => s.setRightSidebarEffectiveTab)
|
||||
|
||||
const rememberedFolderTabByWorkspaceKeyRef = useRef<Record<string, ActiveRightSidebarTab>>({})
|
||||
const lastRightSidebarRouteRequestIdRef = useRef(rightSidebarRouteRequestId)
|
||||
@@ -66,6 +67,12 @@ export function useRightSidebarTabRouting({
|
||||
setStoredTab: setRightSidebarTab
|
||||
})
|
||||
|
||||
// Why: the app file watcher must follow the panel actually shown (e.g. Explorer as a fallback for a hidden stored tab).
|
||||
useEffect(() => {
|
||||
setRightSidebarEffectiveTab(effectiveTab)
|
||||
}, [effectiveTab, setRightSidebarEffectiveTab])
|
||||
useEffect(() => () => setRightSidebarEffectiveTab(null), [setRightSidebarEffectiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
lastRightSidebarRouteRequestIdRef.current = rightSidebarRouteRequestId
|
||||
}, [rightSidebarRouteRequestId])
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types'
|
||||
import type { FileExplorerOperationOwner } from './file-explorer-types'
|
||||
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event'
|
||||
import { useFileExplorerWatch } from './useFileExplorerWatch'
|
||||
|
||||
const owner = vi.hoisted((): { current: FileExplorerOperationOwner } => ({
|
||||
current: { kind: 'runtime', environmentId: 'host-a', executionHostId: 'runtime:host-a' }
|
||||
}))
|
||||
const runtime = vi.hoisted(() => ({ subscribe: vi.fn() }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), {
|
||||
getState: () => ({})
|
||||
})
|
||||
}))
|
||||
vi.mock('./file-explorer-operation-owner', () => ({
|
||||
getFileExplorerOperationOwner: () => owner.current,
|
||||
getFileExplorerOperationOwnerFromState: () => owner.current
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-file-client', () => ({ subscribeRuntimeFileChanges: runtime.subscribe }))
|
||||
|
||||
const refreshDir = vi.fn(async () => {})
|
||||
const refreshTree = vi.fn(async () => 'refreshed' as const)
|
||||
function renderWatch() {
|
||||
return renderHook(() =>
|
||||
useFileExplorerWatch({
|
||||
worktreePath: '/repo',
|
||||
activeWorktreeId: 'wt',
|
||||
dirCache: { '/repo': { children: [] } },
|
||||
setDirCache: vi.fn(),
|
||||
expanded: new Set(),
|
||||
setSelectedPath: vi.fn(),
|
||||
refreshDir,
|
||||
refreshTree,
|
||||
inlineInput: null,
|
||||
dragSourcePath: null,
|
||||
isNativeDragOver: false
|
||||
})
|
||||
)
|
||||
}
|
||||
function emit(
|
||||
runtimeEnvironmentId: string | null = 'host-a',
|
||||
worktreePath = '/repo',
|
||||
overflow = false
|
||||
): void {
|
||||
const payload: FsChangedPayload = {
|
||||
worktreePath,
|
||||
events: [
|
||||
{
|
||||
kind: overflow ? 'overflow' : 'create',
|
||||
absolutePath: overflow ? worktreePath : `${worktreePath}/new.ts`,
|
||||
isDirectory: false
|
||||
}
|
||||
]
|
||||
}
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
|
||||
detail: { payload, runtimeEnvironmentId }
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
async function settle(): Promise<void> {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
runtime.subscribe.mockResolvedValue(vi.fn())
|
||||
owner.current = { kind: 'runtime', environmentId: 'host-a', executionHostId: 'runtime:host-a' }
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
fs: { onFsChanged: vi.fn(() => vi.fn()) }
|
||||
}
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('remote file explorer using the recovered app watcher', () => {
|
||||
it('refreshes from the app broker without opening an independent remote subscription', async () => {
|
||||
renderWatch()
|
||||
emit()
|
||||
await settle()
|
||||
expect(refreshDir).toHaveBeenCalledExactlyOnceWith('/repo')
|
||||
expect(runtime.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores other hosts and roots, including an identical local path', async () => {
|
||||
renderWatch()
|
||||
emit('host-b')
|
||||
emit(null)
|
||||
emit('host-a', '/other', true)
|
||||
emit('host-a', '/repo-sibling', true)
|
||||
await settle()
|
||||
expect(refreshDir).not.toHaveBeenCalled()
|
||||
expect(refreshTree).not.toHaveBeenCalled()
|
||||
emit()
|
||||
await settle()
|
||||
expect(refreshDir).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rereads the whole tree after the watcher recovers with an overflow', async () => {
|
||||
renderWatch()
|
||||
emit('host-a', '/repo', true)
|
||||
await settle()
|
||||
expect(refreshTree).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('changes owners without retaining the old listener and removes the listener on unmount', async () => {
|
||||
const hook = renderWatch()
|
||||
owner.current = { kind: 'runtime', environmentId: 'host-b', executionHostId: 'runtime:host-b' }
|
||||
hook.rerender()
|
||||
emit('host-a')
|
||||
await settle()
|
||||
expect(refreshDir).not.toHaveBeenCalled()
|
||||
emit('host-b')
|
||||
await settle()
|
||||
expect(refreshDir).toHaveBeenCalledOnce()
|
||||
hook.unmount()
|
||||
emit('host-b')
|
||||
await settle()
|
||||
expect(refreshDir).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
+16
-32
@@ -2,6 +2,7 @@
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event'
|
||||
import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types'
|
||||
import type {
|
||||
FileExplorerOperationOwner,
|
||||
@@ -10,7 +11,6 @@ import type {
|
||||
|
||||
const ownerRef = vi.hoisted(() => ({ current: { kind: 'local' } as FileExplorerOperationOwner }))
|
||||
const runtimeWatch = vi.hoisted(() => ({
|
||||
handler: null as ((payload: FsChangedPayload) => void) | null,
|
||||
subscribe: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -40,14 +40,7 @@ describe('useFileExplorerWatch pending refreshes', () => {
|
||||
vi.useFakeTimers()
|
||||
ownerRef.current = { kind: 'local' }
|
||||
mainWatchHandler = null
|
||||
runtimeWatch.handler = null
|
||||
runtimeWatch.subscribe.mockReset()
|
||||
runtimeWatch.subscribe.mockImplementation(
|
||||
async (_context: unknown, handler: WatchHandler): Promise<() => void> => {
|
||||
runtimeWatch.handler = handler
|
||||
return () => undefined
|
||||
}
|
||||
)
|
||||
refreshDir = vi.fn(async () => {})
|
||||
refreshTree = vi.fn(async () => 'refreshed' as const)
|
||||
Object.defineProperty(window, 'api', {
|
||||
@@ -110,6 +103,16 @@ describe('useFileExplorerWatch pending refreshes', () => {
|
||||
})
|
||||
}
|
||||
|
||||
function emitRuntimeBatch(): void {
|
||||
emit((payload) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
|
||||
detail: { payload, runtimeEnvironmentId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
it('resyncs on reopen when hiding Files cancelled a received remote event', async () => {
|
||||
ownerRef.current = { kind: 'ssh', connectionId: 'ssh-1' }
|
||||
const hook = renderWatch()
|
||||
@@ -135,54 +138,35 @@ describe('useFileExplorerWatch pending refreshes', () => {
|
||||
expect(refreshDir).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('flushes runtime RPC batches on the next turn without another debounce window', async () => {
|
||||
it('flushes remote broker batches on the next turn without another debounce window', async () => {
|
||||
ownerRef.current = {
|
||||
kind: 'runtime',
|
||||
environmentId: 'runtime-1',
|
||||
executionHostId: 'runtime:runtime-1'
|
||||
}
|
||||
renderWatch()
|
||||
expect(runtimeWatch.handler).not.toBeNull()
|
||||
expect(runtimeWatch.subscribe).not.toHaveBeenCalled()
|
||||
|
||||
act(() => emit(runtimeWatch.handler!))
|
||||
act(emitRuntimeBatch)
|
||||
expect(refreshDir).not.toHaveBeenCalled()
|
||||
await act(async () => vi.advanceTimersByTimeAsync(0))
|
||||
|
||||
expect(refreshDir).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resyncs after an event races cleanup of a pending runtime subscription', async () => {
|
||||
it('resyncs on reopen after hiding Files canceled a received broker event', async () => {
|
||||
ownerRef.current = {
|
||||
kind: 'runtime',
|
||||
environmentId: 'runtime-1',
|
||||
executionHostId: 'runtime:runtime-1'
|
||||
}
|
||||
let releaseSubscription!: () => void
|
||||
const subscriptionReady = new Promise<void>((resolve) => {
|
||||
releaseSubscription = resolve
|
||||
})
|
||||
runtimeWatch.subscribe.mockImplementation(
|
||||
async (_context: unknown, handler: WatchHandler): Promise<() => void> => {
|
||||
runtimeWatch.handler = handler
|
||||
await subscriptionReady
|
||||
return () => undefined
|
||||
}
|
||||
)
|
||||
const hook = renderWatch()
|
||||
const disposedHandler = runtimeWatch.handler!
|
||||
|
||||
act(emitRuntimeBatch)
|
||||
hook.rerender({ visiblePath: null, activeInteraction: null })
|
||||
hook.rerender({ visiblePath: '/repo', activeInteraction: null })
|
||||
act(() => emit(disposedHandler))
|
||||
await act(async () => vi.advanceTimersByTimeAsync(0))
|
||||
|
||||
expect(refreshTree).toHaveBeenCalledOnce()
|
||||
expect(refreshDir).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
releaseSubscription()
|
||||
await subscriptionReady
|
||||
})
|
||||
})
|
||||
it.each(['rename', 'drag', 'native'] as const)(
|
||||
'does not refresh the current root on reopen after foreign events during %s',
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
} from './file-explorer-types'
|
||||
import type { InlineInput } from './file-explorer-inline-input-row'
|
||||
import { useAppStore } from '@/store'
|
||||
import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
|
||||
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event'
|
||||
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
|
||||
import {
|
||||
getFileExplorerOperationOwner,
|
||||
@@ -228,45 +228,24 @@ export function useFileExplorerWatch({
|
||||
processPayload(payload)
|
||||
}
|
||||
|
||||
const handleRuntimeChange = (
|
||||
event: WindowEventMap[typeof ORCA_WORKTREE_FILE_CHANGE_EVENT]
|
||||
): void => {
|
||||
if (event.detail.runtimeEnvironmentId === activeRuntimeEnvironmentId) {
|
||||
handleFsChanged(event.detail.payload)
|
||||
}
|
||||
}
|
||||
let unsubscribeListener: (() => void) | null = null
|
||||
if (activeRuntimeEnvironmentId?.trim() && activeWorktreeId) {
|
||||
// Why: remote runtime watch events don't enter the local Electron fs:changed bus, so subscribe directly.
|
||||
void subscribeRuntimeFileChanges(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId },
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId: undefined
|
||||
},
|
||||
handleFsChanged,
|
||||
(err) => {
|
||||
console.warn('[filesystem-watch] failed to subscribe to runtime file changes', {
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
error: err.message
|
||||
})
|
||||
}
|
||||
)
|
||||
.then((unsubscribe) => {
|
||||
if (disposed) {
|
||||
unsubscribe()
|
||||
return
|
||||
}
|
||||
unsubscribeListener = unsubscribe
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[filesystem-watch] failed to subscribe to runtime file changes', {
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
})
|
||||
// The app watcher owns recovery; a separate stream would strand this consumer after reconnect.
|
||||
window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleRuntimeChange)
|
||||
} else {
|
||||
unsubscribeListener = window.api.fs.onFsChanged(handleFsChanged)
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleRuntimeChange)
|
||||
unsubscribeListener?.()
|
||||
if (activeResyncByWatchKey.get(currentWatchKey) === scheduler.requestFullRefresh) {
|
||||
activeResyncByWatchKey.delete(currentWatchKey)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import {
|
||||
selectEditorExternalWatchTargets,
|
||||
type EditorExternalWatchTargetState
|
||||
} from './editor-external-watch-targets'
|
||||
|
||||
function makeState(
|
||||
overrides: Partial<EditorExternalWatchTargetState> = {}
|
||||
): EditorExternalWatchTargetState {
|
||||
return {
|
||||
settings: getDefaultSettings('/home/me'),
|
||||
openFiles: [],
|
||||
activeWorktreeId: 'folder:remote-folder',
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
folderWorkspaces: [
|
||||
makeFolderWorkspace({
|
||||
id: 'remote-folder',
|
||||
folderPath: '/srv/folder',
|
||||
executionHostId: 'runtime:env-a'
|
||||
})
|
||||
],
|
||||
projectGroups: [],
|
||||
rightSidebarOpen: true,
|
||||
// Source Control is git-only, so a folder workspace renders Explorer as the fallback.
|
||||
rightSidebarTab: 'source-control',
|
||||
rightSidebarExplorerView: 'files',
|
||||
gitStatusHugeByWorktree: {},
|
||||
sshConnectionStates: new Map(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
it('watches a remote folder whose Explorer is shown as the fallback for a hidden stored tab', () => {
|
||||
const { targets } = selectEditorExternalWatchTargets(
|
||||
makeState({ rightSidebarEffectiveTab: 'explorer' })
|
||||
)
|
||||
expect(targets).toEqual([
|
||||
expect.objectContaining({
|
||||
worktreeId: 'folder:remote-folder',
|
||||
worktreePath: '/srv/folder',
|
||||
runtimeEnvironmentId: 'env-a'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('drops the sidebar watch when the shown tab is not Explorer even if the stored tab is', () => {
|
||||
const { targets } = selectEditorExternalWatchTargets(
|
||||
makeState({ rightSidebarTab: 'explorer', rightSidebarEffectiveTab: 'workspaces' })
|
||||
)
|
||||
expect(targets).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to the stored tab before the sidebar publishes what it shows', () => {
|
||||
const { targets } = selectEditorExternalWatchTargets(
|
||||
makeState({ rightSidebarTab: 'explorer', rightSidebarEffectiveTab: null })
|
||||
)
|
||||
expect(targets.map((target) => target.runtimeEnvironmentId)).toEqual(['env-a'])
|
||||
})
|
||||
@@ -32,7 +32,8 @@ export type EditorExternalWatchTargetState = Pick<
|
||||
| 'sshConnectionStates'
|
||||
| 'folderWorkspaces'
|
||||
| 'projectGroups'
|
||||
>
|
||||
> &
|
||||
Partial<Pick<AppState, 'rightSidebarEffectiveTab'>>
|
||||
|
||||
type WatchedTargetsSnapshot = {
|
||||
targets: EditorExternalWatchTarget[]
|
||||
@@ -46,6 +47,7 @@ let cachedActiveWorktreeId: string | null = null
|
||||
let cachedRuntimeEnvironmentId: string | undefined
|
||||
let cachedRightSidebarOpen: boolean | null = null
|
||||
let cachedRightSidebarTab: AppState['rightSidebarTab'] | null = null
|
||||
let cachedRightSidebarEffectiveTab: AppState['rightSidebarEffectiveTab'] | undefined
|
||||
let cachedRightSidebarExplorerView: AppState['rightSidebarExplorerView'] | null = null
|
||||
let cachedGitStatusHugeByWorktree: AppState['gitStatusHugeByWorktree'] | null = null
|
||||
let cachedSshConnectionStates: AppState['sshConnectionStates'] | null = null
|
||||
@@ -119,6 +121,7 @@ export function selectEditorExternalWatchTargets(
|
||||
cachedRuntimeEnvironmentId === runtimeEnvironmentId &&
|
||||
cachedRightSidebarOpen === state.rightSidebarOpen &&
|
||||
cachedRightSidebarTab === state.rightSidebarTab &&
|
||||
cachedRightSidebarEffectiveTab === state.rightSidebarEffectiveTab &&
|
||||
cachedRightSidebarExplorerView === state.rightSidebarExplorerView &&
|
||||
cachedGitStatusHugeByWorktree === state.gitStatusHugeByWorktree &&
|
||||
cachedSshConnectionStates === state.sshConnectionStates &&
|
||||
@@ -158,11 +161,13 @@ export function selectEditorExternalWatchTargets(
|
||||
!state.gitStatusHugeByWorktree[activeWorktreeId] &&
|
||||
(!activeRepo.connectionId ||
|
||||
state.sshConnectionStates.get(activeRepo.connectionId)?.status === 'connected')
|
||||
// Why: the stored tab can be hidden for this workspace (Source Control on a folder) while Explorer renders as the fallback.
|
||||
const shownRightSidebarTab = state.rightSidebarEffectiveTab ?? state.rightSidebarTab
|
||||
const activeWorktreeNeedsSidebarWatch =
|
||||
activeWorktreeId !== null &&
|
||||
state.rightSidebarOpen &&
|
||||
((state.rightSidebarTab === 'explorer' && state.rightSidebarExplorerView === 'files') ||
|
||||
(state.rightSidebarTab === 'source-control' && sourceControlCanConsumeWatch))
|
||||
((shownRightSidebarTab === 'explorer' && state.rightSidebarExplorerView === 'files') ||
|
||||
(shownRightSidebarTab === 'source-control' && sourceControlCanConsumeWatch))
|
||||
if (activeWorktreeNeedsSidebarWatch) {
|
||||
// Why: this app-level watcher owns Explorer/Source-Control subscriptions so downstream consumers don't fight over watch/unwatch IPC.
|
||||
let owners = targetOwnersByWorktreeId.get(activeWorktreeId)
|
||||
@@ -245,6 +250,7 @@ export function selectEditorExternalWatchTargets(
|
||||
cachedRuntimeEnvironmentId = runtimeEnvironmentId
|
||||
cachedRightSidebarOpen = state.rightSidebarOpen
|
||||
cachedRightSidebarTab = state.rightSidebarTab
|
||||
cachedRightSidebarEffectiveTab = state.rightSidebarEffectiveTab
|
||||
cachedRightSidebarExplorerView = state.rightSidebarExplorerView
|
||||
cachedGitStatusHugeByWorktree = state.gitStatusHugeByWorktree
|
||||
cachedSshConnectionStates = state.sshConnectionStates
|
||||
|
||||
@@ -42,6 +42,8 @@ export type RightSidebarState = {
|
||||
rightSidebarTabByWorktree: Record<string, ActiveRightSidebarTab>
|
||||
rightSidebarExplorerViewByWorktree: Record<string, RightSidebarExplorerView>
|
||||
rightSidebarVisual: RightSidebarVisualState | null
|
||||
/** In memory: the tab the mounted sidebar renders, which differs from rightSidebarTab when that tab is hidden here. */
|
||||
rightSidebarEffectiveTab: ActiveRightSidebarTab | null
|
||||
activityBarPosition: ActivityBarPosition
|
||||
toggleRightSidebar: () => void
|
||||
setRightSidebarOpen: (open: boolean) => void
|
||||
@@ -58,6 +60,7 @@ export type RightSidebarState = {
|
||||
openRightSidebarVisual: (route: RightSidebarVisualRoute) => void
|
||||
closeRightSidebarVisual: () => void
|
||||
setRightSidebarVisualWidth: (width: number) => void
|
||||
setRightSidebarEffectiveTab: (tab: ActiveRightSidebarTab | null) => void
|
||||
setActivityBarPosition: (position: ActivityBarPosition) => void
|
||||
}
|
||||
|
||||
@@ -72,6 +75,7 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
|
||||
rightSidebarTabByWorktree: {},
|
||||
rightSidebarExplorerViewByWorktree: {},
|
||||
rightSidebarVisual: null,
|
||||
rightSidebarEffectiveTab: null,
|
||||
activityBarPosition: 'top',
|
||||
toggleRightSidebar: () =>
|
||||
set((s) => ({
|
||||
@@ -196,6 +200,8 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
|
||||
const visual = selectVisibleRightSidebarVisual(s)
|
||||
return visual ? { rightSidebarVisual: { ...visual, width } } : {}
|
||||
}),
|
||||
setRightSidebarEffectiveTab: (tab) =>
|
||||
set((s) => (s.rightSidebarEffectiveTab === tab ? {} : { rightSidebarEffectiveTab: tab })),
|
||||
setActivityBarPosition: (position) => set({ activityBarPosition: position })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { createRestartSession } from './helpers/orca-restart'
|
||||
import { startOrcadConvertHost } from './helpers/orcad-convert-host'
|
||||
import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile'
|
||||
import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow'
|
||||
import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store'
|
||||
import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker'
|
||||
import { toRuntimeExecutionHostId } from '../../src/shared/execution-host'
|
||||
import { folderWorkspaceKey } from '../../src/shared/workspace-scope'
|
||||
|
||||
const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE
|
||||
test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template')
|
||||
|
||||
function captureRendererMessages(page: Page): void {
|
||||
page.on('console', (message) => {
|
||||
if (
|
||||
message.type() === 'error' ||
|
||||
message.type() === 'warning' ||
|
||||
message.text().startsWith('[file-change-event]')
|
||||
) {
|
||||
console.log('[renderer-event]', message.text())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const workspace of ['worktree', 'folder'] as const) {
|
||||
test(`remote ${workspace} explorer receives host-created files after server restart`, async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch.
|
||||
{}, testInfo) => {
|
||||
test.setTimeout(5 * 60_000)
|
||||
const host = startOrcadConvertHost('docker', testInfo)
|
||||
const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! })
|
||||
let app: ElectronApplication | null = null
|
||||
try {
|
||||
if (!host.exec) {
|
||||
throw new Error('Docker controls are unavailable')
|
||||
}
|
||||
const execute = host.exec
|
||||
const first = await session.launch()
|
||||
app = first.app
|
||||
await waitForSessionReady(first.page)
|
||||
await session.close(app)
|
||||
app = null
|
||||
const folderPath = '/tmp/orca-explorer-folder'
|
||||
execute(`mkdir -p '${folderPath}'`)
|
||||
const seeded = seedRelayEraProfile(session.userDataDir, host.input, {
|
||||
repoPath: host.remoteRepoPath,
|
||||
folderPath
|
||||
})
|
||||
const launched = await session.launch()
|
||||
app = launched.app
|
||||
captureRendererMessages(launched.page)
|
||||
await waitForSessionReady(launched.page)
|
||||
await convertAndRetain(launched.page, session.userDataDir, seeded)
|
||||
const environment = (
|
||||
await launched.page.evaluate(() => window.api.runtimeEnvironments.list())
|
||||
).find((entry) => entry.orcadDeployment?.sshTargetId === seeded.targetId)
|
||||
if (!environment) {
|
||||
throw new Error('Missing managed environment')
|
||||
}
|
||||
await session.close(app)
|
||||
app = null
|
||||
const restarted = await session.launch()
|
||||
app = restarted.app
|
||||
const page = restarted.page
|
||||
captureRendererMessages(page)
|
||||
await waitForSessionReady(page)
|
||||
await waitForStartupWorktreeRefresh(page)
|
||||
const folderId = await page.evaluate(
|
||||
(path) =>
|
||||
window.__store?.getState().folderWorkspaces.find((folder) => folder.folderPath === path)
|
||||
?.id,
|
||||
folderPath
|
||||
)
|
||||
if (!folderId) {
|
||||
throw new Error('Converted folder workspace was not restored')
|
||||
}
|
||||
const worktreeId = workspace === 'folder' ? folderWorkspaceKey(folderId) : seeded.worktreeId
|
||||
const rootPath = workspace === 'folder' ? folderPath : host.remoteRepoPath
|
||||
await page.evaluate(
|
||||
({ id, hostId }) => window.__store?.getState().setActiveWorktree(id, hostId),
|
||||
{ id: worktreeId, hostId: toRuntimeExecutionHostId(environment.id) }
|
||||
)
|
||||
await dismissTransientAnnouncement(page)
|
||||
await page.evaluate(() => {
|
||||
window.addEventListener('orca:worktree-file-change', (event) => {
|
||||
if (event instanceof CustomEvent) {
|
||||
console.log('[file-change-event]', JSON.stringify(event.detail))
|
||||
}
|
||||
})
|
||||
})
|
||||
const ready = page.waitForEvent('console', {
|
||||
predicate: (message) =>
|
||||
message.text().startsWith('[file-change-event]') &&
|
||||
message.text().includes('WATCH_READY.txt'),
|
||||
timeout: 30_000
|
||||
})
|
||||
execute(`printf 'ready\n' > '${rootPath}/WATCH_READY.txt'`)
|
||||
await ready
|
||||
await expect(page.getByText('WATCH_READY.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
const serverPids = (): string =>
|
||||
execute(
|
||||
'for f in /root/.orca-remote/orcad-*/.orcad-pid; do pid=$(cat "$f" 2>/dev/null) && kill -0 "$pid" 2>/dev/null && echo "$pid"; done; true'
|
||||
).trim()
|
||||
const before = serverPids()
|
||||
expect(before).toMatch(/^\d+$/)
|
||||
execute('kill -TERM $(cat /root/.orca-remote/orcad-*/.orcad-pid)')
|
||||
await expect.poll(serverPids, { timeout: 30_000 }).toBe('')
|
||||
execute(`printf 'changed during outage\n' > '${rootPath}/DURING_OUTAGE.txt'`)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
async ({ id, worktreeId }) => {
|
||||
const response = await window.api.runtimeEnvironments.call({
|
||||
selector: id,
|
||||
method: 'files.read',
|
||||
params: { worktree: `id:${worktreeId}`, relativePath: 'DURING_OUTAGE.txt' }
|
||||
})
|
||||
return response.ok ? JSON.stringify(response.result) : ''
|
||||
},
|
||||
{ id: environment.id, worktreeId }
|
||||
),
|
||||
{ timeout: 180_000 }
|
||||
)
|
||||
.toContain('changed during outage')
|
||||
console.log(
|
||||
'[managed-server-restart]',
|
||||
JSON.stringify({ before, after: serverPids(), workspace, worktreeId })
|
||||
)
|
||||
const resumed = page.waitForEvent('console', {
|
||||
predicate: (message) =>
|
||||
message.text().startsWith('[file-change-event]') &&
|
||||
message.text().includes('WATCH_RECOVERED.txt'),
|
||||
timeout: 30_000
|
||||
})
|
||||
execute(`printf 'watcher recovered\n' > '${rootPath}/WATCH_RECOVERED.txt'`)
|
||||
const created = await serverCall(page, environment.id, 'files.read', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
relativePath: 'WATCH_RECOVERED.txt'
|
||||
})
|
||||
console.log('[host-post-restart-file]', created)
|
||||
expect(created).toContain('watcher recovered')
|
||||
await resumed
|
||||
console.log('[recovered-broker-event]', 'WATCH_RECOVERED.txt')
|
||||
await page.screenshot({ path: testInfo.outputPath('remote-explorer-observed.png') })
|
||||
await expect(page.getByText('DURING_OUTAGE.txt', { exact: true })).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
await expect(page.getByText('WATCH_RECOVERED.txt', { exact: true })).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
await page.screenshot({ path: testInfo.outputPath('remote-explorer-refreshed.png') })
|
||||
} finally {
|
||||
if (app) {
|
||||
await session.close(app)
|
||||
}
|
||||
await session.dispose()
|
||||
host.cleanup()
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user