a deleted file stays open, says so, and can be saved back

Keeping the tab when an agent deletes the file was deliberate — closing it would
let an `rm -rf` of a build directory take unsaved work with it — but it was only
half the idea, and the missing half made it worse than useless:

  the tab looked identical to a live file, and
  you could not SAVE it, because the editor thought nothing had changed

So the buffer was the only copy left, and the one action that would rescue it was
locked until you typed a throwaway character to force the dirty flag. Reported
from a real session.

VS Code's answer, and the right one: a file that has left the disk is DIRTY,
because the buffer now differs from what is there (nothing).

  markMissing   missing: true + isDirty: true — save works immediately
  the tab       name struck through, amber icon instead of the dirty dot
  markSaved     clears `missing`, because saving PUT IT BACK — leaving the warning
                up would make the fix look like a failure
  reloadFile    clears it too, for a file that reappears from elsewhere

That makes "save recreates the file" an informed recovery rather than the accident
it was: the tab says the file is gone before you press it.

Not a bug, and checked rather than assumed: the report that a tab showed "my
previous content on top of the agent's" was the file genuinely containing both.
On disk it reads `- Sidecasadasrs assumed up` — `Sidecars` with `asadas` typed
into the middle of it. The buffer had been saved over the deleted file, the agent
then edited that file, and the editor was showing the truth.

tsgo clean, frontend builds, 846 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 23:56:41 +00:00
co-authored by Claude Opus 5
parent f534b818b9
commit 20aa0cc880
3 changed files with 45 additions and 8 deletions
@@ -45,6 +45,7 @@ export const CodeEditorView = ({
closeFile,
setContent,
reloadFile,
markMissing,
markSaved,
getActiveFile,
searchForFile,
@@ -185,10 +186,12 @@ export const CodeEditorView = ({
if (file.isDirty) continue;
try {
const res = await readFileRef.current(file.path);
if (!alive || res.content === file.content) continue;
reloadFile(file.path, res.content);
if (!alive) continue;
if (res.content !== file.content) reloadFile(file.path, res.content);
} catch {
// Gone, or unreadable. Leave the tab exactly as it is — see above.
// Gone, or unreadable. The tab stays — see above — but it says so now, instead of looking
// identical to a file that is still there.
if (alive) markMissing(file.path);
}
}
})();
@@ -1,5 +1,5 @@
import { Link } from 'react-router';
import { X } from 'lucide-react';
import { X, FileX } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import type { OpenFile } from './useEditorState';
@@ -38,8 +38,15 @@ export const EditorTabs = ({ files, activePath, onSelect, onClose, theme, search
const label = (
<>
<FileIcon name={file.name} />
<span>{file.name}</span>
{file.isDirty && <span className="w-2 h-2 rounded-full bg-blue-400 shrink-0" />}
{/* Struck through when the file is gone from disk. The tab stays open on purpose — the buffer
is the only copy left — and this is what makes that legible instead of looking like an
ordinary tab that happens to have vanished from the tree. */}
<span className={file.missing ? 'line-through opacity-60' : undefined}>{file.name}</span>
{file.missing ? (
<FileX className="h-3 w-3 shrink-0 text-amber-500" />
) : (
file.isDirty && <span className="w-2 h-2 rounded-full bg-blue-400 shrink-0" />
)}
</>
);
const labelClass = 'flex items-center gap-1.5 text-sm cursor-pointer whitespace-nowrap';
@@ -7,6 +7,8 @@ export type OpenFile = {
content: string;
originalContent: string;
isDirty: boolean;
/** Open, but no longer on disk. The buffer is the only copy left — saving puts it back. */
missing?: boolean;
};
/**
@@ -85,13 +87,37 @@ export const useEditorState = (urlState = false) => {
*/
const reloadFile = (path: string, content: string) => {
setFiles((prev) =>
prev.map((f) => (f.path === path ? { ...f, content, originalContent: content, isDirty: false } : f)),
prev.map((f) =>
f.path === path ? { ...f, content, originalContent: content, isDirty: false, missing: false } : f,
),
);
};
/**
* The file is open but no longer on disk — an agent deleted it, or a task moved it.
*
* The tab deliberately stays: closing it would let an `rm -rf` of a build directory take unsaved work
* with it. But it was indistinguishable from a live file, so the buffer looked authoritative when it
* was the only copy left.
*
* Marked DIRTY as well as missing, and that is the useful half: the content differs from what is on
* disk (which is nothing), so Cmd+S writes it back and the file returns. Dirty also stops the refresh
* loop retrying a read that will keep failing.
*/
const markMissing = (path: string) => {
setFiles((prev) => prev.map((f) => (f.path === path ? { ...f, missing: true, isDirty: true } : f)));
};
/**
* Our own write landed. Clears `missing` too: saving a deleted file PUTS IT BACK, so the tab must stop
* saying it is gone — otherwise the one action that fixes the situation leaves the warning on screen
* and reads as having failed.
*/
const markSaved = (path: string, content: string) => {
setFiles((prev) =>
prev.map((f) => (f.path === path ? { ...f, originalContent: content, content, isDirty: false } : f)),
prev.map((f) =>
f.path === path ? { ...f, originalContent: content, content, isDirty: false, missing: false } : f,
),
);
};
@@ -105,6 +131,7 @@ export const useEditorState = (urlState = false) => {
closeFile,
setContent,
reloadFile,
markMissing,
markSaved,
getActiveFile,
// Null when this editor does not own the address bar — rows and tabs then stay buttons.