From 9be39b6eb764102de16491cf08d3d8ca615fdc53 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:44:14 -0700 Subject: [PATCH] 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 --- .github/workflows/e2e.yml | 7 +- config/scripts/ci-e2e-job-selection.mjs | 8 +- config/scripts/ci-e2e-job-selection.test.mjs | 14 ++ config/scripts/pr-e2e-source-routing.mjs | 8 + ...ebar-titlebar-drag-regions.render.test.tsx | 1 + ...sidebar-tab-routing.effective-tab.test.tsx | 48 +++++ .../use-right-sidebar-tab-routing.ts | 7 + .../useFileExplorerWatch.broker.test.tsx | 133 ++++++++++++++ ...FileExplorerWatch.pending-refresh.test.tsx | 48 ++--- .../right-sidebar/useFileExplorerWatch.ts | 43 ++--- ...r-external-watch-fallback-explorer.test.ts | 61 +++++++ .../hooks/editor-external-watch-targets.ts | 12 +- .../editor/actions/right-sidebar-state.ts | 6 + .../ssh-orcad-explorer-watch-recovery.spec.ts | 164 ++++++++++++++++++ 14 files changed, 488 insertions(+), 72 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.effective-tab.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/useFileExplorerWatch.broker.test.tsx create mode 100644 src/renderer/src/hooks/editor-external-watch-fallback-explorer.test.ts create mode 100644 tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bd518c980f3..73e294a1633 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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: diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 637dc2a516f..8fd7fb19dd2 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -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 ) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 98f7ee1a193..e75299156cf 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -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 + ) +}) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 3a0c9fd4abd..06d3515c265 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -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'], diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx index 40a3966047f..829d0e68469 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx @@ -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, diff --git a/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.effective-tab.test.tsx b/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.effective-tab.test.tsx new file mode 100644 index 00000000000..601b786adac --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.effective-tab.test.tsx @@ -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]) +}) diff --git a/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.ts b/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.ts index 41f2066257f..a89b7e2f931 100644 --- a/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.ts +++ b/src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.ts @@ -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>({}) 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]) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.broker.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.broker.test.tsx new file mode 100644 index 00000000000..e09739fc876 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.broker.test.tsx @@ -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 { + 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() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx index 21c16a0b2f5..890fbbad0a3 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx @@ -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((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', diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index 1b6c09507be..b44077f0df5 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -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) diff --git a/src/renderer/src/hooks/editor-external-watch-fallback-explorer.test.ts b/src/renderer/src/hooks/editor-external-watch-fallback-explorer.test.ts new file mode 100644 index 00000000000..120e4f5a8ce --- /dev/null +++ b/src/renderer/src/hooks/editor-external-watch-fallback-explorer.test.ts @@ -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 { + 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']) +}) diff --git a/src/renderer/src/hooks/editor-external-watch-targets.ts b/src/renderer/src/hooks/editor-external-watch-targets.ts index 715d64e10ce..b3be004ff37 100644 --- a/src/renderer/src/hooks/editor-external-watch-targets.ts +++ b/src/renderer/src/hooks/editor-external-watch-targets.ts @@ -32,7 +32,8 @@ export type EditorExternalWatchTargetState = Pick< | 'sshConnectionStates' | 'folderWorkspaces' | 'projectGroups' -> +> & + Partial> 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 diff --git a/src/renderer/src/store/slices/editor/actions/right-sidebar-state.ts b/src/renderer/src/store/slices/editor/actions/right-sidebar-state.ts index 10c3d9f749a..ebc35894287 100644 --- a/src/renderer/src/store/slices/editor/actions/right-sidebar-state.ts +++ b/src/renderer/src/store/slices/editor/actions/right-sidebar-state.ts @@ -42,6 +42,8 @@ export type RightSidebarState = { rightSidebarTabByWorktree: Record rightSidebarExplorerViewByWorktree: Record 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 }) } } diff --git a/tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts b/tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts new file mode 100644 index 00000000000..22be5034097 --- /dev/null +++ b/tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts @@ -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() + } + }) +}