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:
Neil
2026-10-09 04:44:14 -07:00
committed by GitHub
co-authored by m4air
parent 4845ae8716
commit 9be39b6eb7
14 changed files with 488 additions and 72 deletions
+4 -3
View File
@@ -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:
+6 -2
View File
@@ -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
)
})
+8
View File
@@ -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'],
@@ -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,
@@ -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()
})
})
@@ -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()
}
})
}