the file preview becomes a carousel

arrows, a thumbnail strip, and left/right/escape on the keyboard — in the viewer
itself, so BOTH hosts get it: the Files screen and any panel embedding it.

IT LISTS THE FOLDER ITSELF rather than being handed a collection. the viewer is given
one path and is reached by two screens that get there differently — a link, a search
result, the grid. listing here means neither host has to pass anything and the strip
is right however the file was opened.

THE HOST DECIDES HOW TO WRITE THE STEP, because the two genuinely disagree: the Files
screen puts a NAME in `?view=` relative to the folder in its pathname, an embedded
panel puts an absolute path. so `onNavigate` is a callback rather than the viewer
setting a param, and a host that offers none simply gets no carousel — which is the
right default for one that cannot express it.

the strip uses the FILE BROWSER'S thumbnails: same route, same cache, same 256 the
grid asks for. so a folder already browsed has a warm strip, and a HEIC works here
because it works there rather than being a second thing to teach about formats.
`getThumbUrl` joins getRawUrl and getRenderUrl as a shared builder, with the token an
<img> cannot send as a header.

directories and archives are excluded. opening an archive EXTRACTS rather than views,
so a strip you step through would offer a step that does something else entirely.

the keyboard listener ignores inputs, textareas and contenteditable — the viewer can
be editing a file, and stealing arrow keys from a text area makes the editor unusable.

