the folder is the URL: /files/Tests/test folder
`?path=/Tests/test_folder` becomes `/files/Tests/test%20folder`. Directories only —
an open file stays `?view=`, because it is what is on top of you rather than where
you are, and that split is what keeps closing a pane from moving you.
── The landmine, found before building on it ──
`server.tsx` 404s any path ending .js/.css/.map/.png/.svg/.ico/.webmanifest/.woff2
that is not in `build/`, so it never reaches the SPA. A folder named `logo.png` is
unusual but legal, and it would have deep-linked to a 404 that reads as a broken
link rather than a rule.
Narrowed to SINGLE-SEGMENT paths, which is safe on evidence rather than by
loosening a rule: the bundler emits exactly three files, all at the root of
`build/`, so a real asset never has a second slash; and multi-segment assets
(`public/**`, `/plugins/<app>/icon.png`) are served by Bun's `routes` table before
this handler runs.
── Encoding is the part that breaks quietly ──
Per SEGMENT, never the whole path — encodeURIComponent would eat the separators.
14 tests over the round trip, because a filename may carry anything but `/` and
NUL. The ones that matter: `#` truncates a URL at the fragment if unescaped, a
literal `%` is mistaken for an escape, and malformed input must not throw
(decodeURIComponent('%zz') raises URIError, and that would take the screen down).
Parentheses stay readable — encodeURIComponent leaves them alone — so
`Kanban (copy 2)` survives as `Kanban%20(copy%202)`.
── The rest ──
- `/files` → `/files/*`. The bare route still matches with an empty splat, so every
existing link, the dock entry and DEFAULT_DOCK_PATHS keep working.
- Breadcrumbs take a `To` instead of a query string and are still real links.
- Old `?path=` URLs redirect once, in place, with `replace` so it is not a
back-button step. A bookmark from this morning still lands where it meant to.
- `setViewerParams` stopped copying `path` across, because there is nothing to
copy — the folder is the pathname and is untouched by a search-param write.
Checked and needing nothing: the page-title rule is `startsWith('/files')`, and the
dock uses react-router NavLink without `end`, so `/files/Tests` keeps the tile lit.
tsgo clean, frontend builds, 831 pass / 7 fail — +14 new, same 7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -57,7 +57,11 @@ export function App() {
|
||||
<Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} />
|
||||
<Route path="/chat/g/*" element={<Dashboard.SessionListPage />} />
|
||||
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
|
||||
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
||||
{/* Splat, because the folder is the URL now: `/files/Tests/test folder`. The bare `/files`
|
||||
still matches with an empty splat and means home, so every existing link and the dock
|
||||
entry keep working. Only DIRECTORIES live here — an open file stays `?view=`, since it is
|
||||
what is on top of you rather than where you are. */}
|
||||
<Route path="/files/*" element={<Dashboard.FilesScreen />} />
|
||||
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||
|
||||
+11
-1
@@ -212,7 +212,17 @@ const serveBuilt = async (req: Request): Promise<Response> => {
|
||||
// A request that LOOKS like an asset and is not one must 404, never fall through to the shell. Serving
|
||||
// HTML with a JS content-type produces "Unexpected token '<'" and a blank page — a failure mode that
|
||||
// reads as a broken build rather than a missing file.
|
||||
if (/\.(js|css|map|png|svg|ico|webmanifest|woff2?)$/.test(path)) {
|
||||
//
|
||||
// SINGLE-SEGMENT only, and that is load-bearing. The bundler emits everything to the root of `build/`
|
||||
// — `chunk-<hash>.js`, `chunk-<hash>.css`, the shell — so a real build asset never has a second slash.
|
||||
// Multi-segment assets (`public/**`, `/plugins/<app>/icon.png`) are served by Bun's `routes` table
|
||||
// before this handler runs and never arrive here at all.
|
||||
//
|
||||
// Without the segment test, `/files/Tests/logo.png` — a client route naming a DIRECTORY that happens to
|
||||
// end in `.png` — 404s instead of reaching the SPA. Rare, but it fails silently and looks like a broken
|
||||
// deep link rather than a rule.
|
||||
const isSingleSegment = path.indexOf('/', 1) === -1;
|
||||
if (isSingleSegment && /\.(js|css|map|png|svg|ico|webmanifest|woff2?)$/.test(path)) {
|
||||
const file = Bun.file(join(BUILD_DIR, path.slice(1)));
|
||||
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
||||
return new Response(file, { headers: { 'cache-control': 'public, max-age=31536000, immutable' } });
|
||||
|
||||
@@ -26,7 +26,7 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, urlPath }: FileBr
|
||||
path={fileBrowserManager.currentPath}
|
||||
onNavigate={handleNavigate}
|
||||
basePath={basePath}
|
||||
searchForPath={fileBrowserManager.searchForPath}
|
||||
hrefForPath={fileBrowserManager.hrefForPath}
|
||||
/>
|
||||
<UploadProgress fileBrowserManager={fileBrowserManager} />
|
||||
<FileViewContainer fileBrowserManager={fileBrowserManager} />
|
||||
|
||||
+8
-6
@@ -6,20 +6,22 @@ type BreadcrumbProps = {
|
||||
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,
|
||||
* Where a folder lives, 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.
|
||||
*
|
||||
* A `To` rather than a query string since 2026-08-15 — the folder is the pathname now.
|
||||
*/
|
||||
searchForPath?: ((path: string) => string) | null;
|
||||
hrefForPath?: ((path: string) => { pathname: string; search: string }) | null;
|
||||
};
|
||||
|
||||
export const Breadcrumb = ({ path, onNavigate, basePath = '/', searchForPath }: BreadcrumbProps) => {
|
||||
export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: 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}>
|
||||
hrefForPath ? (
|
||||
<Link to={hrefForPath(to)} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { decodeFolderPath, encodeFolderPath, folderFromPathname, folderHref } from './files-route';
|
||||
|
||||
// The folder is in the URL path now, so every character a filesystem allows has to survive a round trip
|
||||
// through a URL. This is the file that says it does.
|
||||
|
||||
describe('folderHref', () => {
|
||||
test('the base folder is the bare route, with no trailing slash', () => {
|
||||
expect(folderHref('/')).toBe('/files');
|
||||
expect(folderHref('')).toBe('/files');
|
||||
});
|
||||
|
||||
test('the case that prompted this', () => {
|
||||
expect(folderHref('/Tests/test_folder/Kanban (copy 2)')).toBe('/files/Tests/test_folder/Kanban%20(copy%202)');
|
||||
});
|
||||
|
||||
test('parentheses are left readable; only the space is escaped', () => {
|
||||
// encodeURIComponent deliberately leaves ( ) ! \' * ~ alone. That is why the example above stays
|
||||
// legible in the address bar instead of turning into %28copy%202%29.
|
||||
expect(folderHref('/a (b)')).toBe('/files/a%20(b)');
|
||||
});
|
||||
|
||||
test('separators survive, contents are escaped', () => {
|
||||
// The whole reason for encoding per segment: encodeURIComponent('/a/b') would eat the slashes.
|
||||
expect(folderHref('/a/b')).toBe('/files/a/b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round trip', () => {
|
||||
test('every character a filename can legally carry comes back unchanged', () => {
|
||||
const names = [
|
||||
'Kanban (copy 2)',
|
||||
'a b',
|
||||
'hash#tag',
|
||||
'question?mark',
|
||||
'percent%20literal',
|
||||
'amp&and',
|
||||
'plus+plus',
|
||||
"quote'single",
|
||||
'semi;colon',
|
||||
'at@sign',
|
||||
'equals=sign',
|
||||
'brack[et]',
|
||||
'emoji🦆duck',
|
||||
'accénts',
|
||||
'.dotfile',
|
||||
'dash-under_score',
|
||||
];
|
||||
for (const name of names) {
|
||||
const path = `/Tests/${name}`;
|
||||
expect(folderFromPathname(folderHref(path))).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
test('a hash in a name does not truncate the path', () => {
|
||||
// The one that would silently lose everything after it if the segment were not encoded, because a
|
||||
// browser treats a bare # as the fragment.
|
||||
expect(folderHref('/a#b/c')).toBe('/files/a%23b/c');
|
||||
expect(folderFromPathname('/files/a%23b/c')).toBe('/a#b/c');
|
||||
});
|
||||
|
||||
test('a literal percent is not mistaken for an escape', () => {
|
||||
expect(folderHref('/100%')).toBe('/files/100%25');
|
||||
expect(folderFromPathname('/files/100%25')).toBe('/100%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('folderFromPathname', () => {
|
||||
test('the bare route is the base folder', () => {
|
||||
expect(folderFromPathname('/files')).toBe('/');
|
||||
});
|
||||
|
||||
test('a trailing slash is the base folder, not an empty segment', () => {
|
||||
expect(folderFromPathname('/files/')).toBe('/');
|
||||
});
|
||||
|
||||
test('anything outside the route answers the base rather than making the caller check', () => {
|
||||
expect(folderFromPathname('/chat')).toBe('/');
|
||||
expect(folderFromPathname('/filesystem/x')).toBe('/');
|
||||
expect(folderFromPathname('/')).toBe('/');
|
||||
});
|
||||
|
||||
test('malformed encoding is kept verbatim rather than throwing', () => {
|
||||
// decodeURIComponent('%zz') raises a URIError. A hand-mangled URL should land you somewhere odd, not
|
||||
// take the screen down with an uncaught exception.
|
||||
expect(() => folderFromPathname('/files/%zz')).not.toThrow();
|
||||
expect(folderFromPathname('/files/%zz')).toBe('/%zz');
|
||||
});
|
||||
|
||||
test('empty segments collapse, so a double slash is not a nameless folder', () => {
|
||||
expect(folderFromPathname('/files//a//b')).toBe('/a/b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encode/decode directly', () => {
|
||||
test('encodeFolderPath returns no leading slash, so it can be joined', () => {
|
||||
expect(encodeFolderPath('/a/b')).toBe('a/b');
|
||||
expect(encodeFolderPath('/')).toBe('');
|
||||
});
|
||||
|
||||
test('decodeFolderPath is always absolute', () => {
|
||||
expect(decodeFolderPath('a/b')).toBe('/a/b');
|
||||
expect(decodeFolderPath('')).toBe('/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// The folder you are looking at, as a URL path.
|
||||
//
|
||||
// `/files/Tests/test folder/Kanban (copy 2)` rather than `/files?path=/Tests/...`. The folder is WHERE
|
||||
// YOU ARE, which is what a path is for; the viewer stays in `?view=` because it is what is on top of you,
|
||||
// not where you are. That split is the same one `setViewerParams` has always documented, and it is what
|
||||
// keeps closing a pane from moving you.
|
||||
//
|
||||
// ── Why encode per segment ──
|
||||
//
|
||||
// A file name may contain anything the filesystem allows except `/` and NUL — `#`, `?`, `%`, spaces,
|
||||
// quotes, emoji. Encoding the whole path at once would eat the separators; encoding per segment keeps
|
||||
// them and escapes the rest. `encodeURIComponent` leaves `(`, `)`, `!`, `'`, `*` and `~` alone, which is
|
||||
// why `Kanban (copy 2)` survives readably while the space becomes `%20`.
|
||||
|
||||
export const FILES_ROUTE_BASE = '/files';
|
||||
|
||||
/**
|
||||
* A folder path (`/a/b c`) as URL path segments (`a/b%20c`). No leading slash — the caller joins it to
|
||||
* the route base, and an empty string is the base folder.
|
||||
*/
|
||||
export function encodeFolderPath(path: string): string {
|
||||
return path.split('/').filter(Boolean).map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* The inverse: URL path segments back to a folder path, always absolute.
|
||||
*
|
||||
* A segment that is not valid percent-encoding is kept verbatim rather than throwing —
|
||||
* `decodeURIComponent('%zz')` raises a URIError, and a malformed URL should land you somewhere odd, not
|
||||
* take the whole screen down with an uncaught exception.
|
||||
*/
|
||||
export function decodeFolderPath(segments: string): string {
|
||||
const parts = segments
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
});
|
||||
return parts.length ? `/${parts.join('/')}` : '/';
|
||||
}
|
||||
|
||||
/** The full pathname for a folder. The base folder is the bare route, not a trailing slash. */
|
||||
export function folderHref(path: string): string {
|
||||
const encoded = encodeFolderPath(path);
|
||||
return encoded ? `${FILES_ROUTE_BASE}/${encoded}` : FILES_ROUTE_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder a `/files/...` pathname refers to.
|
||||
*
|
||||
* Anything that is not under the base returns `/`, so a caller never has to check first.
|
||||
*/
|
||||
export function folderFromPathname(pathname: string): string {
|
||||
if (pathname === FILES_ROUTE_BASE) return '/';
|
||||
if (!pathname.startsWith(`${FILES_ROUTE_BASE}/`)) return '/';
|
||||
return decodeFolderPath(pathname.slice(FILES_ROUTE_BASE.length + 1));
|
||||
}
|
||||
+31
-25
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router';
|
||||
import { useSearchParams, useNavigate, useLocation } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
|
||||
import { usePinnedFiles } from '../usePinnedFiles';
|
||||
import { EDITOR_FILE_PARAM } from '../../CodeEditor/useEditorState';
|
||||
import { getFileType } from '../../FileViewer';
|
||||
import { folderFromPathname, folderHref } from './files-route';
|
||||
import { useTasks, type TaskSummary } from '../useTasks';
|
||||
import { useAgents, type AgentSummary } from '../useAgents';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
@@ -14,23 +15,15 @@ import { useNewDashboardDraft } from '../../Dashboards/useNewDashboardDraft';
|
||||
import { copyToClipboard, canReadClipboard } from 'helpers/clipboard';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The old query param. Kept only to recognise and redirect links made before 2026-08-15 — the folder
|
||||
* lives in the URL PATH now (`/files/Tests/test folder`). Nothing writes it.
|
||||
*/
|
||||
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 location = useLocation();
|
||||
const openNewDashboard = useNewDashboardDraft();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const homeRoot = 'home';
|
||||
@@ -42,27 +35,40 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
// 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 currentPath = urlPath ? folderFromPathname(location.pathname) : localPath;
|
||||
const setCurrentPath = (path: string) => {
|
||||
if (urlPath) setSearchParams((prev) => withPath(prev, path, basePath));
|
||||
// The search string rides along, so walking into a folder does not close an open pane — the same
|
||||
// property the old `withPath` had, now expressed as "change the pathname, keep the query".
|
||||
if (urlPath) navigate({ pathname: folderHref(path), search: location.search });
|
||||
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();
|
||||
/**
|
||||
* Where a crumb points. A real `To` rather than a query string, because the folder is the pathname now;
|
||||
* the current search rides along so cmd-clicking a crumb keeps whatever is open.
|
||||
*/
|
||||
const hrefForPath = (path: string) => ({ pathname: folderHref(path), search: location.search });
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* open. The folder is no longer among them — it is the pathname — so it survives without being copied
|
||||
* across, which is what used to be needed here.
|
||||
*/
|
||||
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 setViewerParams = (params: Record<string, string>) => setSearchParams(new URLSearchParams(params));
|
||||
|
||||
/**
|
||||
* Links from before the folder moved into the path still say `?path=/Tests`. Rewrite them once, in
|
||||
* place, so an old bookmark or a stale dock entry lands where it meant to instead of silently opening
|
||||
* home. `replace` so it does not become a back-button step.
|
||||
*/
|
||||
const legacyPath = urlPath ? searchParams.get(FILES_PATH_PARAM) : null;
|
||||
useEffect(() => {
|
||||
if (!legacyPath) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete(FILES_PATH_PARAM);
|
||||
navigate({ pathname: folderHref(legacyPath), search: next.toString() }, { replace: true });
|
||||
}, [legacyPath]);
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [rootDir, setRootDir] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -852,7 +858,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
// Null when this browser does not own the address bar — the breadcrumb then stays buttons.
|
||||
searchForPath: urlPath ? searchForPath : null,
|
||||
hrefForPath: urlPath ? hrefForPath : null,
|
||||
// Directory listing
|
||||
visibleEntries,
|
||||
loading,
|
||||
|
||||
Reference in New Issue
Block a user