put the open file in the url on /code-editor

audit m5. the active file is `?file=`, tree file rows and tabs are links, and a
`?file=` naming something that is not open now opens it — which is the part that makes
a pasted link actually work rather than just describe.

the open-tab *set* stays local state and i want that on the record as a choice, not an
omission. it is a working session, not an address: it grows without bound, every entry
costs a read on load, and nobody has ever linked someone else to a tab bar.

opt-in via a prop from the screen rather than the workspace identity the file browser
uses, because /code-editor renders CodeEditorView directly inside a Widget instead of
through the panel wrapper — there is no workspace to ask. a dashboard editor is
unchanged.

tree *folder* rows stay buttons, and unlike the file browser's folders this needs
nobody's call: expanding a directory is disclosure, not navigation.

two things fixed while in here. the tab close control was a role="button" span nested
inside the tab's own button — invalid before, and a nested interactive inside an anchor
after — so it is a sibling button with an aria-label now. and closeFile picked the
next-active file inside a setFiles updater, which is the impurity react double-invokes
in development to catch.

a path that fails to read is remembered, so a broken link errors once instead of once
per render, and the address is left alone rather than rewritten.
This commit is contained in:
2026-08-07 12:34:10 +00:00
parent 5daa598b63
commit 990ead93b9
6 changed files with 202 additions and 69 deletions
@@ -4,7 +4,9 @@ import { Widget } from 'widgets/Widget';
export const CodeEditor = () => (
<div className="h-full w-full flex items-center justify-center">
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
<CodeEditorView className="h-full w-full" />
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
wrapper deliberately does not pass this: a dashboard can hold two editors. */}
<CodeEditorView className="h-full w-full" urlState />
</Widget>
</div>
);
@@ -14,13 +14,26 @@ type CodeEditorViewProps = {
theme?: string;
root?: string;
initialPath?: string;
/** Put the open file in `?file=`. Opt-in — only the /code-editor screen is guaranteed one editor. */
urlState?: boolean;
};
export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', initialPath }: CodeEditorViewProps) => {
const { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile } =
useEditorState();
export const CodeEditorView = ({
className,
theme = 'vs-dark',
root = 'home',
initialPath,
urlState,
}: CodeEditorViewProps) => {
const { files, activePath, setActivePath, openFile, closeFile, setContent, 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
// depending on them — the same trap that silently broke the Jellyfin playback reports.
const readFileRef = useRef(readFile);
readFileRef.current = readFile;
const unreadable = useRef(new Set<string>());
const activeFile = getActiveFile();
@@ -38,6 +51,30 @@ export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', in
}
};
/**
* A URL that names a file has to actually open it — otherwise the param is a label, not an address.
* Covers a pasted link, a reload, and Back onto a tab that has since been closed. A path that fails to
* read is remembered so a broken link produces one error rather than one per render, and the address is
* left alone: an id that does not resolve gets an empty pane, it does not get rewritten.
*/
useEffect(() => {
if (!activePath || unreadable.current.has(activePath)) return;
if (files.some((f) => f.path === activePath)) return;
let alive = true;
const path = activePath;
readFileRef
.current(path)
.then((res) => alive && openFile(path, path.split('/').pop() ?? path, res.content))
.catch(() => {
unreadable.current.add(path);
if (alive) toast.error('Failed to read file');
});
return () => {
alive = false;
};
}, [activePath, files]);
const handleSave = async () => {
if (!activeFile || !activeFile.isDirty) return;
try {
@@ -80,7 +117,12 @@ export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', in
>
Explorer
</div>
<FileTree root={root} basePath={initialPath ?? '/'} onOpenFile={handleOpenFile} />
<FileTree
root={root}
basePath={initialPath ?? '/'}
onOpenFile={handleOpenFile}
searchForFile={searchForFile}
/>
</div>
</ResizablePanel>
<ResizableHandle />
@@ -92,6 +134,7 @@ export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', in
onSelect={setActivePath}
onClose={closeFile}
theme={theme}
searchForFile={searchForFile}
/>
{activeFile ? (
<Editor
@@ -1,3 +1,4 @@
import { Link } from 'react-router';
import { X } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import type { OpenFile } from './useEditorState';
@@ -8,6 +9,8 @@ type EditorTabsProps = {
onSelect: (path: string) => void;
onClose: (path: string) => void;
theme?: string;
/** Query string for a file, when the editor owns the address bar. Absent, the tabs stay buttons. */
searchForFile?: ((path: string) => string) | null;
};
const FileIcon = ({ name }: { name: string }) => {
@@ -15,7 +18,7 @@ const FileIcon = ({ name }: { name: string }) => {
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
};
export const EditorTabs = ({ files, activePath, onSelect, onClose, theme }: EditorTabsProps) => {
export const EditorTabs = ({ files, activePath, onSelect, onClose, theme, searchForFile }: EditorTabsProps) => {
if (files.length === 0) return null;
const isDark = theme !== 'vs';
@@ -26,35 +29,52 @@ export const EditorTabs = ({ files, activePath, onSelect, onClose, theme }: Edit
const inactiveColor = isDark ? '#888' : '#666';
return (
<div className="flex items-center overflow-x-auto shrink-0 code-editor-scrollable" style={{ borderBottom: `1px solid ${borderColor}` }}>
<div
className="flex items-center overflow-x-auto shrink-0 code-editor-scrollable"
style={{ borderBottom: `1px solid ${borderColor}` }}
>
{files.map((file) => {
const isActive = file.path === activePath;
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" />}
</>
);
const labelClass = 'flex items-center gap-1.5 text-sm cursor-pointer whitespace-nowrap';
return (
<button
// Close is a sibling of the tab, not a child of it. It used to be a `role="button"` span nested
// inside the tab's own button, which is invalid either way and would be a nested interactive
// element inside an anchor now — the exact shape the audit says to avoid.
<div
key={file.path}
onClick={() => onSelect(file.path)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer whitespace-nowrap transition-colors"
className="flex items-center gap-1 pl-3 pr-2 py-1.5 transition-colors"
style={{
background: isActive ? activeBg : inactiveBg,
color: isActive ? activeColor : inactiveColor,
borderRight: `1px solid ${borderColor}`,
}}
>
<FileIcon name={file.name} />
<span>{file.name}</span>
{file.isDirty && <span className="w-2 h-2 rounded-full bg-blue-400 shrink-0" />}
<span
role="button"
className="ml-1 p-0.5 rounded transition-colors"
{searchForFile ? (
<Link to={{ search: searchForFile(file.path) }} className={labelClass}>
{label}
</Link>
) : (
<button onClick={() => onSelect(file.path)} className={labelClass}>
{label}
</button>
)}
<button
onClick={() => onClose(file.path)}
aria-label={`Close ${file.name}`}
className="p-0.5 rounded cursor-pointer transition-colors hover:bg-white/10"
style={{ color: inactiveColor }}
onClick={(ev) => {
ev.stopPropagation();
onClose(file.path);
}}
>
<X className="h-3 w-3" />
</span>
</button>
</button>
</div>
);
})}
</div>
@@ -1,12 +1,19 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
/**
* `searchForFile` turns file rows into real links when the editor owns the address bar. Folder rows stay
* buttons on purpose: expanding a directory is disclosure, not navigation — it opens nothing, it changes
* no selection, and there would be nothing for a new tab to show.
*/
type FileTreeProps = {
root: string;
basePath: string;
onOpenFile: (path: string, name: string) => void;
searchForFile?: ((path: string) => string) | null;
};
type TreeNodeProps = {
@@ -14,6 +21,7 @@ type TreeNodeProps = {
parentPath: string;
root: string;
onOpenFile: (path: string, name: string) => void;
searchForFile?: ((path: string) => string) | null;
depth: number;
};
@@ -28,7 +36,7 @@ const sortEntries = (entries: DirEntry[]) => {
return [...dirs, ...files];
};
const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps) => {
const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth }: TreeNodeProps) => {
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -54,31 +62,43 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
setExpanded((prev) => !prev);
};
const rowClass =
'flex items-center gap-1 w-full px-1 py-0.5 text-sm rounded cursor-pointer transition-colors text-left text-[#ccc] hover:bg-white/5';
const rowStyle = { paddingLeft: `${depth * 12 + 4}px` };
const body = (
<>
{isDir ? (
<>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-[#888]" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-[#888]" />
)}
<Folder className="h-4 w-4 shrink-0 text-duck-yellow fill-duck-yellow/30" />
</>
) : (
<>
<span className="w-3.5 shrink-0" />
<FileIcon name={entry.name} />
</>
)}
<span className="truncate">{entry.name}</span>
{loading && <span className="text-xs text-[#888] ml-auto">...</span>}
</>
);
return (
<div>
<button
onClick={handleClick}
className="flex items-center gap-1 w-full px-1 py-0.5 text-sm rounded cursor-pointer transition-colors text-left text-[#ccc] hover:bg-white/5"
style={{ paddingLeft: `${depth * 12 + 4}px` }}
>
{isDir ? (
<>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-[#888]" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-[#888]" />
)}
<Folder className="h-4 w-4 shrink-0 text-duck-yellow fill-duck-yellow/30" />
</>
) : (
<>
<span className="w-3.5 shrink-0" />
<FileIcon name={entry.name} />
</>
)}
<span className="truncate">{entry.name}</span>
{loading && <span className="text-xs text-[#888] ml-auto">...</span>}
</button>
{!isDir && searchForFile ? (
<Link to={{ search: searchForFile(fullPath) }} className={rowClass} style={rowStyle}>
{body}
</Link>
) : (
<button onClick={handleClick} className={rowClass} style={rowStyle}>
{body}
</button>
)}
{isDir && expanded && children && (
<div>
{children.map((child) => (
@@ -88,6 +108,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
parentPath={fullPath}
root={root}
onOpenFile={onOpenFile}
searchForFile={searchForFile}
depth={depth + 1}
/>
))}
@@ -97,7 +118,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
);
};
export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
export const FileTree = ({ root, basePath, onOpenFile, searchForFile }: FileTreeProps) => {
const { listDir } = useFilesAPI(root);
const [entries, setEntries] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -134,6 +155,7 @@ export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
parentPath={basePath}
root={root}
onOpenFile={onOpenFile}
searchForFile={searchForFile}
depth={0}
/>
))}
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router';
export type OpenFile = {
path: string;
@@ -8,29 +9,65 @@ export type OpenFile = {
isDirty: boolean;
};
export const useEditorState = () => {
/**
* Which file the editor is showing, when it is the editor that owns the address bar. Opt-in for the same
* reason `?path=` is in the file browser: this app also mounts as a dashboard panel, and a dashboard can
* hold two of them, which one shared param would drive in lockstep.
*/
export const EDITOR_FILE_PARAM = 'file';
/**
* The *set* of open tabs is deliberately not in the URL. It is a working session, not an address — it
* grows without bound, every entry costs a read on load, and nobody links someone else to a tab bar.
* `file` is the selection, and a link to one opens it; the tabs accumulate around it as you browse.
*/
export const useEditorState = (urlState = false) => {
const [files, setFiles] = useState<OpenFile[]>([]);
const [activePath, setActivePath] = useState<string | null>(null);
const [localActive, setLocalActive] = useState<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const activePath = urlState ? searchParams.get(EDITOR_FILE_PARAM) : localActive;
/** `replace` for a close — shutting a tab is a mutation, not somewhere you navigated to. */
const setActivePath = (path: string | null, replace = false) => {
if (path === activePath) return;
if (!urlState) {
setLocalActive(path);
return;
}
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (path) next.set(EDITOR_FILE_PARAM, path);
else next.delete(EDITOR_FILE_PARAM);
return next;
},
{ replace },
);
};
/** Query string for a file, so a tree row and a tab can be real links. */
const searchForFile = (path: string) => {
const next = new URLSearchParams(searchParams);
next.set(EDITOR_FILE_PARAM, path);
return next.toString();
};
const openFile = (path: string, name: string, content: string) => {
setFiles((prev) => {
const existing = prev.find((f) => f.path === path);
if (existing) return prev;
if (prev.some((f) => f.path === path)) return prev;
return [...prev, { path, name, content, originalContent: content, isDirty: false }];
});
setActivePath(path);
};
// The next-active pick is computed here rather than inside the updater it used to sit in: a state
// updater must be pure, and React invokes it twice in development precisely to catch this.
const closeFile = (path: string) => {
setFiles((prev) => {
const next = prev.filter((f) => f.path !== path);
if (activePath === path) {
const idx = prev.findIndex((f) => f.path === path);
const newActive = next[Math.min(idx, next.length - 1)] ?? null;
setActivePath(newActive?.path ?? null);
}
return next;
});
const idx = files.findIndex((f) => f.path === path);
const next = files.filter((f) => f.path !== path);
setFiles(next);
if (activePath === path) setActivePath(next[Math.min(idx, next.length - 1)]?.path ?? null, true);
};
const setContent = (path: string, content: string) => {
@@ -45,9 +82,18 @@ export const useEditorState = () => {
);
};
const getActiveFile = (): OpenFile | null => {
return files.find((f) => f.path === activePath) ?? null;
};
const getActiveFile = (): OpenFile | null => files.find((f) => f.path === activePath) ?? null;
return { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile };
return {
files,
activePath,
setActivePath,
openFile,
closeFile,
setContent,
markSaved,
getActiveFile,
// Null when this editor does not own the address bar — rows and tabs then stay buttons.
searchForFile: urlState ? searchForFile : null,
};
};