it renders nothing when the folder holds fewer than two viewable files: a strip
containing the thing already on screen, beside two arrows that do nothing, is worse
than no strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 09:28:38 +01:00
co-authored by Claude Opus 5
parent eaecf563ff
commit 53f3ca33b5
8 changed files with 229 additions and 2 deletions
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
import { getFileType, getThumbUrl } from './file-types';
import { useFileViewer } from './FileViewerContext';
/**
* Move between the files in a folder without going back to the listing.
*
* ── Which files ──
*
* Everything in the same folder the viewer can SHOW — images, video, audio, pdf, markdown, code, text.
* Not directories and not archives: opening one is an extraction rather than a view, so putting them in
* a strip of things you step through would offer a step that does something else entirely.
*
* ── It lists the folder itself ──
*
* The viewer is given one path and nothing else, and it is rendered by two different screens that reach
* it in two different ways. Listing the folder here means neither has to pass a collection, and the
* strip is right the moment a file is opened from anywhere — a link somebody sent, a search result, or
* the grid.
*
* ── The thumbnails are the file browser's ──
*
* Same `/thumb` route, same cache, same size the grid asks for. So a folder already browsed has a warm
* strip, and a HEIC in it works exactly where it works elsewhere rather than being a second thing to
* teach about formats.
*/
const STEPPABLE = new Set(['image', 'video', 'audio', 'pdf', 'markdown', 'code', 'text']);
export const FileCarousel = ({ onClose }: { onClose?: () => void }) => {
const { filePath, root, onNavigate } = useFileViewer();
const files = useFilesAPI(root || 'home');
const [entries, setEntries] = useState<DirEntry[]>([]);
const folder = filePath.slice(0, filePath.lastIndexOf('/')) || '/';
useEffect(() => {
let live = true;
void files
.listDir(folder)
.then((data) => {
if (live) setEntries(data.entries ?? []);
})
.catch(() => {
// A folder that cannot be listed simply gets no strip. The file itself is already open and
// readable; failing loudly here would break a working view over its navigation.
if (live) setEntries([]);
});
return () => {
live = false;
};
// `folder` only: re-listing on every file change would refetch the same directory for each step.
}, [folder]);
const siblings = useMemo(
() =>
entries
.filter((e) => e.type === 'file' && STEPPABLE.has(getFileType(e.name)))
.sort((a, b) => a.name.localeCompare(b.name)),
[entries],
);
const index = siblings.findIndex((e) => `${folder === '/' ? '' : folder}/${e.name}` === filePath);
const go = useCallback(
(delta: number) => {
if (index === -1 || !onNavigate) return;
const next = siblings[index + delta];
if (next) onNavigate(`${folder === '/' ? '' : folder}/${next.name}`);
},
[index, siblings, folder, onNavigate],
);
/*
Keyboard, on the window.
Ignored while somebody is typing — the viewer can be EDITING a file, and stealing arrow keys from a
text area would make the editor unusable. `isContentEditable` catches the rich cases that are not
inputs.
*/
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) return;
if (event.key === 'Escape' && onClose) {
event.preventDefault();
onClose();
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
go(-1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
go(1);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [go, onClose]);
// One file in the folder is not a carousel. The strip would be a row containing the thing already on
// screen, and two arrows that do nothing.
if (!onNavigate || siblings.length < 2) return null;
return (
<div className="flex shrink-0 items-center gap-2 border-t border-duck-dark/10 bg-background/60 px-2 py-2 dark:border-foreground/10">
<Caret side="left" disabled={index <= 0} onClick={() => go(-1)} />
<div className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto">
{siblings.map((entry, i) => {
const path = `${folder === '/' ? '' : folder}/${entry.name}`;
const active = i === index;
return (
<button
key={entry.name}
type="button"
onClick={() => onNavigate(path)}
title={entry.name}
/* `ref` scrolls the active one into view as you step, so the strip follows rather than
being something you have to drag along behind you. */
ref={(el) => {
if (active) el?.scrollIntoView({ block: 'nearest', inline: 'center' });
}}
className={`h-12 w-16 shrink-0 overflow-hidden rounded border-2 transition-colors ${
active ? 'border-duck-teal' : 'border-transparent opacity-60 hover:opacity-100'
}`}
>
{entry.thumb ? (
<img
src={getThumbUrl(path, root)}
alt=""
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
) : (
<span className="flex size-full items-center justify-center px-1 text-[9px] leading-tight break-all text-duck-dark/60 dark:text-foreground/60">
{entry.name.slice(0, 18)}
</span>
)}
</button>
);
})}
</div>
<Caret side="right" disabled={index === -1 || index >= siblings.length - 1} onClick={() => go(1)} />
</div>
);
};
const Caret = ({ side, disabled, onClick }: { side: 'left' | 'right'; disabled: boolean; onClick: () => void }) => {
const Icon = side === 'left' ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={side === 'left' ? 'Previous (←)' : 'Next (→)'}
className="shrink-0 rounded p-1 text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-25 dark:text-foreground/60 dark:hover:bg-foreground/5"
>
<Icon className="size-5" />
</button>
);
};
@@ -5,6 +5,14 @@ export type FileViewerContextValue = {
filePath: string;
fileName: string;
root: string;
/**
* Show a different file, without leaving the viewer. Absent when the host cannot express it.
*
* Each host writes it differently — the Files screen puts a name in `?view=`, an embedded panel puts a
* path — so the viewer asks rather than deciding, and a host that offers nothing simply gets no
* carousel.
*/
onNavigate?: (filePath: string) => void;
fileType: FileType;
content: string | null;
loading: boolean;
@@ -13,6 +13,8 @@ type FileViewerProviderProps = {
root?: string;
content?: string;
onOpenFile?: (filePath: string, root: string) => void;
/** Show a different file in the same viewer — what the carousel calls. */
onNavigate?: (filePath: string) => void;
onReplaceView?: (viewPath: string, viewRoot: string, ephemeralPath: string, ephemeralRoot: string) => void;
autoPlay?: boolean;
children: ReactNode;
@@ -45,6 +47,7 @@ export const FileViewerProvider = ({
root = 'home',
content: directContent,
onOpenFile,
onNavigate,
onReplaceView,
autoPlay = false,
children,
@@ -171,6 +174,7 @@ export const FileViewerProvider = ({
<FileViewerContext
value={{
filePath,
onNavigate,
fileName,
root,
fileType,
@@ -2,6 +2,7 @@ import { X } from 'lucide-react';
import { FileViewerProvider } from './FileViewerProvider';
import { FileViewerHeader } from './FileViewerHeader';
import { FileViewerBody } from './FileViewerBody';
import { FileCarousel } from './FileCarousel';
type FileViewerViewProps = {
filePath: string;
@@ -9,10 +10,12 @@ type FileViewerViewProps = {
root?: string;
content?: string;
onClose?: () => void;
/** Enables the carousel. Without it the viewer shows one file and no strip. */
onNavigate?: (filePath: string) => void;
};
export const FileViewerView = ({ filePath, fileName, root, content, onClose }: FileViewerViewProps) => (
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content}>
export const FileViewerView = ({ filePath, fileName, root, content, onClose, onNavigate }: FileViewerViewProps) => (
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content} onNavigate={onNavigate}>
<div className="h-full flex flex-col">
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 text-duck-dark/70 dark:text-foreground/70">
<FileViewerHeader />
@@ -28,6 +31,11 @@ export const FileViewerView = ({ filePath, fileName, root, content, onClose }: F
<div className="flex-1 min-h-0">
<FileViewerBody />
</div>
{/* Below the content, outside the scrolling area — a strip that scrolled away with a long
markdown file would be a control you have to go looking for. Renders nothing when the folder
holds one viewable file, or when the host gave no `onNavigate`. */}
<FileCarousel onClose={onClose} />
</div>
</FileViewerProvider>
);
@@ -160,6 +160,19 @@ export function getRawUrl(filePath: string, root?: string, audioTrack?: number):
return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}${audioParam}`;
}
/**
* The file browser's own thumbnail, at the size its grid asks for.
*
* Same route, same cache, same size — so a folder already browsed has a warm carousel strip, and a
* format that needs a plugin to decode works here exactly where it works there.
*/
export function getThumbUrl(filePath: string, root?: string, size = 256): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
}
export function getAudioTracksUrl(filePath: string, root?: string): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
@@ -1,4 +1,5 @@
export { FileViewerView } from './FileViewerView';
export { FileCarousel } from './FileCarousel';
export { FileViewerProvider } from './FileViewerProvider';
export { FileViewerHeader } from './FileViewerHeader';
export { FileViewerBody } from './FileViewerBody';
@@ -53,6 +53,15 @@ export function ViewerProvider({ children }: { children: ReactNode }) {
fileName={fileName}
onOpenFile={onOpenFile}
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
screen's own convention — so the absolute path the carousel works in is converted back. */
onNavigate={(next) =>
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
return params;
})
}
>
{children}
</FileViewerProvider>
@@ -83,6 +92,15 @@ export function EphemeralProvider({ children }: { children: ReactNode }) {
root={ephemeralRoot}
onOpenFile={onOpenFile}
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
screen's own convention — so the absolute path the carousel works in is converted back. */
onNavigate={(next) =>
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
return params;
})
}
>
{children}
</FileViewerProvider>
@@ -104,6 +122,15 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) {
root={ephemeral2Root}
autoPlay={ephemeral2Auto}
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
screen's own convention — so the absolute path the carousel works in is converted back. */
onNavigate={(next) =>
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
return params;
})
}
>
{children}
</FileViewerProvider>
+1
View File
@@ -66,6 +66,7 @@ export {
export type { DirEntry, TaskSummary } from './apps/FileBrowser';
export {
FileViewerView,
FileCarousel,
FileViewerProvider,
FileViewerHeader,
FileViewerBody,