diff --git a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx index d176198c..f013e465 100644 --- a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx +++ b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx @@ -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(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 `` 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 = ({
- Explorer + Explorer +
diff --git a/src/workspaces/officerdev/src/apps/CodeEditor/FileTree.tsx b/src/workspaces/officerdev/src/apps/CodeEditor/FileTree.tsx index 4e1f95f3..119c2990 100644 --- a/src/workspaces/officerdev/src/apps/CodeEditor/FileTree.tsx +++ b/src/workspaces/officerdev/src/apps/CodeEditor/FileTree.tsx @@ -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(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} /> ))} @@ -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(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} /> ))} diff --git a/src/workspaces/officerdev/src/apps/CodeEditor/useEditorState.ts b/src/workspaces/officerdev/src/apps/CodeEditor/useEditorState.ts index 91f65263..ced0b763 100644 --- a/src/workspaces/officerdev/src/apps/CodeEditor/useEditorState.ts +++ b/src/workspaces/officerdev/src/apps/CodeEditor/useEditorState.ts @@ -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.