Force file content reload when opening paths from terminal links (#5575)

Introduce a `fileContentReloadNonce` on open files and a new
`forceContentReload` option for `openFile`. When terminal links or file-open
actions re-open an existing clean tab, increment the nonce to trigger a
refetch of the file content. This provides a manual recovery path for when
remote file watchers miss external writes.
This commit is contained in:
Jinjing 2026-06-17 01:03:28 -07:00 committed by GitHub
parent 38e289602d
commit b1563c8dae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 287 additions and 29 deletions

View File

@ -0,0 +1,129 @@
// @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 type { FileContent } from './editor-panel-content-types'
const mocks = vi.hoisted(() => ({
readRuntimeFileContent: vi.fn(),
getConnectionId: vi.fn(),
getState: vi.fn()
}))
vi.mock('@/runtime/runtime-file-client', () => ({
getRuntimeFileReadScope: vi.fn(
(
settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined,
connectionId?: string
) => connectionId ?? settings?.activeRuntimeEnvironmentId ?? null
),
readRuntimeFileContent: mocks.readRuntimeFileContent,
subscribeRuntimeFileChanges: vi.fn()
}))
vi.mock('@/runtime/runtime-git-client', () => ({
getRuntimeGitBranchDiff: vi.fn(),
getRuntimeGitCommitDiff: vi.fn(),
getRuntimeGitDiff: vi.fn(),
getRuntimeGitScope: vi.fn(() => null)
}))
vi.mock('@/lib/connection-context', () => ({
getConnectionId: mocks.getConnectionId
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: mocks.getState
}
}))
import { useEditorPanelContentState } from './useEditorPanelContentState'
type ProbeProps = {
activeFile: OpenFile
openFiles: OpenFile[]
}
let latestFileContents: Record<string, FileContent> = {}
function HookProbe({ activeFile, openFiles }: ProbeProps): null {
latestFileContents = useEditorPanelContentState({
activeFile,
isChangesMode: false,
openFiles,
gitStatusByWorktree: {},
editorViewMode: {}
}).fileContents
return null
}
function createOpenFile(overrides: Partial<OpenFile> = {}): OpenFile {
return {
id: '/repo/file.ts',
filePath: '/repo/file.ts',
relativePath: 'file.ts',
worktreeId: 'wt-1',
language: 'typescript',
isDirty: false,
mode: 'edit',
...overrides
}
}
describe('useEditorPanelContentState', () => {
let container: HTMLDivElement | null = null
let root: Root | null = null
beforeEach(() => {
latestFileContents = {}
mocks.readRuntimeFileContent.mockReset()
mocks.getConnectionId.mockReset()
mocks.getConnectionId.mockReturnValue(undefined)
mocks.getState.mockReset()
mocks.getState.mockReturnValue({ settings: null })
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
container = null
root = null
})
it('reloads a clean file when its file content reload nonce changes', async () => {
const activeFile = createOpenFile()
mocks.readRuntimeFileContent
.mockResolvedValueOnce({ content: 'old content', isBinary: false })
.mockResolvedValueOnce({ content: 'fresh content', isBinary: false })
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
})
await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('old content'))
const reloadedFile = { ...activeFile, fileContentReloadNonce: 1 }
await act(async () => {
root?.render(<HookProbe activeFile={reloadedFile} openFiles={[reloadedFile]} />)
})
await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('fresh content'))
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2)
expect(mocks.readRuntimeFileContent).toHaveBeenLastCalledWith(
expect.objectContaining({
filePath: '/repo/file.ts',
relativePath: 'file.ts',
worktreeId: 'wt-1'
})
)
})
})

View File

