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:
@@ -42,9 +42,9 @@ was in this list; the Projects feature was removed end to end on 2026-07-30, so
|
||||
and navigate-site 22 below still name it — they are the record of work that happened, not of code that exists.)
|
||||
**Section routes** (a param that names a view rather than an entity): `/settings/:page/:section`,
|
||||
`/soulseek/:section`, `/headscale/:section`, `/wallet/:section`, `/system-monitor/:scope`.
|
||||
**Query-param screens:** `/music?path=`, `/files?path=`.
|
||||
**Genuinely flat, still:** `/` `/plans` `/terminal` `/desktop` `/code-editor` `/qr-transfer`.
|
||||
`/code-editor` is M5 — the last one that ought not to be.
|
||||
**Query-param screens:** `/music?path=`, `/files?path=`, `/code-editor?file=`.
|
||||
**Genuinely flat, still:** `/` `/plans` `/terminal` `/desktop` `/qr-transfer`. Of these only `/plans` selects
|
||||
something (M9); the other four have nothing addressable to put in an address.
|
||||
The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}`
|
||||
plus a `:section` each (M6). There has never been a `/settings/apps`; that entry was wrong when this list
|
||||
was written.
|
||||
@@ -81,7 +81,7 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
|
||||
| ~~M2~~ | ~~`Screens/Dashboard/TaskLogs/index.tsx:104`~~ | a task-log run | `/task-logs/:id` | **Done.** As predicted — the detail fetch already keyed off the id, so only its source changed. `showDetail` is gone; the mobile swap and both back arrows derive from the param. |
|
||||
| ~~M3~~ | ~~`Screens/Dashboard/Activity/ActivityScreen.tsx:63,73`~~ | background task / detached job | `/activity/:id` | **Done.** One param for both row kinds; the screen looks the id up in the polled registry and derives `task=`/`path=` from the row. The SSE effect now depends on that derived *string*, so the 3s poll no longer risks re-opening the stream. An id that has left the registry says so instead of hanging on "waiting for output". |
|
||||
| ~~M4~~ | ~~`FileBrowser/.../useFileBrowserApp.ts:269`~~, `FileItem.tsx:516`, ~~`Breadcrumb.tsx:16`~~ | a folder | `/files?path=<dir>` | **Partly done — the rest is an owner decision, not a defect.** `currentPath` is `?path=` on `/files`, so back/forward and linking a folder work, and the crumbs are `<Link>`s. Two things the audit line did not know: `?view=` is *ephemeral* (wiped on mount by `useFileViewerPanels`), so `path` is the screen's first durable param, and four `setSearchParams({…})` calls replaced the whole query string — opening any file would have silently reset the folder. They go through a `setViewerParams` helper now that keeps `path`. Opt-in via the parsed `WorkspaceIdentity` (`screens/files`), because a dashboard can hold two browsers and one shared param would move both. **Folder *items* stay buttons:** ⌘/Ctrl/Shift-click is already bound to multi-select in `FileItem.tsx` and open is double-click, so anchor semantics collide with an existing gesture. |
|
||||
| M5 | `CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33` | open source file / active tab | `/code-editor?file=<path>` (+ `open=` for tabs) | tree file rows → `<Link>`; active file in a param. |
|
||||
| ~~M5~~ | ~~`CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33`~~ | open source file / active tab | `/code-editor?file=<path>` | **Done, minus `open=`.** The active file is `?file=`; tree *file* rows and tabs are `<Link>`s. **The tab set stays local** — it is a working session, not an address: it grows without bound, each entry costs a read on load, and nobody links someone else to a tab bar. A `?file=` naming a file that is not open now *opens* it, which is what makes a pasted link work; a path that fails to read is remembered so a bad link errors once instead of once per render, and the address is left alone rather than rewritten. **Tree folder rows stay buttons** — unlike the M4 case this needs no owner call, because expanding a directory is disclosure, not navigation. Two things fixed in passing: the tab close control was a `role="button"` span *nested inside* the tab (invalid then, a nested interactive inside an anchor now) and is a sibling `<button>` with an `aria-label`; and `closeFile` computed the next-active file *inside* a `setFiles` updater, which is exactly the impurity React double-invokes to catch. |
|
||||
| ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `<NavLink>` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was *almost* right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. |
|
||||
| M7 | `workspaces/components/Combobox.tsx:53` | caller-supplied route | (existing) | shared widget: `href`-bearing options do `navigate(option.href)`; render them as `<Link>`. Every caller inherits the opaque click. |
|
||||
| ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. |
|
||||
@@ -218,7 +218,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
- [x] **M2** TaskLogs → `/task-logs/:id`; rows are `<Link>`s, `showDetail` deleted. **Needs runtime test.**
|
||||
- [x] **M3** Activity → `/activity/:id`; the `{label, query}` selection object is gone — the id is the URL and the stream query is derived from the registry row. `/activity` also had no `usePageTitle` rule (it read "Officer"); added. **Needs runtime test.**
|
||||
- [x] **M4** FileBrowser folders → `/files?path=`; breadcrumbs are `<Link>`s. Folder *rows* deliberately still buttons — ⌘-click is multi-select, open is double-click; converting them needs an owner call on the gesture.
|
||||
- [ ] **M5** CodeEditor active file → `/code-editor?file=` (`FileTree.tsx:59`, `EditorTabs.tsx:33`).
|
||||
- [x] **M5** CodeEditor active file → `/code-editor?file=`; tree file rows and tabs are `<Link>`s. The open-tab *set* stays local state, on purpose — see the findings row.
|
||||
- [ ] **M7** Combobox: render `href` options as `<Link>` (`Combobox.tsx:53`).
|
||||
|
||||
### Phase 3 — Whole-workspace routing decisions (needs a design call first)
|
||||
|
||||
@@ -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>
|
||||
</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,13 +62,12 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
|
||||
setExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
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` }}
|
||||
>
|
||||
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 ? (
|
||||
@@ -78,7 +85,20 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
{loading && <span className="text-xs text-[#888] ml-auto">...</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user