put the browsed folder in the url on /files

the file browser's currentPath was useState, so back and forward did nothing and a
folder could not be linked to. it is `?path=` now on /files, and the breadcrumbs are
real links.

opt-in, keyed on the parsed workspace identity rather than the base path: a dashboard
can hold two file browsers and one shared param would move both, while an unscoped
panel (cwd `~`) sits on dashboards too, so `basePath === '/'` would have caught the
wrong ones.

two things the audit line did not know. `?view=` is ephemeral — useFileViewerPanels
wipes it on mount — so `path` is this screen's first durable param. and four
setSearchParams({...}) calls replaced the whole query string, which would have made
opening any file silently reset the folder to home; they go through a setViewerParams
helper now that carries `path` across.

folder rows stay buttons. cmd/ctrl/shift-click is already multi-select in FileItem and
open is double-click, so anchor semantics collide with a gesture that exists. that is a
product decision, not a defect — written up for the owner rather than guessed at.
This commit is contained in:
2026-08-07 12:28:49 +00:00
parent ec4aaaae8a
commit 5daa598b63
5 changed files with 95 additions and 27 deletions
@@ -11,16 +11,23 @@ import { useFileBrowserApp } from './useFileBrowserApp';
type FileBrowserAppProps = {
basePath?: string;
rootOverride?: string;
/** Put the current folder in `?path=`. Opt-in — only the /files screen is guaranteed one browser. */
urlPath?: boolean;
};
export const FileBrowserApp = ({ basePath = '/', rootOverride }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride);
export const FileBrowserApp = ({ basePath = '/', rootOverride, urlPath }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath);
const { handleNavigate } = fileBrowserManager;
return (
<div className="flex flex-col h-full overflow-hidden">
<Toolbar fileBrowserManager={fileBrowserManager} />
<Breadcrumb path={fileBrowserManager.currentPath} onNavigate={handleNavigate} basePath={basePath} />
<Breadcrumb
path={fileBrowserManager.currentPath}
onNavigate={handleNavigate}
basePath={basePath}
searchForPath={fileBrowserManager.searchForPath}
/>
<UploadProgress fileBrowserManager={fileBrowserManager} />
<FileViewContainer fileBrowserManager={fileBrowserManager} />
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
@@ -4,7 +4,14 @@ import { FileBrowserApp } from './FileBrowserApp';
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
export const FileBrowserPanelWrapper = () => {
const { cwd } = useWorkspace();
const { cwd, workspace } = useWorkspace();
const basePath = cwdToPath(cwd);
return <FileBrowserApp basePath={basePath} rootOverride={basePath !== '/' ? 'home' : undefined} />;
// Only the /files screen puts its folder in the URL. A dashboard can hold two file browsers, and one
// shared `?path=` would move both — so the address bar belongs to the workspace that is guaranteed to
// host exactly one. The test is the framework's parsed identity, not the base path: an unscoped panel
// (cwd `~`) sits on dashboards too, so `basePath === '/'` would catch the wrong browsers.
const urlPath = workspace?.kind === 'screen' && workspace.id === 'files';
return <FileBrowserApp basePath={basePath} rootOverride={basePath !== '/' ? 'home' : undefined} urlPath={urlPath} />;
};
@@ -1,25 +1,43 @@
import { Link } from 'react-router';
import { ChevronRight, Home } from 'lucide-react';
type BreadcrumbProps = {
path: string;
onNavigate: (path: string) => void;
basePath?: string;
/**
* Query string for a folder, when this browser owns the address bar. Given, the crumbs are real links
* and cmd-click opens the folder in a new tab; absent (a dashboard panel, the widget) they stay buttons,
* because that browser's location is not in the URL and there would be nothing for the new tab to read.
*/
searchForPath?: ((path: string) => string) | null;
};
export const Breadcrumb = ({ path, onNavigate, basePath = '/' }: BreadcrumbProps) => {
export const Breadcrumb = ({ path, onNavigate, basePath = '/', searchForPath }: BreadcrumbProps) => {
const relativePath = basePath !== '/' && path.startsWith(basePath) ? path.slice(basePath.length) : path;
const segments = relativePath.split('/').filter(Boolean);
const Crumb = ({ to, children, className }: { to: string; children: React.ReactNode; className: string }) =>
searchForPath ? (
<Link to={{ search: searchForPath(to) }} className={className}>
{children}
</Link>
) : (
<button onClick={() => onNavigate(to)} className={className}>
{children}
</button>
);
return (
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<nav className="flex items-center gap-1 text-sm flex-wrap">
<button
onClick={() => onNavigate(basePath)}
<Crumb
to={basePath}
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
<Home className="h-4 w-4" />
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
</button>
</Crumb>
{segments.map((segment, i) => {
const relative = '/' + segments.slice(0, i + 1).join('/');
@@ -32,12 +50,12 @@ export const Breadcrumb = ({ path, onNavigate, basePath = '/' }: BreadcrumbProps
{isLast ? (
<span className="text-duck-dark font-semibold">{segment}</span>
) : (
<button
onClick={() => onNavigate(segmentPath)}
<Crumb
to={segmentPath}
className="text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
{segment}
</button>
</Crumb>
)}
</span>
);
@@ -8,21 +8,55 @@ import { useUserState } from 'state/useUserState';
import { useAuth } from 'hooks/useAuth';
import { useFilesRefresh } from '../../../channels';
export const useFileBrowserApp = (basePath: string, rootOverride?: string) => {
/**
* Where the browser is looking, when it is the one browser that owns the address bar. `urlPath` is opt-in
* because the same app mounts as a dashboard panel, and a dashboard can hold two of them — one shared
* param would move both. The /files screen holds exactly one, so there it is the source of truth.
*/
export const FILES_PATH_PARAM = 'path';
/** The base folder is the bare address, so it drops the param rather than spelling itself out. */
const withPath = (prev: URLSearchParams, path: string, basePath: string) => {
const next = new URLSearchParams(prev);
if (path === basePath) next.delete(FILES_PATH_PARAM);
else next.set(FILES_PATH_PARAM, path);
return next;
};
export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPath = false) => {
const { user } = useAuth();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const homeRoot = 'home';
const [, setGlobalPath] = useUserState<string>('files/currentPath', '/');
const [currentPath, setLocalPath] = useState(basePath);
const [localPath, setLocalPath] = useState(basePath);
const scoped = basePath !== '/';
// Navigation is session-local — the browser always opens at home, never restoring the
// last path. The main (unscoped) browser still mirrors its folder into files/currentPath
// so the Create Dashboard flow can default a new dashboard's cwd to it.
// Navigation is session-local for a panel — it always opens at its base and never restores the last
// path. On /files it is the URL instead, so back/forward work and a folder can be linked to; a fresh
// visit still opens at home, because a URL with no `path` means the base. Either way the main
// (unscoped) browser mirrors its folder into files/currentPath so the Create Dashboard flow can
// default a new dashboard's cwd to it.
const currentPath = urlPath ? (searchParams.get(FILES_PATH_PARAM) ?? basePath) : localPath;
const setCurrentPath = (path: string) => {
setLocalPath(path);
if (urlPath) setSearchParams((prev) => withPath(prev, path, basePath));
else setLocalPath(path);
if (!scoped) setGlobalPath(path);
};
/** Query string for a folder — the current params with `path` set, so an open viewer survives a crumb. */
const searchForPath = (path: string) => withPath(searchParams, path, basePath).toString();
/**
* The ephemeral viewer params replace each other wholesale: opening a file closes whatever overlay was
* open. `path` is not one of them — it is where you are, not what is on top of it — so it survives.
*/
const setViewerParams = (params: Record<string, string>) =>
setSearchParams((prev) => {
const next = new URLSearchParams(params);
const keep = prev.get(FILES_PATH_PARAM);
if (keep) next.set(FILES_PATH_PARAM, keep);
return next;
});
const [entries, setEntries] = useState<DirEntry[]>([]);
const [rootDir, setRootDir] = useState('');
const [loading, setLoading] = useState(true);
@@ -246,7 +280,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string) => {
} else {
const parentPath = entry.path.substring(0, entry.path.lastIndexOf('/')) || '/';
setCurrentPath(parentPath);
setSearchParams({ view: entry.path });
setViewerParams({ view: entry.path });
}
setSearchQuery('');
};
@@ -266,7 +300,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string) => {
setCurrentPath(next);
} else {
const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
setSearchParams({ view: filePath });
setViewerParams({ view: filePath });
}
};
@@ -430,7 +464,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string) => {
const { audioPath } = await files.tts(filePath, { saveNextTo: true });
toast.dismiss(toastId);
refresh();
setSearchParams({ view: filePath, ephemeral: audioPath });
setViewerParams({ view: filePath, ephemeral: audioPath });
} catch {
toast.error('Failed to generate speech audio', { id: toastId });
}
@@ -438,7 +472,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string) => {
const handlePlay = (entry: DirEntry) => {
const filePath = entryPath(entry.name);
setSearchParams({ play: filePath });
setViewerParams({ play: filePath });
};
const handleExtract = async (entry: DirEntry) => {
@@ -621,6 +655,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string) => {
homeRoot,
currentPath,
setCurrentPath,
// Null when this browser does not own the address bar — the breadcrumb then stays buttons.
searchForPath: urlPath ? searchForPath : null,
// Directory listing
visibleEntries,
loading,