fix(web): editor picks up external file changes and warns on conflict

Two related bugs in the editor:

1) External file changes were invisible. useFileRead had no
   refetchInterval — the query fetched once on mount and never
   again. The agent editing the file (or any other tool) showed
   nothing in the open editor until a manual reload. Fix: add
   refetchInterval: 5_000 and refetchOnWindowFocus: true to
   useFileRead (same cadence as useFileList).

2) Even with refetch, the sync effect set both buffer and
   savedContent to data.content, which silently overwrote the
   user's unsaved edits every 5s. Fix: only overwrite buffer when
   !dirty. savedContent still tracks the latest disk version so we
   can detect 'file changed externally while editing'.

When the file is dirty AND the disk version differs, show a
'File changed on disk' banner with a Reload button. The user
decides whether to keep their unsaved changes or accept the disk
version. (The dependency lint warning is intentionally suppressed
with a comment explaining the design.)
This commit is contained in:
tao.chen
2026-07-06 18:33:02 +08:00
parent c647bb98a9
commit 8bb0bdfb4c
2 changed files with 40 additions and 2 deletions
+36 -2
View File
@@ -20,12 +20,34 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
const saveInProgress = write.isPending;
const saveTimeoutRef = useRef<number | undefined>(undefined);
// Detect that the file on disk has changed since we last loaded/saved it.
// We only consider it "changed externally" if the server content differs
// from what we last persisted (savedContent). The query refetches every
// 5s (see useFileRead) so this flips when another tool edits the file.
const externalChange =
data !== undefined && data.content !== savedContent && !dirty;
// Sync editor buffer with fetched content when the file changes or loads.
// Guard: do NOT clobber the user's unsaved edits. If dirty, the user is
// editing and the next refetch must not stomp the buffer.
useEffect(() => {
if (data) {
if (!data) return;
setSavedContent(data.content);
if (!dirty) {
setBuffer(data.content);
setSavedContent(data.content);
}
// Intentionally NOT including `dirty` in deps: we want this effect to
// run when `data` changes; on the first refetch that comes back with
// new content while dirty, the warning above (`externalChange` is gated
// by !dirty) won't fire, but the next save will surface the diff. The
// user can also click Reload to accept the disk version.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
const reloadFromDisk = useCallback(() => {
if (!data) return;
setBuffer(data.content);
setSavedContent(data.content);
}, [data]);
const editorLanguage =
@@ -162,6 +184,18 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
Loading
</div>
)}
{externalChange && (
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-700 dark:text-amber-300">
<span>File changed on disk.</span>
<button
type="button"
onClick={reloadFromDisk}
className="rounded border border-amber-500/40 px-2 py-0.5 hover:bg-amber-500/20"
>
Reload
</button>
</div>
)}
<div className="min-h-0 flex-1">
<Editor
key={path}
+4
View File
@@ -87,6 +87,10 @@ export function useFileRead(
queryKey: fileReadQueryKey(workspaceId ?? "", path ?? ""),
queryFn: () => fetchFileRead(workspaceId!, path!),
enabled: !!workspaceId && !!path,
// Poll so external edits (the agent, another tool) are visible in
// the editor without requiring a manual refresh.
refetchInterval: 5_000,
refetchOnWindowFocus: true,
});
}