the code editor refreshes itself, and Cmd+S works
The file browser has updated itself at the end of an agent turn for a long time,
because it listens to `files:refresh-signal`. The editor did not, and sat showing
whatever it read when you opened it.
Now it does, plus a button in the Explorer header for a change made somewhere the
signal does not reach — a task, a terminal, another machine. Two triggers, one
behaviour:
the tree re-lists, including every EXPANDED folder. A collapsed one has
nothing on screen to be stale, and re-listing all of them on every
turn is a burst of requests with nothing to show for it.
open files re-read, and only those with no unsaved edits.
── What a refresh must never do ──
Overwrite unsaved work. A dirty tab is skipped entirely: it keeps its edits and
its dot, and does not participate. "Reload everything" is the obvious
implementation and it would silently destroy the thing you were in the middle of.
A DELETED file keeps its tab too, rather than closing underneath you — content
intact, and saving writes it back. An agent doing `rm -rf` on a build directory
must not take your unsaved notes with it.
Content arrives through `executeEdits` with a full-range replace (checked in
@monaco-editor/react rather than assumed), so a refresh keeps your cursor and your
undo history. `setValue` would have reset both.
── The bug that made all of that pointless ──
Cmd+S did nothing. Monaco keeps the callback it was handed at mount, and `<Editor>`
only remounts when the file PATH changes — so the closure was built on the render
where the file was OPENED, with `isDirty: false`, and `handleSave` returns early on
a clean file. Open, type, save: no toast, no error, dirty dot stays. It reads
through a ref now.
That one matters here specifically: a file you cannot save stays dirty forever, and
a dirty file is skipped by every refresh. The feature above would have appeared to
work and then quietly stopped for the file you were actually editing.
Inferred from the code, not observed — worth confirming by hand.
tsgo clean, frontend builds, 846 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Editor, { type OnMount } from '@monaco-editor/react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import type { editor as MonacoEditor } from 'monaco-editor';
|
||||
import { toast } from 'sonner';
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||
@@ -8,6 +9,7 @@ import { FileTree } from './FileTree';
|
||||
import { EditorTabs } from './EditorTabs';
|
||||
import { useEditorState } from './useEditorState';
|
||||
import { getLanguage } from './language-map';
|
||||
import { useFilesRefresh } from '../../channels';
|
||||
|
||||
type CodeEditorViewProps = {
|
||||
className?: string;
|
||||
@@ -25,8 +27,18 @@ export const CodeEditorView = ({
|
||||
initialPath,
|
||||
urlState,
|
||||
}: CodeEditorViewProps) => {
|
||||
const { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile, searchForFile } =
|
||||
useEditorState(urlState);
|
||||
const {
|
||||
files,
|
||||
activePath,
|
||||
setActivePath,
|
||||
openFile,
|
||||
closeFile,
|
||||
setContent,
|
||||
reloadFile,
|
||||
markSaved,
|
||||
getActiveFile,
|
||||
searchForFile,
|
||||
} = useEditorState(urlState);
|
||||
const { readFile, writeFile } = useFilesAPI(root);
|
||||
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
|
||||
// useFilesAPI rebuilds its verbs every render, so the loader below reads them through a ref instead of
|
||||
@@ -75,6 +87,54 @@ export const CodeEditorView = ({
|
||||
};
|
||||
}, [activePath, files]);
|
||||
|
||||
/**
|
||||
* Re-read the disk: the tree, and every open file that has no unsaved edits.
|
||||
*
|
||||
* Two triggers, one behaviour. `files:refresh-signal` is the channel an agent turn bumps — the file
|
||||
* browser has listened to it for a long time, which is why it updates itself at the end of a turn and
|
||||
* the editor sat there showing whatever it read when you opened it. The button is the same thing by
|
||||
* hand, for a task that ran somewhere else or a change made in a terminal.
|
||||
*
|
||||
* A DIRTY file is never re-read. Overwriting unsaved work because something else touched the directory
|
||||
* would be the worst thing this feature could do, and it is exactly what a naive "reload everything"
|
||||
* does. The tab keeps its edits and its dot; the file simply does not participate.
|
||||
*
|
||||
* A file that has been DELETED is left open too, rather than closed underneath you: its tab stays, its
|
||||
* content stays, and saving it writes it back. Closing tabs on a refresh would mean an agent's `rm -rf`
|
||||
* of a build directory could take your unsaved notes with it.
|
||||
*/
|
||||
const [refreshSignal] = useFilesRefresh();
|
||||
const [manualRefresh, setManualRefresh] = useState(0);
|
||||
const refreshToken = refreshSignal + manualRefresh;
|
||||
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
const firstToken = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (firstToken.current) {
|
||||
// Mount: everything on screen was just read. Re-reading it here would double every open.
|
||||
firstToken.current = false;
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
for (const file of filesRef.current) {
|
||||
if (file.isDirty) continue;
|
||||
try {
|
||||
const res = await readFileRef.current(file.path);
|
||||
if (!alive || res.content === file.content) continue;
|
||||
reloadFile(file.path, res.content);
|
||||
} catch {
|
||||
// Gone, or unreadable. Leave the tab exactly as it is — see above.
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [refreshToken]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!activeFile || !activeFile.isDirty) return;
|
||||
try {
|
||||
@@ -86,10 +146,24 @@ export const CodeEditorView = ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl-S, through a ref.
|
||||
*
|
||||
* Monaco keeps whichever callback it was handed at mount, and `<Editor>` only remounts when the file
|
||||
* PATH changes — so the closure it held was built on the render where the file was opened, when
|
||||
* `activeFile.isDirty` was false. `handleSave` returns early on a clean file, which meant: open, type,
|
||||
* press save, and nothing happens. No toast, no error, and the dirty dot stays.
|
||||
*
|
||||
* Reading through a ref keeps one registration and always calls the current save. Same trap as
|
||||
* `readFileRef` above, and the one the Jellyfin reports hit.
|
||||
*/
|
||||
const saveRef = useRef(handleSave);
|
||||
saveRef.current = handleSave;
|
||||
|
||||
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSave();
|
||||
void saveRef.current();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -112,16 +186,24 @@ export const CodeEditorView = ({
|
||||
<ResizablePanel defaultSize={20} minSize={10} maxSize={40}>
|
||||
<div className="h-full flex flex-col" style={{ background: bg }}>
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider"
|
||||
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider flex items-center justify-between gap-2"
|
||||
style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }}
|
||||
>
|
||||
Explorer
|
||||
<span>Explorer</span>
|
||||
<button
|
||||
onClick={() => setManualRefresh((n) => n + 1)}
|
||||
title="Refresh the tree and reload unmodified open files"
|
||||
className="p-0.5 rounded hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<FileTree
|
||||
root={root}
|
||||
basePath={initialPath ?? '/'}
|
||||
onOpenFile={handleOpenFile}
|
||||
searchForFile={searchForFile}
|
||||
refreshToken={refreshToken}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
@@ -14,6 +14,12 @@ type FileTreeProps = {
|
||||
basePath: string;
|
||||
onOpenFile: (path: string, name: string) => void;
|
||||
searchForFile?: ((path: string) => string) | null;
|
||||
/**
|
||||
* Changes when the tree should re-read the disk. Threaded to EVERY node rather than just the root,
|
||||
* because an expanded folder holds its own children — refreshing only the top would leave a directory
|
||||
* an agent had just written to showing the listing from before its turn.
|
||||
*/
|
||||
refreshToken?: number;
|
||||
};
|
||||
|
||||
type TreeNodeProps = {
|
||||
@@ -23,6 +29,7 @@ type TreeNodeProps = {
|
||||
onOpenFile: (path: string, name: string) => void;
|
||||
searchForFile?: ((path: string) => string) | null;
|
||||
depth: number;
|
||||
refreshToken?: number;
|
||||
};
|
||||
|
||||
const FileIcon = ({ name }: { name: string }) => {
|
||||
@@ -36,13 +43,34 @@ const sortEntries = (entries: DirEntry[]) => {
|
||||
return [...dirs, ...files];
|
||||
};
|
||||
|
||||
const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth }: TreeNodeProps) => {
|
||||
const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, refreshToken }: TreeNodeProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState<DirEntry[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { listDir } = useFilesAPI(root);
|
||||
const isDir = entry.type === 'directory';
|
||||
const fullPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`;
|
||||
const listDirRef = useRef(listDir);
|
||||
listDirRef.current = listDir;
|
||||
|
||||
// Only while EXPANDED. A collapsed folder has nothing on screen to be stale, and re-listing every
|
||||
// closed directory on every agent turn would be a burst of requests nobody can see the result of.
|
||||
const firstToken = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstToken.current) {
|
||||
firstToken.current = false;
|
||||
return;
|
||||
}
|
||||
if (!isDir || !expanded) return;
|
||||
let alive = true;
|
||||
listDirRef
|
||||
.current(fullPath)
|
||||
.then((res) => alive && setChildren(sortEntries(res.entries)))
|
||||
.catch(() => alive && setChildren([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [refreshToken]);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!isDir) {
|
||||
@@ -110,6 +138,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth }:
|
||||
onOpenFile={onOpenFile}
|
||||
searchForFile={searchForFile}
|
||||
depth={depth + 1}
|
||||
refreshToken={refreshToken}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -118,11 +147,30 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth }:
|
||||
);
|
||||
};
|
||||
|
||||
export const FileTree = ({ root, basePath, onOpenFile, searchForFile }: FileTreeProps) => {
|
||||
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken }: FileTreeProps) => {
|
||||
const { listDir } = useFilesAPI(root);
|
||||
const [entries, setEntries] = useState<DirEntry[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const listDirRef = useRef(listDir);
|
||||
listDirRef.current = listDir;
|
||||
|
||||
const firstToken = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstToken.current) {
|
||||
firstToken.current = false;
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
listDirRef
|
||||
.current(basePath)
|
||||
.then((res) => alive && setEntries(sortEntries(res.entries)))
|
||||
.catch(() => alive && setEntries([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [refreshToken]);
|
||||
|
||||
const loadRoot = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -157,6 +205,7 @@ export const FileTree = ({ root, basePath, onOpenFile, searchForFile }: FileTree
|
||||
onOpenFile={onOpenFile}
|
||||
searchForFile={searchForFile}
|
||||
depth={0}
|
||||
refreshToken={refreshToken}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -76,6 +76,19 @@ export const useEditorState = (urlState = false) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace an open file's content with what is now on disk, leaving it CLEAN.
|
||||
*
|
||||
* Distinct from `setContent`, which is the user typing and marks the tab dirty, and from `markSaved`,
|
||||
* which records that our own write landed. This is "someone else changed this file" — an agent turn, a
|
||||
* task, another editor — so both content and original move together and nothing is owed to disk.
|
||||
*/
|
||||
const reloadFile = (path: string, content: string) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.path === path ? { ...f, content, originalContent: content, isDirty: false } : f)),
|
||||
);
|
||||
};
|
||||
|
||||
const markSaved = (path: string, content: string) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.path === path ? { ...f, originalContent: content, content, isDirty: false } : f)),
|
||||
@@ -91,6 +104,7 @@ export const useEditorState = (urlState = false) => {
|
||||
openFile,
|
||||
closeFile,
|
||||
setContent,
|
||||
reloadFile,
|
||||
markSaved,
|
||||
getActiveFile,
|
||||
// Null when this editor does not own the address bar — rows and tabs then stay buttons.
|
||||
|
||||
Reference in New Issue
Block a user