perf(editor): stop hidden panels duplicating remote reloads (#13634)

* perf(editor): park hidden external reloads

* fix(editor): sync content refs after commit

* fix(editor): close post-commit ref window

* test(editor): cover commit-time visibility

* fix(editor): keep invalidated content visible and stop redundant reads

- Mark invalidated file content stale instead of dropping it so a reveal swaps
  bytes in place; the read-generation fence already blocks stale write-backs.
- Skip the lazy read when the newest read for the tab is still outstanding, so
  a worktree flip-flop cannot double-fetch.
- Stop claiming Cmd/Ctrl+S outside the workspace view, where no mounted panel
  owns the request and the keystroke was silently swallowed.
This commit is contained in:
Neil
2026-08-11 01:14:37 -07:00
committed by GitHub
parent 8fad723f84
commit baeb1b3cc8
21 changed files with 1362 additions and 106 deletions
+12 -16
View File
@@ -30,19 +30,14 @@ import {
ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT,
ORCA_EDITOR_SAVE_AND_CLOSE_EVENT,
ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT,
type EditorRequestCmdSaveDetail,
type EditorRequestFileCloseDetail,
requestEditorSaveQuiesce
} from './editor/editor-autosave'
import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload'
import { preventUnloadAndScheduleShutdownCheckpointReset } from '@/lib/shutdown-checkpoint-guard'
import EditorAutosaveController from './editor/EditorAutosaveController'
import type {
Tab,
TabContentType,
TabGroupLayoutNode,
TerminalTab,
TuiAgent
} from '../../../shared/types'
import type { Tab, TabGroupLayoutNode, TerminalTab, TuiAgent } from '../../../shared/types'
import { hasFeatureInteraction } from '../../../shared/feature-interactions'
import BrowserPane from './browser-pane/BrowserPane'
import { RetainedBrowserPaneOverlayLayer } from './browser-pane/BrowserPaneOverlayLayer'
@@ -185,18 +180,12 @@ import {
combineTerminalWorktreeParkIds,
useManualTerminalWorktreeParking
} from './terminal-pane/use-manual-terminal-worktree-parking'
import { EDITOR_TAB_CONTENT_TYPES, getEditorCmdSaveFileId } from './editor/editor-cmd-save-target'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
// Why: gate handler runs after a dialog advances so a stray carry-over click can't act on the next dialog; ~200ms absorbs a physical double-click while staying responsive.
const CLOSE_DIALOG_DEBOUNCE_MS = 200
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
'editor',
'diff',
'conflict-review',
'check-details'
])
type TerminalStoreSnapshot = ReturnType<typeof useAppStore.getState>
function haveSameIdSet(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
@@ -2077,10 +2066,17 @@ function Terminal(): React.JSX.Element | null {
target?.closest('textarea:not(.xterm-helper-textarea), input') !== null
if (!inEditor) {
const state = useAppStore.getState()
if (state.activeTabType === 'editor' && state.activeFileId) {
const floatingPanelOwnsEvent =
isEventTargetInsideFloatingWorkspacePanel(e.target) || floatingWorkspaceFocused
const requestedFileId = getEditorCmdSaveFileId(state, floatingPanelOwnsEvent)
if (requestedFileId) {
e.preventDefault()
notifyTerminalCapture('editor.save')
window.dispatchEvent(new Event(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT))
window.dispatchEvent(
new CustomEvent<EditorRequestCmdSaveDetail>(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, {
detail: { fileId: requestedFileId }
})
)
return
}
}
@@ -23,16 +23,20 @@ import {
selectEditorPanelGitStatusEntries
} from './editor-panel-git-entry-selector'
import { createEditorPanelDraftSelector } from './editor-panel-draft-selector'
import { attemptEditorFileSave } from './editor-file-save-attempt'
import { createCurrentMarkdownArtifactRequest } from './markdown-artifact-upload'
import { useEditorPanelSave } from './useEditorPanelSave'
function EditorPanelInner({
activeFileId: activeFileIdProp,
activeViewStateId: activeViewStateIdProp,
isVisible = true,
isCmdSaveOwner = isVisible,
markdownAnnotationsEnabled = true
}: {
activeFileId?: string | null
activeViewStateId?: string | null
isVisible?: boolean
isCmdSaveOwner?: boolean
markdownAnnotationsEnabled?: boolean
} = {}): React.JSX.Element | null {
const openFiles = useAppStore((s) => s.openFiles)
@@ -114,7 +118,8 @@ function EditorPanelInner({
isChangesMode: requestedChangesMode,
openFiles,
gitStatusEntries,
editorViewMode
editorViewMode,
isVisible
})
const isChangesMode =
requestedChangesMode &&
@@ -172,37 +177,18 @@ function EditorPanelInner({
[activeFile, markFileDirty]
)
const handleSaveForFile = useCallback(
async (file: typeof activeFile, content: string): Promise<boolean> => {
if (!file) {
return false
}
const saveTargetFile =
file.mode === 'markdown-preview'
? (openFiles.find(
(openFile) =>
openFile.id === file.markdownPreviewSourceFileId && openFile.mode === 'edit'
) ?? null)
: file
if (!saveTargetFile) {
return false
}
if (saveTargetFile.isUntitled) {
requestRenameForFile(saveTargetFile.id)
return false
}
return attemptEditorFileSave({ fileId: saveTargetFile.id, fallbackContent: content })
},
[openFiles, requestRenameForFile]
)
const handleSave = useCallback(
async (content: string): Promise<boolean> => {
return handleSaveForFile(activeFile, content)
},
[activeFile, handleSaveForFile]
)
useEditorCmdSaveRequest({ activeFile, openFiles, fileContents, handleSave })
const { handleSave, handleSaveForFile } = useEditorPanelSave({
activeFile,
openFiles,
requestRenameForFile
})
useEditorCmdSaveRequest({
activeFile,
openFiles,
fileContents,
handleSave,
enabled: isCmdSaveOwner
})
const handleCopyPath = useCallback(async (): Promise<void> => {
if (!activeFile) {
@@ -49,6 +49,10 @@ export type EditorRequestFileCloseDetail = {
fileId: string
}
export type EditorRequestCmdSaveDetail = {
fileId: string
}
export function isExternalReloadableEditorTab(file: OpenFile): boolean {
return (
file.mode === 'edit' ||
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { Tab } from '../../../../shared/types'
import { getEditorCmdSaveFileId } from './editor-cmd-save-target'
function makeTab(contentType: Tab['contentType'], entityId: string): Tab {
return {
id: `tab-${entityId}`,
entityId,
contentType,
label: entityId,
groupId: 'group-1',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
describe('getEditorCmdSaveFileId', () => {
it('targets the main active editor when the floating panel does not own the event', () => {
const getActiveTab = vi.fn(() => makeTab('editor', 'floating-file'))
expect(
getEditorCmdSaveFileId(
{
activeFileId: 'main-file',
activeTabType: 'editor',
activeView: 'terminal',
getActiveTab
},
false
)
).toBe('main-file')
expect(getActiveTab).not.toHaveBeenCalled()
})
it('claims nothing on a non-workspace view so the shortcut is not swallowed', () => {
const getActiveTab = vi.fn(() => null)
for (const activeView of ['tasks', 'settings', 'activity'] as const) {
expect(
getEditorCmdSaveFileId(
{ activeFileId: 'main-file', activeTabType: 'editor', activeView, getActiveTab },
false
)
).toBeNull()
}
})
it('targets only an active floating editor and never falls through to main', () => {
const getActiveTab = vi
.fn<(worktreeId: string) => Tab | null>()
.mockReturnValueOnce(makeTab('editor', 'floating-file'))
.mockReturnValueOnce(makeTab('browser', 'floating-browser'))
const state = {
activeFileId: 'main-file',
activeTabType: 'editor',
activeView: 'terminal' as const,
getActiveTab
}
expect(getEditorCmdSaveFileId(state, true)).toBe('floating-file')
expect(getEditorCmdSaveFileId(state, true)).toBeNull()
expect(getActiveTab).toHaveBeenNthCalledWith(1, FLOATING_TERMINAL_WORKTREE_ID)
expect(getActiveTab).toHaveBeenNthCalledWith(2, FLOATING_TERMINAL_WORKTREE_ID)
})
it('still targets the floating editor from a non-workspace view', () => {
const getActiveTab = vi.fn(() => makeTab('editor', 'floating-file'))
expect(
getEditorCmdSaveFileId(
{ activeFileId: 'main-file', activeTabType: 'editor', activeView: 'tasks', getActiveTab },
true
)
).toBe('floating-file')
})
})
@@ -0,0 +1,34 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { Tab, TabContentType, TopLevelView } from '../../../../shared/types'
export const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
'editor',
'diff',
'conflict-review',
'check-details'
])
type EditorCmdSaveState = {
activeFileId: string | null
activeTabType: string | null
activeView: TopLevelView
getActiveTab: (worktreeId: string) => Tab | null
}
export function getEditorCmdSaveFileId(
state: EditorCmdSaveState,
floatingPanelOwnsEvent: boolean
): string | null {
if (!floatingPanelOwnsEvent) {
// Why: outside the workspace view no mounted panel claims the request, so
// returning an id would swallow Cmd/Ctrl+S on Tasks/Settings without saving.
// The floating panel floats above every view and keeps its own ownership.
return state.activeView === 'terminal' && state.activeTabType === 'editor'
? state.activeFileId
: null
}
const activeTab = state.getActiveTab(FLOATING_TERMINAL_WORKTREE_ID)
return activeTab && EDITOR_TAB_CONTENT_TYPES.has(activeTab.contentType)
? activeTab.entityId
: null
}
@@ -25,6 +25,8 @@ export type FileContent = {
mimeType?: string
fileIdentity?: string
loadError?: string
/** Superseded by an external change; still rendered until the lazy reload lands. */
isStale?: boolean
}
export type DiffContent = GitDiffResult
@@ -0,0 +1,16 @@
import type { OpenFile } from '@/store/slices/editor'
export function getEditorSaveTargetFile(
activeFile: OpenFile,
openFiles: OpenFile[]
): OpenFile | null {
if (activeFile.mode !== 'markdown-preview') {
return activeFile
}
return (
openFiles.find(
(openFile) =>
openFile.id === activeFile.markdownPreviewSourceFileId && openFile.mode === 'edit'
) ?? null
)
}
@@ -0,0 +1,142 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import { ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT } from './editor-autosave'
import { useEditorCmdSaveRequest } from './useEditorCmdSaveRequest'
const storeState = vi.hoisted(() => ({ editorDrafts: {} as Record<string, string> }))
const EMPTY_FILE_CONTENTS: Parameters<typeof useEditorCmdSaveRequest>[0]['fileContents'] = {}
vi.mock('@/store', () => ({ useAppStore: { getState: () => storeState } }))
type ProbeProps = {
activeFile: OpenFile
enabled: boolean
fileContents?: Parameters<typeof useEditorCmdSaveRequest>[0]['fileContents']
onSave: (content: string) => Promise<boolean>
openFiles?: OpenFile[]
}
function SaveProbe({
activeFile,
enabled,
fileContents = EMPTY_FILE_CONTENTS,
onSave,
openFiles
}: ProbeProps): null {
useEditorCmdSaveRequest({
activeFile,
openFiles: openFiles ?? [activeFile],
fileContents,
handleSave: onSave,
enabled
})
return null
}
function makeFile(id: string): OpenFile {
return {
id,
filePath: `/repo/${id}.md`,
relativePath: `${id}.md`,
worktreeId: `worktree-${id}`,
language: 'markdown',
isDirty: true,
mode: 'edit'
}
}
describe('useEditorCmdSaveRequest', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
storeState.editorDrafts = {}
container = document.body.appendChild(document.createElement('div'))
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
it('saves only the visible panel that owns the requested file', () => {
const visibleFile = makeFile('visible')
const otherVisibleFile = makeFile('other-visible')
const hiddenFile = makeFile('hidden')
const visibleSave = vi.fn(async () => true)
const mirroredSave = vi.fn(async () => true)
const otherVisibleSave = vi.fn(async () => true)
const hiddenSave = vi.fn(async () => true)
storeState.editorDrafts = {
[visibleFile.id]: 'visible draft',
[otherVisibleFile.id]: 'other draft',
[hiddenFile.id]: 'hidden draft'
}
act(() => {
root.render(
<>
<SaveProbe activeFile={visibleFile} enabled onSave={visibleSave} />
<SaveProbe activeFile={visibleFile} enabled={false} onSave={mirroredSave} />
<SaveProbe activeFile={otherVisibleFile} enabled onSave={otherVisibleSave} />
<SaveProbe activeFile={hiddenFile} enabled={false} onSave={hiddenSave} />
</>
)
})
act(() => {
window.dispatchEvent(
new CustomEvent(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, {
detail: { fileId: visibleFile.id }
})
)
})
expect(visibleSave).toHaveBeenCalledExactlyOnceWith('visible draft')
expect(mirroredSave).not.toHaveBeenCalled()
expect(otherVisibleSave).not.toHaveBeenCalled()
expect(hiddenSave).not.toHaveBeenCalled()
})
it('uses the preview tab for ownership and the source file for content', () => {
const sourceFile = makeFile('source')
const previewFile: OpenFile = {
...sourceFile,
id: 'markdown-preview::source',
markdownPreviewSourceFileId: sourceFile.id,
mode: 'markdown-preview'
}
const save = vi.fn(async () => true)
storeState.editorDrafts = { [sourceFile.id]: 'source draft' }
act(() => {
root.render(
<SaveProbe
activeFile={previewFile}
enabled
onSave={save}
openFiles={[sourceFile, previewFile]}
/>
)
})
act(() => {
window.dispatchEvent(
new CustomEvent(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, {
detail: { fileId: sourceFile.id }
})
)
window.dispatchEvent(
new CustomEvent(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, {
detail: { fileId: previewFile.id }
})
)
})
expect(save).toHaveBeenCalledExactlyOnceWith('source draft')
})
})
@@ -1,11 +1,16 @@
import { useEffect } from 'react'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT } from './editor-autosave'
import {
ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT,
type EditorRequestCmdSaveDetail
} from './editor-autosave'
import type { FileContent } from './editor-panel-content-types'
import { getEditorSaveTargetFile } from './editor-save-target'
type UseEditorCmdSaveRequestParams = {
activeFile: OpenFile | null
enabled: boolean
openFiles: OpenFile[]
fileContents: Record<string, FileContent>
handleSave: (content: string) => Promise<boolean>
@@ -13,22 +18,23 @@ type UseEditorCmdSaveRequestParams = {
export function useEditorCmdSaveRequest({
activeFile,
enabled,
openFiles,
fileContents,
handleSave
}: UseEditorCmdSaveRequestParams): void {
useEffect(() => {
const handler = (): void => {
if (!activeFile) {
if (!enabled) {
return
}
const handler = (event: Event): void => {
if (
!activeFile ||
(event as CustomEvent<EditorRequestCmdSaveDetail>).detail?.fileId !== activeFile.id
) {
return
}
const saveTargetFile =
activeFile.mode === 'markdown-preview'
? (openFiles.find(
(openFile) =>
openFile.id === activeFile.markdownPreviewSourceFileId && openFile.mode === 'edit'
) ?? null)
: activeFile
const saveTargetFile = getEditorSaveTargetFile(activeFile, openFiles)
if (!saveTargetFile) {
return
}
@@ -46,5 +52,5 @@ export function useEditorCmdSaveRequest({
}
window.addEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, handler)
return () => window.removeEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, handler)
}, [activeFile, fileContents, handleSave, openFiles])
}, [activeFile, enabled, fileContents, handleSave, openFiles])
}
@@ -1,7 +1,7 @@
/* oxlint-disable max-lines -- Why: content loading, retry, and external-change
subscriptions share in-flight caches and state setters; splitting them would
make the hook coordination harder to audit. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import { getConnectionIdForFile, isWorktreeConnectionResolved } from '@/lib/connection-context'
import { joinPath } from '@/lib/path'
@@ -32,6 +32,7 @@ import {
shouldReloadDiffOnGitStatusChange
} from './editor-panel-diff-reload'
import {
type EditorPanelContentLoadOptions,
useEditorPanelExternalContentEvents,
usePruneClosedEditorContent
} from './useEditorPanelExternalContentEvents'
@@ -39,14 +40,20 @@ import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry'
import { useLocalLogTail } from './useLocalLogTail'
import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner'
const inFlightFileReads = new Map<string, Promise<FileContent>>()
const inFlightDiffReads = new Map<string, Promise<DiffContent>>()
type InFlightContentRead<T> = {
externalEventGeneration?: number
promise: Promise<T>
}
const inFlightFileReads = new Map<string, InFlightContentRead<FileContent>>()
const inFlightDiffReads = new Map<string, InFlightContentRead<DiffContent>>()
type GitStatusByWorktree = ReturnType<typeof useAppStore.getState>['gitStatusByWorktree']
type EditorViewModeByFile = ReturnType<typeof useAppStore.getState>['editorViewMode']
type UseEditorPanelContentStateParams = {
activeFile: OpenFile | null
isVisible?: boolean
isChangesMode: boolean
openFiles: OpenFile[]
gitStatusEntries: GitStatusByWorktree[string] | undefined
@@ -78,6 +85,18 @@ function stampCleanTabDiskBaseline(id: string, result: FileContent): void {
}
}
// Why: the newest read for a tab is already on its way, so a re-run of the lazy
// effect (a worktree flip-flop re-adds `isVisible`) must not fire a second RPC.
// An invalidation bumps the tab's generation, so a superseded read never counts.
function hasLiveRead(
generationsById: Record<string, number>,
outstandingById: Record<string, number>,
id: string
): boolean {
const generation = generationsById[id]
return generation !== undefined && outstandingById[id] === generation
}
function inFlightReadKey(connectionId: string | undefined, filePath: string): string {
return `${connectionId ?? ''}::${filePath}`
}
@@ -100,6 +119,7 @@ function inFlightDiffKey(
export function useEditorPanelContentState({
activeFile,
isVisible = true,
isChangesMode,
openFiles,
gitStatusEntries,
@@ -108,7 +128,6 @@ export function useEditorPanelContentState({
const [fileContents, setFileContents] = useState<Record<string, FileContent>>({})
const [diffContents, setDiffContents] = useState<Record<string, DiffContent>>({})
const diffContentsRef = useRef(diffContents)
diffContentsRef.current = diffContents
const fileLoadRetryAttemptsRef = useRef<Record<string, number>>({})
// Why: per-tab read generations let a forced/external reload supersede an
// older in-flight read so a slower stale promise cannot overwrite fresh state.
@@ -116,14 +135,74 @@ export function useEditorPanelContentState({
const diffReadGenerationRef = useRef<Record<string, number>>({})
const fileReadGenerationCounterRef = useRef(0)
const diffReadGenerationCounterRef = useRef(0)
const outstandingFileReadsRef = useRef<Record<string, number>>({})
const outstandingDiffReadsRef = useRef<Record<string, number>>({})
const openFilesRef = useRef(openFiles)
openFilesRef.current = openFiles
const editorViewModeRef = useRef(editorViewMode)
editorViewModeRef.current = editorViewMode
const isVisibleRef = useRef(isVisible)
const selectedConflictReviewFile =
activeFile?.mode === 'conflict-review' && activeFile.conflictReview?.selectedFileId
? (openFiles.find((file) => file.id === activeFile.conflictReview?.selectedFileId) ?? null)
: null
const activeContentFileId = selectedConflictReviewFile?.id ?? activeFile?.id ?? null
const activeContentFileIdRef = useRef(activeContentFileId)
useLayoutEffect(() => {
// Why: event-driven readers must only observe state from committed renders.
diffContentsRef.current = diffContents
openFilesRef.current = openFiles
editorViewModeRef.current = editorViewMode
isVisibleRef.current = isVisible
activeContentFileIdRef.current = activeContentFileId
}, [activeContentFileId, diffContents, editorViewMode, isVisible, openFiles])
const invalidateFileContent = useCallback((fileIds: string[]): void => {
const uniqueIds = new Set(fileIds)
for (const fileId of uniqueIds) {
fileReadGenerationRef.current[fileId] = ++fileReadGenerationCounterRef.current
delete fileLoadRetryAttemptsRef.current[fileId]
}
setFileContents((prev) => {
const next = { ...prev }
let changed = false
for (const fileId of uniqueIds) {
const existing = next[fileId]
// Why: keep the last-known bytes rendered and swap them when the lazy
// reload lands — dropping them flashes "Loading…" on every reveal.
if (existing && existing.isStale !== true) {
next[fileId] = { ...existing, isStale: true }
changed = true
}
}
return changed ? next : prev
})
}, [])
const invalidateDiffContent = useCallback((fileIds: string[]): void => {
const uniqueIds = new Set(fileIds)
for (const fileId of uniqueIds) {
diffReadGenerationRef.current[fileId] = ++diffReadGenerationCounterRef.current
}
setDiffContents((prev) => {
const next = { ...prev }
let changed = false
for (const fileId of uniqueIds) {
if (fileId in next) {
delete next[fileId]
changed = true
}
}
return changed ? next : prev
})
}, [])
const invalidateContent = useCallback(
(fileIds: string[]): void => {
invalidateFileContent(fileIds)
invalidateDiffContent(fileIds)
},
[invalidateDiffContent, invalidateFileContent]
)
const loadFileContent = useCallback(
async (
@@ -131,11 +210,12 @@ export function useEditorPanelContentState({
id: string,
worktreeId?: string,
relativePath?: string,
options?: { force?: boolean }
options?: EditorPanelContentLoadOptions
): Promise<void> => {
const generation = fileReadGenerationCounterRef.current + 1
fileReadGenerationCounterRef.current = generation
fileReadGenerationRef.current[id] = generation
outstandingFileReadsRef.current[id] = generation
try {
const resolvedConnectionId = getConnectionIdForFile(worktreeId ?? null, filePath)
const connectionId = resolvedConnectionId ?? undefined
@@ -219,14 +299,19 @@ export function useEditorPanelContentState({
}
const readScope = getRuntimeFileReadScope(readSettings, readConnectionId)
const key = inFlightReadKey(readScope, filePath)
if (options?.force) {
const registeredRead = inFlightFileReads.get(key)
if (
options?.force &&
(options.externalEventGeneration === undefined ||
registeredRead?.externalEventGeneration !== options.externalEventGeneration)
) {
// Why: forced reloads must not attach to a currently registered read
// started before the external change landed.
inFlightFileReads.delete(key)
}
let pending = inFlightFileReads.get(key)
if (!pending) {
pending = readRuntimeFileContent({
const promise = readRuntimeFileContent({
settings: readSettings,
filePath,
relativePath: readRelativePath,
@@ -235,6 +320,7 @@ export function useEditorPanelContentState({
expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId,
includeLocalLogMetadata: isLiveTailLogTab
}) as Promise<FileContent>
pending = { externalEventGeneration: options?.externalEventGeneration, promise }
inFlightFileReads.set(key, pending)
queueMicrotask(() => {
if (inFlightFileReads.get(key) === pending) {
@@ -242,7 +328,7 @@ export function useEditorPanelContentState({
}
})
}
const result = await pending
const result = await pending.promise
if (fileReadGenerationRef.current[id] !== generation) {
return
}
@@ -258,19 +344,24 @@ export function useEditorPanelContentState({
...prev,
[id]: { content: '', isBinary: false, loadError: message }
}))
} finally {
if (outstandingFileReadsRef.current[id] === generation) {
delete outstandingFileReadsRef.current[id]
}
}
},
[]
)
const loadDiffContent = useCallback(
async (file: OpenFile | null, options?: { force?: boolean }): Promise<void> => {
async (file: OpenFile | null, options?: EditorPanelContentLoadOptions): Promise<void> => {
if (!file || (file.mode === 'edit' && !canUseChangesModeForFile(file))) {
return
}
const generation = diffReadGenerationCounterRef.current + 1
diffReadGenerationCounterRef.current = generation
diffReadGenerationRef.current[file.id] = generation
outstandingDiffReadsRef.current[file.id] = generation
try {
const worktreePath = file.filePath.slice(
0,
@@ -293,14 +384,19 @@ export function useEditorPanelContentState({
gitScope ?? undefined,
compareAgainstHead
)
if (options?.force) {
const registeredRead = inFlightDiffReads.get(key)
if (
options?.force &&
(options.externalEventGeneration === undefined ||
registeredRead?.externalEventGeneration !== options.externalEventGeneration)
) {
// Why: forced diff reloads must not attach to a read started before
// the external change landed.
inFlightDiffReads.delete(key)
}
let pending = inFlightDiffReads.get(key)
if (!pending) {
pending = (
const promise = (
effectiveDiffSource === 'commit'
? commitCompare
? getRuntimeGitCommitDiff(
@@ -351,6 +447,7 @@ export function useEditorPanelContentState({
}
)
) as Promise<DiffContent>
pending = { externalEventGeneration: options?.externalEventGeneration, promise }
inFlightDiffReads.set(key, pending)
queueMicrotask(() => {
if (inFlightDiffReads.get(key) === pending) {
@@ -358,7 +455,7 @@ export function useEditorPanelContentState({
}
})
}
const result = await pending
const result = await pending.promise
if (diffReadGenerationRef.current[file.id] !== generation) {
return
}
@@ -377,6 +474,10 @@ export function useEditorPanelContentState({
modifiedIsBinary: false
}
}))
} finally {
if (outstandingDiffReadsRef.current[file.id] === generation) {
delete outstandingDiffReadsRef.current[file.id]
}
}
},
[]
@@ -417,7 +518,21 @@ export function useEditorPanelContentState({
useLocalLogTail({ openFiles, fileContents, setFileContents, reloadContent })
const needsFileRead = (fileId: string): boolean => {
const cached = fileContents[fileId]
return (
(!cached || cached.isStale === true) &&
!hasLiveRead(fileReadGenerationRef.current, outstandingFileReadsRef.current, fileId)
)
}
const needsDiffRead = (fileId: string): boolean =>
!diffContents[fileId] &&
!hasLiveRead(diffReadGenerationRef.current, outstandingDiffReadsRef.current, fileId)
useEffect(() => {
if (!isVisible) {
return
}
if (activeFile?.mode === 'conflict-review' && !selectedConflictReviewFile) {
const snapshotEntries = activeFile.conflictReview?.entries ?? []
if (snapshotEntries.length === 0) {
@@ -437,7 +552,7 @@ export function useEditorPanelContentState({
}
const absolutePath = joinPath(activeFile.filePath, entry.path)
if (!fileContents[absolutePath]) {
if (needsFileRead(absolutePath)) {
void loadFileContent(absolutePath, absolutePath, activeFile.worktreeId, entry.path)
}
}
@@ -452,7 +567,7 @@ export function useEditorPanelContentState({
if (fileToLoad.conflict?.kind === 'conflict-placeholder') {
return
}
if (!fileContents[fileToLoad.id]) {
if (needsFileRead(fileToLoad.id)) {
void loadFileContent(
fileToLoad.filePath,
fileToLoad.id,
@@ -460,10 +575,10 @@ export function useEditorPanelContentState({
fileToLoad.relativePath
)
}
if (isChangesMode && !diffContents[fileToLoad.id]) {
if (isChangesMode && needsDiffRead(fileToLoad.id)) {
void loadDiffContent(fileToLoad)
}
} else if (isReloadableSingleFileDiffTab(fileToLoad) && !diffContents[fileToLoad.id]) {
} else if (isReloadableSingleFileDiffTab(fileToLoad) && needsDiffRead(fileToLoad.id)) {
void loadDiffContent(fileToLoad)
}
// oxlint-disable-next-line react-hooks/exhaustive-deps
@@ -474,11 +589,12 @@ export function useEditorPanelContentState({
activeFile?.conflictReview?.snapshotTimestamp,
selectedConflictReviewFile?.id,
isChangesMode,
isVisible,
gitStatusEntries
])
useEditorPanelFileLoadRetry({
activeFile,
activeFile: isVisible ? activeFile : null,
fileContents,
fileLoadRetryAttemptsRef,
loadFileContent,
@@ -523,6 +639,10 @@ export function useEditorPanelContentState({
if (!(isChangesMode || activeFileShouldReloadOnGitStatusChange)) {
return
}
if (!isVisibleRef.current) {
invalidateDiffContent([current.id])
return
}
// Why: the lazy-load effect already fetches on first open; forcing here
// races a duplicate git-diff RPC for the same tab.
if (!diffContentsRef.current[current.id]) {
@@ -534,6 +654,7 @@ export function useEditorPanelContentState({
activeFileGitStatusSignature,
isChangesMode,
activeFile?.id,
invalidateDiffContent,
loadDiffContent
])
@@ -546,16 +667,12 @@ export function useEditorPanelContentState({
if (!current || !isReloadableSingleFileDiffTab(current)) {
return
}
setDiffContents((prev) => {
if (!prev[current.id]) {
return prev
}
const next = { ...prev }
delete next[current.id]
return next
})
invalidateDiffContent([current.id])
if (!isVisibleRef.current) {
return
}
void loadDiffContent(current, { force: true })
}, [activeFile?.diffContentReloadNonce, activeFile?.id, loadDiffContent])
}, [activeFile?.diffContentReloadNonce, activeFile?.id, invalidateDiffContent, loadDiffContent])
useEffect(() => {
const nonce = activeFile?.fileContentReloadNonce
@@ -570,20 +687,26 @@ export function useEditorPanelContentState({
) {
return
}
setFileContents((prev) => {
if (!prev[current.id]) {
return prev
}
const next = { ...prev }
delete next[current.id]
return next
})
invalidateFileContent([current.id])
if (!isVisibleRef.current) {
return
}
void loadFileContent(current.filePath, current.id, current.worktreeId, current.relativePath, {
force: true
})
}, [activeFile?.fileContentReloadNonce, activeFile?.filePath, activeFile?.id, loadFileContent])
}, [
activeFile?.fileContentReloadNonce,
activeFile?.filePath,
activeFile?.id,
invalidateFileContent,
loadFileContent
])
useEditorPanelExternalContentEvents({
activeContentFileIdRef,
invalidateContent,
invalidateDiffContent,
isVisibleRef,
loadDiffContent,
loadFileContent,
openFilesRef,
@@ -0,0 +1,242 @@
// @vitest-environment happy-dom
import { act, useLayoutEffect, useRef, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT } from './editor-autosave'
import type { DiffContent, FileContent } from './editor-panel-content-types'
import { useEditorPanelExternalContentEvents } from './useEditorPanelExternalContentEvents'
type ProbeCalls = {
invalidate: ReturnType<typeof vi.fn>
invalidateDiff: ReturnType<typeof vi.fn>
loadDiff: ReturnType<typeof vi.fn>
loadFile: ReturnType<typeof vi.fn>
}
type ProbeProps = {
activeFileId: string
calls: ProbeCalls
isVisible: boolean
openFiles: OpenFile[]
}
function ExternalContentProbe({ activeFileId, calls, isVisible, openFiles }: ProbeProps): null {
const activeContentFileIdRef = useRef(activeFileId)
const isVisibleRef = useRef(isVisible)
const openFilesRef = useRef(openFiles)
const editorViewModeRef = useRef({})
const [, setFileContents] = useState<Record<string, FileContent>>({})
const [, setDiffContents] = useState<Record<string, DiffContent>>({})
useLayoutEffect(() => {
activeContentFileIdRef.current = activeFileId
isVisibleRef.current = isVisible
openFilesRef.current = openFiles
}, [activeFileId, isVisible, openFiles])
useEditorPanelExternalContentEvents({
activeContentFileIdRef,
editorViewModeRef,
invalidateContent: calls.invalidate,
invalidateDiffContent: calls.invalidateDiff,
isVisibleRef,
loadDiffContent: calls.loadDiff,
loadFileContent: calls.loadFile,
openFilesRef,
setDiffContents,
setFileContents
} as Parameters<typeof useEditorPanelExternalContentEvents>[0])
return null
}
function makeFile(id: string, overrides: Partial<OpenFile> = {}): OpenFile {
return {
id,
filePath: `/remote/repo/${id}.ts`,
relativePath: `${id}.ts`,
worktreeId: 'ssh-worktree',
language: 'typescript',
isDirty: false,
mode: 'edit',
...overrides
}
}
function makeCalls(): ProbeCalls {
return {
invalidate: vi.fn(),
invalidateDiff: vi.fn(),
loadDiff: vi.fn(async () => undefined),
loadFile: vi.fn(async () => undefined)
}
}
function dispatchExternalChange(relativePath: string): void {
act(() => {
window.dispatchEvent(
new CustomEvent(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, {
detail: {
worktreeId: 'ssh-worktree',
worktreePath: '/remote/repo',
relativePath
}
})
)
})
}
describe('useEditorPanelExternalContentEvents', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.body.appendChild(document.createElement('div'))
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
it('routes one remote change to one visible owner instead of every retained panel', () => {
const changedFile = makeFile('changed')
const otherFiles = Array.from({ length: 9 }, (_, index) => makeFile(`other-${index}`))
const openFiles = [changedFile, ...otherFiles]
const ownerCalls = makeCalls()
const retainedCalls = otherFiles.map(() => makeCalls())
act(() => {
root.render(
<>
<ExternalContentProbe
activeFileId={changedFile.id}
calls={ownerCalls}
isVisible
openFiles={openFiles}
/>
{otherFiles.map((file, index) => (
<ExternalContentProbe
key={file.id}
activeFileId={file.id}
calls={retainedCalls[index]}
isVisible={false}
openFiles={openFiles}
/>
))}
</>
)
})
dispatchExternalChange(changedFile.relativePath)
expect(ownerCalls.loadFile).toHaveBeenCalledOnce()
expect(retainedCalls.flatMap((calls) => calls.loadFile.mock.calls)).toHaveLength(0)
for (const calls of retainedCalls) {
expect(calls.invalidate).toHaveBeenCalledExactlyOnceWith([changedFile.id])
}
})
it('invalidates a hidden owner without reloading until reveal', () => {
const changedFile = makeFile('changed')
const calls = makeCalls()
act(() => {
root.render(
<ExternalContentProbe
activeFileId={changedFile.id}
calls={calls}
isVisible={false}
openFiles={[changedFile]}
/>
)
})
dispatchExternalChange(changedFile.relativePath)
expect(calls.loadFile).not.toHaveBeenCalled()
expect(calls.invalidate).toHaveBeenCalledExactlyOnceWith([changedFile.id])
})
it('keeps dirty hidden content intact', () => {
const changedFile = makeFile('changed', { isDirty: true })
const calls = makeCalls()
act(() => {
root.render(
<ExternalContentProbe
activeFileId={changedFile.id}
calls={calls}
isVisible={false}
openFiles={[changedFile]}
/>
)
})
dispatchExternalChange(changedFile.relativePath)
expect(calls.loadFile).not.toHaveBeenCalled()
expect(calls.invalidate).not.toHaveBeenCalled()
})
it('invalidates an inactive cached Changes diff after reloading its visible file', () => {
const changedFile = makeFile('changed')
const calls = makeCalls()
act(() => {
root.render(
<ExternalContentProbe
activeFileId={changedFile.id}
calls={calls}
isVisible
openFiles={[changedFile]}
/>
)
})
dispatchExternalChange(changedFile.relativePath)
expect(calls.loadFile).toHaveBeenCalledOnce()
expect(calls.loadDiff).not.toHaveBeenCalled()
expect(calls.invalidateDiff).toHaveBeenCalledExactlyOnceWith([changedFile.id])
})
it('uses one event generation for visible source and preview reloads', () => {
const source = makeFile('source', {
filePath: '/remote/repo/shared.md',
relativePath: 'shared.md'
})
const preview = makeFile('preview', {
filePath: '/remote/repo/shared.md',
relativePath: 'shared.md',
mode: 'markdown-preview',
markdownPreviewSourceFileId: source.id
})
const sourceCalls = makeCalls()
const previewCalls = makeCalls()
act(() => {
root.render(
<>
<ExternalContentProbe
activeFileId={source.id}
calls={sourceCalls}
isVisible
openFiles={[source, preview]}
/>
<ExternalContentProbe
activeFileId={preview.id}
calls={previewCalls}
isVisible
openFiles={[source, preview]}
/>
</>
)
})
dispatchExternalChange(source.relativePath)
const sourceOptions = sourceCalls.loadFile.mock.calls[0]?.[4]
const previewOptions = previewCalls.loadFile.mock.calls[0]?.[4]
expect(sourceOptions?.externalEventGeneration).toBeTypeOf('number')
expect(previewOptions?.externalEventGeneration).toBe(sourceOptions?.externalEventGeneration)
})
})
@@ -13,14 +13,23 @@ import { isReloadableSingleFileDiffTab } from './editor-panel-diff-reload'
type EditorViewModeByFile = ReturnType<typeof useAppStore.getState>['editorViewMode']
export type EditorPanelContentLoadOptions = {
force?: boolean
externalEventGeneration?: number
}
type UseEditorPanelExternalContentEventsParams = {
loadDiffContent: (file: OpenFile | null, options?: { force?: boolean }) => Promise<void>
activeContentFileIdRef: MutableRefObject<string | null>
invalidateContent: (fileIds: string[]) => void
invalidateDiffContent: (fileIds: string[]) => void
isVisibleRef: MutableRefObject<boolean>
loadDiffContent: (file: OpenFile | null, options?: EditorPanelContentLoadOptions) => Promise<void>
loadFileContent: (
filePath: string,
id: string,
worktreeId?: string,
relativePath?: string,
options?: { force?: boolean }
options?: EditorPanelContentLoadOptions
) => Promise<void>
openFilesRef: MutableRefObject<OpenFile[]>
editorViewModeRef: MutableRefObject<EditorViewModeByFile>
@@ -28,7 +37,24 @@ type UseEditorPanelExternalContentEventsParams = {
setDiffContents: Dispatch<SetStateAction<Record<string, DiffContent>>>
}
const externalEventGenerations = new WeakMap<Event, number>()
let externalEventGenerationCounter = 0
function getExternalEventGeneration(event: Event): number {
const existing = externalEventGenerations.get(event)
if (existing !== undefined) {
return existing
}
const generation = ++externalEventGenerationCounter
externalEventGenerations.set(event, generation)
return generation
}
export function useEditorPanelExternalContentEvents({
activeContentFileIdRef,
invalidateContent,
invalidateDiffContent,
isVisibleRef,
loadDiffContent,
loadFileContent,
openFilesRef,
@@ -42,6 +68,9 @@ export function useEditorPanelExternalContentEvents({
if (!detail) {
return
}
const eventGeneration = getExternalEventGeneration(event)
const invalidatedDiffFileIds: string[] = []
const invalidatedFileIds: string[] = []
for (const file of getOpenFilesForExternalFileChange(openFilesRef.current, detail)) {
// Why: a dirty file keeps its unsaved buffer (issue #7265) — it is
// marked changed-on-disk upstream and resolves via the editor banner,
@@ -49,24 +78,52 @@ export function useEditorPanelExternalContentEvents({
if (file.isDirty) {
continue
}
if (!isVisibleRef.current || file.id !== activeContentFileIdRef.current) {
invalidatedFileIds.push(file.id)
continue
}
if (file.mode === 'edit' || file.mode === 'markdown-preview') {
// Why: external writes must replace any in-flight pre-change read so
// the tab shows the new on-disk content, not a stale dedupe result.
void loadFileContent(file.filePath, file.id, file.worktreeId, file.relativePath, {
force: true
force: true,
externalEventGeneration: eventGeneration
})
if (editorViewModeRef.current[file.id] === 'changes') {
void loadDiffContent(file, { force: true })
void loadDiffContent(file, {
force: true,
externalEventGeneration: eventGeneration
})
} else {
invalidatedDiffFileIds.push(file.id)
}
} else if (isReloadableSingleFileDiffTab(file)) {
void loadDiffContent(file, { force: true })
void loadDiffContent(file, {
force: true,
externalEventGeneration: eventGeneration
})
}
}
if (invalidatedFileIds.length > 0) {
invalidateContent(invalidatedFileIds)
}
if (invalidatedDiffFileIds.length > 0) {
invalidateDiffContent(invalidatedDiffFileIds)
}
}
window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener)
return () =>
window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener)
}, [editorViewModeRef, loadDiffContent, loadFileContent, openFilesRef])
}, [
activeContentFileIdRef,
editorViewModeRef,
invalidateContent,
invalidateDiffContent,
isVisibleRef,
loadDiffContent,
loadFileContent,
openFilesRef
])
useEffect(() => {
const handler = (event: Event): void => {
@@ -10,6 +10,7 @@ import {
type FileContent
} from './editor-panel-content-types'
import {
OWNER_NOT_READY_RETRY_DELAY_MS,
OWNER_NOT_READY_RETRY_LIMIT,
shouldRetryFileLoadError,
useEditorPanelFileLoadRetry
@@ -46,19 +47,21 @@ function Harness({
file,
fileContents,
attemptsRef,
isVisible = true,
loadFileContent,
setFileContents
}: {
file: OpenFile
fileContents: Record<string, FileContent>
attemptsRef: { current: Record<string, number> }
isVisible?: boolean
loadFileContent: (filePath: string, id: string) => Promise<void>
setFileContents: (
updater: (prev: Record<string, FileContent>) => Record<string, FileContent>
) => void
}): null {
useEditorPanelFileLoadRetry({
activeFile: file,
activeFile: isVisible ? file : null,
fileContents,
fileLoadRetryAttemptsRef: attemptsRef,
loadFileContent: loadFileContent as never,
@@ -100,6 +103,67 @@ describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', ()
expect(shouldRetryFileLoadError('Access denied: outside allowed directories')).toBe(false)
})
it('does not spend retry budget when hiding cancels a pending retry', () => {
setTimeoutSpy.mockRestore()
setTimeoutSpy = vi.spyOn(window, 'setTimeout')
const file = makeFile()
const attemptsRef = { current: {} as Record<string, number> }
const fileContents: Record<string, FileContent> = {
[file.id]: { content: '', isBinary: false, loadError: WORKTREE_OWNER_NOT_READY_ERROR }
}
const loadFileContent = vi.fn(async () => undefined)
const setFileContents = vi.fn((updater) => updater(fileContents))
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(
<Harness
file={file}
fileContents={fileContents}
attemptsRef={attemptsRef}
loadFileContent={loadFileContent}
setFileContents={setFileContents as never}
/>
)
})
expect(loadFileContent).not.toHaveBeenCalled()
expect(attemptsRef.current[file.id]).toBeUndefined()
act(() => {
root?.render(
<Harness
file={file}
fileContents={fileContents}
attemptsRef={attemptsRef}
isVisible={false}
loadFileContent={loadFileContent}
setFileContents={setFileContents as never}
/>
)
})
act(() => vi.advanceTimersByTime(OWNER_NOT_READY_RETRY_DELAY_MS))
expect(loadFileContent).not.toHaveBeenCalled()
expect(attemptsRef.current[file.id]).toBeUndefined()
act(() => {
root?.render(
<Harness
file={file}
fileContents={fileContents}
attemptsRef={attemptsRef}
loadFileContent={loadFileContent}
setFileContents={setFileContents as never}
/>
)
})
act(() => vi.advanceTimersByTime(OWNER_NOT_READY_RETRY_DELAY_MS))
expect(loadFileContent).toHaveBeenCalledOnce()
expect(attemptsRef.current[file.id]).toBe(1)
})
it('stops after the budget and shows a truthful terminal message, then Retry re-arms', () => {
const file = makeFile()
const attemptsRef = { current: {} as Record<string, number> }
@@ -98,7 +98,6 @@ export function useEditorPanelFileLoadRetry({
const delayMs = ownerNotReady
? OWNER_NOT_READY_RETRY_DELAY_MS
: (FILE_LOAD_RETRY_DELAYS_MS[retryCount] ?? FILE_LOAD_RETRY_DELAYS_MS[0])
fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] = retryCount + 1
const timeoutId = window.setTimeout(() => {
const currentFile = openFilesRef.current.find((file) => file.id === activeFileLoadRetryId)
if (
@@ -107,6 +106,7 @@ export function useEditorPanelFileLoadRetry({
) {
return
}
fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] = retryCount + 1
setFileContents((prev) => {
if (prev[currentFile.id]?.loadError !== activeFileLoadError) {
return prev
@@ -0,0 +1,39 @@
import { useCallback } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import { attemptEditorFileSave } from './editor-file-save-attempt'
import { getEditorSaveTargetFile } from './editor-save-target'
type UseEditorPanelSaveParams = {
activeFile: OpenFile | null
openFiles: OpenFile[]
requestRenameForFile: (fileId: string) => void
}
export function useEditorPanelSave({
activeFile,
openFiles,
requestRenameForFile
}: UseEditorPanelSaveParams) {
const handleSaveForFile = useCallback(
async (file: OpenFile | null, content: string): Promise<boolean> => {
if (!file) {
return false
}
const saveTargetFile = getEditorSaveTargetFile(file, openFiles)
if (!saveTargetFile) {
return false
}
if (saveTargetFile.isUntitled) {
requestRenameForFile(saveTargetFile.id)
return false
}
return attemptEditorFileSave({ fileId: saveTargetFile.id, fallbackContent: content })
},
[openFiles, requestRenameForFile]
)
const handleSave = useCallback(
(content: string): Promise<boolean> => handleSaveForFile(activeFile, content),
[activeFile, handleSaveForFile]
)
return { handleSave, handleSaveForFile }
}
@@ -0,0 +1,443 @@
// @vitest-environment happy-dom
import { act, useLayoutEffect, useMemo } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import type { GitStatusEntry } from '../../../../shared/types'
import type { DiffContent, FileContent } from './editor-panel-content-types'
const mocks = vi.hoisted(() => ({
getRuntimeGitDiff: vi.fn(),
getState: vi.fn(),
readRuntimeFileContent: vi.fn()
}))
vi.mock('@/runtime/runtime-file-client', () => ({
getRuntimeFileReadScope: vi.fn(() => null),
readRuntimeFileContent: mocks.readRuntimeFileContent,
subscribeRuntimeFileChanges: vi.fn()
}))
vi.mock('@/runtime/runtime-git-client', () => ({
getRuntimeGitBranchDiff: vi.fn(),
getRuntimeGitCommitDiff: vi.fn(),
getRuntimeGitDiff: mocks.getRuntimeGitDiff,
getRuntimeGitScope: vi.fn(() => null)
}))
vi.mock('@/lib/connection-context', () => ({
getConnectionId: vi.fn(),
getConnectionIdForFile: vi.fn(),
isWorktreeConnectionResolved: vi.fn(() => true)
}))
vi.mock('@/lib/runtime-workspace-file-route', () => ({
findWorkspaceFileRoute: vi.fn(() => null)
}))
vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } }))
import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT } from './editor-autosave'
import { useEditorPanelContentState } from './useEditorPanelContentState'
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
}
type ProbeSnapshot = {
diffContents: Record<string, DiffContent>
fileContents: Record<string, FileContent>
}
type ProbeProps = {
activeFile: OpenFile
editorViewMode?: Record<string, 'edit' | 'changes'>
gitStatusEntries?: GitStatusEntry[]
isChangesMode?: boolean
isVisible?: boolean
name?: string
openFiles?: OpenFile[]
}
const snapshots = new Map<string, ProbeSnapshot>()
const EMPTY_EDITOR_VIEW_MODE: Record<string, 'edit' | 'changes'> = {}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
function makeFile(id: string, overrides: Partial<OpenFile> = {}): OpenFile {
return {
id,
filePath: `/repo/${id}.ts`,
relativePath: `${id}.ts`,
worktreeId: 'wt-1',
language: 'typescript',
isDirty: false,
mode: 'edit',
...overrides
}
}
function Probe({
activeFile,
editorViewMode = EMPTY_EDITOR_VIEW_MODE,
gitStatusEntries,
isChangesMode = false,
isVisible = true,
name = 'main',
openFiles
}: ProbeProps): null {
const panelOpenFiles = useMemo(() => openFiles ?? [activeFile], [activeFile, openFiles])
const state = useEditorPanelContentState({
activeFile,
editorViewMode,
gitStatusEntries,
isChangesMode,
isVisible,
openFiles: panelOpenFiles
})
snapshots.set(name, {
diffContents: state.diffContents,
fileContents: state.fileContents
})
return null
}
function dispatchExternalChange(file: OpenFile): void {
act(() => {
window.dispatchEvent(
new CustomEvent(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, {
detail: {
worktreeId: file.worktreeId,
worktreePath: '/repo',
relativePath: file.relativePath
}
})
)
})
}
function ExternalChangeLayoutEmitter({ file }: { file: OpenFile }): null {
useLayoutEffect(() => {
window.dispatchEvent(
new CustomEvent(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, {
detail: {
worktreeId: file.worktreeId,
worktreePath: '/repo',
relativePath: file.relativePath
}
})
)
}, [file])
return null
}
function textDiff(modifiedContent: string): DiffContent {
return {
kind: 'text',
originalContent: 'old',
modifiedContent,
originalIsBinary: false,
modifiedIsBinary: false
}
}
describe('useEditorPanelContentState visibility', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
snapshots.clear()
mocks.getRuntimeGitDiff.mockReset()
mocks.readRuntimeFileContent.mockReset()
mocks.getState.mockReset()
mocks.getState.mockReturnValue({
settings: null,
openFiles: [],
setLastKnownDiskSignature: vi.fn()
})
container = document.body.appendChild(document.createElement('div'))
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
it('defers the initial remote read until reveal', async () => {
const file = makeFile('initial')
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'remote', isBinary: false })
await act(async () => root.render(<Probe activeFile={file} isVisible={false} />))
expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled()
await act(async () => root.render(<Probe activeFile={file} />))
await vi.waitFor(() =>
expect(snapshots.get('main')?.fileContents[file.id]?.content).toBe('remote')
)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce()
})
it('invalidates a hidden tab and rejects its older in-flight result', async () => {
const file = makeFile('stale')
const staleRead = createDeferred<FileContent>()
const freshRead = createDeferred<FileContent>()
mocks.readRuntimeFileContent
.mockReturnValueOnce(staleRead.promise)
.mockReturnValueOnce(freshRead.promise)
await act(async () => root.render(<Probe activeFile={file} />))
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce())
await act(async () => root.render(<Probe activeFile={file} isVisible={false} />))
dispatchExternalChange(file)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce()
await act(async () => {
staleRead.resolve({ content: 'stale', isBinary: false })
await staleRead.promise
})
expect(snapshots.get('main')?.fileContents[file.id]).toBeUndefined()
await act(async () => root.render(<Probe activeFile={file} />))
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2))
await act(async () => {
freshRead.resolve({ content: 'fresh', isBinary: false })
await freshRead.promise
})
await vi.waitFor(() =>
expect(snapshots.get('main')?.fileContents[file.id]?.content).toBe('fresh')
)
})
it('keeps invalidated bytes on screen until the reveal read lands', async () => {
const file = makeFile('no-flash')
const freshRead = createDeferred<FileContent>()
mocks.readRuntimeFileContent
.mockResolvedValueOnce({ content: 'old', isBinary: false })
.mockReturnValueOnce(freshRead.promise)
await act(async () => root.render(<Probe activeFile={file} />))
await vi.waitFor(() =>
expect(snapshots.get('main')?.fileContents[file.id]?.content).toBe('old')
)
await act(async () => root.render(<Probe activeFile={file} isVisible={false} />))
dispatchExternalChange(file)
expect(snapshots.get('main')?.fileContents[file.id]?.content).toBe('old')
await act(async () => root.render(<Probe activeFile={file} />))
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2))
// Why: an unresolved reveal read must not blank the pane to "Loading…".
expect(snapshots.get('main')?.fileContents[file.id]?.content).toBe('old')
await act(async () => {
freshRead.resolve({ content: 'fresh', isBinary: false })
await freshRead.promise
})
await vi.waitFor(() =>
expect(snapshots.get('main')?.fileContents[file.id]).toEqual({
content: 'fresh',
isBinary: false
})
)
})
it('does not re-read while the reveal read is still in flight', async () => {
const file = makeFile('flip-flop')
const pendingRead = createDeferred<FileContent>()
mocks.readRuntimeFileContent.mockReturnValue(pendingRead.promise)
await act(async () => root.render(<Probe activeFile={file} isVisible={false} />))
await act(async () => root.render(<Probe activeFile={file} />))
expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce()
await act(async () => root.render(<Probe activeFile={file} isVisible={false} />))
await act(async () => root.render(<Probe activeFile={file} />))
expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce()
await act(async () => {
pendingRead.resolve({ content: 'remote', isBinary: false })
await pendingRead.promise
})
await vi.waitFor(() =>
expect(snapshots.get('main')?.fileContents[file.id]?.content).toBe('remote')
)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce()
})
it('publishes hidden visibility before a watcher event in the same commit', async () => {
const file = makeFile('commit-visibility')
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'old', isBinary: false })
await act(async () => root.render(<Probe activeFile={file} />))
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce())
await act(async () =>
root.render(
<>
<Probe activeFile={file} isVisible={false} />
<ExternalChangeLayoutEmitter file={file} />
</>
)
)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce()
expect(snapshots.get('main')?.fileContents[file.id]).toEqual({
content: 'old',
isBinary: false,
isStale: true
})
})
it('shares one post-change read between visible source and preview panels', async () => {
const source = makeFile('source', {
filePath: '/repo/readme.md',
relativePath: 'readme.md',
language: 'markdown'
})
const preview = makeFile('preview', {
filePath: '/repo/readme.md',
relativePath: 'readme.md',
language: 'markdown',
mode: 'markdown-preview',
markdownPreviewSourceFileId: source.id
})
mocks.readRuntimeFileContent
.mockResolvedValueOnce({ content: 'old', isBinary: false })
.mockResolvedValueOnce({ content: 'fresh', isBinary: false })
await act(async () => {
root.render(
<>
<Probe activeFile={source} name="source" openFiles={[source, preview]} />
<Probe activeFile={preview} name="preview" openFiles={[source, preview]} />
</>
)
})
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce())
dispatchExternalChange(source)
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2))
await vi.waitFor(() => {
expect(snapshots.get('source')?.fileContents[source.id]?.content).toBe('fresh')
expect(snapshots.get('preview')?.fileContents[preview.id]?.content).toBe('fresh')
})
})
it('invalidates a hidden file nonce without reading until reveal', async () => {
const file = makeFile('file-nonce')
mocks.readRuntimeFileContent
.mockResolvedValueOnce({ content: 'old', isBinary: false })
.mockResolvedValueOnce({ content: 'fresh', isBinary: false })
await act(async () => root.render(<Probe activeFile={file} />))
await vi.waitFor(() =>
expect(snapshots.get('main')?.fileContents[file.id]?.content).toBe('old')
)
const changed = { ...file, fileContentReloadNonce: 1 }
await act(async () => root.render(<Probe activeFile={changed} isVisible={false} />))
expect(snapshots.get('main')?.fileContents[file.id]?.isStale).toBe(true)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce()
await act(async () => root.render(<Probe activeFile={changed} />))
await vi.waitFor(() =>
expect(snapshots.get('main')?.fileContents[file.id]?.content).toBe('fresh')
)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2)
})
it('invalidates a hidden diff nonce without reading until reveal', async () => {
const file = makeFile('diff-nonce', { mode: 'diff', diffSource: 'unstaged' })
const staleDiff = createDeferred<DiffContent>()
const freshDiff = createDeferred<DiffContent>()
mocks.getRuntimeGitDiff
.mockReturnValueOnce(staleDiff.promise)
.mockReturnValueOnce(freshDiff.promise)
await act(async () => root.render(<Probe activeFile={file} />))
await vi.waitFor(() => expect(mocks.getRuntimeGitDiff).toHaveBeenCalledOnce())
const changed = { ...file, diffContentReloadNonce: 1 }
await act(async () => root.render(<Probe activeFile={changed} isVisible={false} />))
expect(mocks.getRuntimeGitDiff).toHaveBeenCalledOnce()
await act(async () => {
staleDiff.resolve(textDiff('stale diff'))
await staleDiff.promise
})
expect(snapshots.get('main')?.diffContents[file.id]).toBeUndefined()
await act(async () => root.render(<Probe activeFile={changed} />))
await vi.waitFor(() => expect(mocks.getRuntimeGitDiff).toHaveBeenCalledTimes(2))
await act(async () => {
freshDiff.resolve(textDiff('fresh diff'))
await freshDiff.promise
})
await vi.waitFor(() =>
expect(snapshots.get('main')?.diffContents[file.id]?.modifiedContent).toBe('fresh diff')
)
expect(mocks.getRuntimeGitDiff).toHaveBeenCalledTimes(2)
})
it('invalidates a hidden Git-status diff without reading until reveal', async () => {
const file = makeFile('status', { mode: 'diff', diffSource: 'unstaged' })
const status: GitStatusEntry[] = [
{ path: file.relativePath, status: 'modified', area: 'unstaged' }
]
mocks.getRuntimeGitDiff
.mockResolvedValueOnce(textDiff('old diff'))
.mockResolvedValueOnce(textDiff('fresh diff'))
await act(async () => root.render(<Probe activeFile={file} />))
await vi.waitFor(() => expect(snapshots.get('main')?.diffContents[file.id]).toBeDefined())
await act(async () =>
root.render(<Probe activeFile={file} gitStatusEntries={status} isVisible={false} />)
)
expect(snapshots.get('main')?.diffContents[file.id]).toBeUndefined()
expect(mocks.getRuntimeGitDiff).toHaveBeenCalledOnce()
await act(async () => root.render(<Probe activeFile={file} gitStatusEntries={status} />))
await vi.waitFor(() =>
expect(snapshots.get('main')?.diffContents[file.id]?.modifiedContent).toBe('fresh diff')
)
expect(mocks.getRuntimeGitDiff).toHaveBeenCalledTimes(2)
})
it('invalidates an inactive cached Changes diff after an external edit', async () => {
const file = makeFile('changes-cache')
const changesMode = { [file.id]: 'changes' as const }
mocks.readRuntimeFileContent
.mockResolvedValueOnce({ content: 'old', isBinary: false })
.mockResolvedValueOnce({ content: 'fresh', isBinary: false })
mocks.getRuntimeGitDiff
.mockResolvedValueOnce(textDiff('old diff'))
.mockResolvedValueOnce(textDiff('fresh diff'))
await act(async () =>
root.render(<Probe activeFile={file} editorViewMode={changesMode} isChangesMode />)
)
await vi.waitFor(() => expect(snapshots.get('main')?.diffContents[file.id]).toBeDefined())
await act(async () => root.render(<Probe activeFile={file} />))
dispatchExternalChange(file)
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2))
expect(mocks.getRuntimeGitDiff).toHaveBeenCalledOnce()
expect(snapshots.get('main')?.diffContents[file.id]).toBeUndefined()
await act(async () =>
root.render(<Probe activeFile={file} editorViewMode={changesMode} isChangesMode />)
)
await vi.waitFor(() =>
expect(snapshots.get('main')?.diffContents[file.id]?.modifiedContent).toBe('fresh diff')
)
expect(mocks.getRuntimeGitDiff).toHaveBeenCalledTimes(2)
})
})
@@ -2518,6 +2518,16 @@ describe('FloatingTerminalPanel close behavior', () => {
expect(editorPanel.props.markdownAnnotationsEnabled).toBe(false)
expect(editorPanel.props.activeFileId).toBe('notes')
expect(editorPanel.props.isVisible).toBe(true)
})
it('marks the retained floating editor hidden when the panel is closed', async () => {
setFloatingEditorTabs([makeFile({ id: 'notes' })])
const element = await renderPanel(false)
const editorPanel = findByProp(element, 'activeFileId')
expect(editorPanel.props.isVisible).toBe(false)
})
it('keeps the panel open when the explicit close action removes the last tab', async () => {
@@ -1947,6 +1947,7 @@ export function FloatingTerminalPanel({
<EditorPanel
activeFileId={activeEditorFile.id}
activeViewStateId={activeEditorUnifiedId}
isVisible={open}
markdownAnnotationsEnabled={false}
/>
</Suspense>
@@ -25,6 +25,7 @@ const EditorPanel = lazy(() => import('../editor/EditorPanel'))
export default function TabGroupPanel({
groupId,
worktreeId,
isVisible,
isFocused,
hasSplitGroups,
touchesRightEdge,
@@ -40,6 +41,7 @@ export default function TabGroupPanel({
}: {
groupId: string
worktreeId: string
isVisible: boolean
isFocused: boolean
hasSplitGroups: boolean
touchesRightEdge: boolean
@@ -324,7 +326,12 @@ export default function TabGroupPanel({
</div>
}
>
<EditorPanel activeFileId={activeTab.entityId} activeViewStateId={activeTab.id} />
<EditorPanel
activeFileId={activeTab.entityId}
activeViewStateId={activeTab.id}
isVisible={isVisible}
isCmdSaveOwner={isFocused}
/>
</Suspense>
</div>
)}
@@ -100,6 +100,7 @@ describe('TabGroupSplitLayout', () => {
return tabGroupPanelElement.props as {
groupId: string
worktreeId: string
isVisible: boolean
isFocused: boolean
hasSplitGroups: boolean
reserveClosedExplorerToggleSpace: boolean
@@ -112,6 +113,7 @@ describe('TabGroupSplitLayout', () => {
expect.objectContaining({
groupId: 'group-1',
worktreeId: 'wt-1',
isVisible: false,
isFocused: false,
hasSplitGroups: false,
reserveClosedExplorerToggleSpace: true,
@@ -125,6 +127,7 @@ describe('TabGroupSplitLayout', () => {
expect.objectContaining({
groupId: 'group-1',
worktreeId: 'wt-1',
isVisible: true,
isFocused: true,
hasSplitGroups: false,
reserveClosedExplorerToggleSpace: true,
@@ -182,6 +182,7 @@ function SplitNode({
<TabGroupPanel
groupId={node.groupId}
worktreeId={worktreeId}
isVisible={isWorktreeActive}
// Why: hidden worktrees stay mounted so their PTYs and split layouts
// survive worktree switches, but only the visible worktree may own the
// global terminal shortcuts. If an offscreen group's pane stays