Keep editor tabs in sync with external file changes instead of silently dropping them (#7591)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
9cc177b8ea
commit
482bfbc9bb
|
|
@ -1,13 +1,21 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { attachEditorAutosaveController } from './editor-autosave-controller'
|
||||
import { attachRestoredTabConflictScan } from './editor-restored-tab-conflict-scan'
|
||||
|
||||
export default function EditorAutosaveController(): null {
|
||||
useEffect(() => {
|
||||
// Why: autosave and quit coordination need to survive editor tab switches,
|
||||
// but keeping the full EditorPanel mounted while hidden widened the restart
|
||||
// surface too far. Keep only this narrow controller alive between mounts.
|
||||
return attachEditorAutosaveController(useAppStore)
|
||||
const detachAutosave = attachEditorAutosaveController(useAppStore)
|
||||
// Why: restored dirty tabs must be conflict-checked app-level, before any
|
||||
// panel mounts — autosave can otherwise write over an offline agent edit.
|
||||
const detachConflictScan = attachRestoredTabConflictScan(useAppStore)
|
||||
return () => {
|
||||
detachAutosave()
|
||||
detachConflictScan()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
|
||||
// Why: EditorContent's mode renderers (Monaco, DiffViewer, ...) are lazy();
|
||||
// renderToStaticMarkup cannot resolve them. Stubbing them keeps the banner
|
||||
// branch structure renderable so its placement is pinned by tests.
|
||||
vi.mock('@/lib/lazy-with-retry', () => ({
|
||||
lazyWithRetry: () => () => null
|
||||
}))
|
||||
|
||||
import { EditorContent, getMarkdownSourceLineOffset } from './EditorContent'
|
||||
|
||||
function createOpenFile(overrides: Partial<OpenFile> = {}): OpenFile {
|
||||
|
|
@ -67,7 +75,7 @@ describe('EditorContent', () => {
|
|||
handleDirtyStateHint={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleSaveForFile={vi.fn()}
|
||||
reloadFileContent={vi.fn()}
|
||||
reloadContent={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
@ -75,4 +83,137 @@ describe('EditorContent', () => {
|
|||
expect(html).toContain('Access denied')
|
||||
expect(html).not.toContain('Unable to render notebook')
|
||||
})
|
||||
|
||||
it('shows the changed-on-disk banner above a dirty edit tab', () => {
|
||||
const activeFile = createOpenFile({
|
||||
id: '/repo/file.ts',
|
||||
filePath: '/repo/file.ts',
|
||||
relativePath: 'file.ts',
|
||||
language: 'typescript',
|
||||
isDirty: true,
|
||||
externalMutation: 'changed'
|
||||
})
|
||||
const html = renderToStaticMarkup(
|
||||
<EditorContent
|
||||
activeFile={activeFile}
|
||||
viewStateScopeId={activeFile.id}
|
||||
fileContents={{ [activeFile.id]: { content: 'saved text', isBinary: false } }}
|
||||
diffContents={{}}
|
||||
editBuffers={{ [activeFile.id]: 'saved text plus edits' }}
|
||||
openFiles={[activeFile]}
|
||||
worktreeEntries={[]}
|
||||
resolvedLanguage="typescript"
|
||||
isMarkdown={false}
|
||||
isMermaid={false}
|
||||
isCsv={false}
|
||||
isNotebook={false}
|
||||
mdViewMode="rich"
|
||||
isChangesMode={false}
|
||||
sideBySide={false}
|
||||
pendingEditorReveal={null}
|
||||
handleContentChange={vi.fn()}
|
||||
handleContentChangeForFile={vi.fn()}
|
||||
handleDirtyStateHint={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleSaveForFile={vi.fn()}
|
||||
reloadContent={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('role="alert"')
|
||||
expect(html).toContain('changed on disk')
|
||||
expect(html).toContain('Reload from Disk')
|
||||
})
|
||||
|
||||
it('shows the changed-on-disk banner above a dirty unstaged diff without collapsing it', () => {
|
||||
const activeFile = createOpenFile({
|
||||
id: 'diff:/repo/file.ts',
|
||||
filePath: '/repo/file.ts',
|
||||
relativePath: 'file.ts',
|
||||
language: 'typescript',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged',
|
||||
isDirty: true,
|
||||
externalMutation: 'changed'
|
||||
})
|
||||
const html = renderToStaticMarkup(
|
||||
<EditorContent
|
||||
activeFile={activeFile}
|
||||
viewStateScopeId={activeFile.id}
|
||||
fileContents={{}}
|
||||
diffContents={{
|
||||
[activeFile.id]: { kind: 'text', originalContent: 'old', modifiedContent: 'new' } as never
|
||||
}}
|
||||
editBuffers={{ [activeFile.id]: 'new plus edits' }}
|
||||
openFiles={[activeFile]}
|
||||
worktreeEntries={[]}
|
||||
resolvedLanguage="typescript"
|
||||
isMarkdown={false}
|
||||
isMermaid={false}
|
||||
isCsv={false}
|
||||
isNotebook={false}
|
||||
mdViewMode="rich"
|
||||
isChangesMode={false}
|
||||
sideBySide={false}
|
||||
pendingEditorReveal={null}
|
||||
handleContentChange={vi.fn()}
|
||||
handleContentChangeForFile={vi.fn()}
|
||||
handleDirtyStateHint={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleSaveForFile={vi.fn()}
|
||||
reloadContent={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('role="alert"')
|
||||
expect(html).toContain('changed on disk')
|
||||
// Why: the diff-mode wrapper must give the viewer its height back — a
|
||||
// flex-1-only wrapper collapsed the DiffViewer to 0px (found live).
|
||||
expect(html).toContain('flex h-full min-h-0 flex-col')
|
||||
})
|
||||
|
||||
it('shows the changed-on-disk banner on a dirty markdown diff in preview mode', () => {
|
||||
const activeFile = createOpenFile({
|
||||
id: 'diff:/repo/notes.md',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
language: 'markdown',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged',
|
||||
isDirty: true,
|
||||
externalMutation: 'changed'
|
||||
})
|
||||
const html = renderToStaticMarkup(
|
||||
<EditorContent
|
||||
activeFile={activeFile}
|
||||
viewStateScopeId={activeFile.id}
|
||||
fileContents={{}}
|
||||
diffContents={{
|
||||
[activeFile.id]: { kind: 'text', originalContent: 'old', modifiedContent: 'new' } as never
|
||||
}}
|
||||
editBuffers={{ [activeFile.id]: 'new plus edits' }}
|
||||
openFiles={[activeFile]}
|
||||
worktreeEntries={[]}
|
||||
resolvedLanguage="markdown"
|
||||
isMarkdown
|
||||
isMermaid={false}
|
||||
isCsv={false}
|
||||
isNotebook={false}
|
||||
mdViewMode="preview"
|
||||
isChangesMode={false}
|
||||
sideBySide={false}
|
||||
pendingEditorReveal={null}
|
||||
handleContentChange={vi.fn()}
|
||||
handleContentChangeForFile={vi.fn()}
|
||||
handleDirtyStateHint={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleSaveForFile={vi.fn()}
|
||||
reloadContent={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('role="alert"')
|
||||
expect(html).toContain('changed on disk')
|
||||
expect(html).toContain('Previewing the modified version of this diff')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { getDiffContentSignature } from './diff-content-signature'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { CheckRunDetailsPanel } from './CheckRunDetailsPanel'
|
||||
import { ExternalFileChangeBanner } from './ExternalFileChangeBanner'
|
||||
|
||||
const MonacoEditor = lazy(() => import('./MonacoEditor'))
|
||||
const DiffViewer = lazy(() => import('./DiffViewer'))
|
||||
|
|
@ -139,7 +140,7 @@ export function EditorContent({
|
|||
handleDirtyStateHint,
|
||||
handleSave,
|
||||
handleSaveForFile,
|
||||
reloadFileContent
|
||||
reloadContent
|
||||
}: {
|
||||
activeFile: OpenFile
|
||||
viewStateScopeId: string
|
||||
|
|
@ -166,7 +167,7 @@ export function EditorContent({
|
|||
handleDirtyStateHint: (dirty: boolean) => void
|
||||
handleSave: (content: string) => Promise<void>
|
||||
handleSaveForFile: (file: OpenFile, content: string) => Promise<void>
|
||||
reloadFileContent: (file: OpenFile) => void
|
||||
reloadContent: (file: OpenFile) => void
|
||||
}): React.JSX.Element {
|
||||
const editorViewStateKey =
|
||||
viewStateScopeId === activeFile.id
|
||||
|
|
@ -505,10 +506,7 @@ export function EditorContent({
|
|||
if (fc.loadError) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<FileLoadErrorView
|
||||
message={fc.loadError}
|
||||
onRetry={() => reloadFileContent(contentFile)}
|
||||
/>
|
||||
<FileLoadErrorView message={fc.loadError} onRetry={() => reloadContent(contentFile)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -709,9 +707,7 @@ export function EditorContent({
|
|||
)
|
||||
}
|
||||
if (fc.loadError) {
|
||||
return (
|
||||
<FileLoadErrorView message={fc.loadError} onRetry={() => reloadFileContent(activeFile)} />
|
||||
)
|
||||
return <FileLoadErrorView message={fc.loadError} onRetry={() => reloadContent(activeFile)} />
|
||||
}
|
||||
if (fc.isBinary) {
|
||||
return (
|
||||
|
|
@ -758,9 +754,7 @@ export function EditorContent({
|
|||
)
|
||||
}
|
||||
if (fc.loadError) {
|
||||
return (
|
||||
<FileLoadErrorView message={fc.loadError} onRetry={() => reloadFileContent(activeFile)} />
|
||||
)
|
||||
return <FileLoadErrorView message={fc.loadError} onRetry={() => reloadContent(activeFile)} />
|
||||
}
|
||||
if (fc.isBinary) {
|
||||
if (fc.isImage) {
|
||||
|
|
@ -777,8 +771,16 @@ export function EditorContent({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
const externalChangeBanner =
|
||||
activeFile.externalMutation === 'changed' ? (
|
||||
<ExternalFileChangeBanner
|
||||
file={activeFile}
|
||||
currentContent={editBuffers[activeFile.id] ?? fc.content}
|
||||
reloadContent={reloadContent}
|
||||
/>
|
||||
) : null
|
||||
if (isChangesMode) {
|
||||
return (
|
||||
const changesView = (
|
||||
<ChangesModeView
|
||||
activeFile={activeFile}
|
||||
dc={diffContents[activeFile.id]}
|
||||
|
|
@ -792,9 +794,19 @@ export function EditorContent({
|
|||
onSave={isMarkdown ? md.mdSave : handleSave}
|
||||
/>
|
||||
)
|
||||
if (!externalChangeBanner) {
|
||||
return changesView
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{externalChangeBanner}
|
||||
<div className="min-h-0 flex-1">{changesView}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{externalChangeBanner}
|
||||
{activeFile.conflict && (
|
||||
<ConflictBanner
|
||||
file={activeFile}
|
||||
|
|
@ -890,9 +902,20 @@ export function EditorContent({
|
|||
modifiedDiffBuffer === undefined &&
|
||||
dc.modifiedContent.length === 0
|
||||
)
|
||||
// Why: rendered once for every diff sub-branch below (preview and source)
|
||||
// so a dirty markdown diff in preview mode surfaces the conflict too.
|
||||
const diffExternalChangeBanner =
|
||||
activeFile.externalMutation === 'changed' ? (
|
||||
<ExternalFileChangeBanner
|
||||
file={activeFile}
|
||||
currentContent={modifiedDiffContent}
|
||||
reloadContent={reloadContent}
|
||||
/>
|
||||
) : null
|
||||
if (isMarkdown && mdViewMode === 'preview' && dc.largeDiffRenderLimit?.limited !== true) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{diffExternalChangeBanner}
|
||||
<div className="border-b border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
{/* Why: a rendered markdown preview cannot express additions and
|
||||
deletions simultaneously, so preview mode intentionally shows the
|
||||
|
|
@ -927,7 +950,7 @@ export function EditorContent({
|
|||
const diffReloadNonce = activeFile.diffContentReloadNonce ?? 0
|
||||
const originalModelKey = `${diffViewStateKey}:original:${getDiffContentSignature(dc.originalContent)}`
|
||||
const modifiedModelKey = `${diffViewStateKey}:modified:${getDiffContentSignature(dc.modifiedContent)}:${diffReloadNonce}`
|
||||
return (
|
||||
const diffViewer = (
|
||||
<DiffViewer
|
||||
key={`${viewStateScopeId}:${diffReloadNonce}:${getDiffContentSignature(dc.modifiedContent)}`}
|
||||
modelKey={diffViewStateKey}
|
||||
|
|
@ -947,6 +970,22 @@ export function EditorContent({
|
|||
onSave={isEditable ? (isMarkdown ? md.mdSave : handleSave) : undefined}
|
||||
/>
|
||||
)
|
||||
// Why: editable unstaged diffs can hold unsaved edits, so they get the same
|
||||
// changed-on-disk recovery banner as edit tabs; its reload refetches the
|
||||
// diff body rather than plain file content.
|
||||
if (activeFile.externalMutation !== 'changed') {
|
||||
return diffViewer
|
||||
}
|
||||
return (
|
||||
// Why: h-full (not flex-1) — the diff-mode container is not a flex parent,
|
||||
// so flex-1 resolves to zero height and collapses this wrapper. The inner
|
||||
// div must itself be a flex column because DiffViewer's root sizes with
|
||||
// flex-1 and collapses to 0px inside a block parent.
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{diffExternalChangeBanner}
|
||||
<div className="flex min-h-0 flex-1 flex-col">{diffViewer}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a minimal read-only banner that shows the raw front-matter content
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ function EditorPanelInner({
|
|||
activeFile.mode === 'edit' &&
|
||||
canUseChangesModeForFile(activeFile) &&
|
||||
editorViewMode[activeFile.id] === 'changes'
|
||||
const { fileContents, diffContents, reloadFileContent } = useEditorPanelContentState({
|
||||
const { fileContents, diffContents, reloadContent } = useEditorPanelContentState({
|
||||
activeFile,
|
||||
isChangesMode: requestedChangesMode,
|
||||
openFiles,
|
||||
|
|
@ -377,7 +377,7 @@ function EditorPanelInner({
|
|||
onDirtyStateHint={handleDirtyStateHint}
|
||||
onSave={handleSave}
|
||||
onSaveForFile={handleSaveForFile}
|
||||
onReloadFileContent={reloadFileContent}
|
||||
onReloadContent={reloadContent}
|
||||
onCloseMarkdownTableOfContents={() =>
|
||||
setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ type EditorPanelShellProps = {
|
|||
onDirtyStateHint: (dirty: boolean) => void
|
||||
onSave: (content: string) => Promise<void>
|
||||
onSaveForFile: (file: OpenFile, content: string) => Promise<void>
|
||||
onReloadFileContent: (file: OpenFile) => void
|
||||
onReloadContent: (file: OpenFile) => void
|
||||
onCloseMarkdownTableOfContents: () => void
|
||||
onCloseRenameDialog: () => void
|
||||
onRenameConfirm: (newRelPath: string) => Promise<void>
|
||||
|
|
@ -86,7 +86,7 @@ export function EditorPanelShell({
|
|||
onDirtyStateHint,
|
||||
onSave,
|
||||
onSaveForFile,
|
||||
onReloadFileContent,
|
||||
onReloadContent,
|
||||
onCloseMarkdownTableOfContents,
|
||||
onCloseRenameDialog,
|
||||
onRenameConfirm,
|
||||
|
|
@ -152,7 +152,7 @@ export function EditorPanelShell({
|
|||
handleDirtyStateHint={onDirtyStateHint}
|
||||
handleSave={onSave}
|
||||
handleSaveForFile={onSaveForFile}
|
||||
reloadFileContent={onReloadFileContent}
|
||||
reloadContent={onReloadContent}
|
||||
showMarkdownTableOfContents={showMarkdownTableOfContents}
|
||||
showMarkdownFrontmatter={markdownFrontmatterVisible}
|
||||
onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
|
||||
const toastMock = vi.hoisted(() => vi.fn())
|
||||
const readRuntimeFileContentMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('sonner', () => ({
|
||||
toast: toastMock
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-file-client', () => ({
|
||||
readRuntimeFileContent: readRuntimeFileContentMock
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
settingsForRuntimeOwner: () => null
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionIdForFile: () => undefined
|
||||
}))
|
||||
|
||||
import {
|
||||
ExternalFileChangeBanner,
|
||||
keepTabEditsOverExternalChange,
|
||||
reloadTabContentFromDisk
|
||||
} from './ExternalFileChangeBanner'
|
||||
import { getDiskBaselineSignature } from './diff-content-signature'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const file = {
|
||||
id: 'file-1',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
worktreeId: 'wt-1',
|
||||
mode: 'edit',
|
||||
isDirty: true,
|
||||
externalMutation: 'changed'
|
||||
} as OpenFile
|
||||
|
||||
describe('ExternalFileChangeBanner', () => {
|
||||
const clearEditorDraft = vi.fn()
|
||||
const markFileDirty = vi.fn()
|
||||
const setExternalMutation = vi.fn()
|
||||
const setEditorDraft = vi.fn()
|
||||
const setLastKnownDiskSignature = vi.fn()
|
||||
|
||||
function mockStoreState(editorDrafts: Record<string, string>, openFiles: OpenFile[] = [file]) {
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
clearEditorDraft,
|
||||
markFileDirty,
|
||||
setExternalMutation,
|
||||
setEditorDraft,
|
||||
setLastKnownDiskSignature,
|
||||
editorDrafts,
|
||||
openFiles,
|
||||
settings: {}
|
||||
} as never)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
readRuntimeFileContentMock.mockResolvedValue({ content: 'disk content', isBinary: false })
|
||||
mockStoreState({})
|
||||
})
|
||||
|
||||
it('renders the overwrite warning, all three actions, and an alert role', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalFileChangeBanner file={file} currentContent="buffer" reloadContent={vi.fn()} />
|
||||
)
|
||||
expect(html).toContain('role="alert"')
|
||||
expect(html).toContain('changed on disk')
|
||||
expect(html).toContain('Saving will overwrite')
|
||||
expect(html).toContain('Compare')
|
||||
expect(html).toContain('Reload from Disk')
|
||||
expect(html).toContain('Keep My Edits')
|
||||
})
|
||||
|
||||
it('reload clears the draft, dirty flag, and mark before refetching content', () => {
|
||||
mockStoreState({ 'file-1': 'unsaved text' })
|
||||
const calls: string[] = []
|
||||
clearEditorDraft.mockImplementation(() => calls.push('clearEditorDraft'))
|
||||
markFileDirty.mockImplementation(() => calls.push('markFileDirty'))
|
||||
setExternalMutation.mockImplementation(() => calls.push('setExternalMutation'))
|
||||
const reloadContent = vi.fn(() => calls.push('reloadContent'))
|
||||
|
||||
reloadTabContentFromDisk(file, reloadContent)
|
||||
|
||||
expect(clearEditorDraft).toHaveBeenCalledWith('file-1')
|
||||
expect(markFileDirty).toHaveBeenCalledWith('file-1', false)
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-1', null)
|
||||
expect(reloadContent).toHaveBeenCalledWith(file)
|
||||
// Why: the draft shadows loaded content (editBuffers ?? fileContents), so
|
||||
// the refetch must come last or the stale unsaved text stays visible.
|
||||
expect(calls).toEqual([
|
||||
'clearEditorDraft',
|
||||
'markFileDirty',
|
||||
'setExternalMutation',
|
||||
'reloadContent'
|
||||
])
|
||||
})
|
||||
|
||||
it('reload offers an undo toast that restores the discarded draft and the conflict', () => {
|
||||
mockStoreState({ 'file-1': 'discarded draft' })
|
||||
|
||||
reloadTabContentFromDisk(file, vi.fn())
|
||||
|
||||
expect(toastMock).toHaveBeenCalledTimes(1)
|
||||
const options = toastMock.mock.calls[0][1] as {
|
||||
action: { label: string; onClick: () => void }
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
// Why: after a real reload the tab is clean with no draft — the undo
|
||||
// guard only restores over that untouched state.
|
||||
mockStoreState({}, [{ ...file, isDirty: false, externalMutation: undefined } as OpenFile])
|
||||
|
||||
options.action.onClick()
|
||||
|
||||
expect(setEditorDraft).toHaveBeenCalledWith('file-1', 'discarded draft')
|
||||
expect(markFileDirty).toHaveBeenCalledWith('file-1', true)
|
||||
// Why: disk still differs from the restored draft — the conflict (and its
|
||||
// autosave suspension) must return with the edits.
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-1', 'changed')
|
||||
})
|
||||
|
||||
it('undo is a no-op when the tab closed while the toast was up', () => {
|
||||
mockStoreState({ 'file-1': 'discarded draft' })
|
||||
|
||||
reloadTabContentFromDisk(file, vi.fn())
|
||||
|
||||
const options = toastMock.mock.calls[0][1] as {
|
||||
action: { label: string; onClick: () => void }
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
mockStoreState({}, [])
|
||||
|
||||
options.action.onClick()
|
||||
|
||||
expect(setEditorDraft).not.toHaveBeenCalled()
|
||||
expect(markFileDirty).not.toHaveBeenCalled()
|
||||
expect(setExternalMutation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('undo is a no-op when the user typed or saved after the reload', () => {
|
||||
mockStoreState({ 'file-1': 'discarded draft' })
|
||||
|
||||
reloadTabContentFromDisk(file, vi.fn())
|
||||
|
||||
const options = toastMock.mock.calls[0][1] as {
|
||||
action: { label: string; onClick: () => void }
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
// Why: post-reload edits are newer intent than the discarded draft —
|
||||
// undoing over them would be a second silent discard.
|
||||
mockStoreState({ 'file-1': 'newer post-reload edits' }, [
|
||||
{ ...file, isDirty: true, externalMutation: undefined } as OpenFile
|
||||
])
|
||||
|
||||
options.action.onClick()
|
||||
|
||||
expect(setEditorDraft).not.toHaveBeenCalled()
|
||||
expect(setExternalMutation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not toast when there was no draft to restore', () => {
|
||||
mockStoreState({})
|
||||
|
||||
reloadTabContentFromDisk(file, vi.fn())
|
||||
|
||||
expect(toastMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('undo restores the pre-reload disk signature with the draft', async () => {
|
||||
// Why: the reload re-stamps the baseline to the new disk content; without
|
||||
// restoring the old signature the restart scan sees disk == baseline and
|
||||
// silently drops the conflict the undo just brought back.
|
||||
mockStoreState({ 'file-1': 'discarded draft' }, [
|
||||
{ ...file, lastKnownDiskSignature: 'pre-reload-signature' } as OpenFile
|
||||
])
|
||||
|
||||
reloadTabContentFromDisk(file, vi.fn())
|
||||
|
||||
const options = toastMock.mock.calls[0][1] as {
|
||||
action: { label: string; onClick: () => void }
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
mockStoreState({}, [{ ...file, isDirty: false, externalMutation: undefined } as OpenFile])
|
||||
|
||||
options.action.onClick()
|
||||
|
||||
expect(setLastKnownDiskSignature).toHaveBeenCalledWith('file-1', 'pre-reload-signature')
|
||||
})
|
||||
|
||||
it('keep-my-edits clears the mark without touching the draft or dirty flag', () => {
|
||||
keepTabEditsOverExternalChange(file)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-1', null)
|
||||
expect(clearEditorDraft).not.toHaveBeenCalled()
|
||||
expect(markFileDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keep-my-edits advances the disk baseline so the dismissal survives restart', async () => {
|
||||
readRuntimeFileContentMock.mockResolvedValue({ content: 'agent content', isBinary: false })
|
||||
mockStoreState({}, [{ ...file, externalMutation: undefined } as OpenFile])
|
||||
|
||||
keepTabEditsOverExternalChange(file)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(setLastKnownDiskSignature).toHaveBeenCalledWith(
|
||||
'file-1',
|
||||
getDiskBaselineSignature('agent content')
|
||||
)
|
||||
})
|
||||
|
||||
it('keep-my-edits does not stamp a baseline over a newer conflict', async () => {
|
||||
// Why: if the file changed again before the read resolved, the fresh
|
||||
// 'changed' mark owns the baseline — stamping would hide that conflict
|
||||
// from the restart scan.
|
||||
readRuntimeFileContentMock.mockResolvedValue({ content: 'even newer', isBinary: false })
|
||||
mockStoreState({}, [{ ...file, externalMutation: 'changed' } as OpenFile])
|
||||
|
||||
keepTabEditsOverExternalChange(file)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(setLastKnownDiskSignature).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import React, { useState } from 'react'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getConnectionIdForFile } from '@/lib/connection-context'
|
||||
import { readRuntimeFileContent } from '@/runtime/runtime-file-client'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { ExternalFileChangeCompareDialog } from './ExternalFileChangeCompareDialog'
|
||||
import { getDiskBaselineSignature } from './diff-content-signature'
|
||||
import { trackExternalChangeConflictAction } from './editor-external-change-telemetry'
|
||||
|
||||
// Why: when an external process (usually an agent) rewrites a file while the
|
||||
// tab holds unsaved edits, the reload pipeline preserves the buffer and marks
|
||||
// the tab externalMutation='changed' (issue #7265). This banner is the
|
||||
// recovery path — without it the tab is silently stale until close/reopen and
|
||||
// the next save clobbers the newer disk content unannounced.
|
||||
|
||||
const RELOAD_UNDO_TOAST_DURATION_MS = 8_000
|
||||
|
||||
export function reloadTabContentFromDisk(
|
||||
file: OpenFile,
|
||||
reloadContent: (file: OpenFile) => void
|
||||
): void {
|
||||
const state = useAppStore.getState()
|
||||
const discardedDraft = state.editorDrafts[file.id]
|
||||
const discardedDiskSignature = state.openFiles.find(
|
||||
(openFile) => openFile.id === file.id
|
||||
)?.lastKnownDiskSignature
|
||||
// Why: drop the draft before reloading — the buffer shadows loaded content
|
||||
// (editBuffers ?? fileContents), so a reload alone would keep showing the
|
||||
// stale unsaved text.
|
||||
state.clearEditorDraft(file.id)
|
||||
state.markFileDirty(file.id, false)
|
||||
state.setExternalMutation(file.id, null)
|
||||
reloadContent(file)
|
||||
if (discardedDraft === undefined) {
|
||||
return
|
||||
}
|
||||
// Why: on diff tabs the reload rotates the Monaco model, destroying the undo
|
||||
// stack — without this toast a mistaken click is an unrecoverable discard.
|
||||
toast(
|
||||
translate('auto.components.editor.ExternalFileChangeBanner.5c02de9b31', 'Reloaded from disk'),
|
||||
{
|
||||
description: file.relativePath,
|
||||
duration: RELOAD_UNDO_TOAST_DURATION_MS,
|
||||
action: {
|
||||
label: translate('auto.components.editor.ExternalFileChangeBanner.d1e830fa22', 'Undo'),
|
||||
onClick: () => {
|
||||
const current = useAppStore.getState()
|
||||
const liveFile = current.openFiles.find((openFile) => openFile.id === file.id)
|
||||
// Why: the tab may have closed while the toast was up; restoring a
|
||||
// draft for a dead fileId would strand an orphan buffer. And if the
|
||||
// user already typed after the reload (dirty), that newer work wins
|
||||
// — undoing over it would be a second silent discard. isDirty is the
|
||||
// signal: the editor content-sync repopulates editorDrafts with the
|
||||
// reloaded content itself, so draft existence proves nothing.
|
||||
if (!liveFile || liveFile.isDirty) {
|
||||
return
|
||||
}
|
||||
current.setEditorDraft(file.id, discardedDraft)
|
||||
current.markFileDirty(file.id, true)
|
||||
// Why: the disk still differs from the restored draft, so the
|
||||
// conflict (and its autosave suspension) must come back with it.
|
||||
current.setExternalMutation(file.id, 'changed')
|
||||
if (discardedDiskSignature !== undefined) {
|
||||
// Why: the reload re-stamped the baseline to the new disk content;
|
||||
// restoring the pre-reload signature with the draft keeps the
|
||||
// restart scan re-deriving the conflict the undo just brought back.
|
||||
current.setLastKnownDiskSignature(file.id, discardedDiskSignature)
|
||||
}
|
||||
trackExternalChangeConflictAction(file, 'undo_reload')
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function keepTabEditsOverExternalChange(file: OpenFile): void {
|
||||
const state = useAppStore.getState()
|
||||
state.setExternalMutation(file.id, null)
|
||||
// Why: the dismissal must survive restart — without advancing the baseline
|
||||
// to the current disk content, the restored-tab conflict scan re-derives
|
||||
// the dismissed conflict from the stale signature on every launch.
|
||||
// Best-effort: a failed read leaves the old signature, which can only
|
||||
// re-surface the banner — never lose data.
|
||||
void readRuntimeFileContent({
|
||||
settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId),
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
worktreeId: file.worktreeId,
|
||||
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.isBinary) {
|
||||
return
|
||||
}
|
||||
const current = useAppStore.getState()
|
||||
const liveFile = current.openFiles.find((openFile) => openFile.id === file.id)
|
||||
// Why: only stamp while the dismissal still stands — a save or a newer
|
||||
// conflict marked in the interim owns the baseline.
|
||||
if (!liveFile || liveFile.externalMutation === 'changed') {
|
||||
return
|
||||
}
|
||||
current.setLastKnownDiskSignature(file.id, getDiskBaselineSignature(result.content))
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export function ExternalFileChangeBanner({
|
||||
file,
|
||||
currentContent,
|
||||
reloadContent
|
||||
}: {
|
||||
file: OpenFile
|
||||
/** The tab's live buffer (draft if dirty) — what "Keep My Edits" keeps. */
|
||||
currentContent: string
|
||||
/** Refetches the tab's content — file body for edit tabs, diff body for
|
||||
* unstaged diff tabs. */
|
||||
reloadContent: (file: OpenFile) => void
|
||||
}): React.JSX.Element {
|
||||
const [compareOpen, setCompareOpen] = useState(false)
|
||||
|
||||
const handleReload = (): void => {
|
||||
trackExternalChangeConflictAction(file, 'reload')
|
||||
reloadTabContentFromDisk(file, reloadContent)
|
||||
}
|
||||
const handleKeepEdits = (): void => {
|
||||
trackExternalChangeConflictAction(file, 'keep')
|
||||
keepTabEditsOverExternalChange(file)
|
||||
}
|
||||
const handleCompare = (): void => {
|
||||
trackExternalChangeConflictAction(file, 'compare')
|
||||
setCompareOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
// Why: role=alert because the banner appears asynchronously (an agent
|
||||
// rewrote the file) — screen readers must announce it unprompted.
|
||||
<div role="alert" className="border-b border-amber-500/20 bg-amber-500/10 px-4 py-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<TriangleAlert className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
{/* Why: wraps instead of truncating — the overwrite warning at the
|
||||
end of the sentence is the part the user must not lose. */}
|
||||
<span className="min-w-0 font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeBanner.7c41e90d12',
|
||||
'This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" size="xs" variant="outline" onClick={handleCompare}>
|
||||
{translate('auto.components.editor.ExternalFileChangeBanner.90b2ce7d43', 'Compare')}
|
||||
</Button>
|
||||
<Button type="button" size="xs" variant="outline" onClick={handleReload}>
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeBanner.3fa2b8d417',
|
||||
'Reload from Disk'
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" size="xs" variant="ghost" onClick={handleKeepEdits}>
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeBanner.a95d02c644',
|
||||
'Keep My Edits'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{compareOpen && (
|
||||
<ExternalFileChangeCompareDialog
|
||||
file={file}
|
||||
currentContent={currentContent}
|
||||
open={compareOpen}
|
||||
onOpenChange={setCompareOpen}
|
||||
onReload={handleReload}
|
||||
onKeepEdits={handleKeepEdits}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readRuntimeFileContent: vi.fn(),
|
||||
getConnectionIdForFile: vi.fn(),
|
||||
getState: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-file-client', () => ({
|
||||
readRuntimeFileContent: mocks.readRuntimeFileContent
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
settingsForRuntimeOwner: () => null
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionIdForFile: mocks.getConnectionIdForFile
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: mocks.getState }
|
||||
}))
|
||||
// Why: the lazy DiffViewer chunk cannot resolve under happy-dom; a stub that
|
||||
// echoes its props pins the disk-left/buffer-right wiring instead.
|
||||
vi.mock('@/lib/lazy-with-retry', () => ({
|
||||
lazyWithRetry: () => (props: { originalContent: string; modifiedContent: string }) => (
|
||||
<div data-testid="diff-stub">
|
||||
original:{props.originalContent}|modified:{props.modifiedContent}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
import { ExternalFileChangeCompareDialog } from './ExternalFileChangeCompareDialog'
|
||||
|
||||
const file = {
|
||||
id: 'file-1',
|
||||
filePath: '/repo/notes.ts',
|
||||
relativePath: 'notes.ts',
|
||||
worktreeId: 'wt-1',
|
||||
mode: 'edit',
|
||||
isDirty: true,
|
||||
externalMutation: 'changed'
|
||||
} as OpenFile
|
||||
|
||||
describe('ExternalFileChangeCompareDialog', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.readRuntimeFileContent.mockReset()
|
||||
mocks.getConnectionIdForFile.mockReset()
|
||||
mocks.getConnectionIdForFile.mockReturnValue(undefined)
|
||||
mocks.getState.mockReturnValue({ settings: null })
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
container?.remove()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function render(element: React.JSX.Element): Promise<void> {
|
||||
await act(async () => {
|
||||
root = createRoot(container!)
|
||||
root.render(element)
|
||||
})
|
||||
}
|
||||
|
||||
it('shows the disk version left and the buffer right once the read resolves', async () => {
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'disk version', isBinary: false })
|
||||
|
||||
await render(
|
||||
<ExternalFileChangeCompareDialog
|
||||
file={file}
|
||||
currentContent="buffer version"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onReload={vi.fn()}
|
||||
onKeepEdits={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.body.textContent).toContain('File changed on disk')
|
||||
expect(document.body.textContent).toContain('original:disk version|modified:buffer version')
|
||||
expect(document.body.textContent).toContain('Reload from Disk')
|
||||
expect(document.body.textContent).toContain('Keep My Edits')
|
||||
})
|
||||
|
||||
it('surfaces a read failure instead of a blank comparison', async () => {
|
||||
mocks.readRuntimeFileContent.mockRejectedValue(new Error('transport down'))
|
||||
|
||||
await render(
|
||||
<ExternalFileChangeCompareDialog
|
||||
file={file}
|
||||
currentContent="buffer version"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onReload={vi.fn()}
|
||||
onKeepEdits={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.body.textContent).toContain('Could not read the file from disk')
|
||||
expect(document.body.textContent).toContain('transport down')
|
||||
})
|
||||
|
||||
it('wires the footer actions and closes the dialog around them', async () => {
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'disk version', isBinary: false })
|
||||
const onReload = vi.fn()
|
||||
const onKeepEdits = vi.fn()
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
await render(
|
||||
<ExternalFileChangeCompareDialog
|
||||
file={file}
|
||||
currentContent="buffer version"
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onReload={onReload}
|
||||
onKeepEdits={onKeepEdits}
|
||||
/>
|
||||
)
|
||||
|
||||
const buttons = Array.from(document.body.querySelectorAll('button'))
|
||||
await act(async () => {
|
||||
buttons.find((b) => b.textContent === 'Reload from Disk')?.click()
|
||||
})
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
expect(onReload).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
buttons.find((b) => b.textContent === 'Keep My Edits')?.click()
|
||||
})
|
||||
expect(onKeepEdits).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import React, { Suspense, useEffect, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
|
||||
import { getConnectionIdForFile } from '@/lib/connection-context'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { readRuntimeFileContent } from '@/runtime/runtime-file-client'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const DiffViewer = lazy(() => import('./DiffViewer'))
|
||||
|
||||
type DiskReadState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'binary' }
|
||||
| { kind: 'ready'; content: string }
|
||||
|
||||
// Why: choosing between "Reload from Disk" and "Keep My Edits" blind is the
|
||||
// sharpest edge of the changed-on-disk banner — this dialog shows exactly
|
||||
// what each choice discards before the user commits (issue #7265 follow-up).
|
||||
export function ExternalFileChangeCompareDialog({
|
||||
file,
|
||||
currentContent,
|
||||
open,
|
||||
onOpenChange,
|
||||
onReload,
|
||||
onKeepEdits
|
||||
}: {
|
||||
file: OpenFile
|
||||
/** The tab's live buffer — the unsaved edits the user would keep. */
|
||||
currentContent: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onReload: () => void
|
||||
onKeepEdits: () => void
|
||||
}): React.JSX.Element {
|
||||
const [diskState, setDiskState] = useState<DiskReadState>({ kind: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setDiskState({ kind: 'loading' })
|
||||
// Why: read at open time — the banner can be minutes old and the agent
|
||||
// may have written again since; the comparison must show current disk.
|
||||
void readRuntimeFileContent({
|
||||
settings: settingsForRuntimeOwner(useAppStore.getState().settings, file.runtimeEnvironmentId),
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
worktreeId: file.worktreeId,
|
||||
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setDiskState(
|
||||
result.isBinary ? { kind: 'binary' } : { kind: 'ready', content: result.content }
|
||||
)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setDiskState({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, file.filePath, file.relativePath, file.worktreeId, file.runtimeEnvironmentId])
|
||||
|
||||
const language = detectLanguage(file.relativePath)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[80vh] w-[90vw] max-w-5xl flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl">
|
||||
<DialogHeader className="border-b border-border/60 p-4">
|
||||
<DialogTitle>
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeCompareDialog.4b8de20a11',
|
||||
'File changed on disk'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeCompareDialog.90cc31e4d7',
|
||||
'Disk version on the left, your unsaved edits on the right.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1">
|
||||
{diskState.kind === 'loading' ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeCompareDialog.8fe30ab254',
|
||||
'Reading file from disk...'
|
||||
)}
|
||||
</div>
|
||||
) : diskState.kind === 'error' ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeCompareDialog.e2b1cd0393',
|
||||
'Could not read the file from disk: {{value0}}',
|
||||
{ value0: diskState.message }
|
||||
)}
|
||||
</div>
|
||||
) : diskState.kind === 'binary' ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeCompareDialog.b6cf20d514',
|
||||
'The file on disk is binary — no text comparison available.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Suspense
|
||||
// Why: the DiffViewer chunk loads lazily after the disk read —
|
||||
// without a fallback the 80vh body flashes blank in between.
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeCompareDialog.2c8f1e07b9',
|
||||
'Loading comparison...'
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<DiffViewer
|
||||
modelKey={`external-change-compare:${file.id}`}
|
||||
originalContent={diskState.content}
|
||||
modifiedContent={currentContent}
|
||||
language={language}
|
||||
filePath={file.filePath}
|
||||
relativePath={file.relativePath}
|
||||
sideBySide
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="border-t border-border/60 p-4">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
onReload()
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeCompareDialog.3fa2b8d417',
|
||||
'Reload from Disk'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
onKeepEdits()
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.editor.ExternalFileChangeCompareDialog.a95d02c644',
|
||||
'Keep My Edits'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,3 +9,19 @@ export function getDiffContentSignature(content: string): string {
|
|||
}
|
||||
return (hash >>> 0).toString(16)
|
||||
}
|
||||
|
||||
// Why: the disk baseline guards against silently overwriting external writes
|
||||
// (issue #7265), where a hash collision means a missed conflict — so it gets
|
||||
// two independent FNV lanes plus the length instead of the single 32-bit lane
|
||||
// that suffices for cosmetic model rotation above.
|
||||
export function getDiskBaselineSignature(content: string): string {
|
||||
let hashA = 2166136261
|
||||
let hashB = 84696351
|
||||
for (let i = 0; i < content.length; i += 1) {
|
||||
const code = content.charCodeAt(i)
|
||||
hashA ^= code
|
||||
hashA = Math.imul(hashA, 16777619)
|
||||
hashB = Math.imul(hashB ^ code, 1099511627)
|
||||
}
|
||||
return `${(hashA >>> 0).toString(16)}-${(hashB >>> 0).toString(16)}-${content.length.toString(16)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
// Why: the changed-on-disk conflict flow (issue #7265) — draft preservation,
|
||||
// echo-aware backstop marking, autosave suspension, and baseline settling —
|
||||
// in the headless autosave controller. Split from
|
||||
// editor-autosave-controller.test.ts to stay under max-lines.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, requestEditorFileSave } from './editor-autosave'
|
||||
import { attachEditorAutosaveController } from './editor-autosave-controller'
|
||||
import { __clearSelfWriteRegistryForTests, recordSelfWrite } from './editor-self-write-registry'
|
||||
import { createEditorStore, stubEditorWindow } from './editor-autosave-controller-test-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getConnectionIdForFile: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionIdForFile: mocks.getConnectionIdForFile
|
||||
}))
|
||||
|
||||
function openDirtyFile(store: ReturnType<typeof createEditorStore>, draft = 'unsaved edit'): void {
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/file.ts',
|
||||
relativePath: 'file.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().setEditorDraft('/repo/file.ts', draft)
|
||||
store.getState().markFileDirty('/repo/file.ts', true)
|
||||
}
|
||||
|
||||
function dispatchExternalChange(): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, {
|
||||
detail: { worktreeId: 'wt-1', worktreePath: '/repo', relativePath: 'file.ts' }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('editor autosave changed-on-disk conflict flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.getConnectionIdForFile.mockReset()
|
||||
mocks.getConnectionIdForFile.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
__clearSelfWriteRegistryForTests()
|
||||
})
|
||||
|
||||
it('preserves dirty drafts and marks the tab changed-on-disk on external file change', () => {
|
||||
stubEditorWindow()
|
||||
const store = createEditorStore()
|
||||
openDirtyFile(store)
|
||||
|
||||
const cleanup = attachEditorAutosaveController(store)
|
||||
try {
|
||||
dispatchExternalChange()
|
||||
|
||||
const file = store.getState().openFiles[0]
|
||||
expect(file?.isDirty).toBe(true)
|
||||
expect(file?.externalMutation).toBe('changed')
|
||||
expect(store.getState().editorDrafts['/repo/file.ts']).toBe('unsaved edit')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears drafts and a stale changed-on-disk mark for clean tabs on external file change', () => {
|
||||
stubEditorWindow()
|
||||
const store = createEditorStore()
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/file.ts',
|
||||
relativePath: 'file.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().setExternalMutation('/repo/file.ts', 'changed')
|
||||
|
||||
const cleanup = attachEditorAutosaveController(store)
|
||||
try {
|
||||
dispatchExternalChange()
|
||||
|
||||
const file = store.getState().openFiles[0]
|
||||
expect(file?.isDirty).toBe(false)
|
||||
expect(file?.externalMutation).toBeUndefined()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not backstop-mark a dirty tab for the echo of its own save', () => {
|
||||
stubEditorWindow()
|
||||
const store = createEditorStore()
|
||||
openDirtyFile(store, 'typed during save')
|
||||
// Why: the combined-Changes reload notification routes through the
|
||||
// controller for the saved path — a fresh self-write stamp means the
|
||||
// event is Orca's own echo, not an external change.
|
||||
recordSelfWrite('/repo/file.ts', 'orca save')
|
||||
|
||||
const cleanup = attachEditorAutosaveController(store)
|
||||
try {
|
||||
dispatchExternalChange()
|
||||
|
||||
const file = store.getState().openFiles[0]
|
||||
expect(file?.externalMutation).toBeUndefined()
|
||||
expect(file?.isDirty).toBe(true)
|
||||
expect(store.getState().editorDrafts['/repo/file.ts']).toBe('typed during save')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('suspends autosave while a tab is marked changed-on-disk and resumes when cleared', async () => {
|
||||
const writeFile = stubEditorWindow()
|
||||
const store = createEditorStore()
|
||||
openDirtyFile(store, 'user edit')
|
||||
store.getState().setExternalMutation('/repo/file.ts', 'changed')
|
||||
|
||||
const cleanup = attachEditorAutosaveController(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
|
||||
// Keep My Edits clears the mark — autosave resumes and overwrites.
|
||||
store.getState().setExternalMutation('/repo/file.ts', null)
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(writeFile).toHaveBeenCalledWith({
|
||||
filePath: '/repo/file.ts',
|
||||
content: 'user edit'
|
||||
})
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('suspends autosave while a restored tab awaits disk baseline verification', async () => {
|
||||
const writeFile = stubEditorWindow()
|
||||
const store = createEditorStore()
|
||||
openDirtyFile(store, 'restored draft')
|
||||
// Why: mimic hydration — the scan has not yet compared disk against the
|
||||
// persisted baseline, so autosave must hold off (a slow remote read must
|
||||
// not lose a race to this timer).
|
||||
store.setState({
|
||||
openFiles: store
|
||||
.getState()
|
||||
.openFiles.map((f) =>
|
||||
f.id === '/repo/file.ts' ? { ...f, pendingDiskBaselineVerification: true } : f
|
||||
)
|
||||
} as never)
|
||||
|
||||
const cleanup = attachEditorAutosaveController(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
|
||||
store.getState().clearPendingDiskBaselineVerification('/repo/file.ts')
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(writeFile).toHaveBeenCalledWith({
|
||||
filePath: '/repo/file.ts',
|
||||
content: 'restored draft'
|
||||
})
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the changed-on-disk mark after a successful save', async () => {
|
||||
const writeFile = stubEditorWindow()
|
||||
const store = createEditorStore()
|
||||
openDirtyFile(store, 'user version')
|
||||
store.getState().setExternalMutation('/repo/file.ts', 'changed')
|
||||
|
||||
const cleanup = attachEditorAutosaveController(store)
|
||||
try {
|
||||
await requestEditorFileSave({ fileId: '/repo/file.ts' })
|
||||
|
||||
expect(writeFile).toHaveBeenCalledWith({
|
||||
filePath: '/repo/file.ts',
|
||||
content: 'user version'
|
||||
})
|
||||
const file = store.getState().openFiles[0]
|
||||
expect(file?.isDirty).toBe(false)
|
||||
expect(file?.externalMutation).toBeUndefined()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// Why: shared rig for autosave-controller suites — the controller needs a
|
||||
// real editor store slice plus a window stub (event target, timers, fs
|
||||
// bridge), and duplicating that per test file bloats suites past max-lines.
|
||||
import { vi } from 'vitest'
|
||||
import { createStore, type StoreApi } from 'zustand/vanilla'
|
||||
import { createEditorSlice } from '@/store/slices/editor'
|
||||
import type { AppState } from '@/store'
|
||||
|
||||
export type EditorWindowStub = {
|
||||
addEventListener: Window['addEventListener']
|
||||
removeEventListener: Window['removeEventListener']
|
||||
dispatchEvent: Window['dispatchEvent']
|
||||
setTimeout: Window['setTimeout']
|
||||
clearTimeout: Window['clearTimeout']
|
||||
api: {
|
||||
fs: {
|
||||
writeFile: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stubs the global window with an isolated event target and fs bridge;
|
||||
* returns the writeFile mock for assertions. */
|
||||
export function stubEditorWindow(): ReturnType<typeof vi.fn> {
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const eventTarget = new EventTarget()
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: eventTarget.addEventListener.bind(eventTarget),
|
||||
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
|
||||
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
api: {
|
||||
fs: {
|
||||
writeFile
|
||||
}
|
||||
}
|
||||
} satisfies EditorWindowStub)
|
||||
return writeFile
|
||||
}
|
||||
|
||||
export function createEditorStore(): StoreApi<AppState> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return createStore<any>()((...args: any[]) => ({
|
||||
activeWorktreeId: 'wt-1',
|
||||
settings: {
|
||||
editorAutoSave: true,
|
||||
editorAutoSaveDelayMs: 1000
|
||||
},
|
||||
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
|
||||
})) as unknown as StoreApi<AppState>
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
|||
import {
|
||||
canAutoSaveOpenFile,
|
||||
getOpenFilesForExternalFileChange,
|
||||
isAutosaveSuspendedForFile,
|
||||
normalizeAutoSaveDelayMs,
|
||||
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
|
||||
ORCA_EDITOR_FILE_SAVED_EVENT,
|
||||
|
|
@ -27,8 +28,16 @@ import {
|
|||
type EditorSaveFileDetail,
|
||||
type EditorSaveQuiesceDetail
|
||||
} from './editor-autosave'
|
||||
import { markFileChangedOnDisk } from './editor-changed-on-disk-mark'
|
||||
import { flushPendingEditorChange } from './editor-pending-flush'
|
||||
import { clearSelfWrite, recordSelfWrite } from './editor-self-write-registry'
|
||||
import {
|
||||
clearSelfWrite,
|
||||
hasRecentSelfWrite,
|
||||
recordSelfWrite,
|
||||
SELF_WRITE_REMOTE_TTL_MS
|
||||
} from './editor-self-write-registry'
|
||||
import { getDiskBaselineSignature } from './diff-content-signature'
|
||||
import { trackExternalChangeConflictAction } from './editor-external-change-telemetry'
|
||||
import {
|
||||
autosaveSubscriberInputsEqual,
|
||||
getAutosaveSubscriberInputs,
|
||||
|
|
@ -62,7 +71,11 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
|||
saveGeneration.set(fileId, (saveGeneration.get(fileId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const queueSave = (file: OpenFile, fallbackContent: string): Promise<void> => {
|
||||
const queueSave = (
|
||||
file: OpenFile,
|
||||
fallbackContent: string,
|
||||
trigger: 'autosave' | 'user' = 'user'
|
||||
): Promise<void> => {
|
||||
clearAutoSaveTimer(file.id)
|
||||
const queuedGeneration = saveGeneration.get(file.id) ?? 0
|
||||
|
||||
|
|
@ -80,6 +93,12 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
|||
return
|
||||
}
|
||||
|
||||
// Why: explicit user saves proceed even while suspended (the banner
|
||||
// warned) and clear both suspension flags below.
|
||||
if (trigger === 'autosave' && isAutosaveSuspendedForFile(liveFile)) {
|
||||
return
|
||||
}
|
||||
|
||||
const contentToSave = state.editorDrafts[file.id] ?? fallbackContent
|
||||
const connectionId =
|
||||
getConnectionIdForFile(liveFile.worktreeId, liveFile.filePath) ?? undefined
|
||||
|
|
@ -91,7 +110,14 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
|||
// round-tripping back into a setContent that jumps the cursor to the
|
||||
// end (and, under round-trip drift, can drop keystrokes typed in the
|
||||
// debounce window). See editor-self-write-registry.
|
||||
recordSelfWrite(liveFile.filePath, contentToSave, liveFile.runtimeEnvironmentId)
|
||||
recordSelfWrite(
|
||||
liveFile.filePath,
|
||||
contentToSave,
|
||||
liveFile.runtimeEnvironmentId,
|
||||
connectionId || liveFile.runtimeEnvironmentId?.trim()
|
||||
? SELF_WRITE_REMOTE_TTL_MS
|
||||
: undefined
|
||||
)
|
||||
try {
|
||||
await writeRuntimeFile(
|
||||
{
|
||||
|
|
@ -122,6 +148,21 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
|||
if (!stillDirty) {
|
||||
nextState.clearEditorDraft(file.id)
|
||||
}
|
||||
// Why: disk now holds contentToSave — future edits baseline on it, and
|
||||
// a restore must not flag our own save as an external change. An
|
||||
// explicit save also settles any pending baseline verification: the
|
||||
// user chose to write, so there is nothing left to verify against.
|
||||
nextState.setLastKnownDiskSignature(file.id, getDiskBaselineSignature(contentToSave))
|
||||
nextState.clearPendingDiskBaselineVerification(file.id)
|
||||
// Why: the write just made disk match the buffer, resolving any
|
||||
// changed-on-disk conflict in favor of the user's content. The banner
|
||||
// warned before this point; keeping the mark would show a stale
|
||||
// conflict for a file that no longer diverges.
|
||||
const savedFile = nextState.openFiles.find((openFile) => openFile.id === file.id)
|
||||
if (savedFile?.externalMutation === 'changed') {
|
||||
trackExternalChangeConflictAction(savedFile, 'save_overwrite')
|
||||
nextState.setExternalMutation(file.id, null)
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<EditorFileSavedDetail>(ORCA_EDITOR_FILE_SAVED_EVENT, {
|
||||
|
|
@ -171,6 +212,9 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
|||
file &&
|
||||
file.isDirty &&
|
||||
canAutoSaveOpenFile(file) &&
|
||||
// Why: suspension holds until the user picks a side via the banner
|
||||
// (or saves manually) — see the queueSave guard.
|
||||
!isAutosaveSuspendedForFile(file) &&
|
||||
draft !== undefined
|
||||
if (!shouldKeepTimer) {
|
||||
clearAutoSaveTimer(fileId)
|
||||
|
|
@ -184,7 +228,12 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
|||
const autoSaveDelayMs = normalizeAutoSaveDelayMs(state.settings.editorAutoSaveDelayMs)
|
||||
for (const file of state.openFiles) {
|
||||
const draft = state.editorDrafts[file.id]
|
||||
if (!file.isDirty || draft === undefined || !canAutoSaveOpenFile(file)) {
|
||||
if (
|
||||
!file.isDirty ||
|
||||
draft === undefined ||
|
||||
!canAutoSaveOpenFile(file) ||
|
||||
isAutosaveSuspendedForFile(file)
|
||||
) {
|
||||
clearAutoSaveTimer(file.id)
|
||||
continue
|
||||
}
|
||||
|
|
@ -198,7 +247,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
|||
const timerId = window.setTimeout(() => {
|
||||
autoSaveTimers.delete(file.id)
|
||||
autoSaveScheduledContent.delete(file.id)
|
||||
void queueSave(file, draft)
|
||||
void queueSave(file, draft, 'autosave')
|
||||
}, autoSaveDelayMs)
|
||||
autoSaveTimers.set(file.id, timerId)
|
||||
}
|
||||
|
|
@ -377,12 +426,35 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
|||
return
|
||||
}
|
||||
|
||||
// Why: dirty files must keep their draft — destroying unsaved edits on an
|
||||
// external write is the data-loss half of issue #7265. Mark them
|
||||
// changed-on-disk instead (backstop for tabs that became dirty during the
|
||||
// notify debounce; the watch hook marks the ones dirty at event time).
|
||||
const reloadingFiles = matchingFiles.filter((file) => !file.isDirty)
|
||||
for (const file of matchingFiles) {
|
||||
if (file.isDirty) {
|
||||
// Why: the self-write check keeps this backstop from marking on the
|
||||
// echo of Orca's own save (the combined-Changes reload notification
|
||||
// routes through here and would otherwise bypass the watch hook's
|
||||
// echo verification).
|
||||
if (!hasRecentSelfWrite(file.filePath, file.runtimeEnvironmentId)) {
|
||||
markFileChangedOnDisk(state, file, {
|
||||
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
|
||||
origin: 'live'
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
clearAutoSaveTimer(file.id)
|
||||
bumpSaveGeneration(file.id)
|
||||
state.markFileDirty(file.id, false)
|
||||
// Why: this file is about to reload fresh disk content, so a stale
|
||||
// changed-on-disk mark (set while it was dirty) is resolved.
|
||||
if (file.externalMutation === 'changed') {
|
||||
state.setExternalMutation(file.id, null)
|
||||
}
|
||||
}
|
||||
state.clearEditorDrafts(matchingFiles.map((file) => file.id))
|
||||
state.clearEditorDrafts(reloadingFiles.map((file) => file.id))
|
||||
}
|
||||
|
||||
// Why: the root store subscriber fires for every terminal title/focus tick.
|
||||
|
|
|
|||
|
|
@ -77,6 +77,16 @@ export function canAutoSaveOpenFile(file: OpenFile): boolean {
|
|||
return file.mode === 'edit' || (file.mode === 'diff' && file.diffSource === 'unstaged')
|
||||
}
|
||||
|
||||
// Why: autosave must not resolve a changed-on-disk conflict by overwriting
|
||||
// the newer external content, nor write over a restored tab whose disk
|
||||
// baseline is still unverified (the conflict may simply not be marked YET).
|
||||
// One predicate so the save-queue gate and the timer scheduler cannot drift.
|
||||
export function isAutosaveSuspendedForFile(
|
||||
file: Pick<OpenFile, 'externalMutation' | 'pendingDiskBaselineVerification'>
|
||||
): boolean {
|
||||
return file.externalMutation === 'changed' || file.pendingDiskBaselineVerification === true
|
||||
}
|
||||
|
||||
export function normalizeAutoSaveDelayMs(value: unknown): number {
|
||||
// Why: settings are persisted locally and can be missing or hand-edited.
|
||||
// Clamp the delay at the write site so autosave never degenerates into an
|
||||
|
|
@ -176,6 +186,9 @@ export function requestEditorFileClose(fileId: string): void {
|
|||
)
|
||||
}
|
||||
|
||||
// CONTRACT: this event fires even when some tabs of the path are dirty —
|
||||
// every consumer MUST skip dirty files per-file. Reloading a dirty tab's
|
||||
// content destroys its unsaved draft (the data-loss half of issue #7265).
|
||||
export function notifyEditorExternalFileChange(target: EditorPathMutationTarget): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<EditorPathMutationTarget>(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { canAutoSaveOpenFile } from './editor-autosave'
|
||||
import { trackExternalChangeConflictShown } from './editor-external-change-telemetry'
|
||||
|
||||
type ChangedOnDiskMarkState = {
|
||||
setExternalMutation: (fileId: string, mutation: 'deleted' | 'renamed' | 'changed' | null) => void
|
||||
}
|
||||
|
||||
// Why: the changed-on-disk mark has one rule — dirty, banner-capable tab,
|
||||
// telemetry only on the first surfacing — but three writers (the watch hook
|
||||
// at fs-event time, the autosave controller's notification backstop, and the
|
||||
// restored-tab conflict scan). Centralizing keeps the rule from drifting;
|
||||
// each writer keeps its own echo/eligibility guard at the call site.
|
||||
export function markFileChangedOnDisk(
|
||||
state: ChangedOnDiskMarkState,
|
||||
file: OpenFile,
|
||||
options: { connectionId: string | undefined; origin: 'live' | 'restore' }
|
||||
): void {
|
||||
if (!file.isDirty || !canAutoSaveOpenFile(file)) {
|
||||
return
|
||||
}
|
||||
if (file.externalMutation !== 'changed') {
|
||||
trackExternalChangeConflictShown(file, options)
|
||||
}
|
||||
state.setExternalMutation(file.id, 'changed')
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
// Why: one place derives the path-free analytics shape for the changed-on-disk
|
||||
// conflict flow (issue #7265), so the three marking sites and the banner
|
||||
// actions cannot drift on enum values. Measures false-banner rates per
|
||||
// transport and which resolution users actually pick.
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { getConnectionIdForFile } from '@/lib/connection-context'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
|
||||
type ConflictSurface = 'edit' | 'unstaged-diff'
|
||||
type ConflictTransport = 'local' | 'ssh' | 'runtime'
|
||||
|
||||
export type ExternalChangeConflictAction =
|
||||
| 'reload'
|
||||
| 'keep'
|
||||
| 'compare'
|
||||
| 'undo_reload'
|
||||
| 'save_overwrite'
|
||||
|
||||
function conflictSurface(file: Pick<OpenFile, 'mode'>): ConflictSurface {
|
||||
return file.mode === 'edit' ? 'edit' : 'unstaged-diff'
|
||||
}
|
||||
|
||||
export function conflictTransport(
|
||||
connectionId: string | undefined,
|
||||
runtimeEnvironmentId: string | null | undefined
|
||||
): ConflictTransport {
|
||||
if (connectionId) {
|
||||
return 'ssh'
|
||||
}
|
||||
if (runtimeEnvironmentId?.trim()) {
|
||||
return 'runtime'
|
||||
}
|
||||
return 'local'
|
||||
}
|
||||
|
||||
export function trackExternalChangeConflictShown(
|
||||
file: Pick<OpenFile, 'mode' | 'runtimeEnvironmentId'>,
|
||||
options: { connectionId: string | undefined; origin: 'live' | 'restore' }
|
||||
): void {
|
||||
track('editor_external_change_conflict_shown', {
|
||||
surface: conflictSurface(file),
|
||||
transport: conflictTransport(options.connectionId, file.runtimeEnvironmentId),
|
||||
origin: options.origin
|
||||
})
|
||||
}
|
||||
|
||||
export function trackExternalChangeConflictAction(
|
||||
file: Pick<OpenFile, 'mode' | 'worktreeId' | 'filePath' | 'runtimeEnvironmentId'>,
|
||||
action: ExternalChangeConflictAction
|
||||
): void {
|
||||
track('editor_external_change_conflict_action', {
|
||||
action,
|
||||
surface: conflictSurface(file),
|
||||
// Why: shown-vs-action cross-tabs per transport are the point of the
|
||||
// metric — false-banner detection needs to see WHICH transports' banners
|
||||
// users dismiss versus act on.
|
||||
transport: conflictTransport(
|
||||
getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
|
||||
file.runtimeEnvironmentId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createStore, type StoreApi } from 'zustand/vanilla'
|
||||
import { createEditorSlice } from '@/store/slices/editor'
|
||||
import type { AppState } from '@/store'
|
||||
import { attachRestoredTabConflictScan } from './editor-restored-tab-conflict-scan'
|
||||
import { getDiskBaselineSignature } from './diff-content-signature'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readRuntimeFileContent: vi.fn(),
|
||||
getConnectionIdForFile: vi.fn(),
|
||||
pathExists: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-file-client', () => ({
|
||||
readRuntimeFileContent: mocks.readRuntimeFileContent
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
settingsForRuntimeOwner: () => null
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionIdForFile: mocks.getConnectionIdForFile
|
||||
}))
|
||||
|
||||
function createEditorStore(): StoreApi<AppState> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return createStore<any>()((...args: any[]) => ({
|
||||
settings: {},
|
||||
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
|
||||
})) as unknown as StoreApi<AppState>
|
||||
}
|
||||
|
||||
function openRestoredDirtyTab(
|
||||
store: StoreApi<AppState>,
|
||||
filePath: string,
|
||||
baselineContent: string
|
||||
): void {
|
||||
store.getState().openFile({
|
||||
filePath,
|
||||
relativePath: filePath.slice(1),
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().setEditorDraft(filePath, 'restored draft')
|
||||
store.getState().markFileDirty(filePath, true)
|
||||
store.getState().setLastKnownDiskSignature(filePath, getDiskBaselineSignature(baselineContent))
|
||||
// Why: hydration flags restored dirty tabs for verification; tests mimic it.
|
||||
store.setState({
|
||||
openFiles: store
|
||||
.getState()
|
||||
.openFiles.map((f) =>
|
||||
f.id === filePath ? { ...f, pendingDiskBaselineVerification: true } : f
|
||||
)
|
||||
} as never)
|
||||
}
|
||||
|
||||
describe('attachRestoredTabConflictScan', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.readRuntimeFileContent.mockReset()
|
||||
mocks.getConnectionIdForFile.mockReset()
|
||||
mocks.getConnectionIdForFile.mockReturnValue(undefined)
|
||||
mocks.pathExists.mockReset()
|
||||
mocks.pathExists.mockResolvedValue(true)
|
||||
vi.stubGlobal('window', { api: { fs: { pathExists: mocks.pathExists } } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('marks a restored dirty tab whose file changed while the app was closed', async () => {
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({
|
||||
content: 'agent rewrote this offline',
|
||||
isBinary: false
|
||||
})
|
||||
const store = createEditorStore()
|
||||
openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline')
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBe('changed')
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a restored dirty tab unmarked when disk still matches its baseline', async () => {
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({
|
||||
content: 'original baseline',
|
||||
isBinary: false
|
||||
})
|
||||
const store = createEditorStore()
|
||||
openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline')
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not read files for clean tabs or tabs without a baseline', async () => {
|
||||
const store = createEditorStore()
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/clean.ts',
|
||||
relativePath: 'clean.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/dirty-no-baseline.ts',
|
||||
relativePath: 'dirty-no-baseline.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().setEditorDraft('/repo/dirty-no-baseline.ts', 'draft')
|
||||
store.getState().markFileDirty('/repo/dirty-no-baseline.ts', true)
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a failed read and marks once the file becomes readable', async () => {
|
||||
// Why: SSH/runtime connections come up after launch; the first reads fail.
|
||||
mocks.readRuntimeFileContent
|
||||
.mockRejectedValueOnce(new Error('connection not ready'))
|
||||
.mockResolvedValue({ content: 'agent rewrote this offline', isBinary: false })
|
||||
const store = createEditorStore()
|
||||
openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline')
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBe('changed')
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the pending-verification flag on both verification outcomes', async () => {
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({
|
||||
content: 'original baseline',
|
||||
isBinary: false
|
||||
})
|
||||
const store = createEditorStore()
|
||||
openRestoredDirtyTab(store, '/repo/match.ts', 'original baseline')
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
// Why: the flag suspends autosave — leaving it set after a clean
|
||||
// verification would strand the tab's autosave forever.
|
||||
expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBeUndefined()
|
||||
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({
|
||||
content: 'agent rewrote this offline',
|
||||
isBinary: false
|
||||
})
|
||||
openRestoredDirtyTab(store, '/repo/mismatch.ts', 'original baseline')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
const mismatchTab = store.getState().openFiles.find((f) => f.id === '/repo/mismatch.ts')
|
||||
expect(mismatchTab?.externalMutation).toBe('changed')
|
||||
expect(mismatchTab?.pendingDiskBaselineVerification).toBeUndefined()
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not re-verify live dirty tabs that were never flagged at hydration', async () => {
|
||||
const store = createEditorStore()
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/live.ts',
|
||||
relativePath: 'live.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().setEditorDraft('/repo/live.ts', 'live edits')
|
||||
store.getState().markFileDirty('/repo/live.ts', true)
|
||||
store.getState().setLastKnownDiskSignature('/repo/live.ts', getDiskBaselineSignature('base'))
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
// Why: in-session drift is the live watcher's job; re-reading here would
|
||||
// turn the scan into a poller and skew the 'restore' telemetry origin.
|
||||
expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves verification with a deleted mark when the file is definitively gone', async () => {
|
||||
// Why: a file deleted while the app was closed can never verify — without
|
||||
// a terminal state the retry loop keeps the tab's autosave silently
|
||||
// suspended for the whole session.
|
||||
mocks.readRuntimeFileContent.mockRejectedValue(new Error('ENOENT'))
|
||||
mocks.pathExists.mockResolvedValue(false)
|
||||
const store = createEditorStore()
|
||||
openRestoredDirtyTab(store, '/repo/deleted.ts', 'original baseline')
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
const tab = store.getState().openFiles[0]
|
||||
expect(tab?.pendingDiskBaselineVerification).toBeUndefined()
|
||||
expect(tab?.externalMutation).toBe('deleted')
|
||||
// Why: the verification is resolved — no further retries may fire.
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps retrying with the suspension intact when the existence probe fails', async () => {
|
||||
// Why: a transport that is down cannot disprove existence — lifting the
|
||||
// suspension unverified would reopen the clobber window the moment the
|
||||
// transport recovers.
|
||||
mocks.readRuntimeFileContent.mockRejectedValue(new Error('connection not ready'))
|
||||
mocks.pathExists.mockRejectedValue(new Error('connection not ready'))
|
||||
const store = createEditorStore()
|
||||
openRestoredDirtyTab(store, '/repo/unreachable.ts', 'original baseline')
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
const tab = store.getState().openFiles[0]
|
||||
expect(tab?.pendingDiskBaselineVerification).toBe(true)
|
||||
expect(tab?.externalMutation).toBeUndefined()
|
||||
expect(mocks.readRuntimeFileContent.mock.calls.length).toBeGreaterThan(1)
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not mark a tab that was saved while the read was in flight', async () => {
|
||||
let resolveRead: (value: { content: string; isBinary: boolean }) => void = () => {}
|
||||
mocks.readRuntimeFileContent.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
const store = createEditorStore()
|
||||
openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline')
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
store.getState().markFileDirty('/repo/file.ts', false)
|
||||
resolveRead({ content: 'agent rewrote this offline', isBinary: false })
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
// Why: a dirty tab restored from a workspace session carries edits based on
|
||||
// disk content that may have changed while the app was closed (an agent write,
|
||||
// a sync tool). The in-memory changed-on-disk mark does not survive restarts,
|
||||
// so without this scan a resumed autosave would silently overwrite that newer
|
||||
// content (issue #7265 follow-up). The scan re-derives the conflict from
|
||||
// ground truth: it reads each restored dirty tab's file and compares the disk
|
||||
// signature against the persisted edit baseline. Autosave is hard-suspended
|
||||
// for those tabs (pendingDiskBaselineVerification, set at hydration) until a
|
||||
// verification resolves — otherwise the read would merely race the autosave
|
||||
// timer and a slow remote read would lose.
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { AppState } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { getConnectionIdForFile } from '@/lib/connection-context'
|
||||
import { readRuntimeFileContent } from '@/runtime/runtime-file-client'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import { canAutoSaveOpenFile } from './editor-autosave'
|
||||
import { getDiskBaselineSignature } from './diff-content-signature'
|
||||
import { markFileChangedOnDisk } from './editor-changed-on-disk-mark'
|
||||
|
||||
type AppStoreApi = Pick<StoreApi<AppState>, 'getState' | 'subscribe'>
|
||||
|
||||
// Why: SSH/runtime reads fail while the connection is still coming up after
|
||||
// launch. Retry fast for the first minute, then keep probing slowly —
|
||||
// giving up on a transport error would either strand the tab's autosave
|
||||
// suspension or lift it unverified right as the transport comes back up.
|
||||
// Only a definitive not-found (file deleted while the app was closed) ends
|
||||
// the loop early; see probeFileMissing.
|
||||
const VERIFY_RETRY_MS = 2_000
|
||||
const VERIFY_SLOW_RETRY_MS = 15_000
|
||||
const VERIFY_FAST_ATTEMPTS = 30
|
||||
|
||||
export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
|
||||
// Why: dedupes in-flight verifications; the store's pending flag is the
|
||||
// durable "needs verification" signal.
|
||||
const inFlightFileIds = new Set<string>()
|
||||
const attemptsByFileId = new Map<string, number>()
|
||||
const retryTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||
let disposed = false
|
||||
|
||||
// Why: distinguishes "file was deleted while the app was closed" (a
|
||||
// definitive not-found) from a transport still coming up. Only local/SSH
|
||||
// paths can be probed — for runtime-owned files window.api.fs would stat
|
||||
// the client-local path and misreport a remote file as gone.
|
||||
const probeFileMissing = async (file: OpenFile): Promise<boolean> => {
|
||||
const settings = settingsForRuntimeOwner(store.getState().settings, file.runtimeEnvironmentId)
|
||||
if (settings?.activeRuntimeEnvironmentId?.trim()) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const exists = await globalThis.window?.api?.fs?.pathExists?.({
|
||||
filePath: file.filePath,
|
||||
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
|
||||
})
|
||||
return exists === false
|
||||
} catch {
|
||||
// Why: a failed probe can't disprove existence — keep retrying.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const verify = async (file: OpenFile): Promise<void> => {
|
||||
// Why: OpenFile ids are file paths — a marker left behind by an early
|
||||
// exit would silently skip every future verification of a reopened
|
||||
// same-path tab. Only a scheduled retry may keep the marker set.
|
||||
let retryScheduled = false
|
||||
try {
|
||||
const state = store.getState()
|
||||
const result = await readRuntimeFileContent({
|
||||
settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId),
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
worktreeId: file.worktreeId,
|
||||
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
|
||||
})
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const liveFile = store.getState().openFiles.find((f) => f.id === file.id)
|
||||
if (!liveFile) {
|
||||
return
|
||||
}
|
||||
// Why: verification resolved — lift the autosave suspension regardless
|
||||
// of outcome. If a save raced the read, the save already re-baselined
|
||||
// and cleared the flag itself; wasPending distinguishes that case.
|
||||
const wasPending = liveFile.pendingDiskBaselineVerification === true
|
||||
store.getState().clearPendingDiskBaselineVerification(file.id)
|
||||
if (
|
||||
!wasPending ||
|
||||
result.isBinary ||
|
||||
!liveFile.isDirty ||
|
||||
liveFile.externalMutation === 'changed'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (getDiskBaselineSignature(result.content) !== file.lastKnownDiskSignature) {
|
||||
markFileChangedOnDisk(store.getState(), liveFile, {
|
||||
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
|
||||
origin: 'restore'
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (await probeFileMissing(file)) {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
// Why: a definitive not-found IS ground truth — there is no newer
|
||||
// disk content for a save to clobber, so verification is resolved.
|
||||
// Converge to the live delete affordance (tombstone mark) instead of
|
||||
// retrying forever with the tab's autosave silently suspended.
|
||||
const liveFile = store.getState().openFiles.find((f) => f.id === file.id)
|
||||
if (!liveFile) {
|
||||
return
|
||||
}
|
||||
const wasPending = liveFile.pendingDiskBaselineVerification === true
|
||||
store.getState().clearPendingDiskBaselineVerification(file.id)
|
||||
if (wasPending && liveFile.isDirty && liveFile.externalMutation !== 'changed') {
|
||||
store.getState().setExternalMutation(file.id, 'deleted')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const attempts = (attemptsByFileId.get(file.id) ?? 0) + 1
|
||||
attemptsByFileId.set(file.id, attempts)
|
||||
retryScheduled = true
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
retryTimers.delete(timer)
|
||||
inFlightFileIds.delete(file.id)
|
||||
scan()
|
||||
},
|
||||
attempts < VERIFY_FAST_ATTEMPTS ? VERIFY_RETRY_MS : VERIFY_SLOW_RETRY_MS
|
||||
)
|
||||
retryTimers.add(timer)
|
||||
} finally {
|
||||
if (!retryScheduled) {
|
||||
inFlightFileIds.delete(file.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scan = (): void => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
for (const file of store.getState().openFiles) {
|
||||
if (
|
||||
!file.pendingDiskBaselineVerification ||
|
||||
!file.isDirty ||
|
||||
!file.lastKnownDiskSignature ||
|
||||
file.externalMutation === 'changed' ||
|
||||
!canAutoSaveOpenFile(file) ||
|
||||
inFlightFileIds.has(file.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
inFlightFileIds.add(file.id)
|
||||
void verify(file)
|
||||
}
|
||||
}
|
||||
|
||||
let previousOpenFiles = store.getState().openFiles
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
const nextOpenFiles = store.getState().openFiles
|
||||
if (nextOpenFiles === previousOpenFiles) {
|
||||
return
|
||||
}
|
||||
previousOpenFiles = nextOpenFiles
|
||||
scan()
|
||||
})
|
||||
scan()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unsubscribe()
|
||||
for (const timer of retryTimers) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
retryTimers.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ import {
|
|||
__getSelfWriteRegistrySizeForTests,
|
||||
clearSelfWrite,
|
||||
hasRecentSelfWrite,
|
||||
recordSelfWrite
|
||||
recordSelfWrite,
|
||||
SELF_WRITE_REMOTE_TTL_MS
|
||||
} from './editor-self-write-registry'
|
||||
|
||||
describe('editor self-write registry', () => {
|
||||
|
|
@ -77,4 +78,15 @@ describe('editor self-write registry', () => {
|
|||
expect(hasRecentSelfWrite('/repo/0.md')).toBe(false)
|
||||
expect(hasRecentSelfWrite('/repo/259.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps remote-TTL stamps alive past the local window', () => {
|
||||
// Why: SSH/runtime watcher echoes can land seconds after the write; the
|
||||
// longer TTL keeps them recognized as Orca's own save.
|
||||
recordSelfWrite('/repo/remote.md', 'content', 'env-1', SELF_WRITE_REMOTE_TTL_MS)
|
||||
|
||||
vi.advanceTimersByTime(751)
|
||||
expect(hasRecentSelfWrite('/repo/remote.md', 'env-1')).toBe(true)
|
||||
vi.advanceTimersByTime(SELF_WRITE_REMOTE_TTL_MS)
|
||||
expect(hasRecentSelfWrite('/repo/remote.md', 'env-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import { normalizeAbsolutePathForComparison } from '@/components/right-sidebar/f
|
|||
// path, bounded by a short TTL so a genuinely external edit that lands after
|
||||
// the window still gets picked up.
|
||||
const SELF_WRITE_TTL_MS = 750
|
||||
// Why: SSH/runtime watcher echoes travel a poll-plus-network path and can
|
||||
// land seconds after the write. A local-sized TTL lets the echo arrive after
|
||||
// the stamp expired, which raises a false changed-on-disk banner on remote
|
||||
// tabs while typing with autosave on.
|
||||
export const SELF_WRITE_REMOTE_TTL_MS = 3000
|
||||
const SELF_WRITE_MAX_STAMPS = 256
|
||||
|
||||
export type RecentSelfWrite = {
|
||||
|
|
@ -49,7 +54,8 @@ function enforceSelfWriteStampLimit(): void {
|
|||
export function recordSelfWrite(
|
||||
absolutePath: string,
|
||||
content?: string,
|
||||
runtimeEnvironmentId?: string | null
|
||||
runtimeEnvironmentId?: string | null,
|
||||
ttlMs: number = SELF_WRITE_TTL_MS
|
||||
): void {
|
||||
const now = Date.now()
|
||||
pruneExpiredSelfWrites(now)
|
||||
|
|
@ -59,7 +65,7 @@ export function recordSelfWrite(
|
|||
stamps.delete(key)
|
||||
stamps.set(key, {
|
||||
content: content ?? null,
|
||||
expiresAt: now + SELF_WRITE_TTL_MS
|
||||
expiresAt: now + ttlMs
|
||||
})
|
||||
enforceSelfWriteStampLimit()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ vi.mock('@/store', () => ({
|
|||
}))
|
||||
|
||||
import { useEditorPanelContentState } from './useEditorPanelContentState'
|
||||
import { getDiskBaselineSignature } from './diff-content-signature'
|
||||
import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT } from './editor-autosave'
|
||||
|
||||
type Deferred<T> = {
|
||||
|
|
@ -87,6 +88,7 @@ type ProbeProps = {
|
|||
|
||||
let latestFileContents: Record<string, FileContent> = {}
|
||||
let latestDiffContents: Record<string, DiffContent> = {}
|
||||
let latestReloadContent: (file: OpenFile) => void = () => {}
|
||||
const EMPTY_GIT_STATUS_BY_WORKTREE: Record<string, GitStatusEntry[]> = {}
|
||||
|
||||
function HookProbe({
|
||||
|
|
@ -103,6 +105,7 @@ function HookProbe({
|
|||
})
|
||||
latestFileContents = state.fileContents
|
||||
latestDiffContents = state.diffContents
|
||||
latestReloadContent = state.reloadContent
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +138,11 @@ describe('useEditorPanelContentState', () => {
|
|||
mocks.isWorktreeConnectionResolved.mockReset()
|
||||
mocks.isWorktreeConnectionResolved.mockReturnValue(true)
|
||||
mocks.getState.mockReset()
|
||||
mocks.getState.mockReturnValue({ settings: null })
|
||||
mocks.getState.mockReturnValue({
|
||||
settings: null,
|
||||
openFiles: [],
|
||||
setLastKnownDiskSignature: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -564,4 +571,124 @@ describe('useEditorPanelContentState', () => {
|
|||
})
|
||||
expect(latestDiffContents[activeFile.id]?.modifiedContent).toBe('fresh diff content')
|
||||
})
|
||||
|
||||
it('routes reloadContent for a diff tab to a forced diff refetch, not a file read', async () => {
|
||||
// Why: the changed-on-disk banner's "Reload from Disk" on an unstaged
|
||||
// diff tab must refetch the diff body — routing it to the file store
|
||||
// would leave the visible diff stale (and vice versa for edit tabs).
|
||||
const activeFile = createOpenFile({
|
||||
id: 'wt-1::diff::unstaged::file.ts',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged'
|
||||
})
|
||||
mocks.getRuntimeGitDiff
|
||||
.mockResolvedValueOnce({
|
||||
kind: 'text',
|
||||
originalContent: 'old',
|
||||
modifiedContent: 'first diff content',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
kind: 'text',
|
||||
originalContent: 'old',
|
||||
modifiedContent: 'reloaded diff content',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: 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(latestDiffContents[activeFile.id]?.modifiedContent).toBe('first diff content')
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
latestReloadContent(activeFile)
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(latestDiffContents[activeFile.id]?.modifiedContent).toBe('reloaded diff content')
|
||||
)
|
||||
expect(mocks.getRuntimeGitDiff).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes reloadContent for an edit tab to a forced file read, not a diff refetch', async () => {
|
||||
const activeFile = createOpenFile()
|
||||
mocks.readRuntimeFileContent
|
||||
.mockResolvedValueOnce({ content: 'old content', isBinary: false })
|
||||
.mockResolvedValueOnce({ content: 'reloaded 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'))
|
||||
|
||||
await act(async () => {
|
||||
latestReloadContent(activeFile)
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(latestFileContents[activeFile.id]?.content).toBe('reloaded content')
|
||||
)
|
||||
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.getRuntimeGitDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stamps the disk baseline when a clean tab load resolves', async () => {
|
||||
const activeFile = createOpenFile()
|
||||
const setLastKnownDiskSignature = vi.fn()
|
||||
mocks.getState.mockReturnValue({
|
||||
settings: null,
|
||||
openFiles: [activeFile],
|
||||
setLastKnownDiskSignature
|
||||
})
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'disk 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(setLastKnownDiskSignature).toHaveBeenCalledWith(
|
||||
activeFile.id,
|
||||
getDiskBaselineSignature('disk content')
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a dirty tab baseline untouched by content loads', async () => {
|
||||
// Why: a dirty tab's draft still derives from the OLD content — moving
|
||||
// the baseline on load would hide the conflict its restore check exists
|
||||
// to catch.
|
||||
const activeFile = createOpenFile({ isDirty: true })
|
||||
const setLastKnownDiskSignature = vi.fn()
|
||||
mocks.getState.mockReturnValue({
|
||||
settings: null,
|
||||
openFiles: [activeFile],
|
||||
setLastKnownDiskSignature
|
||||
})
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'disk 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('disk content'))
|
||||
expect(setLastKnownDiskSignature).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '@/lib/connection-context'
|
||||
import { joinPath } from '@/lib/path'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getDiskBaselineSignature } from './diff-content-signature'
|
||||
import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
|
|
@ -51,7 +52,26 @@ type UseEditorPanelContentStateParams = {
|
|||
type UseEditorPanelContentStateResult = {
|
||||
fileContents: Record<string, FileContent>
|
||||
diffContents: Record<string, DiffContent>
|
||||
reloadFileContent: (file: OpenFile) => void
|
||||
reloadContent: (file: OpenFile) => void
|
||||
}
|
||||
|
||||
// Why: a clean load re-baselines what this tab's future edits are based on; a
|
||||
// dirty tab keeps its baseline (its draft still derives from the older content
|
||||
// the signature was taken over). Best-effort metadata — a failure here must
|
||||
// not convert an already-delivered load into an error view, hence the guard.
|
||||
function stampCleanTabDiskBaseline(id: string, result: FileContent): void {
|
||||
if (result.isBinary || result.loadError) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const state = useAppStore.getState()
|
||||
const loadedFile = state.openFiles.find((file) => file.id === id)
|
||||
if (loadedFile && !loadedFile.isDirty) {
|
||||
state.setLastKnownDiskSignature(id, getDiskBaselineSignature(result.content))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[editor] failed to stamp disk baseline', err)
|
||||
}
|
||||
}
|
||||
|
||||
function inFlightReadKey(connectionId: string | undefined, filePath: string): string {
|
||||
|
|
@ -171,6 +191,7 @@ export function useEditorPanelContentState({
|
|||
}
|
||||
delete fileLoadRetryAttemptsRef.current[id]
|
||||
setFileContents((prev) => ({ ...prev, [id]: result }))
|
||||
stampCleanTabDiskBaseline(id, result)
|
||||
} catch (err) {
|
||||
if (fileReadGenerationRef.current[id] !== generation) {
|
||||
return
|
||||
|
|
@ -304,8 +325,23 @@ export function useEditorPanelContentState({
|
|||
[]
|
||||
)
|
||||
|
||||
const reloadFileContent = useCallback(
|
||||
// Why: the changed-on-disk banner's explicit reload on an unstaged diff tab
|
||||
// must refetch the diff body, not the plain file content — one entry point
|
||||
// branches on the tab mode so every consumer reloads the right store.
|
||||
const reloadContent = useCallback(
|
||||
(file: OpenFile): void => {
|
||||
if (file.mode === 'diff') {
|
||||
setDiffContents((prev) => {
|
||||
if (!prev[file.id]) {
|
||||
return prev
|
||||
}
|
||||
const next = { ...prev }
|
||||
delete next[file.id]
|
||||
return next
|
||||
})
|
||||
void loadDiffContent(file, { force: true })
|
||||
return
|
||||
}
|
||||
delete fileLoadRetryAttemptsRef.current[file.id]
|
||||
setFileContents((prev) => {
|
||||
if (!prev[file.id]) {
|
||||
|
|
@ -319,7 +355,7 @@ export function useEditorPanelContentState({
|
|||
force: true
|
||||
})
|
||||
},
|
||||
[loadFileContent]
|
||||
[loadDiffContent, loadFileContent]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -507,5 +543,5 @@ export function useEditorPanelContentState({
|
|||
setDiffContents
|
||||
)
|
||||
|
||||
return { fileContents, diffContents, reloadFileContent }
|
||||
return { fileContents, diffContents, reloadContent }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ export function useEditorPanelExternalContentEvents({
|
|||
return
|
||||
}
|
||||
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,
|
||||
// a save, or a later clean reload. Reloading here would clobber it.
|
||||
if (file.isDirty) {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', ()
|
|||
})
|
||||
expect(loadFileContent.mock.calls.length).toBe(callsAfterTerminal)
|
||||
|
||||
// Retry (reloadFileContent) clears the attempt budget for a fresh start.
|
||||
// Retry (reloadContent) clears the attempt budget for a fresh start.
|
||||
delete attemptsRef.current[file.id]
|
||||
expect(attemptsRef.current[file.id]).toBeUndefined()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -80,6 +80,11 @@ export default function EditorFileTab({
|
|||
const isConflictReview = file.mode === 'conflict-review'
|
||||
const isCheckDetails = file.mode === 'check-details'
|
||||
const isMarkdownPreviewTab = file.mode === 'markdown-preview'
|
||||
// Why: only deleted/renamed mean the file is gone from its path, which is
|
||||
// what strikethrough conveys. 'changed' keeps a normal label — its surface
|
||||
// is the changed-on-disk banner inside the editor.
|
||||
const isMissingFileMutation =
|
||||
file.externalMutation === 'deleted' || file.externalMutation === 'renamed'
|
||||
const resolvedLanguage =
|
||||
file.mode === 'diff'
|
||||
? detectLanguage(file.relativePath)
|
||||
|
|
@ -308,7 +313,7 @@ export default function EditorFileTab({
|
|||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`${TAB_LABEL_WIDTH_CLASSES}${file.isPreview ? ' italic' : ''}${file.externalMutation ? ' line-through' : ''}`}
|
||||
className={`${TAB_LABEL_WIDTH_CLASSES}${file.isPreview ? ' italic' : ''}${isMissingFileMutation ? ' line-through' : ''}`}
|
||||
style={tabStatusColor ? { color: tabStatusColor } : undefined}
|
||||
onDoubleClick={(e) => {
|
||||
if (file.isPreview && onMakePermanent) {
|
||||
|
|
@ -329,12 +334,12 @@ export default function EditorFileTab({
|
|||
{tabLabel}
|
||||
</span>
|
||||
)}
|
||||
{file.externalMutation && !isRenaming && (
|
||||
{isMissingFileMutation && !isRenaming && (
|
||||
<span className="shrink-0 text-[10px] leading-none font-semibold tracking-wide text-muted-foreground">
|
||||
{file.externalMutation}
|
||||
</span>
|
||||
)}
|
||||
{tabStatus && !isRenaming && !file.externalMutation && (
|
||||
{tabStatus && !isRenaming && !isMissingFileMutation && (
|
||||
<span
|
||||
className="shrink-0 text-[10px] leading-none font-semibold tracking-wide"
|
||||
style={{ color: tabStatusColor }}
|
||||
|
|
|
|||
|
|
@ -521,6 +521,179 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => {
|
|||
dispose()
|
||||
})
|
||||
|
||||
it('marks a dirty edit tab changed-on-disk instead of reloading it', () => {
|
||||
const dirtyFile = {
|
||||
...fileNotes,
|
||||
isDirty: true
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
openFiles: [dirtyFile],
|
||||
setExternalMutation
|
||||
} as never)
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
expect(notifyEditorExternalFileChange).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('still reloads a clean sibling diff tab when the edit tab for the path is dirty', () => {
|
||||
const dirtyFile = {
|
||||
...fileNotes,
|
||||
isDirty: true
|
||||
}
|
||||
const cleanDiffTab = {
|
||||
id: 'diff-notes',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
mode: 'diff' as const,
|
||||
diffSource: 'unstaged' as const,
|
||||
isDirty: false
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
openFiles: [dirtyFile, cleanDiffTab],
|
||||
setExternalMutation
|
||||
} as never)
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile, cleanDiffTab] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
relativePath: 'notes.md',
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not mark a dirty tab for the echo of Orca’s own save', async () => {
|
||||
const dirtyFile = {
|
||||
...fileNotes,
|
||||
isDirty: true
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
openFiles: [dirtyFile],
|
||||
setExternalMutation
|
||||
} as never)
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never)
|
||||
const readFile = vi.fn().mockResolvedValue({ content: 'orca save', isBinary: false })
|
||||
vi.stubGlobal('window', { api: { fs: { readFile } } })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
recordSelfWrite('/repo/notes.md', 'orca save')
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'changed')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks a dirty tab when a genuine external write lands inside the self-write TTL', async () => {
|
||||
const dirtyFile = {
|
||||
...fileNotes,
|
||||
isDirty: true
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
openFiles: [dirtyFile],
|
||||
setExternalMutation
|
||||
} as never)
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never)
|
||||
const readFile = vi.fn().mockResolvedValue({ content: 'agent content', isBinary: false })
|
||||
vi.stubGlobal('window', { api: { fs: { readFile } } })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
recordSelfWrite('/repo/notes.md', 'orca save')
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('shares one echo-verification read across a burst of watcher payloads', async () => {
|
||||
const dirtyFile = {
|
||||
...fileNotes,
|
||||
isDirty: true
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
openFiles: [dirtyFile],
|
||||
setExternalMutation
|
||||
} as never)
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never)
|
||||
const readFile = vi.fn().mockResolvedValue({ content: 'agent content', isBinary: false })
|
||||
vi.stubGlobal('window', { api: { fs: { readFile } } })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
recordSelfWrite('/repo/notes.md', 'orca save')
|
||||
// Why: SSH poll + event streams can deliver several payloads for one
|
||||
// write; each verification is a full-file read, so a burst must share
|
||||
// the in-flight read instead of stacking network round-trips.
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(readFile).toHaveBeenCalledTimes(1)
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks a lone dirty unstaged-diff tab changed-on-disk', () => {
|
||||
const dirtyDiffTab = {
|
||||
id: 'diff-notes',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
mode: 'diff' as const,
|
||||
diffSource: 'unstaged' as const,
|
||||
isDirty: true
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
openFiles: [dirtyDiffTab],
|
||||
setExternalMutation
|
||||
} as never)
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyDiffTab] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('diff-notes', 'changed')
|
||||
expect(notifyEditorExternalFileChange).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not let an update event clear a changed-on-disk mark while the tab stays dirty', () => {
|
||||
const dirtyChangedFile = {
|
||||
...fileNotes,
|
||||
isDirty: true,
|
||||
externalMutation: 'changed' as const
|
||||
}
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
openFiles: [dirtyChangedFile],
|
||||
setExternalMutation
|
||||
} as never)
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyChangedFile] as never)
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', null)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not reload a branch-compare combined diff for working-tree changes', () => {
|
||||
const branchDiffTab = {
|
||||
id: 'wt-1::all-diffs::branch',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { basename, joinPath } from '@/lib/path'
|
|||
import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch'
|
||||
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
|
||||
import {
|
||||
canAutoSaveOpenFile,
|
||||
getOpenFilesForExternalFileChange,
|
||||
isExternalReloadableEditorTab,
|
||||
isWorkingTreeCombinedDiffTab,
|
||||
|
|
@ -28,6 +29,7 @@ import {
|
|||
type WorktreeFileChangeEventDetail
|
||||
} from './worktree-file-change-event'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
import { markFileChangedOnDisk } from '@/components/editor/editor-changed-on-disk-mark'
|
||||
|
||||
// Why: atomic-write patterns (Claude Code's Edit tool, editors like vim,
|
||||
// VSCode) land as a short burst of `update` events — or `delete + create` on
|
||||
|
|
@ -545,7 +547,10 @@ export function createExternalWatchEventHandler(
|
|||
// Why: if a previously-deleted file reappears at the same path (e.g.
|
||||
// the user ran `git checkout`), clear the tombstone so the tab returns
|
||||
// to its normal state and any non-dirty content gets reloaded below.
|
||||
// `createOrUpdatePaths` was collected above.
|
||||
// `createOrUpdatePaths` was collected above. Scoped to deleted/renamed:
|
||||
// a 'changed' mark means the file was rewritten while the tab was dirty,
|
||||
// so a further update event must not clear it — it resolves via reload,
|
||||
// save, or the reload path below.
|
||||
if (createOrUpdatePaths.size > 0) {
|
||||
const state = useAppStore.getState()
|
||||
for (const file of state.openFiles) {
|
||||
|
|
@ -553,7 +558,7 @@ export function createExternalWatchEventHandler(
|
|||
file.worktreeId === target.worktreeId &&
|
||||
openFileRuntimeOwner(file) === target.runtimeEnvironmentId &&
|
||||
(file.mode === 'edit' || file.mode === 'markdown-preview') &&
|
||||
file.externalMutation &&
|
||||
(file.externalMutation === 'deleted' || file.externalMutation === 'renamed') &&
|
||||
createOrUpdatePaths.has(normalizeRuntimePathForComparison(file.filePath))
|
||||
) {
|
||||
state.setExternalMutation(file.id, null)
|
||||
|
|
@ -637,11 +642,27 @@ export function createExternalWatchEventHandler(
|
|||
}
|
||||
continue
|
||||
}
|
||||
if (matching.some((f) => f.isDirty)) {
|
||||
if (hasCombinedDiffConsumer) {
|
||||
scheduleDebouncedExternalReload(notification)
|
||||
const dirtyMatches = matching.filter((f) => f.isDirty)
|
||||
if (dirtyMatches.length > 0) {
|
||||
// Why: an external write landing on a dirty tab must not vanish
|
||||
// silently (issue #7265) — the user was left with a stale tab and a
|
||||
// save that clobbered the newer disk content. Mark the tab so the
|
||||
// editor shows a changed-on-disk banner with an explicit reload path.
|
||||
scheduleChangedOnDiskMark(
|
||||
target,
|
||||
notification,
|
||||
// Why: canAutoSaveOpenFile is exactly the set of tabs that can hold
|
||||
// unsaved edits (edit + unstaged diff) — the tabs the banner serves.
|
||||
dirtyMatches.filter((dirtyFile) => canAutoSaveOpenFile(dirtyFile)).map((f) => f.id)
|
||||
)
|
||||
if (dirtyMatches.length === matching.length) {
|
||||
if (hasCombinedDiffConsumer) {
|
||||
scheduleDebouncedExternalReload(notification)
|
||||
}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
// Clean sibling tabs (e.g. an unstaged diff of the same path) still
|
||||
// reload below; every notification consumer skips dirty files.
|
||||
}
|
||||
const absolutePath = joinPath(notification.worktreePath, notification.relativePath)
|
||||
const recentSelfWrite = getRecentSelfWrite(absolutePath, target.runtimeEnvironmentId)
|
||||
|
|
@ -665,6 +686,90 @@ export function createExternalWatchEventHandler(
|
|||
return { handleFsChanged, dispose }
|
||||
}
|
||||
|
||||
const inFlightEchoVerificationReads = new Map<string, ReturnType<typeof readRuntimeFileContent>>()
|
||||
|
||||
// Why: one save echo can arrive as a burst of watcher payloads (SSH poll +
|
||||
// event streams), and each verification is a full-file read — on remote
|
||||
// transports a network round-trip. Concurrent payloads for the same file
|
||||
// share the in-flight read instead of stacking duplicates.
|
||||
function readFileForEchoVerification(args: {
|
||||
runtimeEnvironmentId: string | null | undefined
|
||||
filePath: string
|
||||
relativePath: string
|
||||
worktreeId: string | null | undefined
|
||||
connectionId: string | undefined
|
||||
}): ReturnType<typeof readRuntimeFileContent> {
|
||||
const key = `${args.runtimeEnvironmentId ?? ''}::${args.connectionId ?? ''}::${args.filePath}`
|
||||
let pending = inFlightEchoVerificationReads.get(key)
|
||||
if (!pending) {
|
||||
pending = readRuntimeFileContent({
|
||||
settings: args.runtimeEnvironmentId
|
||||
? { activeRuntimeEnvironmentId: args.runtimeEnvironmentId }
|
||||
: null,
|
||||
filePath: args.filePath,
|
||||
relativePath: args.relativePath,
|
||||
worktreeId: args.worktreeId ?? undefined,
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
inFlightEchoVerificationReads.set(key, pending)
|
||||
const release = (): void => {
|
||||
if (inFlightEchoVerificationReads.get(key) === pending) {
|
||||
inFlightEchoVerificationReads.delete(key)
|
||||
}
|
||||
}
|
||||
pending.then(release, release)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function markTabsChangedOnDisk(fileIds: string[], connectionId: string | undefined): void {
|
||||
const state = useAppStore.getState()
|
||||
for (const fileId of fileIds) {
|
||||
const file = state.openFiles.find((f) => f.id === fileId)
|
||||
// Why: echo verification resolves async — a save or reload may already
|
||||
// have resolved the conflict; the helper only marks still-dirty tabs.
|
||||
if (file) {
|
||||
markFileChangedOnDisk(state, file, { connectionId, origin: 'live' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleChangedOnDiskMark(
|
||||
target: WatchedTarget,
|
||||
notification: ExternalWatchNotification,
|
||||
fileIds: string[]
|
||||
): void {
|
||||
if (fileIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const absolutePath = joinPath(notification.worktreePath, notification.relativePath)
|
||||
const recentSelfWrite = getRecentSelfWrite(absolutePath, target.runtimeEnvironmentId)
|
||||
// Why: the fs event may be the echo of Orca's own save racing keystrokes
|
||||
// typed during the write. Marking on the echo would show a false "changed
|
||||
// on disk" banner, so verify disk really differs from our last write.
|
||||
if (!recentSelfWrite || recentSelfWrite.content === null) {
|
||||
markTabsChangedOnDisk(fileIds, target.connectionId)
|
||||
return
|
||||
}
|
||||
void readFileForEchoVerification({
|
||||
runtimeEnvironmentId: target.runtimeEnvironmentId,
|
||||
filePath: absolutePath,
|
||||
relativePath: notification.relativePath,
|
||||
worktreeId: notification.worktreeId,
|
||||
connectionId: target.connectionId
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.isBinary || result.content !== recentSelfWrite.content) {
|
||||
markTabsChangedOnDisk(fileIds, target.connectionId)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Why: unreadable disk state can't disprove an external change — keep
|
||||
// the conflict visible rather than risk a silent overwrite.
|
||||
markTabsChangedOnDisk(fileIds, target.connectionId)
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleSelfWriteAwareExternalReload(
|
||||
target: WatchedTarget,
|
||||
notification: ExternalWatchNotification,
|
||||
|
|
@ -680,8 +785,8 @@ function scheduleSelfWriteAwareExternalReload(
|
|||
// Why: a recent self-write stamp only proves the path changed recently; an
|
||||
// agent can write a newer version inside the same TTL. Compare disk content
|
||||
// with the saved text so we suppress only the echo of Orca's own write.
|
||||
void readRuntimeFileContent({
|
||||
settings: runtimeEnvironmentId ? { activeRuntimeEnvironmentId: runtimeEnvironmentId } : null,
|
||||
void readFileForEchoVerification({
|
||||
runtimeEnvironmentId,
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
worktreeId: file.worktreeId,
|
||||
|
|
@ -706,7 +811,9 @@ function scheduleSelfWriteAwareExternalReload(
|
|||
|
||||
function hasCleanExternalReloadTarget(notification: ExternalWatchNotification): boolean {
|
||||
const matching = getOpenFilesForExternalFileChange(useAppStore.getState().openFiles, notification)
|
||||
return matching.length > 0 && matching.every((file) => !file.isDirty)
|
||||
// Why: one clean target is enough — every notification consumer skips dirty
|
||||
// files per-file, so a dirty sibling tab no longer vetoes the reload.
|
||||
return matching.some((file) => !file.isDirty)
|
||||
}
|
||||
|
||||
export function getOverflowExternalReloadTargets(
|
||||
|
|
|
|||
|
|
@ -11522,6 +11522,24 @@
|
|||
},
|
||||
"richMarkdownLargeTextPaste": {
|
||||
"tooLarge": "Paste is too large."
|
||||
},
|
||||
"ExternalFileChangeBanner": {
|
||||
"7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.",
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"5c02de9b31": "Reloaded from disk",
|
||||
"d1e830fa22": "Undo",
|
||||
"90b2ce7d43": "Compare"
|
||||
},
|
||||
"ExternalFileChangeCompareDialog": {
|
||||
"4b8de20a11": "File changed on disk",
|
||||
"90cc31e4d7": "Disk version on the left, your unsaved edits on the right.",
|
||||
"8fe30ab254": "Reading file from disk...",
|
||||
"e2b1cd0393": "Could not read the file from disk: {{value0}}",
|
||||
"b6cf20d514": "The file on disk is binary — no text comparison available.",
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"2c8f1e07b9": "Loading comparison..."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
|
|
|||
|
|
@ -11522,6 +11522,24 @@
|
|||
"3e6c9a2b71": "pendiente",
|
||||
"4f7d0c3e88": "pasos fallidos",
|
||||
"5a8e1d4f23": " · "
|
||||
},
|
||||
"ExternalFileChangeBanner": {
|
||||
"7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.",
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"5c02de9b31": "Reloaded from disk",
|
||||
"d1e830fa22": "Undo",
|
||||
"90b2ce7d43": "Compare"
|
||||
},
|
||||
"ExternalFileChangeCompareDialog": {
|
||||
"4b8de20a11": "File changed on disk",
|
||||
"90cc31e4d7": "Disk version on the left, your unsaved edits on the right.",
|
||||
"8fe30ab254": "Reading file from disk...",
|
||||
"e2b1cd0393": "Could not read the file from disk: {{value0}}",
|
||||
"b6cf20d514": "The file on disk is binary — no text comparison available.",
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"2c8f1e07b9": "Loading comparison..."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
|
|
|||
|
|
@ -11522,6 +11522,24 @@
|
|||
"3e6c9a2b71": "保留中",
|
||||
"4f7d0c3e88": "失敗したステップ",
|
||||
"5a8e1d4f23": " · "
|
||||
},
|
||||
"ExternalFileChangeBanner": {
|
||||
"7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.",
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"5c02de9b31": "Reloaded from disk",
|
||||
"d1e830fa22": "Undo",
|
||||
"90b2ce7d43": "Compare"
|
||||
},
|
||||
"ExternalFileChangeCompareDialog": {
|
||||
"4b8de20a11": "File changed on disk",
|
||||
"90cc31e4d7": "Disk version on the left, your unsaved edits on the right.",
|
||||
"8fe30ab254": "Reading file from disk...",
|
||||
"e2b1cd0393": "Could not read the file from disk: {{value0}}",
|
||||
"b6cf20d514": "The file on disk is binary — no text comparison available.",
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"2c8f1e07b9": "Loading comparison..."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
|
|
|||
|
|
@ -11522,6 +11522,24 @@
|
|||
"3e6c9a2b71": "보류 중",
|
||||
"4f7d0c3e88": "단계 실패",
|
||||
"5a8e1d4f23": " · "
|
||||
},
|
||||
"ExternalFileChangeBanner": {
|
||||
"7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.",
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"5c02de9b31": "Reloaded from disk",
|
||||
"d1e830fa22": "Undo",
|
||||
"90b2ce7d43": "Compare"
|
||||
},
|
||||
"ExternalFileChangeCompareDialog": {
|
||||
"4b8de20a11": "File changed on disk",
|
||||
"90cc31e4d7": "Disk version on the left, your unsaved edits on the right.",
|
||||
"8fe30ab254": "Reading file from disk...",
|
||||
"e2b1cd0393": "Could not read the file from disk: {{value0}}",
|
||||
"b6cf20d514": "The file on disk is binary — no text comparison available.",
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"2c8f1e07b9": "Loading comparison..."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
|
|
|||
|
|
@ -11522,6 +11522,24 @@
|
|||
"3e6c9a2b71": "等待中",
|
||||
"4f7d0c3e88": "步骤失败",
|
||||
"5a8e1d4f23": " · "
|
||||
},
|
||||
"ExternalFileChangeBanner": {
|
||||
"7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.",
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"5c02de9b31": "Reloaded from disk",
|
||||
"d1e830fa22": "Undo",
|
||||
"90b2ce7d43": "Compare"
|
||||
},
|
||||
"ExternalFileChangeCompareDialog": {
|
||||
"4b8de20a11": "File changed on disk",
|
||||
"90cc31e4d7": "Disk version on the left, your unsaved edits on the right.",
|
||||
"8fe30ab254": "Reading file from disk...",
|
||||
"e2b1cd0393": "Could not read the file from disk: {{value0}}",
|
||||
"b6cf20d514": "The file on disk is binary — no text comparison available.",
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"2c8f1e07b9": "Loading comparison..."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
|
|
|||
|
|
@ -78,4 +78,49 @@ describe('workspace session editor drafts', () => {
|
|||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('persists the disk baseline signature only alongside a dirty draft', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
openFiles: [
|
||||
{
|
||||
id: '/tmp/dirty.md',
|
||||
filePath: '/tmp/dirty.md',
|
||||
relativePath: 'dirty.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: true,
|
||||
lastKnownDiskSignature: 'abc123'
|
||||
} as never,
|
||||
{
|
||||
id: '/tmp/clean.md',
|
||||
filePath: '/tmp/clean.md',
|
||||
relativePath: 'clean.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: false,
|
||||
lastKnownDiskSignature: 'def456'
|
||||
} as never
|
||||
],
|
||||
editorDrafts: {
|
||||
'/tmp/dirty.md': 'unsaved edits'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.openFilesByWorktree?.['wt-1']).toEqual([
|
||||
expect.objectContaining({
|
||||
filePath: '/tmp/dirty.md',
|
||||
dirtyDraftContent: 'unsaved edits',
|
||||
lastKnownDiskSignature: 'abc123'
|
||||
}),
|
||||
// Why: a clean tab has no draft to conflict-check on restore, and
|
||||
// persisting the signature anyway would bloat every session write.
|
||||
expect.not.objectContaining({
|
||||
lastKnownDiskSignature: expect.any(String)
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -135,7 +135,13 @@ export function buildEditorSessionData(
|
|||
language: f.language,
|
||||
isPreview: f.isPreview || undefined,
|
||||
runtimeEnvironmentId: f.runtimeEnvironmentId,
|
||||
...(dirtyDraftContent !== undefined ? { dirtyDraftContent } : {})
|
||||
...(dirtyDraftContent !== undefined ? { dirtyDraftContent } : {}),
|
||||
// Why: the edit baseline travels with the dirty draft so a restore can
|
||||
// re-derive a changed-on-disk conflict before autosave may overwrite an
|
||||
// agent write that landed while the app was closed.
|
||||
...(dirtyDraftContent !== undefined && f.lastKnownDiskSignature
|
||||
? { lastKnownDiskSignature: f.lastKnownDiskSignature }
|
||||
: {})
|
||||
})
|
||||
const ids =
|
||||
editFileIdsByWorktree[f.worktreeId] ?? (editFileIdsByWorktree[f.worktreeId] = new Set())
|
||||
|
|
|
|||
|
|
@ -251,8 +251,21 @@ export type OpenFile = {
|
|||
// disk while it's open, we keep the tab around so the user can still see
|
||||
// (and potentially save) their in-memory content. The tab surfaces this as
|
||||
// a strikethrough label plus a "deleted"/"renamed" suffix. Cleared if the
|
||||
// file reappears on disk at its original path.
|
||||
externalMutation?: 'deleted' | 'renamed'
|
||||
// file reappears on disk at its original path. 'changed' means the file was
|
||||
// rewritten on disk while this tab held unsaved edits (issue #7265): the
|
||||
// buffer is preserved and the editor shows a changed-on-disk banner instead
|
||||
// of tab strikethrough.
|
||||
externalMutation?: 'deleted' | 'renamed' | 'changed'
|
||||
/** Why: signature of the disk content this tab's edits are based on (last
|
||||
* load or save). Persisted with dirty drafts so a restore can re-derive a
|
||||
* changed-on-disk conflict from ground truth — an agent write that landed
|
||||
* while the app was closed must not be clobbered by a resumed autosave. */
|
||||
lastKnownDiskSignature?: string
|
||||
/** Why: set at hydration for restored dirty tabs; suspends autosave until
|
||||
* the restored-tab conflict scan has compared disk against the baseline.
|
||||
* Without this hard gate the scan's async read merely races the autosave
|
||||
* timer, and a slow (SSH/runtime) read loses the race. Not persisted. */
|
||||
pendingDiskBaselineVerification?: boolean
|
||||
/** Why: diff bodies are cached in EditorPanel. Re-selecting an existing diff
|
||||
* tab from the tree bumps this so the panel refetches instead of reusing a
|
||||
* stale snapshot. */
|
||||
|
|
@ -497,7 +510,9 @@ export type EditorSlice = {
|
|||
setActiveFile: (fileId: string) => void
|
||||
reorderFiles: (fileIds: string[]) => void
|
||||
markFileDirty: (fileId: string, dirty: boolean) => void
|
||||
setExternalMutation: (fileId: string, mutation: 'deleted' | 'renamed' | null) => void
|
||||
setExternalMutation: (fileId: string, mutation: 'deleted' | 'renamed' | 'changed' | null) => void
|
||||
setLastKnownDiskSignature: (fileId: string, signature: string) => void
|
||||
clearPendingDiskBaselineVerification: (fileId: string) => void
|
||||
clearUntitled: (fileId: string) => void
|
||||
openDiff: (
|
||||
worktreeId: string,
|
||||
|
|
@ -2469,6 +2484,32 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
}
|
||||
}),
|
||||
|
||||
setLastKnownDiskSignature: (fileId, signature) =>
|
||||
set((s) => {
|
||||
const file = s.openFiles.find((f) => f.id === fileId)
|
||||
if (!file || file.lastKnownDiskSignature === signature) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
openFiles: s.openFiles.map((f) =>
|
||||
f.id === fileId ? { ...f, lastKnownDiskSignature: signature } : f
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
clearPendingDiskBaselineVerification: (fileId) =>
|
||||
set((s) => {
|
||||
const file = s.openFiles.find((f) => f.id === fileId)
|
||||
if (!file?.pendingDiskBaselineVerification) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
openFiles: s.openFiles.map((f) =>
|
||||
f.id === fileId ? { ...f, pendingDiskBaselineVerification: undefined } : f
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
clearUntitled: (fileId) =>
|
||||
set((s) => ({
|
||||
openFiles: s.openFiles.map((f) => (f.id === fileId ? { ...f, isUntitled: undefined } : f))
|
||||
|
|
@ -4300,6 +4341,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
isDirty: pf.dirtyDraftContent !== undefined,
|
||||
isPreview: pf.isPreview,
|
||||
runtimeEnvironmentId: pf.runtimeEnvironmentId,
|
||||
lastKnownDiskSignature: pf.lastKnownDiskSignature,
|
||||
// Why: hard-suspends autosave until the restored-tab conflict scan
|
||||
// verifies disk against the baseline — an async race would let a
|
||||
// slow remote read lose to the autosave timer and clobber an
|
||||
// offline agent write.
|
||||
pendingDiskBaselineVerification:
|
||||
pf.dirtyDraftContent !== undefined && pf.lastKnownDiskSignature !== undefined
|
||||
? true
|
||||
: undefined,
|
||||
mode: 'edit'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1364,6 +1364,25 @@ const terminalPaneSplitSchema = z
|
|||
})
|
||||
.strict()
|
||||
|
||||
// Why: measures the changed-on-disk conflict flow (issue #7265) — how often
|
||||
// conflicts surface per transport (false-banner detection on ssh/runtime
|
||||
// echoes) and which resolution users pick. Deliberately path-free.
|
||||
const editorExternalChangeConflictShownSchema = z
|
||||
.object({
|
||||
surface: z.enum(['edit', 'unstaged-diff']),
|
||||
transport: z.enum(['local', 'ssh', 'runtime']),
|
||||
origin: z.enum(['live', 'restore'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
const editorExternalChangeConflictActionSchema = z
|
||||
.object({
|
||||
action: z.enum(['reload', 'keep', 'compare', 'undo_reload', 'save_overwrite']),
|
||||
surface: z.enum(['edit', 'unstaged-diff']),
|
||||
transport: z.enum(['local', 'ssh', 'runtime'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
// ── Event registry: the one record the validator consumes ───────────────
|
||||
//
|
||||
// The validator does `eventSchemas[name].safeParse(props)`. `EventMap` is
|
||||
|
|
@ -1451,6 +1470,9 @@ export const eventSchemas = {
|
|||
setup_guide_step_completed: setupGuideStepCompletedSchema,
|
||||
terminal_pane_split: terminalPaneSplitSchema,
|
||||
|
||||
editor_external_change_conflict_shown: editorExternalChangeConflictShownSchema,
|
||||
editor_external_change_conflict_action: editorExternalChangeConflictActionSchema,
|
||||
|
||||
smart_sort_class_distribution: smartSortClassDistributionSchema,
|
||||
smart_sort_class_1_promotion: smartSortClass1PromotionSchema,
|
||||
smart_to_recent_switch: smartToRecentSwitchSchema
|
||||
|
|
|
|||
|
|
@ -1022,6 +1022,9 @@ export type PersistedOpenFile = {
|
|||
runtimeEnvironmentId?: string | null
|
||||
/** Unsaved editor buffer captured for hot exit; presence restores the tab dirty. */
|
||||
dirtyDraftContent?: string
|
||||
/** Signature of the disk content the dirty draft is based on; lets restore
|
||||
* re-derive a changed-on-disk conflict from ground truth. */
|
||||
lastKnownDiskSignature?: string
|
||||
}
|
||||
|
||||
export type WorkspaceSessionState = {
|
||||
|
|
|
|||
|
|
@ -161,7 +161,8 @@ const persistedOpenFileSchema = z.object({
|
|||
language: z.string(),
|
||||
isPreview: z.boolean().optional(),
|
||||
runtimeEnvironmentId: z.string().nullable().optional(),
|
||||
dirtyDraftContent: z.string().optional()
|
||||
dirtyDraftContent: z.string().optional(),
|
||||
lastKnownDiskSignature: z.string().optional()
|
||||
})
|
||||
|
||||
// ─── Browser ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in New Issue