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:
2026-08-15 21:10:16 +00:00
co-authored by Claude Opus 5
parent 4874c21dfd
commit 559560de48
7 changed files with 222 additions and 34 deletions
+5 -1
View File
@@ -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
View File
@@ -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} />
@@ -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));
}
@@ -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,