diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 70c2d4ea..793d9d73 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -433,6 +433,16 @@ router.post('/rename', async (ctx) => { * * The cache key contains the file's mtime and size, so this URL's answer genuinely cannot change: a * different file is a different URL. That is what makes a year-long cache correct rather than a bet. + * + * It was only ever half true, and the half that was missing is the half a browser can see. The DISK key + * has always held mtime and size, so the server regenerated correctly every time. The URL held the path + * and the box size and nothing else — so a browser told to keep the answer for a year kept it for a + * year, and a file replaced or renamed into a name already drawn showed the picture that used to be + * there. Nothing cleared it but somebody who knew to hard-reload. + * + * The client now sends `v=-` — see `getThumbUrl`. Nothing here reads it: this route stats + * the file anyway, and a parameter the server depended on would be a second source of truth about which + * file is meant. It is in the URL to be part of the URL, which is the whole job. */ router.get('/thumb', async (ctx) => { const user = ctx.get('user'); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index ab93be59..7b42b9bc 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -52,7 +52,7 @@ import { cardStyle } from '@/components/Card'; import type { DirEntry } from '../../../../hooks/useFilesAPI'; import type { TaskGroup, TaskSummary } from '../../useTasks'; import type { AgentGroup, AgentSummary } from '../../useAgents'; -import { getFileType } from '../../../FileViewer'; +import { getFileType, thumbVersion } from '../../../FileViewer'; import { useTranslation } from '../../../../locales'; import { useFormat } from 'i18n'; @@ -670,7 +670,16 @@ export const FileItem = ({ rounded box would trim content off a photograph for decoration. */ const withThumb = (kind: 'image' | 'video', node: React.ReactNode, className: string, size: number) => ( - + {node} ); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Thumbnail.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Thumbnail.tsx index e92c1bf7..64e2366b 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Thumbnail.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Thumbnail.tsx @@ -52,6 +52,7 @@ export const Thumbnail = ({ path, root, size, + version, isVideo, className, children, @@ -59,6 +60,16 @@ export const Thumbnail = ({ /** Root-relative, the same path every other file-browser call uses. */ path: string; root?: string; + /** + * Which VERSION of the file this is — `mtime-size`, from the listing. + * + * The response is `immutable` for a year, and the URL used to carry only the path, so a browser that + * had drawn a thumbnail for a name went on drawing it however the file behind that name changed. A + * rename into a name already seen showed the picture that used to be there, and nothing short of a + * hard reload cleared it. The SERVER was never wrong — its own cache key has always held mtime and + * size — the URL simply did not say which file it meant. See `getThumbUrl`. + */ + version?: string; /** What to ask the server for. Snapped server-side, so a near-enough number is fine. */ size: number; /** Draws the play badge. The thumbnail of a video is a still, and a still is not obviously a video. */ @@ -79,7 +90,8 @@ export const Thumbnail = ({ const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? ''; const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; - const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`; + const versionParam = version ? `&v=${encodeURIComponent(version)}` : ''; + const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}${versionParam}`; return ( diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx index cb0744df..b6aed840 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx @@ -1,7 +1,7 @@ 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 { getFileType, getThumbUrl, thumbVersion } from './file-types'; import { useFileViewer } from './FileViewerContext'; /** @@ -128,7 +128,7 @@ export const FileCarousel = ({ onClose }: { onClose?: () => void }) => { > {entry.thumb ? ( + Number.isFinite(entry.modifiedAt) && Number.isFinite(entry.size) ? `${entry.modifiedAt}-${entry.size}` : undefined; + 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 3a0c7dd7..290a58b6 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/index.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/index.ts @@ -4,7 +4,7 @@ export { FileViewerProvider } from './FileViewerProvider'; export { FileViewerHeader } from './FileViewerHeader'; export { FileViewerBody } from './FileViewerBody'; export { useFileViewer } from './FileViewerContext'; -export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types'; +export { getFileType, getLang, getExt, getArchiveBaseName, thumbVersion, ARCHIVE_EXTS, type FileType } from './file-types'; // No `appRegistryMetas` here on purpose. The file viewer is not a panel app you can put on a dashboard: // it is mounted as an ephemeral panel by `useFileViewerPanels`, which supplies `FileViewerBody`/`Header`