From 53f3ca33b5477e34a758209adaf5ed42ee6e5a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 09:28:38 +0100 Subject: [PATCH] the file preview becomes a carousel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .../src/apps/FileViewer/FileCarousel.tsx | 165 ++++++++++++++++++ .../src/apps/FileViewer/FileViewerContext.tsx | 8 + .../apps/FileViewer/FileViewerProvider.tsx | 4 + .../src/apps/FileViewer/FileViewerView.tsx | 12 +- .../src/apps/FileViewer/file-types.ts | 13 ++ .../officerdev/src/apps/FileViewer/index.ts | 1 + .../hooks/useFileViewerPanels/Providers.tsx | 27 +++ src/workspaces/officerdev/src/index.ts | 1 + 8 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx new file mode 100644 index 00000000..cb0744df --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx @@ -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([]); + + 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 ( +
+ go(-1)} /> + +
+ {siblings.map((entry, i) => { + const path = `${folder === '/' ? '' : folder}/${entry.name}`; + const active = i === index; + return ( + + ); + })} +
+ + = siblings.length - 1} onClick={() => go(1)} /> +
+ ); +}; + +const Caret = ({ side, disabled, onClick }: { side: 'left' | 'right'; disabled: boolean; onClick: () => void }) => { + const Icon = side === 'left' ? ChevronLeft : ChevronRight; + return ( + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx index e245b31d..5f71d563 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx @@ -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; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx index a70d2cdb..500a07d5 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx @@ -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 = ({ 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) => ( - +export const FileViewerView = ({ filePath, fileName, root, content, onClose, onNavigate }: FileViewerViewProps) => ( +
@@ -28,6 +31,11 @@ export const FileViewerView = ({ filePath, fileName, root, content, onClose }: F
+ + {/* 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`. */} +
); diff --git a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts index 24b56036..66baedd0 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts @@ -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 ', '') ?? ''; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/index.ts b/src/workspaces/officerdev/src/apps/FileViewer/index.ts index 5865781c..3a0c7dd7 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/index.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/index.ts @@ -1,4 +1,5 @@ export { FileViewerView } from './FileViewerView'; +export { FileCarousel } from './FileCarousel'; export { FileViewerProvider } from './FileViewerProvider'; export { FileViewerHeader } from './FileViewerHeader'; export { FileViewerBody } from './FileViewerBody'; diff --git a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx index e82a6f91..7d0d7784 100644 --- a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx +++ b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx @@ -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} @@ -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} @@ -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} diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index 46911b13..dac96706 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -66,6 +66,7 @@ export { export type { DirEntry, TaskSummary } from './apps/FileBrowser'; export { FileViewerView, + FileCarousel, FileViewerProvider, FileViewerHeader, FileViewerBody,