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:
@@ -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} />
|
||||
|
||||
+9
-2
@@ -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} />;
|
||||
};
|
||||
|
||||
+25
-7
@@ -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>
|
||||
);
|
||||
|
||||
+46
-10
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user