@ -388,6 +388,30 @@ export function useEditorPanelContentState({
void loadDiffContent(current, { force: true })
}, [activeFile?.diffContentReloadNonce, activeFile?.id, loadDiffContent])
useEffect(() => {
const nonce = activeFile?.fileContentReloadNonce
if (!activeFile?.id || nonce === undefined || nonce === 0) {
return
}
const current = openFilesRef.current.find((f) => f.id === activeFile.id)
if (
!current ||
current.isDirty ||
(current.mode !== 'edit' && current.mode !== 'markdown-preview')
) {
return
}
setFileContents((prev) => {
if (!prev[current.id]) {
return prev
}
const next = { ...prev }
delete next[current.id]
return next
})
void loadFileContent(current.filePath, current.id, current.worktreeId, current.relativePath)
}, [activeFile?.fileContentReloadNonce, activeFile?.filePath, activeFile?.id, loadFileContent])
useEditorPanelExternalContentEvents({
loadDiffContent,
loadFileContent,

View File

@ -170,14 +170,17 @@ export function openDetectedFilePath(
activateAndRevealWorktree(worktreeId)
}
store.openFile({
filePath,
relativePath,
worktreeId: worktreeId || '',
language: detectLanguage(filePath),
mode: 'edit',
runtimeEnvironmentId
})
store.openFile(
{
filePath,
relativePath,
worktreeId: worktreeId || '',
language: detectLanguage(filePath),
mode: 'edit',
runtimeEnvironmentId
},
{ forceContentReload: true }
)
if (line !== null) {
const targetColumn = column ?? 1

View File

@ -288,7 +288,8 @@ describe('handleOscLink', () => {
await flushDoubleRaf()
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/tmp/src/main.ts' })
expect.objectContaining({ filePath: '/tmp/src/main.ts' }),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
@ -344,7 +345,8 @@ describe('handleOscLink', () => {
expect(openFilePathMock).toHaveBeenCalledWith('/tmp/src/main.ts')
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/tmp/src/main.ts' })
expect.objectContaining({ filePath: '/tmp/src/main.ts' }),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
@ -400,7 +402,8 @@ describe('handleOscLink', () => {
expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/test.txt' })
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/tmp/test.txt' })
expect.objectContaining({ filePath: '/tmp/test.txt' }),
{ forceContentReload: true }
)
expect(openFilePathMock).not.toHaveBeenCalled()
})
@ -424,7 +427,8 @@ describe('handleOscLink', () => {
targetPath: 'C:/repo/src/index.ts'
})
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: 'C:/repo/src/index.ts' })
expect.objectContaining({ filePath: 'C:/repo/src/index.ts' }),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
@ -452,7 +456,8 @@ describe('handleOscLink', () => {
targetPath: '//server/share/repo/test.txt'
})
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '//server/share/repo/test.txt' })
expect.objectContaining({ filePath: '//server/share/repo/test.txt' }),
{ forceContentReload: true }
)
})
@ -483,7 +488,8 @@ describe('handleOscLink', () => {
expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/test.txt' })
expect(openFilePathMock).not.toHaveBeenCalled()
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/tmp/test.txt' })
expect.objectContaining({ filePath: '/tmp/test.txt' }),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
@ -524,7 +530,8 @@ describe('handleOscLink', () => {
expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/test.txt' })
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/tmp/test.txt' })
expect.objectContaining({ filePath: '/tmp/test.txt' }),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
@ -556,7 +563,8 @@ describe('handleOscLink', () => {
expect.objectContaining({
filePath: '//server/Share/Repo/src/app.ts',
relativePath: 'src/app.ts'
})
}),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
@ -588,7 +596,8 @@ describe('handleOscLink', () => {
expect.objectContaining({
filePath: '/tmp/project/docs/README.md',
relativePath: 'project/docs/README.md'
})
}),
{ forceContentReload: true }
)
expect(openFilePathMock).not.toHaveBeenCalled()
})
@ -614,7 +623,8 @@ describe('handleOscLink', () => {
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/home/alice/file.ts'
})
}),
{ forceContentReload: true }
)
expect(openFilePathMock).not.toHaveBeenCalled()
})
@ -646,7 +656,8 @@ describe('handleOscLink', () => {
expect.objectContaining({
filePath: '/tmp/src/main.ts',
relativePath: 'src/main.ts'
})
}),
{ forceContentReload: true }
)
})
@ -679,7 +690,8 @@ describe('handleOscLink', () => {
filePath: '/tmp/src/main.ts',
relativePath: 'src/main.ts',
runtimeEnvironmentId: 'env-1'
})
}),
{ forceContentReload: true }
)
})
@ -703,7 +715,8 @@ describe('handleOscLink', () => {
expect.objectContaining({
filePath: '/home/me/repo/src/main.ts',
relativePath: 'src/main.ts'
})
}),
{ forceContentReload: true }
)
})
@ -723,7 +736,8 @@ describe('handleOscLink', () => {
expect.objectContaining({
filePath: '/home/me/repo/report.html',
relativePath: 'report.html'
})
}),
{ forceContentReload: true }
)
})
@ -872,7 +886,8 @@ describe('handleOscLink', () => {
expect(openFilePathMock).not.toHaveBeenCalled()
expect(openFileMock).toHaveBeenCalledTimes(1)
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/tmp/src/second.ts' })
expect.objectContaining({ filePath: '/tmp/src/second.ts' }),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
@ -1320,7 +1335,8 @@ describe('createFilePathLinkProvider range bounds', () => {
// existence probe; openDetectedFilePath still stats before routing.
expect(window.api.shell.pathExists).not.toHaveBeenCalled()
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/tmp/package.json' })
expect.objectContaining({ filePath: '/tmp/package.json' }),
{ forceContentReload: true }
)
expect(openFilePathMock).not.toHaveBeenCalled()
})
@ -1393,7 +1409,8 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(opened).toBe(true)
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/Users/alice/Documents/Path/file_name' })
expect.objectContaining({ filePath: '/Users/alice/Documents/Path/file_name' }),
{ forceContentReload: true }
)
expect(openFilePathMock).not.toHaveBeenCalled()
})
@ -1417,7 +1434,8 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(opened).toBe(true)
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/home/alice/Documents/Path/file_name' })
expect.objectContaining({ filePath: '/home/alice/Documents/Path/file_name' }),
{ forceContentReload: true }
)
expect(openFilePathMock).not.toHaveBeenCalled()
})
@ -1506,7 +1524,8 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(opened).toBe(true)
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/repo/My Folder' })
expect.objectContaining({ filePath: '/repo/My Folder' }),
{ forceContentReload: true }
)
expect(openFilePathMock).not.toHaveBeenCalled()
})

View File

@ -454,6 +454,62 @@ describe('createEditorSlice openDiff', () => {
expect(store.getState().openFiles[0]?.diffContentReloadNonce).toBe(2)
})
it('bumps fileContentReloadNonce when re-opening an existing clean file with reload requested', () => {
const store = createEditorStore()
const openFileWithReloadRequest = (): void =>
store.getState().openFile(
{
filePath: '/repo/file.ts',
relativePath: 'file.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
},
{ forceContentReload: true }
)
openFileWithReloadRequest()
expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBeUndefined()
openFileWithReloadRequest()
expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBe(1)
openFileWithReloadRequest()
expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBe(2)
})
it('does not bump fileContentReloadNonce when a dirty file is re-opened', () => {
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/file.ts',
relativePath: 'file.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
})
store.getState().markFileDirty('/repo/file.ts', true)
store.getState().openFile(
{
filePath: '/repo/file.ts',
relativePath: 'file.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
},
{ forceContentReload: true }
)
expect(store.getState().openFiles[0]).toEqual(
expect.objectContaining({
isDirty: true,
fileContentReloadNonce: undefined
})
)
})
it('opens the visible diff tab in the requested split group', () => {
const store = createEditorTabsStore()
const sourceTab = store.getState().createUnifiedTab('wt-1', 'terminal', { id: 'terminal-1' })

View File

@ -222,6 +222,9 @@ export type OpenFile = {
* tab from the tree bumps this so the panel refetches instead of reusing a
* stale snapshot. */
diffContentReloadNonce?: number
/** Why: terminal/agent links can be the user's manual recovery path when a
* remote watcher misses an external write. Bumping this refetches clean tabs. */
fileContentReloadNonce?: number
/** Why: CI check full-details tabs are virtual editor tabs backed by fetched
* PR check-run metadata instead of a file on disk. */
checkRunDetails?: OpenCheckRunDetailsState
@ -258,6 +261,7 @@ type EditorOpenTargetOptions = {
targetGroupId?: string
preview?: boolean
runtimeEnvironmentId?: string | null
forceContentReload?: boolean
}
type GitRuntimeOperationOptions = {
@ -404,6 +408,7 @@ export type EditorSlice = {
targetGroupId?: string
recordReplacedPreview?: boolean
suppressActiveRuntimeFallback?: boolean
forceContentReload?: boolean
}
) => void
openNewMarkdownInActiveWorkspace: (groupId: string) => Promise<void>
@ -899,6 +904,19 @@ function withDiffContentReloadRequest(file: OpenFile): OpenFile {
}
}
function shouldRequestExistingFileContentReload(
existing: OpenFile,
nextMode: OpenFile['mode'],
options: EditorOpenTargetOptions | undefined
): boolean {
return (
options?.forceContentReload === true &&
!existing.isDirty &&
(existing.mode === 'edit' || existing.mode === 'markdown-preview') &&
(nextMode === 'edit' || nextMode === 'markdown-preview')
)
}
function isEditorFileIdOccupiedByOtherOwner(
file: Pick<
OpenFile,
@ -1703,6 +1721,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
if (existing) {
// If opening as non-preview, also pin the existing tab
const updatedPreview = isPreview ? existing.isPreview : false
const fileContentReloadNonce = shouldRequestExistingFileContentReload(
existing,
file.mode,
options
)
? (existing.fileContentReloadNonce ?? 0) + 1
: existing.fileContentReloadNonce
const needsExistingUpdate =
existing.mode !== file.mode ||
existing.diffSource !== file.diffSource ||
@ -1716,7 +1741,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
existing.language !== file.language ||
existing.relativePath !== file.relativePath ||
existing.worktreeId !== file.worktreeId ||
existing.runtimeEnvironmentId !== runtimeEnvironmentId
existing.runtimeEnvironmentId !== runtimeEnvironmentId ||
existing.fileContentReloadNonce !== fileContentReloadNonce
if (!needsExistingUpdate) {
return activeResult
}
@ -1740,7 +1766,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
conflict: file.conflict,
skippedConflicts: file.skippedConflicts,
conflictReview: file.conflictReview,
isPreview: updatedPreview
isPreview: updatedPreview,
fileContentReloadNonce
}
: f
),