From 1a282e2a33875fc99be18c9a6f97f226727b1168 Mon Sep 17 00:00:00 2001 From: puxiaokang <1726541+joaner@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:34:31 +0800 Subject: [PATCH 1/4] fix(viewer): stop file:// history replay from self-triggering an init loop Reopening a recording via remembered File System Access permission (?url=file://...) could self-trigger forever: replaying history set activeId, which changed appendFilesAsDatasets's identity, which re-ran the SPA bootstrap effect that depends on it, which replayed history again. Symptoms were a flickering sidebar/player and hundreds of WorkerSourceCancelledError as the player was torn down and rebuilt in a tight loop, occasionally crashing the tab. Direct drag-and-drop was unaffected since it's a one-shot event handler, not an effect. Root-caused and fixed at the design-pattern level rather than patched: - useSpaUrlBootstrap wraps its handlers in React's useEffectEvent, so its effect only ever depends on (active, url) - no callback identity churn can retrigger it. react-hooks/exhaustive-deps now errors if this guarantee is ever violated, turning the bug class into a lint error instead of a silent regression. - resolveAppendGroupId/resolveActiveId (datasetSources.ts) make the "which group is really active" logic pure and directly testable, fixing the underlying activeId "orphan id" bounce. - Regression tests pin both the pure logic and the hook's Effect timing (useSpaUrlBootstrap.test.tsx feeds in a worse-than-production case: a fresh handler identity on every render). Also splits the 1400+ line RosViewerImpl.tsx into focused, independently testable hooks/components (useDatasetSession, usePlayerLifecycle, useRecordingSourceActions, useDatasetHistory, useThemeLanguagePreferences, useOpenFeedback, RosViewerLandingScreen, sourceLoading, rosViewerUtils), leaving RosViewerImpl.tsx as a thin composition layer. Fixed a latent bug found during the split: the hidden #rosview-inline-file/-tar inputs must render unconditionally even once a player exists, or history replay can't click them. --- src/features/viewer/RosViewer.types.ts | 101 ++ src/features/viewer/RosViewerImpl.tsx | 1460 ++--------------- .../viewer/RosViewerLandingScreen.tsx | 193 +++ src/features/viewer/rosViewerUtils.ts | 75 + src/features/viewer/sourceLoading.ts | 134 ++ src/features/viewer/useDatasetHistory.ts | 40 + src/features/viewer/useDatasetSession.ts | 214 +++ src/features/viewer/useOpenFeedback.ts | 34 + src/features/viewer/usePlayerLifecycle.ts | 250 +++ .../viewer/useRecordingSourceActions.ts | 553 +++++++ .../viewer/useSpaUrlBootstrap.test.tsx | 246 +++ src/features/viewer/useSpaUrlBootstrap.ts | 115 ++ .../viewer/useThemeLanguagePreferences.ts | 57 + src/shared/utils/datasetSources.test.ts | 158 ++ src/shared/utils/datasetSources.ts | 50 +- 15 files changed, 2372 insertions(+), 1308 deletions(-) create mode 100644 src/features/viewer/RosViewer.types.ts create mode 100644 src/features/viewer/RosViewerLandingScreen.tsx create mode 100644 src/features/viewer/rosViewerUtils.ts create mode 100644 src/features/viewer/sourceLoading.ts create mode 100644 src/features/viewer/useDatasetHistory.ts create mode 100644 src/features/viewer/useDatasetSession.ts create mode 100644 src/features/viewer/useOpenFeedback.ts create mode 100644 src/features/viewer/usePlayerLifecycle.ts create mode 100644 src/features/viewer/useRecordingSourceActions.ts create mode 100644 src/features/viewer/useSpaUrlBootstrap.test.tsx create mode 100644 src/features/viewer/useSpaUrlBootstrap.ts create mode 100644 src/features/viewer/useThemeLanguagePreferences.ts diff --git a/src/features/viewer/RosViewer.types.ts b/src/features/viewer/RosViewer.types.ts new file mode 100644 index 0000000..799380b --- /dev/null +++ b/src/features/viewer/RosViewer.types.ts @@ -0,0 +1,101 @@ +import type React from 'react'; +import type { Player } from '@/core/types/player'; +import type { PreferencePersistence } from '@/core/preferences/types'; +import type { FoxgloveLayoutData } from '@/core/preferences/foxgloveLayout'; +import type { OpenPanelInput } from '@/features/layout/dockviewController'; +import type { RosViewExtension } from '@/core/extensions/types'; +import type { FileListItem } from '@/shared/utils/datasetSources'; +import type { RosViewerChrome, RosViewerMode } from './embedChrome'; + +export interface RosViewerProps { + url?: string; + file?: File; + urls?: string[]; + files?: File[]; + /** + * When `true`, every dataset produced from `file`/`files`/`url`/`urls`/ + * `fileManifest` is merged into a single multi-source session (topics and + * time range unioned) instead of the default "list + switch" behavior. + * @default false + */ + mergeSources?: boolean; + theme?: 'light' | 'dark' | 'system'; + language?: 'en' | 'zh' | 'ja'; + /** CSS class applied to the outermost container element. */ + className?: string; + /** Inline styles applied to the outermost container element. */ + style?: React.CSSProperties; + onFatalError?: (error: Error) => void; + /** + * `'localStorage'`: read/write `ioai.rosview.prefs`. `'off'`: no storage (host owns prefs). + * @default 'localStorage' + */ + preferencePersistence?: PreferencePersistence; + /** Fired when the user changes theme in the navbar (orthogonal to persistence). */ + onThemeChange?: (theme: 'light' | 'dark' | 'system') => void; + /** Fired when the user changes language in the navbar. */ + onLanguageChange?: (language: 'en' | 'zh' | 'ja') => void; + /** Fired after this component rewrites SPA query state and the host should re-read `window.location.search`. */ + onSpaUrlQuerySync?: () => void; + /** + * Remote dataset manifest: JSON URL or parsed rows. + * Merged/deduped with `url` / `urls`; fetch errors are logged only and do not block other sources. + */ + fileManifest?: string | FileListItem[]; + /** Optional third-party extension contributions for sidebar and playback overlays. */ + extensions?: RosViewExtension[]; + /** Optional center label override shown in navbar source area. */ + navbarSourceName?: string; + /** Whether to show the left navbar brand button. @default true */ + showNavbarBrand?: boolean; + /** Custom label for the left navbar brand button (defaults to product name). */ + navbarBrandLabel?: string; + /** Whether to show navbar language switcher. @default true */ + showLanguageSwitcher?: boolean; + /** Whether to show navbar theme switcher. @default true */ + showThemeSwitcher?: boolean; + /** Prefer auto layout bootstrap over welcome placeholder in embedded mode. @default false */ + preferAutoLayout?: boolean; + /** + * `spa`: sync `?url=` with the active source; restore `file://` / `folder://` from IndexedDB on load, and `sample://` from the sample manifest. + * `off`: library / embed — never writes the URL; custom locators in `url` do not auto-restore. + * @default 'off' + */ + urlState?: 'spa' | 'off'; + /** + * Opaque host payload forwarded to every `RosViewExtension` as `context.hostContext`. + * RosView does not read or validate this object. + */ + hostContext?: unknown; + /** + * Embed preset. `tool` opens panels without a recording source (MinimalPlayer) and defaults to panels-only chrome. + * @default 'viewer' + */ + mode?: RosViewerMode; + /** When false, mount MinimalPlayer and workspace without url/file. @default true (false when mode='tool'). */ + requireSource?: boolean; + /** Chrome preset; overridden by explicit showNavbar/showSidebar/showPlaybackBar. */ + chrome?: RosViewerChrome; + showNavbar?: boolean; + showSidebar?: boolean; + showPlaybackBar?: boolean; + /** Hide navbar file menus and disable recording drag-and-drop in the workspace. */ + hideOpenFileMenus?: boolean; + /** + * `'inherit'`: follow `preferencePersistence`. `'off'`: never read/write layout localStorage. + * @default 'inherit' + */ + layoutPersistence?: PreferencePersistence | 'inherit'; + layoutStorageKey?: string; + /** Applied on mount before saved layout. */ + initialLayout?: FoxgloveLayoutData; + /** Shorthand single-panel layout when `initialLayout` is omitted. */ + defaultPanel?: OpenPanelInput; + /** When true (default for mode='tool'), skip Dockview Welcome placeholder. */ + suppressWelcomePanel?: boolean; + onLayoutReady?: (info: { panelCount: number }) => void; + onPlayerReady?: (ctx: { player: Player; hasSource: boolean }) => void; + onSourceLoadingChange?: (loading: boolean) => void; + /** Sidebar tab id to select on first mount (e.g. extension `sidebarTabs[].id`). */ + initialSidebarTab?: string; +} diff --git a/src/features/viewer/RosViewerImpl.tsx b/src/features/viewer/RosViewerImpl.tsx index 738db0d..f945aa7 100644 --- a/src/features/viewer/RosViewerImpl.tsx +++ b/src/features/viewer/RosViewerImpl.tsx @@ -1,477 +1,56 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { cn } from '@/shared/lib/utils'; -import { createRosViewIntl } from '@/shared/intl/createRosViewIntl'; +import React, { useMemo, useRef, useState } from 'react'; import { RosViewProvider } from './RosViewProvider'; import { AppShell } from '@/app/AppShell'; -import { SampleDatasetDialog } from '@/features/workspace/common/SampleDatasetDialog'; -import { getArchiveUrl, getSampleDatasetsManifestUrl, loadSampleDatasets } from '@/services/sampleDatasets'; -import type { SampleDataset } from '@/services/sampleDatasets'; -import { extractRosFilesFromTarArchive } from '@/shared/utils/tarRosRecordings'; -import { WorkerSerializedSource, isWorkerSourceCancelledError } from '@/infra/workers/WorkerSerializedSource'; -import { IterablePlayer } from '@/core/players/IterablePlayer'; -import { MinimalPlayer } from '@/core/players/MinimalPlayer'; -import type { Player } from '@/core/types/player'; -import { useMessagePipelineStore } from '@/core/pipeline/store'; -import { Navbar } from '@/features/workspace/navbar/Navbar'; -import { WelcomeScreen } from '@/features/workspace/common/WelcomeScreen'; -import { LoadingOverlay } from '@/features/workspace/common/LoadingOverlay'; -import { Skeleton } from '@/shared/ui/skeleton'; -import type { DatasetItem, FileListItem } from '@/shared/utils/datasetSources'; -import { - createDatasetGroupId, - datasetGroupKey, - datasetItemsFromListItems, - dedupeDatasetItems, - filterRosFilesFromFileList, - groupDatasets, - isRosRecordingFilename, - mergeDatasetLists, - normalizeRosViewSources, - parseRemoteDatasetListJson, -} from '@/shared/utils/datasetSources'; -import { CombinedSourceProxy, type CombinedSourceMember } from '@/infra/workers/CombinedSourceProxy'; -import { - collectRosFilesFromUserDirectoryChoice, - walkDirectoryHandle, -} from '@/shared/utils/collectDirectoryRosFiles'; -import { - ensureReadPermission, - fingerprintRosFileSet, - getDatasetHistoryEntry, - getLatestReplayableHistoryByLocalLocator, - listDatasetHistory, - tarFileFingerprint, - upsertDatasetHistoryEntry, -} from '@/shared/utils/datasetHistory'; -import type { DatasetHistoryListItem, DatasetHistoryStoredEntry } from '@/shared/utils/datasetHistory'; -import { - alignFileHandlesToRosFiles, - collectRosRecordingFilesFromDataTransfer, - collectRosRecordingFileHandlesFromDataTransfer, -} from '@/shared/utils/collectDragFileHandles'; -import { pickRosRecordingFiles } from '@/shared/utils/openRosRecordingFilePicker'; -import { resolveBrowserHttpUrl } from '@/shared/utils/resolveBrowserHttpUrl'; -import { - type SourceLocator, - isCustomLocalLocatorString, - parseSourceLocator, - pushSpaUrlParam, - serializeSourceLocator, -} from '@/shared/utils/sourceLocator'; -import { readPreferences, writePreferences } from '@/core/preferences/readWritePreferences'; -import { - mergeInitialUiPreferences, - readUiPreferenceParamsFromSearch, -} from '@/core/preferences/mergeInitialUiPreferences'; import type { PreferencePersistence } from '@/core/preferences/types'; -import type { FoxgloveLayoutData } from '@/core/preferences/foxgloveLayout'; import { ROS_VIEW_LAYOUT_STORAGE_KEY } from '@/core/preferences/storageKeys'; -import type { OpenPanelInput } from '@/features/layout/dockviewController'; -import { resolveEmbedChrome, type RosViewerChrome, type RosViewerMode } from './embedChrome'; +import { resolveEmbedChrome } from './embedChrome'; import { RosViewerLayoutProvider } from './RosViewerLayoutContext'; -import type { RosViewExtension } from '@/core/extensions/types'; -import { toast } from 'sonner'; -import { - loadHdf5WasmBinary, - loadSqlWasmBinary, - loadZstdWasmBinary, - needsZstdWasmForWorker, -} from '@/infra/workers/preloadWorkerWasm'; - -function extensionForDataset(ds: DatasetItem): string | undefined { - if (ds.kind === 'file' && ds.file) { - return ds.file.name.split('.').pop()?.toLowerCase(); - } - if (ds.kind === 'url' && ds.url) { - try { - const path = new URL(ds.url).pathname; - return path.split('.').pop()?.toLowerCase(); - } catch { - return ds.url.split('.').pop()?.toLowerCase(); - } - } - return undefined; -} - -async function createWorkerForExtension(ext: string | undefined): Promise { - if (ext === 'bvh') { - const { default: BvhWorkerClass } = await import('@/infra/workers/bvh.worker.ts?worker&inline'); - return new BvhWorkerClass(); - } - if (ext === 'bag') { - const { default: BagWorkerClass } = await import('@/infra/workers/bag.worker.ts?worker&inline'); - return new BagWorkerClass(); - } - if (ext === 'db3') { - const { default: Db3WorkerClass } = await import('@/infra/workers/db3.worker.ts?worker&inline'); - return new Db3WorkerClass(); - } - if (ext === 'hdf5' || ext === 'h5') { - const { default: Hdf5WorkerClass } = await import('@/infra/workers/hdf5.worker.ts?worker&inline'); - return new Hdf5WorkerClass(); - } - const { default: McapWorkerClass } = await import('@/infra/workers/mcap.worker.ts?worker&inline'); - return new McapWorkerClass(); -} - -async function buildInitArgsForDataset( - ds: DatasetItem, - ext: string | undefined, - autoDataQualityScan: boolean, -): Promise> { - const workerPerf = - typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('workerPerf') === '1'; - const sqlWasmBinary = ext === 'db3' ? await loadSqlWasmBinary() : undefined; - const hdf5WasmBinary = ext === 'hdf5' || ext === 'h5' ? await loadHdf5WasmBinary() : undefined; - const zstdWasmBinary = needsZstdWasmForWorker(ext) ? await loadZstdWasmBinary() : undefined; - if (ds.kind === 'url' && ds.url) { - const init: Record = { - url: resolveBrowserHttpUrl(ds.url), - workerPerf, - autoDataQualityScan, - }; - if (ext === 'db3') { - init.sqlWasmBinary = sqlWasmBinary; - } - if (hdf5WasmBinary) { - init.hdf5WasmBinary = hdf5WasmBinary; - } - if (zstdWasmBinary) { - init.zstdWasmBinary = zstdWasmBinary; - } - if ( - typeof ds.sizeBytes === 'number' && - Number.isFinite(ds.sizeBytes) && - ds.sizeBytes > 0 - ) { - init.knownTotalBytes = Math.floor(ds.sizeBytes); - } - return init; - } - if (ds.kind === 'file' && ds.file) { - const siblingFiles = ds.siblingFiles?.filter((file) => isRosRecordingFilename(file.name)) ?? []; - const files = siblingFiles.length > 0 ? siblingFiles : [ds.file]; - if (ext === 'bag' || ext === 'db3') { - return { - file: ds.file, - files, - workerPerf, - autoDataQualityScan, - ...(sqlWasmBinary ? { sqlWasmBinary } : {}), - ...(zstdWasmBinary ? { zstdWasmBinary } : {}), - }; - } - return { - file: ds.file, - workerPerf, - autoDataQualityScan, - ...(hdf5WasmBinary ? { hdf5WasmBinary } : {}), - ...(zstdWasmBinary ? { zstdWasmBinary } : {}), - }; - } - throw new Error('Invalid dataset item'); -} - -/** - * Creates a Worker + `WorkerSerializedSource` + init args for one dataset - * member. Shared by both the single-file fast path and the multi-file - * `CombinedSourceProxy` path so a group of 1 behaves byte-for-byte like - * today's single-source loading. - */ -async function prepareSourceMember( - ds: DatasetItem, - autoDataQualityScan: boolean, -): Promise<{ member: CombinedSourceMember; ext: string | undefined }> { - const ext = extensionForDataset(ds); - const worker = await createWorkerForExtension(ext); - worker.onerror = (ev) => console.error('MAIN: Worker error', ev); - const initArgs = await buildInitArgsForDataset(ds, ext, autoDataQualityScan); - const source = new WorkerSerializedSource(worker); - return { member: { label: ds.name, source, initArgs }, ext }; -} - -/** Try indices in order: startIdx .. end-1, then 0 .. startIdx-1 */ -function fallbackIndexOrder(length: number, startIdx: number): number[] { - if (length <= 0) return []; - const s = Math.max(0, Math.min(startIdx, length - 1)); - const out: number[] = []; - for (let i = s; i < length; i++) out.push(i); - for (let i = 0; i < s; i++) out.push(i); - return out; -} - -function propsSignature(props: RosViewerProps): string { - const urlState = props.urlState ?? 'off'; - const urls = (props.urls ?? []).map((u) => u.trim()).join('\0'); - const url = props.url?.trim() ?? ''; - const files = (props.files ?? []).map((f) => `${f.name}:${f.size}:${f.lastModified}`).join('\0'); - const file = props.file ? `${props.file.name}:${props.file.size}:${props.file.lastModified}` : ''; - const fileListSig = - props.fileManifest == null - ? '' - : typeof props.fileManifest === 'string' - ? props.fileManifest.trim() - : JSON.stringify(props.fileManifest); - const mergeSources = props.mergeSources ? '1' : '0'; - return `${urlState}|${urls}|${url}|${files}|${file}|${fileListSig}|${mergeSources}`; -} - -function initialUiFromProps(p: RosViewerProps) { - const persistence: PreferencePersistence = p.preferencePersistence ?? 'localStorage'; - const { urlTheme, urlLanguage } = readUiPreferenceParamsFromSearch( - typeof window !== 'undefined' ? window.location.search : '', - ); - return mergeInitialUiPreferences({ - persistence, - propsTheme: p.theme, - propsLanguage: p.language, - urlTheme, - urlLanguage, - stored: persistence === 'localStorage' ? readPreferences() : null, - }); -} - -function errorMessageFromUnknown(error: unknown, fallback: string): string { - if (error instanceof Error) return error.message; - if (typeof error === 'string') return error; - return fallback; -} - -export interface RosViewerProps { - url?: string; - file?: File; - urls?: string[]; - files?: File[]; - /** - * When `true`, every dataset produced from `file`/`files`/`url`/`urls`/ - * `fileManifest` is merged into a single multi-source session (topics and - * time range unioned) instead of the default "list + switch" behavior. - * @default false - */ - mergeSources?: boolean; - theme?: 'light' | 'dark' | 'system'; - language?: 'en' | 'zh' | 'ja'; - /** CSS class applied to the outermost container element. */ - className?: string; - /** Inline styles applied to the outermost container element. */ - style?: React.CSSProperties; - onFatalError?: (error: Error) => void; - /** - * `'localStorage'`: read/write `ioai.rosview.prefs`. `'off'`: no storage (host owns prefs). - * @default 'localStorage' - */ - preferencePersistence?: PreferencePersistence; - /** Fired when the user changes theme in the navbar (orthogonal to persistence). */ - onThemeChange?: (theme: 'light' | 'dark' | 'system') => void; - /** Fired when the user changes language in the navbar. */ - onLanguageChange?: (language: 'en' | 'zh' | 'ja') => void; - /** Fired after this component rewrites SPA query state and the host should re-read `window.location.search`. */ - onSpaUrlQuerySync?: () => void; - /** - * Remote dataset manifest: JSON URL or parsed rows. - * Merged/deduped with `url` / `urls`; fetch errors are logged only and do not block other sources. - */ - fileManifest?: string | FileListItem[]; - /** Optional third-party extension contributions for sidebar and playback overlays. */ - extensions?: RosViewExtension[]; - /** Optional center label override shown in navbar source area. */ - navbarSourceName?: string; - /** Whether to show the left navbar brand button. @default true */ - showNavbarBrand?: boolean; - /** Custom label for the left navbar brand button (defaults to product name). */ - navbarBrandLabel?: string; - /** Whether to show navbar language switcher. @default true */ - showLanguageSwitcher?: boolean; - /** Whether to show navbar theme switcher. @default true */ - showThemeSwitcher?: boolean; - /** Prefer auto layout bootstrap over welcome placeholder in embedded mode. @default false */ - preferAutoLayout?: boolean; - /** - * `spa`: sync `?url=` with the active source; restore `file://` / `folder://` from IndexedDB on load, and `sample://` from the sample manifest. - * `off`: library / embed — never writes the URL; custom locators in `url` do not auto-restore. - * @default 'off' - */ - urlState?: 'spa' | 'off'; - /** - * Opaque host payload forwarded to every `RosViewExtension` as `context.hostContext`. - * RosView does not read or validate this object. - */ - hostContext?: unknown; - /** - * Embed preset. `tool` opens panels without a recording source (MinimalPlayer) and defaults to panels-only chrome. - * @default 'viewer' - */ - mode?: RosViewerMode; - /** When false, mount MinimalPlayer and workspace without url/file. @default true (false when mode='tool'). */ - requireSource?: boolean; - /** Chrome preset; overridden by explicit showNavbar/showSidebar/showPlaybackBar. */ - chrome?: RosViewerChrome; - showNavbar?: boolean; - showSidebar?: boolean; - showPlaybackBar?: boolean; - /** Hide navbar file menus and disable recording drag-and-drop in the workspace. */ - hideOpenFileMenus?: boolean; - /** - * `'inherit'`: follow `preferencePersistence`. `'off'`: never read/write layout localStorage. - * @default 'inherit' - */ - layoutPersistence?: PreferencePersistence | 'inherit'; - layoutStorageKey?: string; - /** Applied on mount before saved layout. */ - initialLayout?: FoxgloveLayoutData; - /** Shorthand single-panel layout when `initialLayout` is omitted. */ - defaultPanel?: OpenPanelInput; - /** When true (default for mode='tool'), skip Dockview Welcome placeholder. */ - suppressWelcomePanel?: boolean; - onLayoutReady?: (info: { panelCount: number }) => void; - onPlayerReady?: (ctx: { player: Player; hasSource: boolean }) => void; - onSourceLoadingChange?: (loading: boolean) => void; - /** Sidebar tab id to select on first mount (e.g. extension `sidebarTabs[].id`). */ - initialSidebarTab?: string; -} - -function resolveLayoutPersistence( - layoutPersistence: PreferencePersistence | 'inherit' | undefined, - preferencePersistence: PreferencePersistence, -): PreferencePersistence { - if (layoutPersistence === undefined || layoutPersistence === 'inherit') { - return preferencePersistence; - } - return layoutPersistence; -} - -function datasetItemToSourceLocator(ds: DatasetItem): SourceLocator | null { - if (ds.kind === 'url' && ds.url) { - const resolvedUrl = resolveBrowserHttpUrl(ds.url); - return { kind: 'remote', raw: ds.url.trim(), resolvedUrl }; - } - if (ds.kind === 'file' && ds.file) { - return { kind: 'local_file', displayName: ds.file.name }; - } - return null; -} +import { RosViewerLandingScreen } from './RosViewerLandingScreen'; +import { useThemeLanguagePreferences } from './useThemeLanguagePreferences'; +import { useDatasetHistory } from './useDatasetHistory'; +import { useDatasetSession } from './useDatasetSession'; +import { usePlayerLifecycle } from './usePlayerLifecycle'; +import { useRecordingSourceActions } from './useRecordingSourceActions'; +import { useOpenFeedback } from './useOpenFeedback'; +import type { RosViewerProps } from './RosViewer.types'; +import { propsSignature, resolveLayoutPersistence } from './rosViewerUtils'; + +export type { RosViewerProps } from './RosViewer.types'; export const RosViewer: React.FC = (props) => { const urlState = props.urlState ?? 'off'; const propSig = propsSignature(props); - const fromProps = useMemo( - () => - normalizeRosViewSources({ - file: props.file, - files: props.files, - url: isCustomLocalLocatorString(props.url) ? undefined : props.url, - urls: urlState === 'spa' ? undefined : props.urls, - fileManifest: Array.isArray(props.fileManifest) ? props.fileManifest : undefined, - mergeSources: props.mergeSources, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps -- propSig fingerprints File identity and source props - [propSig, urlState], - ); - - const [extraDatasets, setExtraDatasets] = useState([]); - - useEffect(() => { - const targets: string[] = []; - if (typeof props.fileManifest === 'string' && props.fileManifest.trim()) { - targets.push(resolveBrowserHttpUrl(props.fileManifest.trim())); - } - if (targets.length === 0) return; - let cancelled = false; - void (async () => { - const merged: FileListItem[] = []; - for (const t of targets) { - try { - const res = await fetch(t); - if (!res.ok) { - console.warn('[RosViewer] dataset list HTTP', res.status, t); - continue; - } - const json: unknown = await res.json(); - merged.push(...parseRemoteDatasetListJson(json)); - } catch (e) { - console.warn('[RosViewer] dataset list fetch failed', t, e); - } - } - if (cancelled || merged.length === 0) return; - setExtraDatasets((prev) => mergeDatasetLists(prev, datasetItemsFromListItems(merged))); - })(); - return () => { - cancelled = true; - }; - }, [propSig, props.fileManifest]); - - const datasets = useMemo(() => mergeDatasetLists(fromProps, extraDatasets), [fromProps, extraDatasets]); - - const datasetsRef = useRef(datasets); - // eslint-disable-next-line react-hooks/refs -- sync latest datasets for async player init - datasetsRef.current = datasets; - const onFatalErrorRef = useRef(props.onFatalError); - useEffect(() => { - onFatalErrorRef.current = props.onFatalError; - }, [props.onFatalError]); + const { lastLoadError, manualOpenHint, setLastLoadError, setManualOpenHint, clearOpenFeedback, showOpenError } = + useOpenFeedback(); - /** Holds a group key (see `datasetGroupKey`); a standalone dataset is a group of one. */ - const [activeId, setActiveId] = useState(null); - /** When fallback loads a non-selected group, highlights it in the sidebar without re-running the activeId load effect. */ - const [loadedGroupId, setLoadedGroupId] = useState(null); - const [lastLoadError, setLastLoadError] = useState(null); - const [manualOpenHint, setManualOpenHint] = useState(null); - - useEffect(() => { - setExtraDatasets([]); - setLoadedGroupId(null); - setManualOpenHint(null); - }, [propSig]); + const { + datasets, + datasetsRef, + setExtraDatasets, + activeId, + setActiveId, + setLoadedGroupId, + activeGroupMembersKey, + resolvedDatasetId, + loadingSourceName, + effectiveSourceName, + hasSource, + fileInputDomIdRef, + tarInputDomIdRef, + appendFilesAsDatasets, + } = useDatasetSession(props, urlState, propSig, clearOpenFeedback, setManualOpenHint); - const [currentTheme, setCurrentTheme] = useState<'light' | 'dark' | 'system'>(() => initialUiFromProps(props).theme); - const [currentLanguage, setCurrentLanguage] = useState<'en' | 'zh' | 'ja'>(() => initialUiFromProps(props).language); - const [player, setPlayer] = useState(null); const [sampleDialogOpen, setSampleDialogOpen] = useState(false); - const [remoteUrlBusy, setRemoteUrlBusy] = useState(false); - const [historyItems, setHistoryItems] = useState([]); - const fileInputDomIdRef = useRef('rosview-landing-file'); - const tarInputDomIdRef = useRef('rosview-landing-tar'); - /** Invalidates in-flight SPA `file://` / `folder://` / `sample://` bootstrap when deps change or effect cleans up. */ - const spaUrlBootstrapGenRef = useRef(0); - /** When set, successful player init writes `sample://…` to the address bar instead of the resolved archive URL. */ + /** When set, successful player init writes `sample://…` to the address bar instead of the resolved archive URL. Shared between `usePlayerLifecycle` (reads/clears on success) and `useRecordingSourceActions` (sets it). */ const spaSampleLocatorParamRef = useRef(null); - const offlineIntl = useMemo(() => createRosViewIntl(currentLanguage), [currentLanguage]); - - const clearOpenFeedback = useCallback(() => { - setLastLoadError(null); - setManualOpenHint(null); - }, []); - - const showOpenError = useCallback((message: string) => { - setLastLoadError(message); - setManualOpenHint(null); - toast.error(message); - }, []); - - const refreshHistory = useCallback(async () => { - const list = await listDatasetHistory(); - setHistoryItems(list); - }, []); + const persistence: PreferencePersistence = props.preferencePersistence ?? 'localStorage'; + const { currentTheme, currentLanguage, offlineIntl, handleThemeChange, handleLanguageChange } = + useThemeLanguagePreferences(props, persistence); - useEffect(() => { - void refreshHistory(); - }, [refreshHistory]); + const { historyItems, recordHistoryEntry } = useDatasetHistory(); - const recordHistoryEntry = useCallback( - async (row: Omit) => { - try { - await upsertDatasetHistoryEntry(row); - } catch (e) { - console.warn('[RosViewer] dataset history write failed', e); - } - await refreshHistory(); - }, - [refreshHistory], - ); - - const persistence: PreferencePersistence = props.preferencePersistence ?? 'localStorage'; const requireSource = props.requireSource ?? props.mode !== 'tool'; const embedChrome = useMemo( () => @@ -487,730 +66,55 @@ export const RosViewer: React.FC = (props) => { const resolvedLayoutPersistence = resolveLayoutPersistence(props.layoutPersistence, persistence); const layoutStorageKey = props.layoutStorageKey ?? ROS_VIEW_LAYOUT_STORAGE_KEY; const suppressWelcomePanel = props.suppressWelcomePanel ?? props.mode === 'tool'; - const onThemeChangeProp = props.onThemeChange; - const onLanguageChangeProp = props.onLanguageChange; - const handleThemeChange = useCallback( - (theme: 'light' | 'dark' | 'system') => { - setCurrentTheme(theme); - if (persistence === 'localStorage' && (theme === 'light' || theme === 'dark')) { - writePreferences({ theme }); - } - onThemeChangeProp?.(theme); - }, - [persistence, onThemeChangeProp], - ); - - const handleLanguageChange = useCallback( - (language: 'en' | 'zh' | 'ja') => { - setCurrentLanguage(language); - if (persistence === 'localStorage') { - writePreferences({ language }); - } - onLanguageChangeProp?.(language); - }, - [persistence, onLanguageChangeProp], - ); - - useEffect(() => { - if (props.theme != null) setCurrentTheme(props.theme); - }, [props.theme]); - - useEffect(() => { - if (props.language != null) setCurrentLanguage(props.language); - }, [props.language]); - - const groups = useMemo(() => groupDatasets(datasets), [datasets]); - - useEffect(() => { - if (datasets.length === 0) { - setActiveId(null); - return; - } - setActiveId((prev) => { - if (prev && datasets.some((d) => datasetGroupKey(d) === prev)) return prev; - return datasetGroupKey(datasets[0]); - }); - }, [datasets]); - - /** Members of the group the load effect targets, stable while unrelated datasets change. */ - const activeGroupMembersKey = useMemo(() => { - const resolvedGroupId = loadedGroupId ?? activeId; - const group = groups.find((g) => g.groupId === resolvedGroupId); - return group ? group.members.map((m) => m.id).join('\u0000') : ''; - }, [groups, loadedGroupId, activeId]); - - const resolvedDatasetId = loadedGroupId ?? activeId; - const activeGroup = useMemo( - () => (resolvedDatasetId ? groups.find((g) => g.groupId === resolvedDatasetId) ?? null : null), - [groups, resolvedDatasetId], - ); - const activeDataset = activeGroup?.members[0] ?? null; - - const loadingSourceName = activeGroup - ? activeGroup.members.length > 1 - ? `${activeGroup.members[0].name} +${String(activeGroup.members.length - 1)}` - : activeDataset?.kind === 'url' - ? activeDataset.url - : activeDataset?.file?.name - : undefined; - const effectiveSourceName = props.navbarSourceName ?? loadingSourceName; - - const hasSource = datasets.length > 0; - const sourceLoading = hasSource && player == null && lastLoadError == null; - - const onPlayerReadyRef = useRef(props.onPlayerReady); - useEffect(() => { - onPlayerReadyRef.current = props.onPlayerReady; - }, [props.onPlayerReady]); - const playerReadyFiredRef = useRef(false); - - useEffect(() => { - playerReadyFiredRef.current = false; - }, [player]); - - useEffect(() => { - if (!player) return; - const fireIfReady = () => { - if (playerReadyFiredRef.current) return; - if (useMessagePipelineStore.getState().playerState.presence !== 'ready') return; - playerReadyFiredRef.current = true; - onPlayerReadyRef.current?.({ player, hasSource }); - }; - fireIfReady(); - return useMessagePipelineStore.subscribe(fireIfReady); - }, [player, hasSource]); - - const onSourceLoadingChangeRef = useRef(props.onSourceLoadingChange); - useEffect(() => { - onSourceLoadingChangeRef.current = props.onSourceLoadingChange; - }, [props.onSourceLoadingChange]); - - useEffect(() => { - onSourceLoadingChangeRef.current?.(sourceLoading); - }, [sourceLoading]); - - useEffect(() => { - if (requireSource || hasSource) return; - const minimal = new MinimalPlayer(); - setPlayer(minimal); - return () => { - minimal.close(); - setPlayer(null); - }; - }, [requireSource, hasSource]); - - useEffect(() => { - fileInputDomIdRef.current = hasSource ? 'rosview-inline-file' : 'rosview-landing-file'; - tarInputDomIdRef.current = hasSource ? 'rosview-inline-tar' : 'rosview-landing-tar'; - }, [hasSource]); - - const appendFilesAsDatasets = useCallback(( - files: File[], - focusFirstNew = true, - groupFiles?: File[], - forceNewSession = false, - ) => { - const siblingFiles = groupFiles && groupFiles.length > 1 ? [...groupFiles] : undefined; - // Default behavior: new recording files merge into whatever session is - // currently active (topics/time-range union), matching the common - // "open base recording, later add an incrementally-produced file" - // workflow. When nothing is active yet, the whole batch becomes one - // fresh session together. `forceNewSession` opts out (used by - // tar-archive extraction and history replay, which represent opening a - // self-contained recording bundle rather than adding to the workspace). - const currentGroups = groupDatasets(datasetsRef.current); - const hasActiveGroup = - !forceNewSession && activeId != null && currentGroups.some((g) => g.groupId === activeId); - const groupId = hasActiveGroup ? activeId : createDatasetGroupId(); - const items = dedupeDatasetItems( - files.map((f) => ({ - id: `file:${f.name}:${f.size}:${f.lastModified}`, - kind: 'file' as const, - name: f.name, - file: f, - groupId, - ...(siblingFiles ? { siblingFiles } : {}), - })), - ); - setExtraDatasets((prev) => mergeDatasetLists(prev, items)); - if (items.length > 0 && focusFirstNew) { - setActiveId(groupId); - setLoadedGroupId(null); - clearOpenFeedback(); - } else if (items.length > 0) { - setLoadedGroupId(null); - clearOpenFeedback(); - } - }, [activeId, clearOpenFeedback]); - - const recordLocalRosFilesHistory = useCallback( - (files: File[], fileHandles?: FileSystemFileHandle[]) => { - if (files.length === 0) return; - const displayName = - files.length === 1 ? files[0].name : `${files[0].name} +${String(files.length - 1)}`; - const canReplayWithHandles = Array.isArray(fileHandles) && fileHandles.length === files.length; - const fileSetFingerprint = fingerprintRosFileSet(files); - void recordHistoryEntry({ - kind: canReplayWithHandles ? 'files' : 'file_meta', - displayName, - fileSetFingerprint, - ...(canReplayWithHandles ? { fileHandles } : {}), - detail: - files.length > 1 - ? offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: files.length }) - : undefined, - }); - }, - [offlineIntl, recordHistoryEntry], - ); - - const handleDropRosRecordingFiles = useCallback( - async (inputFiles: File[], dataTransferItems?: DataTransferItemList) => { - const dropped = await collectRosRecordingFilesFromDataTransfer(dataTransferItems); - const files = dropped?.files.length ? dropped.files : filterRosFilesFromFileList(inputFiles); - if (files.length === 0) return; - let fileHandles = dropped?.fileHandles; - if (!fileHandles && dataTransferItems && dataTransferItems.length > 0) { - const raw = await collectRosRecordingFileHandlesFromDataTransfer(dataTransferItems); - if (raw?.length) { - fileHandles = await alignFileHandlesToRosFiles(files, raw); - } - } - clearOpenFeedback(); - if (urlState === 'spa') { - spaSampleLocatorParamRef.current = null; - } - appendFilesAsDatasets(files, true, files); - if (dropped?.directoryHandle) { - void recordHistoryEntry({ - kind: 'directory', - displayName: dropped.directoryHandle.name, - directoryHandle: dropped.directoryHandle, - detail: offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: files.length }), - }); - if (urlState === 'spa') { - pushSpaUrlParam( - serializeSourceLocator({ kind: 'local_folder', displayName: dropped.directoryHandle.name }), - ); - } - } else { - recordLocalRosFilesHistory(files, fileHandles); - } - }, - [appendFilesAsDatasets, clearOpenFeedback, offlineIntl, recordHistoryEntry, recordLocalRosFilesHistory, urlState], - ); - - const handleOpenDirectory = useCallback(async () => { - clearOpenFeedback(); - if (urlState === 'spa') { - spaSampleLocatorParamRef.current = null; - } - try { - const { files, directoryHandle } = await collectRosFilesFromUserDirectoryChoice(); - if (files.length === 0) { - return; - } - appendFilesAsDatasets(files, true, files); - if (directoryHandle) { - await recordHistoryEntry({ - kind: 'directory', - displayName: directoryHandle.name, - directoryHandle, - detail: offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: files.length }), - }); - if (urlState === 'spa') { - pushSpaUrlParam( - serializeSourceLocator({ kind: 'local_folder', displayName: directoryHandle.name }), - ); - } - } else { - await recordHistoryEntry({ - kind: 'directory_fallback', - displayName: offlineIntl.formatMessage({ id: 'welcome.historyFolderPicker' }), - detail: offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: files.length }), - }); - } - } catch (e) { - showOpenError(errorMessageFromUnknown(e, offlineIntl.formatMessage({ id: 'errors.loadFailed' }))); - } - }, [appendFilesAsDatasets, clearOpenFeedback, offlineIntl, recordHistoryEntry, showOpenError, urlState]); - - const handleDragOver = useCallback((e: React.DragEvent) => { - if (!Array.from(e.dataTransfer.types).includes('Files')) { - return; - } - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - }, []); - - const handleDrop = useCallback( - (e: React.DragEvent) => { - if (!Array.from(e.dataTransfer.types).includes('Files')) { - return; - } - e.preventDefault(); - void handleDropRosRecordingFiles(Array.from(e.dataTransfer.files), e.dataTransfer.items); - }, - [handleDropRosRecordingFiles], - ); - - useEffect(() => { - if (!requireSource && !hasSource) { - return; - } - if (!hasSource || !activeId) { - setPlayer(null); - return; - } - - const groupsLive = groupDatasets(datasetsRef.current); - if (!groupsLive.find((g) => g.groupId === activeId)) { - setPlayer(null); - return; - } - - let cancelled = false; - let createdPlayer: IterablePlayer | null = null; - - const run = async () => { - setLastLoadError(null); - const startIdx = groupsLive.findIndex((g) => g.groupId === activeId); - const order = fallbackIndexOrder(groupsLive.length, startIdx >= 0 ? startIdx : 0); - let lastErr: unknown = null; - const autoDataQualityScan = - persistence === 'localStorage' && readPreferences()?.autoDataQualityScan === true; - - for (const idx of order) { - if (cancelled) return; - const group = groupsLive[idx]; - - let preparedMembers: CombinedSourceMember[]; - try { - const prepared = await Promise.all( - group.members.map((ds) => prepareSourceMember(ds, autoDataQualityScan)), - ); - preparedMembers = prepared.map((p) => p.member); - } catch (err) { - if (cancelled) return; - lastErr = err; - continue; - } - if (cancelled) { - for (const m of preparedMembers) m.source.terminate(); - return; - } - - // A group of one uses `WorkerSerializedSource` directly, matching - // today's single-file path byte-for-byte; only 2+ members go through - // `CombinedSourceProxy`. - const newPlayer = - preparedMembers.length === 1 - ? new IterablePlayer(preparedMembers[0].source) - : new IterablePlayer(new CombinedSourceProxy(preparedMembers)); - createdPlayer = newPlayer; - if (!cancelled) setPlayer(newPlayer); - - try { - const initArgs = preparedMembers.length === 1 ? preparedMembers[0].initArgs : {}; - await newPlayer.initialize(initArgs); - if (cancelled) { - newPlayer.close(); - createdPlayer = null; - return; - } - // Do not call setActiveId here: it would re-run this effect's cleanup and close the player we just opened (multi-source fallback). - setLoadedGroupId(group.groupId !== activeId ? group.groupId : null); - clearOpenFeedback(); - if (urlState === 'spa') { - const sampleParam = spaSampleLocatorParamRef.current; - if (sampleParam) { - pushSpaUrlParam(sampleParam); - spaSampleLocatorParamRef.current = null; - } else if (group.members.length === 1) { - const loc = datasetItemToSourceLocator(group.members[0]); - if (loc) { - pushSpaUrlParam(serializeSourceLocator(loc)); - } - } - // Merged multi-file sessions have no single `?url=` locator to - // round-trip; the address bar keeps whatever it last showed. - } - return; - } catch (err) { - if (cancelled || isWorkerSourceCancelledError(err)) { - newPlayer.close(); - createdPlayer = null; - return; - } - lastErr = err; - newPlayer.close(); - createdPlayer = null; - if (!cancelled) setPlayer(null); - } - } - - if (cancelled) return; - const message = - lastErr instanceof Error - ? lastErr.message - : typeof lastErr === 'string' - ? lastErr - : offlineIntl.formatMessage({ id: 'errors.loadFailed' }); - showOpenError(message); - onFatalErrorRef.current?.(lastErr instanceof Error ? lastErr : new Error(message)); - }; - - void run(); - - return () => { - cancelled = true; - createdPlayer?.close(); - setPlayer(null); - }; - // `activeGroupMembersKey` is not read directly but forces a rebuild when - // files are added to (or removed from) the currently active group. - }, [ + const { player, sourceLoading } = usePlayerLifecycle(props, { + requireSource, + hasSource, + lastLoadError, activeId, activeGroupMembersKey, - clearOpenFeedback, - hasSource, - offlineIntl, + datasetsRef, persistence, - requireSource, - showOpenError, urlState, - ]); - - const onDatasetSelect = useCallback( - (id: string) => { - clearOpenFeedback(); - setLoadedGroupId(null); - if (urlState === 'spa') { - spaSampleLocatorParamRef.current = null; - } - setActiveId(id); - }, - [clearOpenFeedback, urlState], - ); - - const onAddFilesFromPicker = useCallback( - (fileList: FileList | null) => { - if (!fileList?.length) return; - clearOpenFeedback(); - if (urlState === 'spa') { - spaSampleLocatorParamRef.current = null; - } - const ros = filterRosFilesFromFileList(fileList); - appendFilesAsDatasets(ros, false, ros); - recordLocalRosFilesHistory(ros); - }, - [appendFilesAsDatasets, clearOpenFeedback, recordLocalRosFilesHistory, urlState], - ); - - function remotePathnameLower(url: string): string { - try { - return new URL(url).pathname.toLowerCase(); - } catch { - return url.split('?')[0].split('#')[0].toLowerCase(); - } - } - - const handleOpenRemoteRecordingUrl = useCallback( - async ( - rawUrl: string, - meta?: { - sample?: { id: string; title: string }; - }, - ) => { - if (urlState === 'spa') { - if (meta?.sample?.id?.trim()) { - spaSampleLocatorParamRef.current = serializeSourceLocator({ - kind: 'sample', - sampleId: meta.sample.id.trim(), - }); - } else { - spaSampleLocatorParamRef.current = null; - } - } - - const trimmed = rawUrl.trim(); - if (!trimmed) return; - const resolved = resolveBrowserHttpUrl(trimmed); - const pathLower = remotePathnameLower(resolved); - const isRemoteTar = - pathLower.endsWith('.tar') || pathLower.endsWith('.tar.gz') || pathLower.endsWith('.tgz'); - - setRemoteUrlBusy(true); - clearOpenFeedback(); - try { - if (isRemoteTar) { - const res = await fetch(resolved); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const buf = await res.arrayBuffer(); - const extracted = extractRosFilesFromTarArchive(buf); - if (extracted.length === 0) { - throw new Error(offlineIntl.formatMessage({ id: 'errors.noRecordingsInArchive' })); - } - appendFilesAsDatasets(extracted, true, undefined, true); - const tail = resolved.split('/').pop() || resolved; - await recordHistoryEntry({ - kind: meta?.sample ? 'sample' : 'remote_tar', - displayName: meta?.sample?.title ?? tail, - url: resolved, - sampleId: meta?.sample?.id, - detail: offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: extracted.length }), - }); - } else { - setExtraDatasets((prev) => mergeDatasetLists(prev, normalizeRosViewSources({ url: resolved }))); - setActiveId(`url:${resolved}`); - setLoadedGroupId(null); - const tail = resolved.split('/').pop() || resolved; - await recordHistoryEntry({ - kind: meta?.sample ? 'sample' : 'url', - displayName: meta?.sample?.title ?? tail, - url: resolved, - sampleId: meta?.sample?.id, - }); - } - } catch (e) { - if (urlState === 'spa') { - spaSampleLocatorParamRef.current = null; - } - showOpenError(errorMessageFromUnknown(e, offlineIntl.formatMessage({ id: 'errors.loadFailed' }))); - } finally { - setRemoteUrlBusy(false); - } - }, - [appendFilesAsDatasets, clearOpenFeedback, offlineIntl, recordHistoryEntry, showOpenError, urlState], - ); - - const handleLocalTarFile = useCallback( - async (file: File) => { - clearOpenFeedback(); - if (urlState === 'spa') { - spaSampleLocatorParamRef.current = null; - } - try { - const extracted = extractRosFilesFromTarArchive(await file.arrayBuffer()); - if (extracted.length === 0) { - throw new Error(offlineIntl.formatMessage({ id: 'errors.noRecordingsInArchive' })); - } - appendFilesAsDatasets(extracted, true, undefined, true); - await recordHistoryEntry({ - kind: 'local_tar', - displayName: file.name, - tarFingerprint: tarFileFingerprint(file), - detail: offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: extracted.length }), - }); - } catch (e) { - showOpenError(errorMessageFromUnknown(e, offlineIntl.formatMessage({ id: 'errors.loadFailed' }))); - } - }, - [appendFilesAsDatasets, clearOpenFeedback, offlineIntl, recordHistoryEntry, showOpenError, urlState], - ); - - const handleSelectSample = useCallback( - async (sample: SampleDataset) => { - const u = getArchiveUrl(sample).trim(); - if (!u) return; - await handleOpenRemoteRecordingUrl(u, { - sample: { id: sample.id, title: sample.title || sample.name }, - }); - }, - [handleOpenRemoteRecordingUrl], - ); - - const handleOpenRecordingFiles = useCallback(async () => { - clearOpenFeedback(); - if (urlState === 'spa') { - spaSampleLocatorParamRef.current = null; - } - const picked = await pickRosRecordingFiles(); - if (picked === null) { - document.getElementById(fileInputDomIdRef.current)?.click(); - return; - } - if (picked.files.length === 0) { - return; - } - recordLocalRosFilesHistory(picked.files, picked.fileHandles); - appendFilesAsDatasets(picked.files, false, picked.files); - }, [appendFilesAsDatasets, clearOpenFeedback, recordLocalRosFilesHistory, urlState]); - - const handleReplayHistory = useCallback( - async (id: string) => { - clearOpenFeedback(); - const entry = await getDatasetHistoryEntry(id); - if (!entry) return; - switch (entry.kind) { - case 'url': - case 'remote_tar': - case 'sample': { - const u = entry.url?.trim(); - if (!u) return; - if (entry.kind === 'sample' && entry.sampleId?.trim()) { - await handleOpenRemoteRecordingUrl(u, { - sample: { id: entry.sampleId.trim(), title: entry.displayName }, - }); - return; - } - await handleOpenRemoteRecordingUrl(u); - return; - } - case 'directory': { - if (urlState === 'spa') { - spaSampleLocatorParamRef.current = null; - } - const dh = entry.directoryHandle; - if (!dh) return; - const ok = await ensureReadPermission(dh); - if (!ok) { - showOpenError(offlineIntl.formatMessage({ id: 'welcome.historyPermissionDenied' })); - return; - } - try { - const out: File[] = []; - await walkDirectoryHandle(dh, 8, 0, out); - if (out.length === 0) { - showOpenError(offlineIntl.formatMessage({ id: 'welcome.historyEmptyDirectory' })); - return; - } - appendFilesAsDatasets(out, true, out, true); - } catch (e) { - showOpenError(errorMessageFromUnknown(e, offlineIntl.formatMessage({ id: 'errors.loadFailed' }))); - } - return; - } - case 'files': { - if (urlState === 'spa') { - spaSampleLocatorParamRef.current = null; - } - const handles = entry.fileHandles ?? []; - if (handles.length === 0) { - document.getElementById(fileInputDomIdRef.current)?.click(); - return; - } - for (const h of handles) { - const granted = await ensureReadPermission(h); - if (!granted) { - showOpenError(offlineIntl.formatMessage({ id: 'welcome.historyPermissionDenied' })); - return; - } - } - try { - const files = await Promise.all(handles.map((h) => h.getFile())); - const ros = files.filter((f) => isRosRecordingFilename(f.name)); - if (ros.length === 0) { - showOpenError(offlineIntl.formatMessage({ id: 'welcome.historyNoSupportedRecordings' })); - return; - } - appendFilesAsDatasets(ros, true, ros, true); - } catch (e) { - showOpenError(errorMessageFromUnknown(e, offlineIntl.formatMessage({ id: 'errors.loadFailed' }))); - } - return; - } - case 'directory_fallback': - void handleOpenDirectory(); - return; - case 'file_meta': - document.getElementById(fileInputDomIdRef.current)?.click(); - return; - case 'local_tar': - document.getElementById(tarInputDomIdRef.current)?.click(); - return; - default: - return; - } - }, - [ - appendFilesAsDatasets, - clearOpenFeedback, - handleOpenDirectory, - handleOpenRemoteRecordingUrl, - offlineIntl, - showOpenError, - urlState, - ], - ); - - useEffect(() => { - if (urlState !== 'spa') return; - const raw = props.url?.trim(); - if (!raw) return; - const loc = parseSourceLocator(raw); - if (!loc || loc.kind === 'remote') return; - - const gen = ++spaUrlBootstrapGenRef.current; - let cancelled = false; - - void (async () => { - setManualOpenHint(null); - if (loc.kind === 'sample') { - if (cancelled || spaUrlBootstrapGenRef.current !== gen) return; - if (!getSampleDatasetsManifestUrl()) { - showOpenError(offlineIntl.formatMessage({ id: 'welcome.sampleManifestNotConfigured' })); - return; - } - const samples = await loadSampleDatasets(); - if (cancelled || spaUrlBootstrapGenRef.current !== gen) return; - const found = samples.find((s) => s.id === loc.sampleId); - if (!found) { - showOpenError(offlineIntl.formatMessage({ id: 'welcome.sampleIdNotFound' }, { id: loc.sampleId })); - return; - } - await handleSelectSample(found); - return; - } - - const row = await getLatestReplayableHistoryByLocalLocator(loc); - if (cancelled || spaUrlBootstrapGenRef.current !== gen) return; - if (!row) { - setLastLoadError(null); - setManualOpenHint( - offlineIntl.formatMessage( - { - id: - loc.kind === 'local_folder' - ? 'welcome.manualOpenFolderHint' - : 'welcome.manualOpenFileHint', - }, - { name: loc.displayName }, - ), - ); - return; - } - await handleReplayHistory(row.id); - })(); - - return () => { - cancelled = true; - spaUrlBootstrapGenRef.current += 1; - }; - }, [urlState, props.url, handleReplayHistory, handleSelectSample, offlineIntl, showOpenError]); - - const openRemotePrompt = useCallback(() => { - const url = - typeof window !== 'undefined' ? window.prompt(offlineIntl.formatMessage({ id: 'viewer.remoteUrlPrompt' })) : null; - if (url?.trim()) void handleOpenRemoteRecordingUrl(url.trim()); - }, [handleOpenRemoteRecordingUrl, offlineIntl]); - - const onSpaUrlQuerySync = props.onSpaUrlQuerySync; + offlineIntl, + clearOpenFeedback, + showOpenError, + setLastLoadError, + setLoadedGroupId, + spaSampleLocatorParamRef, + }); - const handleGoHome = useCallback(() => { - clearOpenFeedback(); - spaSampleLocatorParamRef.current = null; - spaUrlBootstrapGenRef.current += 1; - setExtraDatasets([]); - setLoadedGroupId(null); - setRemoteUrlBusy(false); - if (urlState === 'spa') { - setActiveId(null); - pushSpaUrlParam(null); - onSpaUrlQuerySync?.(); - } - }, [clearOpenFeedback, onSpaUrlQuerySync, urlState]); + const { + remoteUrlBusy, + handleDropRosRecordingFiles, + handleOpenDirectory, + handleDragOver, + handleDrop, + onDatasetSelect, + onAddFilesFromPicker, + handleOpenRemoteRecordingUrl, + handleLocalTarFile, + handleSelectSample, + handleOpenRecordingFiles, + handleReplayHistory, + openRemotePrompt, + handleGoHome, + } = useRecordingSourceActions(props, { + urlState, + appendFilesAsDatasets, + setExtraDatasets, + setActiveId, + setLoadedGroupId, + fileInputDomIdRef, + tarInputDomIdRef, + spaSampleLocatorParamRef, + offlineIntl, + clearOpenFeedback, + showOpenError, + setLastLoadError, + setManualOpenHint, + recordHistoryEntry, + }); const layoutProvider = (children: React.ReactNode) => ( = (props) => { if (!hasSource) { return layoutProvider( -
- { - clearOpenFeedback(); - void handleOpenRecordingFiles(); - }} - onOpenDirectory={handleOpenDirectory} - onOpenTarPick={() => document.getElementById('rosview-landing-tar')?.click()} - onOpenRemotePrompt={openRemotePrompt} - onOpenSampleDialog={() => setSampleDialogOpen(true)} - recentHistoryItems={historyItems.slice(0, 10)} - onReplayHistory={(id) => void handleReplayHistory(id)} - /> - { - clearOpenFeedback(); - void handleOpenRecordingFiles(); - }} - onOpenDirectory={handleOpenDirectory} - onOpenTarPicker={() => document.getElementById('rosview-landing-tar')?.click()} - onSubmitRemoteUrl={handleOpenRemoteRecordingUrl} - remoteSubmitLoading={remoteUrlBusy} - onSelectSample={handleSelectSample} - historyItems={historyItems} - onReplayHistory={(id) => void handleReplayHistory(id)} - /> - { - onAddFilesFromPicker(e.target.files); - e.target.value = ''; - }} - /> - { - const f = e.target.files?.[0]; - if (f) void handleLocalTarFile(f); - e.target.value = ''; - }} - /> - -
+ onAddFilesFromPicker={onAddFilesFromPicker} + onLocalTarSelected={handleLocalTarFile} + onOpenFilePick={() => void handleOpenRecordingFiles()} + onOpenDirectory={handleOpenDirectory} + onOpenRemotePrompt={openRemotePrompt} + onOpenSampleDialog={() => setSampleDialogOpen(true)} + onSubmitRemoteUrl={handleOpenRemoteRecordingUrl} + remoteSubmitLoading={remoteUrlBusy} + onSelectSample={handleSelectSample} + historyItems={historyItems} + onReplayHistory={(id) => void handleReplayHistory(id)} + manualOpenHint={manualOpenHint} + showLoadingSkeleton={false} + sampleDialogOpen={sampleDialogOpen} + onSampleDialogOpenChange={setSampleDialogOpen} + />
, ); } @@ -1363,6 +228,12 @@ export const RosViewer: React.FC = (props) => { return layoutProvider( <> + {/* + Rendered unconditionally (not just while the landing screen below + is shown): history replay can target `#rosview-inline-file` / + `#rosview-inline-tar` via `document.getElementById(...).click()` + even once a player exists and the landing screen has unmounted. + */} = (props) => { e.target.value = ''; }} /> - {!player ? ( -
- { - clearOpenFeedback(); - void handleOpenRecordingFiles(); - }} - onOpenDirectory={handleOpenDirectory} - onOpenTarPick={() => document.getElementById('rosview-inline-tar')?.click()} - onOpenRemotePrompt={openRemotePrompt} - onOpenSampleDialog={() => setSampleDialogOpen(true)} - recentHistoryItems={historyItems.slice(0, 10)} - onReplayHistory={(id) => void handleReplayHistory(id)} - /> - {!lastLoadError && !manualOpenHint ? ( -
-
- - -
- -
- ) : ( - { - clearOpenFeedback(); - void handleOpenRecordingFiles(); - }} - onOpenDirectory={handleOpenDirectory} - onOpenTarPicker={() => document.getElementById('rosview-inline-tar')?.click()} - onSubmitRemoteUrl={handleOpenRemoteRecordingUrl} - remoteSubmitLoading={remoteUrlBusy} - onSelectSample={handleSelectSample} - historyItems={historyItems} - onReplayHistory={(id) => void handleReplayHistory(id)} - /> - )} - -
- ) : ( - appShellElement + onAddFilesFromPicker={onAddFilesFromPicker} + onLocalTarSelected={handleLocalTarFile} + onOpenFilePick={() => void handleOpenRecordingFiles()} + onOpenDirectory={handleOpenDirectory} + onOpenRemotePrompt={openRemotePrompt} + onOpenSampleDialog={() => setSampleDialogOpen(true)} + onSubmitRemoteUrl={handleOpenRemoteRecordingUrl} + remoteSubmitLoading={remoteUrlBusy} + onSelectSample={handleSelectSample} + historyItems={historyItems} + onReplayHistory={(id) => void handleReplayHistory(id)} + manualOpenHint={manualOpenHint} + showLoadingSkeleton={!lastLoadError} + loadingSourceName={loadingSourceName} + onCancelLoading={handleGoHome} + sampleDialogOpen={sampleDialogOpen} + onSampleDialogOpenChange={setSampleDialogOpen} + /> )}
, diff --git a/src/features/viewer/RosViewerLandingScreen.tsx b/src/features/viewer/RosViewerLandingScreen.tsx new file mode 100644 index 0000000..5e20d4e --- /dev/null +++ b/src/features/viewer/RosViewerLandingScreen.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { cn } from '@/shared/lib/utils'; +import { Navbar } from '@/features/workspace/navbar/Navbar'; +import { WelcomeScreen } from '@/features/workspace/common/WelcomeScreen'; +import { LoadingOverlay } from '@/features/workspace/common/LoadingOverlay'; +import { Skeleton } from '@/shared/ui/skeleton'; +import { SampleDatasetDialog } from '@/features/workspace/common/SampleDatasetDialog'; +import type { SampleDataset } from '@/services/sampleDatasets'; +import type { DatasetHistoryListItem } from '@/shared/utils/datasetHistory'; + +export interface RosViewerLandingScreenProps { + className?: string; + style?: React.CSSProperties; + fileInputId: string; + tarInputId: string; + + sourceName?: string; + sourceLoading?: boolean; + theme?: 'light' | 'dark' | 'system'; + language?: 'en' | 'zh' | 'ja'; + onThemeChange: (theme: 'light' | 'dark' | 'system') => void; + onLanguageChange: (language: 'en' | 'zh' | 'ja') => void; + showLanguageSwitcher: boolean; + showThemeSwitcher: boolean; + showNavbarBrand: boolean; + navbarBrandLabel?: string; + onBrandClick: () => void; + + onDragOver: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; + onAddFilesFromPicker: (fileList: FileList | null) => void; + onLocalTarSelected: (file: File) => void; + + onOpenFilePick: () => void; + onOpenDirectory: () => void; + onOpenRemotePrompt: () => void; + onOpenSampleDialog: () => void; + onSubmitRemoteUrl: (url: string) => void | Promise; + remoteSubmitLoading?: boolean; + onSelectSample: (sample: SampleDataset) => void | Promise; + historyItems?: DatasetHistoryListItem[]; + onReplayHistory?: (id: string) => void | Promise; + + manualOpenHint?: string | null; + /** Shows a loading skeleton + cancel overlay instead of the welcome screen while a source is actively loading. */ + showLoadingSkeleton?: boolean; + loadingSourceName?: string; + onCancelLoading?: () => void; + + sampleDialogOpen: boolean; + onSampleDialogOpenChange: (open: boolean) => void; + + /** + * When `false`, the hidden file/tar ``s are not rendered here — + * used when a caller renders them itself outside this component so they + * stay mounted (and clickable via `document.getElementById`) even while + * this screen itself isn't, e.g. `RosViewerImpl`'s "has a source, but the + * player isn't ready yet" case needs `#rosview-inline-file` reachable + * from history-replay even once a player exists and this screen unmounts. + * @default true + */ + renderHiddenInputs?: boolean; +} + +/** + * The "no player yet" screen: navbar + either a loading skeleton (source + * actively loading, no error/hint yet) or the welcome/open-a-recording + * screen, plus the hidden file/tar ``s and sample dialog every open + * flow needs. Shared by `RosViewerImpl`'s pre-source landing page and its + * "has a source, player not ready yet" fallback — those two were + * previously ~60 lines of near-duplicated JSX each. + */ +export const RosViewerLandingScreen: React.FC = ({ + className, + style, + fileInputId, + tarInputId, + sourceName, + sourceLoading, + theme, + language, + onThemeChange, + onLanguageChange, + showLanguageSwitcher, + showThemeSwitcher, + showNavbarBrand, + navbarBrandLabel, + onBrandClick, + onDragOver, + onDrop, + onAddFilesFromPicker, + onLocalTarSelected, + onOpenFilePick, + onOpenDirectory, + onOpenRemotePrompt, + onOpenSampleDialog, + onSubmitRemoteUrl, + remoteSubmitLoading, + onSelectSample, + historyItems, + onReplayHistory, + manualOpenHint, + showLoadingSkeleton, + loadingSourceName, + onCancelLoading, + sampleDialogOpen, + onSampleDialogOpenChange, + renderHiddenInputs = true, +}) => { + return ( +
+ document.getElementById(tarInputId)?.click()} + onOpenRemotePrompt={onOpenRemotePrompt} + onOpenSampleDialog={onOpenSampleDialog} + recentHistoryItems={historyItems?.slice(0, 10)} + onReplayHistory={onReplayHistory} + /> + {showLoadingSkeleton && !manualOpenHint ? ( +
+
+ + +
+ +
+ ) : ( + document.getElementById(tarInputId)?.click()} + onSubmitRemoteUrl={onSubmitRemoteUrl} + remoteSubmitLoading={remoteSubmitLoading} + onSelectSample={onSelectSample} + historyItems={historyItems} + onReplayHistory={onReplayHistory} + /> + )} + {renderHiddenInputs ? ( + <> + { + onAddFilesFromPicker(e.target.files); + e.target.value = ''; + }} + /> + { + const f = e.target.files?.[0]; + if (f) void onLocalTarSelected(f); + e.target.value = ''; + }} + /> + + ) : null} + +
+ ); +}; diff --git a/src/features/viewer/rosViewerUtils.ts b/src/features/viewer/rosViewerUtils.ts new file mode 100644 index 0000000..a186a1b --- /dev/null +++ b/src/features/viewer/rosViewerUtils.ts @@ -0,0 +1,75 @@ +/** Small pure helpers shared across `RosViewerImpl` and its hooks. No React here. */ +import { readPreferences } from '@/core/preferences/readWritePreferences'; +import { + mergeInitialUiPreferences, + readUiPreferenceParamsFromSearch, +} from '@/core/preferences/mergeInitialUiPreferences'; +import type { PreferencePersistence } from '@/core/preferences/types'; +import type { DatasetItem } from '@/shared/utils/datasetSources'; +import { resolveBrowserHttpUrl } from '@/shared/utils/resolveBrowserHttpUrl'; +import type { SourceLocator } from '@/shared/utils/sourceLocator'; +import type { RosViewerProps } from './RosViewer.types'; + +/** + * Fingerprints every prop that identifies "what to load", so effects can + * depend on this single string instead of unstable object/array/File + * references (`files`/`urls`/`fileManifest` arrays are typically recreated + * by the caller on every render). + */ +export function propsSignature(props: RosViewerProps): string { + const urlState = props.urlState ?? 'off'; + const urls = (props.urls ?? []).map((u) => u.trim()).join('\0'); + const url = props.url?.trim() ?? ''; + const files = (props.files ?? []).map((f) => `${f.name}:${f.size}:${f.lastModified}`).join('\0'); + const file = props.file ? `${props.file.name}:${props.file.size}:${props.file.lastModified}` : ''; + const fileListSig = + props.fileManifest == null + ? '' + : typeof props.fileManifest === 'string' + ? props.fileManifest.trim() + : JSON.stringify(props.fileManifest); + const mergeSources = props.mergeSources ? '1' : '0'; + return `${urlState}|${urls}|${url}|${files}|${file}|${fileListSig}|${mergeSources}`; +} + +export function initialUiFromProps(p: RosViewerProps) { + const persistence: PreferencePersistence = p.preferencePersistence ?? 'localStorage'; + const { urlTheme, urlLanguage } = readUiPreferenceParamsFromSearch( + typeof window !== 'undefined' ? window.location.search : '', + ); + return mergeInitialUiPreferences({ + persistence, + propsTheme: p.theme, + propsLanguage: p.language, + urlTheme, + urlLanguage, + stored: persistence === 'localStorage' ? readPreferences() : null, + }); +} + +export function errorMessageFromUnknown(error: unknown, fallback: string): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + return fallback; +} + +export function resolveLayoutPersistence( + layoutPersistence: PreferencePersistence | 'inherit' | undefined, + preferencePersistence: PreferencePersistence, +): PreferencePersistence { + if (layoutPersistence === undefined || layoutPersistence === 'inherit') { + return preferencePersistence; + } + return layoutPersistence; +} + +export function datasetItemToSourceLocator(ds: DatasetItem): SourceLocator | null { + if (ds.kind === 'url' && ds.url) { + const resolvedUrl = resolveBrowserHttpUrl(ds.url); + return { kind: 'remote', raw: ds.url.trim(), resolvedUrl }; + } + if (ds.kind === 'file' && ds.file) { + return { kind: 'local_file', displayName: ds.file.name }; + } + return null; +} diff --git a/src/features/viewer/sourceLoading.ts b/src/features/viewer/sourceLoading.ts new file mode 100644 index 0000000..c266ca0 --- /dev/null +++ b/src/features/viewer/sourceLoading.ts @@ -0,0 +1,134 @@ +/** + * Pure helpers for turning a `DatasetItem` into a ready-to-initialize player + * source (Worker + `WorkerSerializedSource` + init args). No React here — + * this is plain async/data logic shared by the single-file fast path and + * the multi-file `CombinedSourceProxy` path in `RosViewerImpl`. + */ +import { WorkerSerializedSource } from '@/infra/workers/WorkerSerializedSource'; +import type { CombinedSourceMember } from '@/infra/workers/CombinedSourceProxy'; +import { + loadHdf5WasmBinary, + loadSqlWasmBinary, + loadZstdWasmBinary, + needsZstdWasmForWorker, +} from '@/infra/workers/preloadWorkerWasm'; +import { isRosRecordingFilename, type DatasetItem } from '@/shared/utils/datasetSources'; +import { resolveBrowserHttpUrl } from '@/shared/utils/resolveBrowserHttpUrl'; + +export function extensionForDataset(ds: DatasetItem): string | undefined { + if (ds.kind === 'file' && ds.file) { + return ds.file.name.split('.').pop()?.toLowerCase(); + } + if (ds.kind === 'url' && ds.url) { + try { + const path = new URL(ds.url).pathname; + return path.split('.').pop()?.toLowerCase(); + } catch { + return ds.url.split('.').pop()?.toLowerCase(); + } + } + return undefined; +} + +export async function createWorkerForExtension(ext: string | undefined): Promise { + if (ext === 'bvh') { + const { default: BvhWorkerClass } = await import('@/infra/workers/bvh.worker.ts?worker&inline'); + return new BvhWorkerClass(); + } + if (ext === 'bag') { + const { default: BagWorkerClass } = await import('@/infra/workers/bag.worker.ts?worker&inline'); + return new BagWorkerClass(); + } + if (ext === 'db3') { + const { default: Db3WorkerClass } = await import('@/infra/workers/db3.worker.ts?worker&inline'); + return new Db3WorkerClass(); + } + if (ext === 'hdf5' || ext === 'h5') { + const { default: Hdf5WorkerClass } = await import('@/infra/workers/hdf5.worker.ts?worker&inline'); + return new Hdf5WorkerClass(); + } + const { default: McapWorkerClass } = await import('@/infra/workers/mcap.worker.ts?worker&inline'); + return new McapWorkerClass(); +} + +export async function buildInitArgsForDataset( + ds: DatasetItem, + ext: string | undefined, + autoDataQualityScan: boolean, +): Promise> { + const workerPerf = + typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('workerPerf') === '1'; + const sqlWasmBinary = ext === 'db3' ? await loadSqlWasmBinary() : undefined; + const hdf5WasmBinary = ext === 'hdf5' || ext === 'h5' ? await loadHdf5WasmBinary() : undefined; + const zstdWasmBinary = needsZstdWasmForWorker(ext) ? await loadZstdWasmBinary() : undefined; + if (ds.kind === 'url' && ds.url) { + const init: Record = { + url: resolveBrowserHttpUrl(ds.url), + workerPerf, + autoDataQualityScan, + }; + if (ext === 'db3') { + init.sqlWasmBinary = sqlWasmBinary; + } + if (hdf5WasmBinary) { + init.hdf5WasmBinary = hdf5WasmBinary; + } + if (zstdWasmBinary) { + init.zstdWasmBinary = zstdWasmBinary; + } + if (typeof ds.sizeBytes === 'number' && Number.isFinite(ds.sizeBytes) && ds.sizeBytes > 0) { + init.knownTotalBytes = Math.floor(ds.sizeBytes); + } + return init; + } + if (ds.kind === 'file' && ds.file) { + const siblingFiles = ds.siblingFiles?.filter((file) => isRosRecordingFilename(file.name)) ?? []; + const files = siblingFiles.length > 0 ? siblingFiles : [ds.file]; + if (ext === 'bag' || ext === 'db3') { + return { + file: ds.file, + files, + workerPerf, + autoDataQualityScan, + ...(sqlWasmBinary ? { sqlWasmBinary } : {}), + ...(zstdWasmBinary ? { zstdWasmBinary } : {}), + }; + } + return { + file: ds.file, + workerPerf, + autoDataQualityScan, + ...(hdf5WasmBinary ? { hdf5WasmBinary } : {}), + ...(zstdWasmBinary ? { zstdWasmBinary } : {}), + }; + } + throw new Error('Invalid dataset item'); +} + +/** + * Creates a Worker + `WorkerSerializedSource` + init args for one dataset + * member. Shared by both the single-file fast path and the multi-file + * `CombinedSourceProxy` path so a group of 1 behaves byte-for-byte like + * today's single-source loading. + */ +export async function prepareSourceMember( + ds: DatasetItem, + autoDataQualityScan: boolean, +): Promise<{ member: CombinedSourceMember; ext: string | undefined }> { + const ext = extensionForDataset(ds); + const worker = await createWorkerForExtension(ext); + worker.onerror = (ev) => console.error('MAIN: Worker error', ev); + const initArgs = await buildInitArgsForDataset(ds, ext, autoDataQualityScan); + const source = new WorkerSerializedSource(worker); + return { member: { label: ds.name, source, initArgs }, ext }; +} + +/** Try indices in order: startIdx .. end-1, then 0 .. startIdx-1 */ +export function fallbackIndexOrder(length: number, startIdx: number): number[] { + if (length <= 0) return []; + const s = Math.max(0, Math.min(startIdx, length - 1)); + const out: number[] = []; + for (let i = s; i < length; i++) out.push(i); + for (let i = 0; i < s; i++) out.push(i); + return out; +} diff --git a/src/features/viewer/useDatasetHistory.ts b/src/features/viewer/useDatasetHistory.ts new file mode 100644 index 0000000..efe2bf9 --- /dev/null +++ b/src/features/viewer/useDatasetHistory.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + listDatasetHistory, + upsertDatasetHistoryEntry, + type DatasetHistoryListItem, + type DatasetHistoryStoredEntry, +} from '@/shared/utils/datasetHistory'; + +/** + * Owns the IndexedDB-backed "recently opened" list: loads it on mount and + * exposes `recordHistoryEntry` to upsert + refresh in one step. Kept + * separate from the dataset/session state (`useDatasetSession`) since it's + * a simple, self-contained CRUD concern with no bearing on `activeId`. + */ +export function useDatasetHistory() { + const [historyItems, setHistoryItems] = useState([]); + + const refreshHistory = useCallback(async () => { + const list = await listDatasetHistory(); + setHistoryItems(list); + }, []); + + useEffect(() => { + void refreshHistory(); + }, [refreshHistory]); + + const recordHistoryEntry = useCallback( + async (row: Omit) => { + try { + await upsertDatasetHistoryEntry(row); + } catch (e) { + console.warn('[RosViewer] dataset history write failed', e); + } + await refreshHistory(); + }, + [refreshHistory], + ); + + return { historyItems, recordHistoryEntry }; +} diff --git a/src/features/viewer/useDatasetSession.ts b/src/features/viewer/useDatasetSession.ts new file mode 100644 index 0000000..e727875 --- /dev/null +++ b/src/features/viewer/useDatasetSession.ts @@ -0,0 +1,214 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + createDatasetGroupId, + datasetItemsFromListItems, + dedupeDatasetItems, + fileDatasetId, + groupDatasets, + mergeDatasetLists, + normalizeRosViewSources, + parseRemoteDatasetListJson, + resolveActiveId, + resolveAppendGroupId, + type DatasetItem, + type FileListItem, +} from '@/shared/utils/datasetSources'; +import { resolveBrowserHttpUrl } from '@/shared/utils/resolveBrowserHttpUrl'; +import { isCustomLocalLocatorString } from '@/shared/utils/sourceLocator'; +import type { RosViewerProps } from './RosViewer.types'; + +/** + * Owns the dataset/session state machine: the flat list of loaded + * datasets (from props + files/URLs/tar/history opened at runtime), which + * group (`activeId`) is selected, and `appendFilesAsDatasets` — the single + * place new files are folded into that list. + * + * `appendFilesAsDatasets` reads `activeId` and `datasets` through refs + * rather than as `useCallback` dependencies, so its identity stays stable + * no matter how often the session changes. That stability is load-bearing: + * this hook (and callers built on it, e.g. `useSpaUrlBootstrap` / + * `useRecordingSourceActions`) previously caused a production incident + * where an unstable append callback in an Effect's dependency array + * self-triggered repeated history replays, tearing the player down and + * rebuilding it in a tight loop (visible as UI flicker and eventual tab + * crashes). See `resolveAppendGroupId` / `resolveActiveId` in + * `datasetSources.ts` for the other half of that fix. + */ +export function useDatasetSession( + props: RosViewerProps, + urlState: 'spa' | 'off', + propSig: string, + clearOpenFeedback: () => void, + setManualOpenHint: (hint: string | null) => void, +) { + const fromProps = useMemo( + () => + normalizeRosViewSources({ + file: props.file, + files: props.files, + url: isCustomLocalLocatorString(props.url) ? undefined : props.url, + urls: urlState === 'spa' ? undefined : props.urls, + fileManifest: Array.isArray(props.fileManifest) ? props.fileManifest : undefined, + mergeSources: props.mergeSources, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps -- propSig fingerprints File identity and source props + [propSig, urlState], + ); + + const [extraDatasets, setExtraDatasets] = useState([]); + + useEffect(() => { + const targets: string[] = []; + if (typeof props.fileManifest === 'string' && props.fileManifest.trim()) { + targets.push(resolveBrowserHttpUrl(props.fileManifest.trim())); + } + if (targets.length === 0) return; + let cancelled = false; + void (async () => { + const merged: FileListItem[] = []; + for (const t of targets) { + try { + const res = await fetch(t); + if (!res.ok) { + console.warn('[RosViewer] dataset list HTTP', res.status, t); + continue; + } + const json: unknown = await res.json(); + merged.push(...parseRemoteDatasetListJson(json)); + } catch (e) { + console.warn('[RosViewer] dataset list fetch failed', t, e); + } + } + if (cancelled || merged.length === 0) return; + setExtraDatasets((prev) => mergeDatasetLists(prev, datasetItemsFromListItems(merged))); + })(); + return () => { + cancelled = true; + }; + }, [propSig, props.fileManifest]); + + const datasets = useMemo(() => mergeDatasetLists(fromProps, extraDatasets), [fromProps, extraDatasets]); + + const datasetsRef = useRef(datasets); + // eslint-disable-next-line react-hooks/refs -- sync latest datasets for async player init + datasetsRef.current = datasets; + + /** Holds a group key (see `datasetGroupKey`); a standalone dataset is a group of one. */ + const [activeId, setActiveId] = useState(null); + const activeIdRef = useRef(activeId); + // eslint-disable-next-line react-hooks/refs -- read latest activeId in callbacks without churning their identity + activeIdRef.current = activeId; + /** When fallback loads a non-selected group, highlights it in the sidebar without re-running the activeId load effect. */ + const [loadedGroupId, setLoadedGroupId] = useState(null); + + useEffect(() => { + setExtraDatasets([]); + setLoadedGroupId(null); + setManualOpenHint(null); + }, [propSig, setManualOpenHint]); + + const groups = useMemo(() => groupDatasets(datasets), [datasets]); + + useEffect(() => { + setActiveId((prev) => resolveActiveId(datasets, prev)); + }, [datasets]); + + /** Members of the group the load effect targets, stable while unrelated datasets change. */ + const activeGroupMembersKey = useMemo(() => { + const resolvedGroupId = loadedGroupId ?? activeId; + const group = groups.find((g) => g.groupId === resolvedGroupId); + return group ? group.members.map((m) => m.id).join('\u0000') : ''; + }, [groups, loadedGroupId, activeId]); + + const resolvedDatasetId = loadedGroupId ?? activeId; + const activeGroup = useMemo( + () => (resolvedDatasetId ? groups.find((g) => g.groupId === resolvedDatasetId) ?? null : null), + [groups, resolvedDatasetId], + ); + const activeDataset = activeGroup?.members[0] ?? null; + + const loadingSourceName = activeGroup + ? activeGroup.members.length > 1 + ? `${activeGroup.members[0].name} +${String(activeGroup.members.length - 1)}` + : activeDataset?.kind === 'url' + ? activeDataset.url + : activeDataset?.file?.name + : undefined; + const effectiveSourceName = props.navbarSourceName ?? loadingSourceName; + + const hasSource = datasets.length > 0; + + const fileInputDomIdRef = useRef('rosview-landing-file'); + const tarInputDomIdRef = useRef('rosview-landing-tar'); + useEffect(() => { + fileInputDomIdRef.current = hasSource ? 'rosview-inline-file' : 'rosview-landing-file'; + tarInputDomIdRef.current = hasSource ? 'rosview-inline-tar' : 'rosview-landing-tar'; + }, [hasSource]); + + const appendFilesAsDatasets = useCallback( + (files: File[], focusFirstNew = true, groupFiles?: File[], forceNewSession = false) => { + const siblingFiles = groupFiles && groupFiles.length > 1 ? [...groupFiles] : undefined; + const activeId = activeIdRef.current; + const currentDatasets = datasetsRef.current; + // Default behavior: new recording files merge into whatever session is + // currently active (topics/time-range union), matching the common + // "open base recording, later add an incrementally-produced file" + // workflow. When nothing is active yet, the whole batch becomes one + // fresh session together. `forceNewSession` opts out (used by + // tar-archive extraction and history replay, which represent opening a + // self-contained recording bundle rather than adding to the workspace). + const currentGroups = groupDatasets(currentDatasets); + const hasActiveGroup = + !forceNewSession && activeId != null && currentGroups.some((g) => g.groupId === activeId); + // If this batch is already loaded under some group (e.g. reopening the + // same recording via a remembered file-handle from history), reuse that + // group instead of minting a fresh id — see `resolveAppendGroupId` for + // why a fresh id would otherwise leave `activeId` pointed at an "orphan" + // group, causing flicker / repeated worker re-init. + const groupId = hasActiveGroup + ? activeId + : resolveAppendGroupId(currentDatasets, files, createDatasetGroupId()); + const items = dedupeDatasetItems( + files.map((f) => ({ + id: fileDatasetId(f), + kind: 'file' as const, + name: f.name, + file: f, + groupId, + ...(siblingFiles ? { siblingFiles } : {}), + })), + ); + setExtraDatasets((prev) => mergeDatasetLists(prev, items)); + if (items.length > 0 && focusFirstNew) { + setActiveId(groupId); + setLoadedGroupId(null); + clearOpenFeedback(); + } else if (items.length > 0) { + setLoadedGroupId(null); + clearOpenFeedback(); + } + }, + [clearOpenFeedback], + ); + + return { + datasets, + datasetsRef, + setExtraDatasets, + activeId, + setActiveId, + loadedGroupId, + setLoadedGroupId, + groups, + activeGroupMembersKey, + resolvedDatasetId, + activeGroup, + activeDataset, + loadingSourceName, + effectiveSourceName, + hasSource, + fileInputDomIdRef, + tarInputDomIdRef, + appendFilesAsDatasets, + }; +} diff --git a/src/features/viewer/useOpenFeedback.ts b/src/features/viewer/useOpenFeedback.ts new file mode 100644 index 0000000..d57fbc9 --- /dev/null +++ b/src/features/viewer/useOpenFeedback.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; + +/** + * Shared "open a recording" feedback state: an error message and/or a + * "here's how to open this manually" hint. Used by nearly every + * open-a-source flow (drag-drop, remote URL, tar, history replay, SPA + * bootstrap, the player itself) so it's a standalone hook rather than + * bundled into any one of them. + */ +export function useOpenFeedback() { + const [lastLoadError, setLastLoadError] = useState(null); + const [manualOpenHint, setManualOpenHint] = useState(null); + + const clearOpenFeedback = useCallback(() => { + setLastLoadError(null); + setManualOpenHint(null); + }, []); + + const showOpenError = useCallback((message: string) => { + setLastLoadError(message); + setManualOpenHint(null); + toast.error(message); + }, []); + + return { + lastLoadError, + manualOpenHint, + setLastLoadError, + setManualOpenHint, + clearOpenFeedback, + showOpenError, + }; +} diff --git a/src/features/viewer/usePlayerLifecycle.ts b/src/features/viewer/usePlayerLifecycle.ts new file mode 100644 index 0000000..9274373 --- /dev/null +++ b/src/features/viewer/usePlayerLifecycle.ts @@ -0,0 +1,250 @@ +import { useEffect, useRef, useState } from 'react'; +import { isWorkerSourceCancelledError } from '@/infra/workers/WorkerSerializedSource'; +import { CombinedSourceProxy, type CombinedSourceMember } from '@/infra/workers/CombinedSourceProxy'; +import { IterablePlayer } from '@/core/players/IterablePlayer'; +import { MinimalPlayer } from '@/core/players/MinimalPlayer'; +import type { Player } from '@/core/types/player'; +import { useMessagePipelineStore } from '@/core/pipeline/store'; +import { readPreferences } from '@/core/preferences/readWritePreferences'; +import type { PreferencePersistence } from '@/core/preferences/types'; +import { groupDatasets, type DatasetItem } from '@/shared/utils/datasetSources'; +import { pushSpaUrlParam, serializeSourceLocator } from '@/shared/utils/sourceLocator'; +import { fallbackIndexOrder, prepareSourceMember } from './sourceLoading'; +import { datasetItemToSourceLocator } from './rosViewerUtils'; +import type { RosViewerProps } from './RosViewer.types'; +import type { createRosViewIntl } from '@/shared/intl/createRosViewIntl'; + +export interface UsePlayerLifecycleArgs { + requireSource: boolean; + hasSource: boolean; + /** Player-loading UI is suppressed once a prior attempt already reported an error. */ + lastLoadError: string | null; + activeId: string | null; + /** Members of the group the load effect targets; forces a rebuild when files are added to/removed from it. */ + activeGroupMembersKey: string; + datasetsRef: React.RefObject; + persistence: PreferencePersistence; + urlState: 'spa' | 'off'; + offlineIntl: ReturnType; + clearOpenFeedback: () => void; + showOpenError: (message: string) => void; + setLastLoadError: (value: string | null) => void; + setLoadedGroupId: (value: string | null) => void; + /** When set, successful player init writes `sample://…` to the address bar instead of the resolved archive URL. */ + spaSampleLocatorParamRef: React.RefObject; +} + +/** + * Owns the `Player` instance: a `MinimalPlayer` fallback when no source is + * required, and otherwise the real load/fallback loop that turns the active + * dataset group into a `WorkerSerializedSource` (or `CombinedSourceProxy` + * for merged multi-file groups) wrapped in an `IterablePlayer`. Also fires + * `props.onPlayerReady` / `props.onSourceLoadingChange`. + * + * The load effect's dependency array is intentionally narrow — `activeId` + + * `activeGroupMembersKey` (not the full `datasets`/`groups` objects, which + * are read fresh from `datasetsRef` inside the effect). Session state + * (`useDatasetSession`) guarantees `activeId` only changes when there's a + * real, live group to load; see that hook's docs for the production bug + * this invariant fixes. + */ +export function usePlayerLifecycle(props: RosViewerProps, args: UsePlayerLifecycleArgs) { + const { + requireSource, + hasSource, + lastLoadError, + activeId, + activeGroupMembersKey, + datasetsRef, + persistence, + urlState, + offlineIntl, + clearOpenFeedback, + showOpenError, + setLastLoadError, + setLoadedGroupId, + spaSampleLocatorParamRef, + } = args; + + const [player, setPlayer] = useState(null); + const sourceLoading = hasSource && player == null && lastLoadError == null; + + const onFatalErrorRef = useRef(props.onFatalError); + useEffect(() => { + onFatalErrorRef.current = props.onFatalError; + }, [props.onFatalError]); + + const onPlayerReadyRef = useRef(props.onPlayerReady); + useEffect(() => { + onPlayerReadyRef.current = props.onPlayerReady; + }, [props.onPlayerReady]); + const playerReadyFiredRef = useRef(false); + + useEffect(() => { + playerReadyFiredRef.current = false; + }, [player]); + + useEffect(() => { + if (!player) return; + const fireIfReady = () => { + if (playerReadyFiredRef.current) return; + if (useMessagePipelineStore.getState().playerState.presence !== 'ready') return; + playerReadyFiredRef.current = true; + onPlayerReadyRef.current?.({ player, hasSource }); + }; + fireIfReady(); + return useMessagePipelineStore.subscribe(fireIfReady); + }, [player, hasSource]); + + const onSourceLoadingChangeRef = useRef(props.onSourceLoadingChange); + useEffect(() => { + onSourceLoadingChangeRef.current = props.onSourceLoadingChange; + }, [props.onSourceLoadingChange]); + + useEffect(() => { + onSourceLoadingChangeRef.current?.(sourceLoading); + }, [sourceLoading]); + + useEffect(() => { + if (requireSource || hasSource) return; + const minimal = new MinimalPlayer(); + setPlayer(minimal); + return () => { + minimal.close(); + setPlayer(null); + }; + }, [requireSource, hasSource]); + + useEffect(() => { + if (!requireSource && !hasSource) { + return; + } + if (!hasSource || !activeId) { + setPlayer(null); + return; + } + + const groupsLive = groupDatasets(datasetsRef.current); + if (!groupsLive.find((g) => g.groupId === activeId)) { + setPlayer(null); + return; + } + + let cancelled = false; + let createdPlayer: IterablePlayer | null = null; + + const run = async () => { + setLastLoadError(null); + const startIdx = groupsLive.findIndex((g) => g.groupId === activeId); + const order = fallbackIndexOrder(groupsLive.length, startIdx >= 0 ? startIdx : 0); + let lastErr: unknown = null; + const autoDataQualityScan = + persistence === 'localStorage' && readPreferences()?.autoDataQualityScan === true; + + for (const idx of order) { + if (cancelled) return; + const group = groupsLive[idx]; + + let preparedMembers: CombinedSourceMember[]; + try { + const prepared = await Promise.all( + group.members.map((ds) => prepareSourceMember(ds, autoDataQualityScan)), + ); + preparedMembers = prepared.map((p) => p.member); + } catch (err) { + if (cancelled) return; + lastErr = err; + continue; + } + if (cancelled) { + for (const m of preparedMembers) m.source.terminate(); + return; + } + + // A group of one uses `WorkerSerializedSource` directly, matching + // today's single-file path byte-for-byte; only 2+ members go through + // `CombinedSourceProxy`. + const newPlayer = + preparedMembers.length === 1 + ? new IterablePlayer(preparedMembers[0].source) + : new IterablePlayer(new CombinedSourceProxy(preparedMembers)); + createdPlayer = newPlayer; + if (!cancelled) setPlayer(newPlayer); + + try { + const initArgs = preparedMembers.length === 1 ? preparedMembers[0].initArgs : {}; + await newPlayer.initialize(initArgs); + if (cancelled) { + newPlayer.close(); + createdPlayer = null; + return; + } + // Do not call setActiveId here: it would re-run this effect's cleanup and close the player we just opened (multi-source fallback). + setLoadedGroupId(group.groupId !== activeId ? group.groupId : null); + clearOpenFeedback(); + if (urlState === 'spa') { + const sampleParam = spaSampleLocatorParamRef.current; + if (sampleParam) { + pushSpaUrlParam(sampleParam); + spaSampleLocatorParamRef.current = null; + } else if (group.members.length === 1) { + const loc = datasetItemToSourceLocator(group.members[0]); + if (loc) { + pushSpaUrlParam(serializeSourceLocator(loc)); + } + } + // Merged multi-file sessions have no single `?url=` locator to + // round-trip; the address bar keeps whatever it last showed. + } + return; + } catch (err) { + if (cancelled || isWorkerSourceCancelledError(err)) { + newPlayer.close(); + createdPlayer = null; + return; + } + lastErr = err; + newPlayer.close(); + createdPlayer = null; + if (!cancelled) setPlayer(null); + } + } + + if (cancelled) return; + const message = + lastErr instanceof Error + ? lastErr.message + : typeof lastErr === 'string' + ? lastErr + : offlineIntl.formatMessage({ id: 'errors.loadFailed' }); + showOpenError(message); + onFatalErrorRef.current?.(lastErr instanceof Error ? lastErr : new Error(message)); + }; + + void run(); + + return () => { + cancelled = true; + createdPlayer?.close(); + setPlayer(null); + }; + // `activeGroupMembersKey` is not read directly but forces a rebuild when + // files are added to (or removed from) the currently active group. + }, [ + activeId, + activeGroupMembersKey, + clearOpenFeedback, + datasetsRef, + hasSource, + offlineIntl, + persistence, + requireSource, + setLastLoadError, + setLoadedGroupId, + showOpenError, + spaSampleLocatorParamRef, + urlState, + ]); + + return { player, sourceLoading }; +} diff --git a/src/features/viewer/useRecordingSourceActions.ts b/src/features/viewer/useRecordingSourceActions.ts new file mode 100644 index 0000000..9666950 --- /dev/null +++ b/src/features/viewer/useRecordingSourceActions.ts @@ -0,0 +1,553 @@ +import { useCallback, useState } from 'react'; +import type { SampleDataset } from '@/services/sampleDatasets'; +import { getArchiveUrl } from '@/services/sampleDatasets'; +import { extractRosFilesFromTarArchive } from '@/shared/utils/tarRosRecordings'; +import { + filterRosFilesFromFileList, + isRosRecordingFilename, + mergeDatasetLists, + normalizeRosViewSources, + type DatasetItem, +} from '@/shared/utils/datasetSources'; +import { collectRosFilesFromUserDirectoryChoice, walkDirectoryHandle } from '@/shared/utils/collectDirectoryRosFiles'; +import { + ensureReadPermission, + fingerprintRosFileSet, + getDatasetHistoryEntry, + tarFileFingerprint, + type DatasetHistoryStoredEntry, +} from '@/shared/utils/datasetHistory'; +import { + alignFileHandlesToRosFiles, + collectRosRecordingFilesFromDataTransfer, + collectRosRecordingFileHandlesFromDataTransfer, +} from '@/shared/utils/collectDragFileHandles'; +import { pickRosRecordingFiles } from '@/shared/utils/openRosRecordingFilePicker'; +import { resolveBrowserHttpUrl } from '@/shared/utils/resolveBrowserHttpUrl'; +import { pushSpaUrlParam, serializeSourceLocator } from '@/shared/utils/sourceLocator'; +import type { createRosViewIntl } from '@/shared/intl/createRosViewIntl'; +import { useSpaUrlBootstrap } from './useSpaUrlBootstrap'; +import { errorMessageFromUnknown } from './rosViewerUtils'; +import type { RosViewerProps } from './RosViewer.types'; + +export interface UseRecordingSourceActionsArgs { + urlState: 'spa' | 'off'; + appendFilesAsDatasets: (files: File[], focusFirstNew?: boolean, groupFiles?: File[], forceNewSession?: boolean) => void; + setExtraDatasets: React.Dispatch>; + setActiveId: React.Dispatch>; + setLoadedGroupId: (value: string | null) => void; + fileInputDomIdRef: React.RefObject; + tarInputDomIdRef: React.RefObject; + /** When set, successful player init writes `sample://…` to the address bar instead of the resolved archive URL. Shared with `usePlayerLifecycle`, which reads and clears it on success. */ + spaSampleLocatorParamRef: React.RefObject; + offlineIntl: ReturnType; + clearOpenFeedback: () => void; + showOpenError: (message: string) => void; + setLastLoadError: (value: string | null) => void; + setManualOpenHint: (value: string | null) => void; + recordHistoryEntry: (row: Omit) => Promise; +} + +function remotePathnameLower(url: string): string { + try { + return new URL(url).pathname.toLowerCase(); + } catch { + return url.split('?')[0].split('#')[0].toLowerCase(); + } +} + +/** + * Every "open a recording" entry point: drag-and-drop, directory/file + * pickers, remote URL / tar submission, sample selection, history replay, + * the SPA `?url=` bootstrap, and "go home". All funnel into + * `appendFilesAsDatasets` (from `useDatasetSession`) or the direct + * URL-dataset path, recording a history entry along the way. + */ +export function useRecordingSourceActions(props: RosViewerProps, args: UseRecordingSourceActionsArgs) { + const { + urlState, + appendFilesAsDatasets, + setExtraDatasets, + setActiveId, + setLoadedGroupId, + fileInputDomIdRef, + tarInputDomIdRef, + spaSampleLocatorParamRef, + offlineIntl, + clearOpenFeedback, + showOpenError, + setLastLoadError, + setManualOpenHint, + recordHistoryEntry, + } = args; + + const [remoteUrlBusy, setRemoteUrlBusy] = useState(false); + + const recordLocalRosFilesHistory = useCallback( + (files: File[], fileHandles?: FileSystemFileHandle[]) => { + if (files.length === 0) return; + const displayName = + files.length === 1 ? files[0].name : `${files[0].name} +${String(files.length - 1)}`; + const canReplayWithHandles = Array.isArray(fileHandles) && fileHandles.length === files.length; + const fileSetFingerprint = fingerprintRosFileSet(files); + void recordHistoryEntry({ + kind: canReplayWithHandles ? 'files' : 'file_meta', + displayName, + fileSetFingerprint, + ...(canReplayWithHandles ? { fileHandles } : {}), + detail: + files.length > 1 + ? offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: files.length }) + : undefined, + }); + }, + [offlineIntl, recordHistoryEntry], + ); + + const handleDropRosRecordingFiles = useCallback( + async (inputFiles: File[], dataTransferItems?: DataTransferItemList) => { + const dropped = await collectRosRecordingFilesFromDataTransfer(dataTransferItems); + const files = dropped?.files.length ? dropped.files : filterRosFilesFromFileList(inputFiles); + if (files.length === 0) return; + let fileHandles = dropped?.fileHandles; + if (!fileHandles && dataTransferItems && dataTransferItems.length > 0) { + const raw = await collectRosRecordingFileHandlesFromDataTransfer(dataTransferItems); + if (raw?.length) { + fileHandles = await alignFileHandlesToRosFiles(files, raw); + } + } + clearOpenFeedback(); + if (urlState === 'spa') { + spaSampleLocatorParamRef.current = null; + } + appendFilesAsDatasets(files, true, files); + if (dropped?.directoryHandle) { + void recordHistoryEntry({ + kind: 'directory', + displayName: dropped.directoryHandle.name, + directoryHandle: dropped.directoryHandle, + detail: offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: files.length }), + }); + if (urlState === 'spa') { + pushSpaUrlParam( + serializeSourceLocator({ kind: 'local_folder', displayName: dropped.directoryHandle.name }), + ); + } + } else { + recordLocalRosFilesHistory(files, fileHandles); + } + }, + [ + appendFilesAsDatasets, + clearOpenFeedback, + offlineIntl, + recordHistoryEntry, + recordLocalRosFilesHistory, + spaSampleLocatorParamRef, + urlState, + ], + ); + + const handleOpenDirectory = useCallback(async () => { + clearOpenFeedback(); + if (urlState === 'spa') { + spaSampleLocatorParamRef.current = null; + } + try { + const { files, directoryHandle } = await collectRosFilesFromUserDirectoryChoice(); + if (files.length === 0) { + return; + } + appendFilesAsDatasets(files, true, files); + if (directoryHandle) { + await recordHistoryEntry({ + kind: 'directory', + displayName: directoryHandle.name, + directoryHandle, + detail: offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: files.length }), + }); + if (urlState === 'spa') { + pushSpaUrlParam( + serializeSourceLocator({ kind: 'local_folder', displayName: directoryHandle.name }), + ); + } + } else { + await recordHistoryEntry({ + kind: 'directory_fallback', + displayName: offlineIntl.formatMessage({ id: 'welcome.historyFolderPicker' }), + detail: offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: files.length }), + }); + } + } catch (e) { + showOpenError(errorMessageFromUnknown(e, offlineIntl.formatMessage({ id: 'errors.loadFailed' }))); + } + }, [ + appendFilesAsDatasets, + clearOpenFeedback, + offlineIntl, + recordHistoryEntry, + showOpenError, + spaSampleLocatorParamRef, + urlState, + ]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + if (!Array.from(e.dataTransfer.types).includes('Files')) { + return; + } + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + if (!Array.from(e.dataTransfer.types).includes('Files')) { + return; + } + e.preventDefault(); + void handleDropRosRecordingFiles(Array.from(e.dataTransfer.files), e.dataTransfer.items); + }, + [handleDropRosRecordingFiles], + ); + + const onDatasetSelect = useCallback( + (id: string) => { + clearOpenFeedback(); + setLoadedGroupId(null); + if (urlState === 'spa') { + spaSampleLocatorParamRef.current = null; + } + setActiveId(id); + }, + [clearOpenFeedback, setActiveId, setLoadedGroupId, spaSampleLocatorParamRef, urlState], + ); + + const onAddFilesFromPicker = useCallback( + (fileList: FileList | null) => { + if (!fileList?.length) return; + clearOpenFeedback(); + if (urlState === 'spa') { + spaSampleLocatorParamRef.current = null; + } + const ros = filterRosFilesFromFileList(fileList); + appendFilesAsDatasets(ros, false, ros); + recordLocalRosFilesHistory(ros); + }, + [appendFilesAsDatasets, clearOpenFeedback, recordLocalRosFilesHistory, spaSampleLocatorParamRef, urlState], + ); + + const handleOpenRemoteRecordingUrl = useCallback( + async ( + rawUrl: string, + meta?: { + sample?: { id: string; title: string }; + }, + ) => { + if (urlState === 'spa') { + if (meta?.sample?.id?.trim()) { + spaSampleLocatorParamRef.current = serializeSourceLocator({ + kind: 'sample', + sampleId: meta.sample.id.trim(), + }); + } else { + spaSampleLocatorParamRef.current = null; + } + } + + const trimmed = rawUrl.trim(); + if (!trimmed) return; + const resolved = resolveBrowserHttpUrl(trimmed); + const pathLower = remotePathnameLower(resolved); + const isRemoteTar = + pathLower.endsWith('.tar') || pathLower.endsWith('.tar.gz') || pathLower.endsWith('.tgz'); + + setRemoteUrlBusy(true); + clearOpenFeedback(); + try { + if (isRemoteTar) { + const res = await fetch(resolved); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const buf = await res.arrayBuffer(); + const extracted = extractRosFilesFromTarArchive(buf); + if (extracted.length === 0) { + throw new Error(offlineIntl.formatMessage({ id: 'errors.noRecordingsInArchive' })); + } + appendFilesAsDatasets(extracted, true, undefined, true); + const tail = resolved.split('/').pop() || resolved; + await recordHistoryEntry({ + kind: meta?.sample ? 'sample' : 'remote_tar', + displayName: meta?.sample?.title ?? tail, + url: resolved, + sampleId: meta?.sample?.id, + detail: offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: extracted.length }), + }); + } else { + setExtraDatasets((prev) => mergeDatasetLists(prev, normalizeRosViewSources({ url: resolved }))); + setActiveId(`url:${resolved}`); + setLoadedGroupId(null); + const tail = resolved.split('/').pop() || resolved; + await recordHistoryEntry({ + kind: meta?.sample ? 'sample' : 'url', + displayName: meta?.sample?.title ?? tail, + url: resolved, + sampleId: meta?.sample?.id, + }); + } + } catch (e) { + if (urlState === 'spa') { + spaSampleLocatorParamRef.current = null; + } + showOpenError(errorMessageFromUnknown(e, offlineIntl.formatMessage({ id: 'errors.loadFailed' }))); + } finally { + setRemoteUrlBusy(false); + } + }, + [ + appendFilesAsDatasets, + clearOpenFeedback, + offlineIntl, + recordHistoryEntry, + setActiveId, + setExtraDatasets, + setLoadedGroupId, + showOpenError, + spaSampleLocatorParamRef, + urlState, + ], + ); + + const handleLocalTarFile = useCallback( + async (file: File) => { + clearOpenFeedback(); + if (urlState === 'spa') { + spaSampleLocatorParamRef.current = null; + } + try { + const extracted = extractRosFilesFromTarArchive(await file.arrayBuffer()); + if (extracted.length === 0) { + throw new Error(offlineIntl.formatMessage({ id: 'errors.noRecordingsInArchive' })); + } + appendFilesAsDatasets(extracted, true, undefined, true); + await recordHistoryEntry({ + kind: 'local_tar', + displayName: file.name, + tarFingerprint: tarFileFingerprint(file), + detail: offlineIntl.formatMessage({ id: 'welcome.historyFileCount' }, { count: extracted.length }), + }); + } catch (e) { + showOpenError(errorMessageFromUnknown(e, offlineIntl.formatMessage({ id: 'errors.loadFailed' }))); + } + }, + [ + appendFilesAsDatasets, + clearOpenFeedback, + offlineIntl, + recordHistoryEntry, + showOpenError, + spaSampleLocatorParamRef, + urlState, + ], + ); + + const handleSelectSample = useCallback( + async (sample: SampleDataset) => { + const u = getArchiveUrl(sample).trim(); + if (!u) return; + await handleOpenRemoteRecordingUrl(u, { + sample: { id: sample.id, title: sample.title || sample.name }, + }); + }, + [handleOpenRemoteRecordingUrl], + ); + + const handleOpenRecordingFiles = useCallback(async () => { + clearOpenFeedback(); + if (urlState === 'spa') { + spaSampleLocatorParamRef.current = null; + } + const picked = await pickRosRecordingFiles(); + if (picked === null) { + document.getElementById(fileInputDomIdRef.current)?.click(); + return; + } + if (picked.files.length === 0) { + return; + } + recordLocalRosFilesHistory(picked.files, picked.fileHandles); + appendFilesAsDatasets(picked.files, false, picked.files); + }, [ + appendFilesAsDatasets, + clearOpenFeedback, + fileInputDomIdRef, + recordLocalRosFilesHistory, + spaSampleLocatorParamRef, + urlState, + ]); + + const handleReplayHistory = useCallback( + async (id: string) => { + clearOpenFeedback(); + const entry = await getDatasetHistoryEntry(id); + if (!entry) return; + switch (entry.kind) { + case 'url': + case 'remote_tar': + case 'sample': { + const u = entry.url?.trim(); + if (!u) return; + if (entry.kind === 'sample' && entry.sampleId?.trim()) { + await handleOpenRemoteRecordingUrl(u, { + sample: { id: entry.sampleId.trim(), title: entry.displayName }, + }); + return; + } + await handleOpenRemoteRecordingUrl(u); + return; + } + case 'directory': { + if (urlState === 'spa') { + spaSampleLocatorParamRef.current = null; + } + const dh = entry.directoryHandle; + if (!dh) return; + const ok = await ensureReadPermission(dh); + if (!ok) { + showOpenError(offlineIntl.formatMessage({ id: 'welcome.historyPermissionDenied' })); + return; + } + try { + const out: File[] = []; + await walkDirectoryHandle(dh, 8, 0, out); + if (out.length === 0) { + showOpenError(offlineIntl.formatMessage({ id: 'welcome.historyEmptyDirectory' })); + return; + } + appendFilesAsDatasets(out, true, out, true); + } catch (e) { + showOpenError(errorMessageFromUnknown(e, offlineIntl.formatMessage({ id: 'errors.loadFailed' }))); + } + return; + } + case 'files': { + if (urlState === 'spa') { + spaSampleLocatorParamRef.current = null; + } + const handles = entry.fileHandles ?? []; + if (handles.length === 0) { + document.getElementById(fileInputDomIdRef.current)?.click(); + return; + } + for (const h of handles) { + const granted = await ensureReadPermission(h); + if (!granted) { + showOpenError(offlineIntl.formatMessage({ id: 'welcome.historyPermissionDenied' })); + return; + } + } + try { + const files = await Promise.all(handles.map((h) => h.getFile())); + const ros = files.filter((f) => isRosRecordingFilename(f.name)); + if (ros.length === 0) { + showOpenError(offlineIntl.formatMessage({ id: 'welcome.historyNoSupportedRecordings' })); + return; + } + appendFilesAsDatasets(ros, true, ros, true); + } catch (e) { + showOpenError(errorMessageFromUnknown(e, offlineIntl.formatMessage({ id: 'errors.loadFailed' }))); + } + return; + } + case 'directory_fallback': + void handleOpenDirectory(); + return; + case 'file_meta': + document.getElementById(fileInputDomIdRef.current)?.click(); + return; + case 'local_tar': + document.getElementById(tarInputDomIdRef.current)?.click(); + return; + default: + return; + } + }, + [ + appendFilesAsDatasets, + clearOpenFeedback, + fileInputDomIdRef, + handleOpenDirectory, + handleOpenRemoteRecordingUrl, + offlineIntl, + showOpenError, + spaSampleLocatorParamRef, + tarInputDomIdRef, + urlState, + ], + ); + + const { reset: resetSpaUrlBootstrap } = useSpaUrlBootstrap(urlState === 'spa', props.url, { + onManualOpenHint: setManualOpenHint, + onNoLocalHistoryMatch: (loc) => { + setLastLoadError(null); + setManualOpenHint( + offlineIntl.formatMessage( + { id: loc.kind === 'local_folder' ? 'welcome.manualOpenFolderHint' : 'welcome.manualOpenFileHint' }, + { name: loc.displayName }, + ), + ); + }, + onReplayHistoryRow: (rowId) => handleReplayHistory(rowId), + onSelectSample: (sample) => handleSelectSample(sample), + onSampleManifestNotConfigured: () => + showOpenError(offlineIntl.formatMessage({ id: 'welcome.sampleManifestNotConfigured' })), + onSampleNotFound: (sampleId) => + showOpenError(offlineIntl.formatMessage({ id: 'welcome.sampleIdNotFound' }, { id: sampleId })), + }); + + const openRemotePrompt = useCallback(() => { + const url = + typeof window !== 'undefined' ? window.prompt(offlineIntl.formatMessage({ id: 'viewer.remoteUrlPrompt' })) : null; + if (url?.trim()) void handleOpenRemoteRecordingUrl(url.trim()); + }, [handleOpenRemoteRecordingUrl, offlineIntl]); + + const onSpaUrlQuerySync = props.onSpaUrlQuerySync; + + const handleGoHome = useCallback(() => { + clearOpenFeedback(); + spaSampleLocatorParamRef.current = null; + resetSpaUrlBootstrap(); + setExtraDatasets([]); + setLoadedGroupId(null); + setRemoteUrlBusy(false); + if (urlState === 'spa') { + setActiveId(null); + pushSpaUrlParam(null); + onSpaUrlQuerySync?.(); + } + }, [ + clearOpenFeedback, + onSpaUrlQuerySync, + resetSpaUrlBootstrap, + setActiveId, + setExtraDatasets, + setLoadedGroupId, + spaSampleLocatorParamRef, + urlState, + ]); + + return { + remoteUrlBusy, + handleDropRosRecordingFiles, + handleOpenDirectory, + handleDragOver, + handleDrop, + onDatasetSelect, + onAddFilesFromPicker, + handleOpenRemoteRecordingUrl, + handleLocalTarFile, + handleSelectSample, + handleOpenRecordingFiles, + handleReplayHistory, + openRemotePrompt, + handleGoHome, + }; +} diff --git a/src/features/viewer/useSpaUrlBootstrap.test.tsx b/src/features/viewer/useSpaUrlBootstrap.test.tsx new file mode 100644 index 0000000..4543b17 --- /dev/null +++ b/src/features/viewer/useSpaUrlBootstrap.test.tsx @@ -0,0 +1,246 @@ +/** + * @vitest-environment happy-dom + * + * Regression coverage for the production bug this hook was extracted to + * fix: replaying SPA `?url=file://…` history used to be wired up as a + * direct Effect dependency, and the replay handler set state that the + * handler's own identity depended on — so every replay recreated the + * handler, which re-ran the bootstrap Effect, which replayed again, + * forever (flickering UI, hundreds of cancelled worker inits, tab crash). + * + * `useSpaUrlBootstrap` structurally prevents this by calling every handler + * through a React "Effect Event" (`useEffectEvent`), so its Effect only + * depends on `(active, url)`. These tests feed in handlers with a *worse* + * identity churn than production ever had (a brand-new closure on every + * render, referencing state that changes on every call) and assert the + * bootstrap still only fires once per URL. + */ +import { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSpaUrlBootstrap, type SpaUrlBootstrapHandlers } from './useSpaUrlBootstrap'; + +const { getLatestReplayableHistoryByLocalLocatorMock } = vi.hoisted(() => ({ + getLatestReplayableHistoryByLocalLocatorMock: vi.fn<() => Promise<{ id: string } | null>>(), +})); +vi.mock('@/shared/utils/datasetHistory', () => ({ + getLatestReplayableHistoryByLocalLocator: getLatestReplayableHistoryByLocalLocatorMock, +})); + +const { getSampleDatasetsManifestUrlMock, loadSampleDatasetsMock } = vi.hoisted(() => ({ + getSampleDatasetsManifestUrlMock: vi.fn<() => string | undefined>(), + loadSampleDatasetsMock: vi.fn<() => Promise>>(), +})); +vi.mock('@/services/sampleDatasets', () => ({ + getSampleDatasetsManifestUrl: getSampleDatasetsManifestUrlMock, + loadSampleDatasets: loadSampleDatasetsMock, +})); + +async function flushMicrotasks(times = 4): Promise { + for (let i = 0; i < times; i++) { + await act(async () => { + await Promise.resolve(); + }); + } +} + +function noopHandlers(): SpaUrlBootstrapHandlers { + return { + onManualOpenHint: () => {}, + onNoLocalHistoryMatch: () => {}, + onReplayHistoryRow: () => {}, + onSelectSample: () => {}, + onSampleManifestNotConfigured: () => {}, + onSampleNotFound: () => {}, + }; +} + +describe('useSpaUrlBootstrap', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + getLatestReplayableHistoryByLocalLocatorMock.mockReset(); + getSampleDatasetsManifestUrlMock.mockReset(); + loadSampleDatasetsMock.mockReset(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it( + 'regression: an unstable onReplayHistoryRow identity that mutates state does not ' + + 'self-trigger repeated replays', + async () => { + getLatestReplayableHistoryByLocalLocatorMock.mockResolvedValue({ id: 'row-1' }); + const replayCalls: string[] = []; + let renderCount = 0; + + function Probe() { + renderCount += 1; + // Mirrors the real bug's `activeId`: state that changes every time + // history is replayed. + const [activeId, setActiveId] = useState(null); + + // Deliberately a *fresh object every render* — worse than + // production, where only some callbacks were unstable. + const handlers: SpaUrlBootstrapHandlers = { + ...noopHandlers(), + onReplayHistoryRow: (rowId) => { + replayCalls.push(`${rowId}:${activeId ?? 'null'}`); + setActiveId(`group:${replayCalls.length}`); + }, + }; + + useSpaUrlBootstrap(true, 'file://episode_00044.mcap', handlers); + return null; + } + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + expect(getLatestReplayableHistoryByLocalLocatorMock).toHaveBeenCalledTimes(1); + expect(replayCalls).toEqual(['row-1:null']); + // A real self-triggering loop would drive this into the hundreds + // within a few microtask flushes. + expect(renderCount).toBeLessThan(5); + }, + ); + + it('does not re-bootstrap on unrelated re-renders of the same url', async () => { + getLatestReplayableHistoryByLocalLocatorMock.mockResolvedValue({ id: 'row-1' }); + + function Probe({ tick }: { tick: number }) { + const handlers = { ...noopHandlers() }; + useSpaUrlBootstrap(true, 'file://same.mcap', handlers); + return ; + } + + act(() => { + root.render(); + }); + await flushMicrotasks(); + expect(getLatestReplayableHistoryByLocalLocatorMock).toHaveBeenCalledTimes(1); + + // Re-render several times with the same url but a changing unrelated prop. + for (let tick = 1; tick <= 5; tick++) { + act(() => { + root.render(); + }); + } + await flushMicrotasks(); + + expect(getLatestReplayableHistoryByLocalLocatorMock).toHaveBeenCalledTimes(1); + }); + + it('bootstraps again when the url actually changes', async () => { + getLatestReplayableHistoryByLocalLocatorMock.mockResolvedValue({ id: 'row-1' }); + + function Probe({ url }: { url: string }) { + useSpaUrlBootstrap(true, url, noopHandlers()); + return null; + } + + act(() => { + root.render(); + }); + await flushMicrotasks(); + expect(getLatestReplayableHistoryByLocalLocatorMock).toHaveBeenCalledTimes(1); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + expect(getLatestReplayableHistoryByLocalLocatorMock).toHaveBeenCalledTimes(2); + }); + + it('calls onNoLocalHistoryMatch when no history row matches', async () => { + getLatestReplayableHistoryByLocalLocatorMock.mockResolvedValue(null); + const noMatchCalls: string[] = []; + + function Probe() { + const handlers: SpaUrlBootstrapHandlers = { + ...noopHandlers(), + onNoLocalHistoryMatch: (loc) => noMatchCalls.push(loc.displayName), + }; + useSpaUrlBootstrap(true, 'file://never-opened.mcap', handlers); + return null; + } + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + expect(noMatchCalls).toEqual(['never-opened.mcap']); + }); + + it('resolves sample:// locators against the manifest and reports missing ids', async () => { + getSampleDatasetsManifestUrlMock.mockReturnValue('https://example.com/samples.json'); + loadSampleDatasetsMock.mockResolvedValue([{ id: 'known', name: 'Known', title: 'Known' }]); + const notFound: string[] = []; + const selected: string[] = []; + + function Probe({ sampleId }: { sampleId: string }) { + const handlers: SpaUrlBootstrapHandlers = { + ...noopHandlers(), + onSelectSample: (sample) => selected.push(sample.id), + onSampleNotFound: (id) => notFound.push(id), + }; + useSpaUrlBootstrap(true, `sample://${sampleId}`, handlers); + return null; + } + + act(() => { + root.render(); + }); + await flushMicrotasks(); + expect(notFound).toEqual(['missing']); + expect(selected).toEqual([]); + }); + + it( + 'reset() allows re-bootstrapping the same url again after it round-trips through empty ' + + '(e.g. "go home" then reopening the same recording)', + async () => { + getLatestReplayableHistoryByLocalLocatorMock.mockResolvedValue({ id: 'row-1' }); + let resetFn: (() => void) | undefined; + + function Probe({ url }: { url: string | undefined }) { + const { reset } = useSpaUrlBootstrap(true, url, noopHandlers()); + resetFn = reset; + return null; + } + + act(() => { + root.render(); + }); + await flushMicrotasks(); + expect(getLatestReplayableHistoryByLocalLocatorMock).toHaveBeenCalledTimes(1); + + // "Go home": clears the url and resets the bootstrap guard, mirroring + // `handleGoHome` (`resetSpaUrlBootstrap()` + `pushSpaUrlParam(null)`). + act(() => { + resetFn?.(); + root.render(); + }); + await flushMicrotasks(); + expect(getLatestReplayableHistoryByLocalLocatorMock).toHaveBeenCalledTimes(1); + + // Reopening the exact same url again must bootstrap fresh, not be + // silently swallowed by the "already bootstrapped this url" guard. + act(() => { + root.render(); + }); + await flushMicrotasks(); + expect(getLatestReplayableHistoryByLocalLocatorMock).toHaveBeenCalledTimes(2); + }, + ); +}); diff --git a/src/features/viewer/useSpaUrlBootstrap.ts b/src/features/viewer/useSpaUrlBootstrap.ts new file mode 100644 index 0000000..4b53bae --- /dev/null +++ b/src/features/viewer/useSpaUrlBootstrap.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useEffectEvent, useRef } from 'react'; +import { + getLatestReplayableHistoryByLocalLocator, + type SpaLocalHistoryLocator, +} from '@/shared/utils/datasetHistory'; +import { getSampleDatasetsManifestUrl, loadSampleDatasets } from '@/services/sampleDatasets'; +import type { SampleDataset } from '@/services/sampleDatasets'; +import { parseSourceLocator, type SourceLocator } from '@/shared/utils/sourceLocator'; + +export type SpaUrlBootstrapHandlers = { + /** Called with a hint (or `null` to clear it) any time bootstrap starts or needs manual follow-up. */ + onManualOpenHint: (hint: string | null) => void; + /** No remembered local file/folder matches this locator; caller shows a "please pick it manually" hint. */ + onNoLocalHistoryMatch: (loc: SpaLocalHistoryLocator) => void; + /** A remembered local file/folder history row matched; caller replays it (e.g. re-requests FS permission). */ + onReplayHistoryRow: (rowId: string) => void | Promise; + /** `sample://` resolved to a real sample dataset; caller opens it. */ + onSelectSample: (sample: SampleDataset) => void | Promise; + /** The embedder has no sample manifest configured. */ + onSampleManifestNotConfigured: () => void; + /** `sample://` doesn't match any entry in the manifest. */ + onSampleNotFound: (sampleId: string) => void; +}; + +/** + * Drives the SPA `?url=` bootstrap: resolves a `file://` / `folder://` / + * `sample://` locator into either a sample dataset or a remembered local + * file/folder history entry, then hands off to the matching `handlers` + * callback. + * + * ## Why this is its own hook, and why it uses `useEffectEvent` + * + * Every `handlers` callback is invoked through a React "Effect Event" + * (`useEffectEvent`) instead of being listed as an Effect dependency. An + * Effect Event's returned function always calls the *latest* version of the + * callback passed to it (like reading a ref) while its own identity never + * changes, so the bootstrap Effect below only re-runs for a genuine + * `(active, url)` change — never merely because a `handlers` callback was + * recreated. `react-hooks/exhaustive-deps` actively errors if the Effect + * Event result is ever added back to that Effect's dependency array, so + * this guarantee is enforced at lint time, not just by convention. + * + * This matters because of a real production bug: the previous inline + * version of this logic listed its `onReplayHistoryRow` equivalent + * (`handleReplayHistory`) as an Effect dependency. That handler set + * `activeId` state, and its own identity depended on `activeId` — so + * replaying history changed `activeId`, which changed the handler's + * identity, which re-ran the bootstrap Effect, which replayed history + * again, forever. Symptoms were a flickering UI (sidebar mounting/ + * unmounting), hundreds of `WorkerSourceCancelledError`s as the player was + * torn down and rebuilt on every iteration, and eventual tab crashes from + * resource exhaustion. See `useSpaUrlBootstrap.test.tsx` for a regression + * test that pins this down: it feeds in handlers whose identity changes on + * every render (the worst case) and asserts the bootstrap still only runs + * once per URL. + */ +export function useSpaUrlBootstrap( + active: boolean, + url: string | undefined, + handlers: SpaUrlBootstrapHandlers, +): { reset: () => void } { + const genRef = useRef(0); + const lastBootstrappedUrlRef = useRef(null); + + const run = useEffectEvent(async (loc: Exclude, gen: number) => { + handlers.onManualOpenHint(null); + if (loc.kind === 'sample') { + if (!getSampleDatasetsManifestUrl()) { + handlers.onSampleManifestNotConfigured(); + return; + } + const samples = await loadSampleDatasets(); + if (genRef.current !== gen) return; + const found = samples.find((s) => s.id === loc.sampleId); + if (!found) { + handlers.onSampleNotFound(loc.sampleId); + return; + } + await handlers.onSelectSample(found); + return; + } + + const row = await getLatestReplayableHistoryByLocalLocator(loc); + if (genRef.current !== gen) return; + if (!row) { + handlers.onNoLocalHistoryMatch(loc); + return; + } + await handlers.onReplayHistoryRow(row.id); + }); + + useEffect(() => { + if (!active) return; + const raw = url?.trim(); + if (!raw) return; + const loc = parseSourceLocator(raw); + if (!loc || loc.kind === 'remote') return; + if (lastBootstrappedUrlRef.current === raw) return; + lastBootstrappedUrlRef.current = raw; + + const gen = ++genRef.current; + void run(loc, gen); + + return () => { + genRef.current += 1; + }; + }, [active, url]); + + const reset = useCallback(() => { + genRef.current += 1; + lastBootstrappedUrlRef.current = null; + }, []); + + return { reset }; +} diff --git a/src/features/viewer/useThemeLanguagePreferences.ts b/src/features/viewer/useThemeLanguagePreferences.ts new file mode 100644 index 0000000..56124e3 --- /dev/null +++ b/src/features/viewer/useThemeLanguagePreferences.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { createRosViewIntl } from '@/shared/intl/createRosViewIntl'; +import { writePreferences } from '@/core/preferences/readWritePreferences'; +import type { PreferencePersistence } from '@/core/preferences/types'; +import type { RosViewerProps } from './RosViewer.types'; +import { initialUiFromProps } from './rosViewerUtils'; + +export type Theme = 'light' | 'dark' | 'system'; +export type Language = 'en' | 'zh' | 'ja'; + +/** + * Owns `theme`/`language` state: initial value from props/URL/localStorage + * (`initialUiFromProps`), syncs from controlled `props.theme`/`props.language` + * updates, persists user changes when `persistence === 'localStorage'`, and + * derives the `offlineIntl` formatter used for all user-facing strings. + */ +export function useThemeLanguagePreferences(props: RosViewerProps, persistence: PreferencePersistence) { + const [currentTheme, setCurrentTheme] = useState(() => initialUiFromProps(props).theme); + const [currentLanguage, setCurrentLanguage] = useState(() => initialUiFromProps(props).language); + + const offlineIntl = useMemo(() => createRosViewIntl(currentLanguage), [currentLanguage]); + + const onThemeChangeProp = props.onThemeChange; + const onLanguageChangeProp = props.onLanguageChange; + + const handleThemeChange = useCallback( + (theme: Theme) => { + setCurrentTheme(theme); + if (persistence === 'localStorage' && (theme === 'light' || theme === 'dark')) { + writePreferences({ theme }); + } + onThemeChangeProp?.(theme); + }, + [persistence, onThemeChangeProp], + ); + + const handleLanguageChange = useCallback( + (language: Language) => { + setCurrentLanguage(language); + if (persistence === 'localStorage') { + writePreferences({ language }); + } + onLanguageChangeProp?.(language); + }, + [persistence, onLanguageChangeProp], + ); + + useEffect(() => { + if (props.theme != null) setCurrentTheme(props.theme); + }, [props.theme]); + + useEffect(() => { + if (props.language != null) setCurrentLanguage(props.language); + }, [props.language]); + + return { currentTheme, currentLanguage, offlineIntl, handleThemeChange, handleLanguageChange }; +} diff --git a/src/shared/utils/datasetSources.test.ts b/src/shared/utils/datasetSources.test.ts index c4ab3b1..237e428 100644 --- a/src/shared/utils/datasetSources.test.ts +++ b/src/shared/utils/datasetSources.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest'; import { createDatasetGroupId, datasetGroupKey, + dedupeDatasetItems, + fileDatasetId, groupDatasets, + mergeDatasetLists, normalizeRosViewSources, + resolveActiveId, + resolveAppendGroupId, type DatasetItem, } from './datasetSources'; @@ -53,6 +58,159 @@ describe('createDatasetGroupId', () => { }); }); +describe('resolveAppendGroupId', () => { + it('uses the fallback id when none of the files are already loaded', () => { + const file = makeFile('a.mcap'); + const groupId = resolveAppendGroupId([], [file], 'fallback'); + expect(groupId).toBe('fallback'); + }); + + it('reuses the existing group id when a file is already loaded under a different group', () => { + const file = makeFile('a.mcap'); + const existing = [makeDataset(fileDatasetId(file), { groupId: 'group:existing' })]; + const groupId = resolveAppendGroupId(existing, [file], 'fallback-fresh-id'); + expect(groupId).toBe('group:existing'); + }); + + it('falls back when the existing item has no groupId of its own (standalone dataset)', () => { + const file = makeFile('a.mcap'); + const existing = [makeDataset(fileDatasetId(file))]; + const groupId = resolveAppendGroupId(existing, [file], 'fallback'); + expect(groupId).toBe('fallback'); + }); + + it('matches on the first file in the batch that is already known', () => { + const known = makeFile('known.mcap'); + const fresh = makeFile('fresh.mcap'); + const existing = [makeDataset(fileDatasetId(known), { groupId: 'group:existing' })]; + expect(resolveAppendGroupId(existing, [fresh, known], 'fallback')).toBe('group:existing'); + }); + + it('regression: reopening an already-loaded file with a forced new-session id lands on a real group', () => { + // Reproduces the bug: replaying a file via remembered history (or + // re-extracting a tar) always mints a brand-new group id, ignoring + // whether the file is already loaded under a different one. + const file = makeFile('episode_00044.mcap'); + const originalGroupId = 'group:original'; + const prev: DatasetItem[] = [ + { id: fileDatasetId(file), kind: 'file', name: file.name, file, groupId: originalGroupId }, + ]; + + const forcedFreshGroupId = createDatasetGroupId(); + const resolvedGroupId = resolveAppendGroupId(prev, [file], forcedFreshGroupId); + const items = dedupeDatasetItems([ + { id: fileDatasetId(file), kind: 'file', name: file.name, file, groupId: resolvedGroupId }, + ]); + const merged = mergeDatasetLists(prev, items); + + // The id we'd activate must correspond to a real, live group — not an + // orphan that `mergeDatasetLists`'s dedup silently discarded. + expect(resolvedGroupId).toBe(originalGroupId); + expect(groupDatasets(merged).some((g) => g.groupId === resolvedGroupId)).toBe(true); + }); +}); + +describe('resolveActiveId', () => { + it('returns null when there are no datasets', () => { + expect(resolveActiveId([], 'anything')).toBeNull(); + expect(resolveActiveId([], null)).toBeNull(); + }); + + it('keeps the current id when it still names a real group', () => { + const datasets = [makeDataset('a', { groupId: 'g1' }), makeDataset('b')]; + expect(resolveActiveId(datasets, 'g1')).toBe('g1'); + }); + + it('falls back to the first dataset group when current is null or stale', () => { + const datasets = [makeDataset('a', { groupId: 'g1' }), makeDataset('b')]; + expect(resolveActiveId(datasets, null)).toBe('g1'); + expect(resolveActiveId(datasets, 'group:gone')).toBe('g1'); + }); +}); + +describe('resolveAppendGroupId <-> resolveActiveId agreement (regression)', () => { + // These two functions are called from different places (appending files, + // and an Effect that keeps `activeId` valid whenever the dataset list + // changes) but must always agree on which group is "real" — otherwise + // `activeId` bounces between them on every render. That bounce is exactly + // what caused the production bug: each bounce tore down and recreated the + // player/worker, producing a flickering UI and hundreds of + // `WorkerSourceCancelledError`s until the tab crashed. + function simulateAppend( + existing: DatasetItem[], + files: File[], + forceNewSession: boolean, + currentActiveId: string | null, + ) { + const groupId = forceNewSession + ? resolveAppendGroupId(existing, files, createDatasetGroupId()) + : (currentActiveId ?? createDatasetGroupId()); + const items = dedupeDatasetItems( + files.map((f) => ({ id: fileDatasetId(f), kind: 'file' as const, name: f.name, file: f, groupId })), + ); + const datasets = mergeDatasetLists(existing, items); + return { datasets, activatedId: groupId }; + } + + it('a forced-new-session append of an already-loaded file never bounces', () => { + const file = makeFile('episode_00044.mcap'); + const originalGroupId = 'group:original'; + const existing: DatasetItem[] = [ + { id: fileDatasetId(file), kind: 'file', name: file.name, file, groupId: originalGroupId }, + ]; + + const { datasets, activatedId } = simulateAppend(existing, [file], true, null); + const correctedId = resolveActiveId(datasets, activatedId); + + expect(activatedId).toBe(originalGroupId); + expect(correctedId).toBe(activatedId); + }); + + it('appending a genuinely new file never bounces either', () => { + const file = makeFile('brand-new.mcap'); + const { datasets, activatedId } = simulateAppend([], [file], true, null); + const correctedId = resolveActiveId(datasets, activatedId); + expect(correctedId).toBe(activatedId); + }); + + it('repeated replays of the same already-loaded file converge immediately, not after N renders', () => { + const file = makeFile('episode_00044.mcap'); + let existing: DatasetItem[] = []; + let activeId: string | null = null; + + // First "open": genuinely new, becomes its own group. + ({ datasets: existing, activatedId: activeId } = simulateAppend(existing, [file], true, activeId)); + activeId = resolveActiveId(existing, activeId); + const firstGroupId = activeId; + + // Replaying the same file via history N more times (forceNewSession + // true every time) must keep resolving back to the same real group + // instead of drifting to a new orphan id on each pass. + for (let i = 0; i < 5; i++) { + const appended = simulateAppend(existing, [file], true, activeId); + existing = appended.datasets; + activeId = resolveActiveId(existing, appended.activatedId); + expect(activeId).toBe(firstGroupId); + } + }); +}); + +describe('fileDatasetId', () => { + it('is stable for the same name/size/lastModified', () => { + const a = makeFile('a.mcap', 10); + const b = makeFile('a.mcap', 10); + expect(fileDatasetId(a)).toBe(fileDatasetId(b)); + }); + + it('differs when name, size, or lastModified differ', () => { + const a = makeFile('a.mcap', 10); + const b = makeFile('b.mcap', 10); + const c = makeFile('a.mcap', 20); + expect(fileDatasetId(a)).not.toBe(fileDatasetId(b)); + expect(fileDatasetId(a)).not.toBe(fileDatasetId(c)); + }); +}); + describe('normalizeRosViewSources with mergeSources', () => { it('does not assign a groupId by default (existing switcher behavior unchanged)', () => { const items = normalizeRosViewSources({ files: [makeFile('a.mcap'), makeFile('b.mcap')] }); diff --git a/src/shared/utils/datasetSources.ts b/src/shared/utils/datasetSources.ts index d4d3a59..869bf90 100644 --- a/src/shared/utils/datasetSources.ts +++ b/src/shared/utils/datasetSources.ts @@ -31,6 +31,24 @@ export function datasetGroupKey(item: DatasetItem): string { return item.groupId ?? item.id; } +/** + * Picks the group id that should be "active" given the current dataset + * list: keeps `current` if it still names a real, live group, otherwise + * falls back to the first dataset's group (or `null` if there are none). + * + * Kept as a pure function (rather than inlined in a `setState` updater) so + * it can be tested directly against `resolveAppendGroupId`'s output — the + * two must always agree on what the "real" group id is, or `activeId` ends + * up oscillating between them on every render (see the regression test + * `resolveAppendGroupId ↔ resolveActiveId agreement` in + * `datasetSources.test.ts`). + */ +export function resolveActiveId(datasets: DatasetItem[], current: string | null): string | null { + if (datasets.length === 0) return null; + if (current && datasets.some((d) => datasetGroupKey(d) === current)) return current; + return datasetGroupKey(datasets[0]); +} + export interface DatasetGroup { groupId: string; members: DatasetItem[]; @@ -61,6 +79,31 @@ export function createDatasetGroupId(): string { return `group:${Date.now().toString(36)}:${groupIdCounter}`; } +/** + * Resolves the group id a batch of files should be tagged with when + * appending them as datasets under a forced-new-session id (history replay, + * tar extraction). If any of the files are already present in `existing` + * (matched by dataset id, regardless of which group they currently belong + * to), reuses that item's group id instead of `fallbackGroupId`. + * + * This matters because `dedupeDatasetItems`/`mergeDatasetLists` dedupe by + * `id` and keep the *first-seen* entry: a freshly minted `fallbackGroupId` + * would be silently discarded in favor of the already-stored item (which + * keeps its original group id), leaving the caller's `activeId` pointed at + * an "orphan" group that doesn't actually exist in the merged dataset list. + */ +export function resolveAppendGroupId( + existing: DatasetItem[], + files: File[], + fallbackGroupId: string, +): string { + const existingById = new Map(existing.map((d) => [d.id, d])); + const existingGroupId = files + .map((f) => existingById.get(fileDatasetId(f))?.groupId) + .find((id): id is string => id != null); + return existingGroupId ?? fallbackGroupId; +} + /** One row from host `fileManifest` or remote dataset JSON. */ export type FileListItem = { url: string; @@ -80,9 +123,14 @@ function fileKey(file: File): string { return `${file.name}:${file.size}:${file.lastModified}`; } +/** Stable dataset id for a `File`, matching how `makeFileDataset` ids items. */ +export function fileDatasetId(file: File): string { + return `file:${fileKey(file)}`; +} + function makeFileDataset(file: File): DatasetItem { return { - id: `file:${fileKey(file)}`, + id: fileDatasetId(file), kind: 'file', name: file.name, file, From e08d60452401900b60e18be523400809d7bcba0e Mon Sep 17 00:00:00 2001 From: puxiaokang <1726541+joaner@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:48:58 +0800 Subject: [PATCH 2/4] feat(viewer): let users switch a merge to a replace after the fact Opening more files while a session is active still merges them in immediately and non-blocking, but now shows a dismissible toast ("Merged X into the current session") with a "Switch to replace instead" action across every add-file entry point (drag-and-drop, Navbar Open File/Open Directory, the hidden file input). Clicking it detaches the just-added files into their own fresh session and switches to it; the original session is left intact and reachable from the sidebar Data tab rather than being destroyed. - appendFilesAsDatasets (useDatasetSession.ts) now returns { groupId, merged, addedItemIds } instead of void, without touching how it reads activeId/datasets (still via refs, so its identity stays stable) or its own dependency array. - notifyMergeWithUndo (useRecordingSourceActions.ts) reuses the already-available offlineIntl/setExtraDatasets/setActiveId to show the toast and perform the undo; wired into the four non-forced-new- session entry points. Tar extraction and history replay are unambiguous "open a new session" actions already and are untouched. - Extracted fileBatchDisplayName (rosViewerUtils.ts) for the "first +N" label shared by history recording and the new toast. --- src/features/viewer/rosViewerUtils.test.ts | 23 +++++ src/features/viewer/rosViewerUtils.ts | 6 ++ src/features/viewer/useDatasetSession.ts | 13 ++- .../viewer/useRecordingSourceActions.ts | 88 +++++++++++++++++-- src/shared/intl/messages/en/common.json | 2 + src/shared/intl/messages/ja/common.json | 2 + src/shared/intl/messages/zh/common.json | 2 + tests/multi-sources.spec.ts | 44 ++++++++++ 8 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 src/features/viewer/rosViewerUtils.test.ts diff --git a/src/features/viewer/rosViewerUtils.test.ts b/src/features/viewer/rosViewerUtils.test.ts new file mode 100644 index 0000000..4a63c08 --- /dev/null +++ b/src/features/viewer/rosViewerUtils.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { fileBatchDisplayName } from './rosViewerUtils'; + +function makeFile(name: string): File { + return new File([new Uint8Array(4)], name, { lastModified: 1 }); +} + +describe('fileBatchDisplayName', () => { + it('returns an empty string for an empty batch', () => { + expect(fileBatchDisplayName([])).toBe(''); + }); + + it('returns the file name for a single file', () => { + expect(fileBatchDisplayName([makeFile('base.mcap')])).toBe('base.mcap'); + }); + + it('appends a "+N" suffix for multiple files, based on the first one', () => { + expect(fileBatchDisplayName([makeFile('base.mcap'), makeFile('incremental.mcap')])).toBe('base.mcap +1'); + expect( + fileBatchDisplayName([makeFile('a.mcap'), makeFile('b.mcap'), makeFile('c.bag')]), + ).toBe('a.mcap +2'); + }); +}); diff --git a/src/features/viewer/rosViewerUtils.ts b/src/features/viewer/rosViewerUtils.ts index a186a1b..a0ddcb3 100644 --- a/src/features/viewer/rosViewerUtils.ts +++ b/src/features/viewer/rosViewerUtils.ts @@ -63,6 +63,12 @@ export function resolveLayoutPersistence( return layoutPersistence; } +/** "first.mcap" or "first.mcap +N" for a batch of files, used in history/toast labels. */ +export function fileBatchDisplayName(files: File[]): string { + if (files.length === 0) return ''; + return files.length === 1 ? files[0].name : `${files[0].name} +${String(files.length - 1)}`; +} + export function datasetItemToSourceLocator(ds: DatasetItem): SourceLocator | null { if (ds.kind === 'url' && ds.url) { const resolvedUrl = resolveBrowserHttpUrl(ds.url); diff --git a/src/features/viewer/useDatasetSession.ts b/src/features/viewer/useDatasetSession.ts index e727875..41402c2 100644 --- a/src/features/viewer/useDatasetSession.ts +++ b/src/features/viewer/useDatasetSession.ts @@ -17,6 +17,14 @@ import { resolveBrowserHttpUrl } from '@/shared/utils/resolveBrowserHttpUrl'; import { isCustomLocalLocatorString } from '@/shared/utils/sourceLocator'; import type { RosViewerProps } from './RosViewer.types'; +export interface AppendFilesResult { + groupId: string; + /** True when this batch was folded into an already-active group (as opposed to starting a fresh one), and at least one file was genuinely new. */ + merged: boolean; + /** Dataset ids newly added by this call (excludes files that were already present), for the "switch to replace" undo action. */ + addedItemIds: string[]; +} + /** * Owns the dataset/session state machine: the flat list of loaded * datasets (from props + files/URLs/tar/history opened at runtime), which @@ -146,7 +154,7 @@ export function useDatasetSession( }, [hasSource]); const appendFilesAsDatasets = useCallback( - (files: File[], focusFirstNew = true, groupFiles?: File[], forceNewSession = false) => { + (files: File[], focusFirstNew = true, groupFiles?: File[], forceNewSession = false): AppendFilesResult => { const siblingFiles = groupFiles && groupFiles.length > 1 ? [...groupFiles] : undefined; const activeId = activeIdRef.current; const currentDatasets = datasetsRef.current; @@ -178,6 +186,8 @@ export function useDatasetSession( ...(siblingFiles ? { siblingFiles } : {}), })), ); + const existingIds = new Set(currentDatasets.map((d) => d.id)); + const addedItemIds = items.filter((i) => !existingIds.has(i.id)).map((i) => i.id); setExtraDatasets((prev) => mergeDatasetLists(prev, items)); if (items.length > 0 && focusFirstNew) { setActiveId(groupId); @@ -187,6 +197,7 @@ export function useDatasetSession( setLoadedGroupId(null); clearOpenFeedback(); } + return { groupId, merged: hasActiveGroup && addedItemIds.length > 0, addedItemIds }; }, [clearOpenFeedback], ); diff --git a/src/features/viewer/useRecordingSourceActions.ts b/src/features/viewer/useRecordingSourceActions.ts index 9666950..5269053 100644 --- a/src/features/viewer/useRecordingSourceActions.ts +++ b/src/features/viewer/useRecordingSourceActions.ts @@ -1,8 +1,10 @@ import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; import type { SampleDataset } from '@/services/sampleDatasets'; import { getArchiveUrl } from '@/services/sampleDatasets'; import { extractRosFilesFromTarArchive } from '@/shared/utils/tarRosRecordings'; import { + createDatasetGroupId, filterRosFilesFromFileList, isRosRecordingFilename, mergeDatasetLists, @@ -27,12 +29,18 @@ import { resolveBrowserHttpUrl } from '@/shared/utils/resolveBrowserHttpUrl'; import { pushSpaUrlParam, serializeSourceLocator } from '@/shared/utils/sourceLocator'; import type { createRosViewIntl } from '@/shared/intl/createRosViewIntl'; import { useSpaUrlBootstrap } from './useSpaUrlBootstrap'; -import { errorMessageFromUnknown } from './rosViewerUtils'; +import { errorMessageFromUnknown, fileBatchDisplayName } from './rosViewerUtils'; +import type { AppendFilesResult } from './useDatasetSession'; import type { RosViewerProps } from './RosViewer.types'; export interface UseRecordingSourceActionsArgs { urlState: 'spa' | 'off'; - appendFilesAsDatasets: (files: File[], focusFirstNew?: boolean, groupFiles?: File[], forceNewSession?: boolean) => void; + appendFilesAsDatasets: ( + files: File[], + focusFirstNew?: boolean, + groupFiles?: File[], + forceNewSession?: boolean, + ) => AppendFilesResult; setExtraDatasets: React.Dispatch>; setActiveId: React.Dispatch>; setLoadedGroupId: (value: string | null) => void; @@ -56,6 +64,37 @@ function remotePathnameLower(url: string): string { } } +/** + * Merging new files into the active session is silent/non-blocking by + * default (see `appendFilesAsDatasets`), but the user has no way to say "no, + * open these as their own session instead" once it's happened. This surfaces + * a lightweight, dismissible toast with a one-click "switch to replace" + * action: it detaches the files just added from the current group into a + * fresh one and switches to it. The previous session is left untouched (not + * closed), so it stays reachable from the sidebar Data tab. + */ +function notifyMergeWithUndo( + result: AppendFilesResult, + displayName: string, + offlineIntl: ReturnType, + setExtraDatasets: React.Dispatch>, + setActiveId: React.Dispatch>, +): void { + if (!result.merged || result.addedItemIds.length === 0) return; + const addedIds = new Set(result.addedItemIds); + toast(offlineIntl.formatMessage({ id: 'viewer.mergeToast.message' }, { name: displayName }), { + duration: 8000, + action: { + label: offlineIntl.formatMessage({ id: 'viewer.mergeToast.action' }), + onClick: () => { + const replaceGroupId = createDatasetGroupId(); + setExtraDatasets((prev) => prev.map((d) => (addedIds.has(d.id) ? { ...d, groupId: replaceGroupId } : d))); + setActiveId(replaceGroupId); + }, + }, + }); +} + /** * Every "open a recording" entry point: drag-and-drop, directory/file * pickers, remote URL / tar submission, sample selection, history replay, @@ -86,8 +125,7 @@ export function useRecordingSourceActions(props: RosViewerProps, args: UseRecord const recordLocalRosFilesHistory = useCallback( (files: File[], fileHandles?: FileSystemFileHandle[]) => { if (files.length === 0) return; - const displayName = - files.length === 1 ? files[0].name : `${files[0].name} +${String(files.length - 1)}`; + const displayName = fileBatchDisplayName(files); const canReplayWithHandles = Array.isArray(fileHandles) && fileHandles.length === files.length; const fileSetFingerprint = fingerprintRosFileSet(files); void recordHistoryEntry({ @@ -120,7 +158,8 @@ export function useRecordingSourceActions(props: RosViewerProps, args: UseRecord if (urlState === 'spa') { spaSampleLocatorParamRef.current = null; } - appendFilesAsDatasets(files, true, files); + const appendResult = appendFilesAsDatasets(files, true, files); + notifyMergeWithUndo(appendResult, fileBatchDisplayName(files), offlineIntl, setExtraDatasets, setActiveId); if (dropped?.directoryHandle) { void recordHistoryEntry({ kind: 'directory', @@ -143,6 +182,8 @@ export function useRecordingSourceActions(props: RosViewerProps, args: UseRecord offlineIntl, recordHistoryEntry, recordLocalRosFilesHistory, + setActiveId, + setExtraDatasets, spaSampleLocatorParamRef, urlState, ], @@ -158,7 +199,14 @@ export function useRecordingSourceActions(props: RosViewerProps, args: UseRecord if (files.length === 0) { return; } - appendFilesAsDatasets(files, true, files); + const appendResult = appendFilesAsDatasets(files, true, files); + notifyMergeWithUndo( + appendResult, + directoryHandle?.name ?? fileBatchDisplayName(files), + offlineIntl, + setExtraDatasets, + setActiveId, + ); if (directoryHandle) { await recordHistoryEntry({ kind: 'directory', @@ -186,6 +234,8 @@ export function useRecordingSourceActions(props: RosViewerProps, args: UseRecord clearOpenFeedback, offlineIntl, recordHistoryEntry, + setActiveId, + setExtraDatasets, showOpenError, spaSampleLocatorParamRef, urlState, @@ -230,10 +280,20 @@ export function useRecordingSourceActions(props: RosViewerProps, args: UseRecord spaSampleLocatorParamRef.current = null; } const ros = filterRosFilesFromFileList(fileList); - appendFilesAsDatasets(ros, false, ros); + const appendResult = appendFilesAsDatasets(ros, false, ros); + notifyMergeWithUndo(appendResult, fileBatchDisplayName(ros), offlineIntl, setExtraDatasets, setActiveId); recordLocalRosFilesHistory(ros); }, - [appendFilesAsDatasets, clearOpenFeedback, recordLocalRosFilesHistory, spaSampleLocatorParamRef, urlState], + [ + appendFilesAsDatasets, + clearOpenFeedback, + offlineIntl, + recordLocalRosFilesHistory, + setActiveId, + setExtraDatasets, + spaSampleLocatorParamRef, + urlState, + ], ); const handleOpenRemoteRecordingUrl = useCallback( @@ -374,12 +434,22 @@ export function useRecordingSourceActions(props: RosViewerProps, args: UseRecord return; } recordLocalRosFilesHistory(picked.files, picked.fileHandles); - appendFilesAsDatasets(picked.files, false, picked.files); + const appendResult = appendFilesAsDatasets(picked.files, false, picked.files); + notifyMergeWithUndo( + appendResult, + fileBatchDisplayName(picked.files), + offlineIntl, + setExtraDatasets, + setActiveId, + ); }, [ appendFilesAsDatasets, clearOpenFeedback, fileInputDomIdRef, + offlineIntl, recordLocalRosFilesHistory, + setActiveId, + setExtraDatasets, spaSampleLocatorParamRef, urlState, ]); diff --git a/src/shared/intl/messages/en/common.json b/src/shared/intl/messages/en/common.json index e34e125..6ecdb0f 100644 --- a/src/shared/intl/messages/en/common.json +++ b/src/shared/intl/messages/en/common.json @@ -8,5 +8,7 @@ "errors.noRecordingsInArchive": "No supported recordings found in archive", "viewer.resizeSidebar": "Resize sidebar", "viewer.remoteUrlPrompt": "Enter remote recording or TAR URL (https://…)", + "viewer.mergeToast.message": "Merged {name} into the current session", + "viewer.mergeToast.action": "Switch to replace instead", "common.dialogClose": "Close" } diff --git a/src/shared/intl/messages/ja/common.json b/src/shared/intl/messages/ja/common.json index fbc6443..be1c075 100644 --- a/src/shared/intl/messages/ja/common.json +++ b/src/shared/intl/messages/ja/common.json @@ -8,5 +8,7 @@ "errors.noRecordingsInArchive": "アーカイブ内に対応する録画が見つかりません", "viewer.resizeSidebar": "サイドバーの幅を変更", "viewer.remoteUrlPrompt": "リモートの録画または TAR の URL を入力(https://…)", + "viewer.mergeToast.message": "{name} を現在のセッションに統合しました", + "viewer.mergeToast.action": "置き換えに切り替える", "common.dialogClose": "閉じる" } diff --git a/src/shared/intl/messages/zh/common.json b/src/shared/intl/messages/zh/common.json index 463f893..543c9d2 100644 --- a/src/shared/intl/messages/zh/common.json +++ b/src/shared/intl/messages/zh/common.json @@ -8,5 +8,7 @@ "errors.noRecordingsInArchive": "归档中未找到支持的录制文件", "viewer.resizeSidebar": "调整侧栏宽度", "viewer.remoteUrlPrompt": "输入远程录制或 TAR 的 URL(https://…)", + "viewer.mergeToast.message": "已将 {name} 合并到当前会话", + "viewer.mergeToast.action": "改为替换", "common.dialogClose": "关闭" } diff --git a/tests/multi-sources.spec.ts b/tests/multi-sources.spec.ts index b4aa86f..63fe06a 100644 --- a/tests/multi-sources.spec.ts +++ b/tests/multi-sources.spec.ts @@ -87,6 +87,50 @@ test.describe('multi-source merge', () => { await expect(page.getByText('files merged', { exact: false })).toHaveCount(1); }); + test('merging into an active session shows a toast offering to replace instead', async ({ page }) => { + await page.goto('/'); + await page.locator('#rosview-landing-file').setInputFiles([MCAP_MULTI_BASE]); + await waitForRosviewReady(page); + + await page.locator('#rosview-inline-file').setInputFiles([MCAP_MULTI_INCREMENTAL]); + await expect( + page.getByRole('button', { name: '/analysis/hand_pose_overlay/compressed', exact: true }), + ).toBeVisible({ timeout: 30_000 }); + + await expect(page.getByText('Merged test_multi_incremental.mcap into the current session')).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByRole('button', { name: 'Switch to replace instead' })).toBeVisible(); + }); + + test('clicking "switch to replace" detaches the newly-added file into its own session', async ({ page }) => { + await page.goto('/'); + await page.locator('#rosview-landing-file').setInputFiles([MCAP_MULTI_BASE]); + await waitForRosviewReady(page); + + await page.locator('#rosview-inline-file').setInputFiles([MCAP_MULTI_INCREMENTAL]); + await expect( + page.getByRole('button', { name: '/analysis/hand_pose_overlay/compressed', exact: true }), + ).toBeVisible({ timeout: 30_000 }); + // Merged session briefly has all 3 topics before the user acts on the toast. + await expect(page.getByRole('button', { name: '/joint_states', exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Switch to replace instead' }).click(); + + // Now viewing only the incremental file's own topic and time range (3s-7s -> 4s duration). + await expect( + page.getByRole('button', { name: '/analysis/hand_pose_overlay/compressed', exact: true }), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '/joint_states', exact: true })).toHaveCount(0); + await expect(page.getByTestId('playback-time-line')).toContainText('00:04.000', { timeout: 30_000 }); + + // The original (base) session is left intact and switchable, not destroyed. + await page.getByRole('tab', { name: 'Data' }).click(); + const dataTab = page.getByRole('tabpanel'); + await expect(dataTab.getByText('test_multi_base.mcap')).toBeVisible(); + await expect(dataTab.getByText('test_multi_incremental.mcap')).toBeVisible(); + }); + test('topic "more" menu shows the source file once sessions are merged', async ({ page }) => { await page.goto('/'); await page.locator('#rosview-landing-file').setInputFiles([MCAP_MULTI_BASE, MCAP_MULTI_INCREMENTAL]); From 13db5fd8bcac55fdbd1f106134e0bad4576c7eac Mon Sep 17 00:00:00 2001 From: puxiaokang <1726541+joaner@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:56:26 +0800 Subject: [PATCH 3/4] fix(viewer): stack the merge toast vertically instead of a cramped row sonner's default toast layout puts the action button to the right of the message in a single row, which read as cramped once the message wrapped to two lines. Scope a classNames override to just this toast (message stacked above a full-width action button) via Tailwind's important-modifier utilities, since sonner's own attribute-selector base styles set align-items/gap/margin directly and would otherwise win on specificity. Scoped per-toast rather than globally, so plain toast.error()/toast.success() calls elsewhere are unaffected. --- .../viewer/useRecordingSourceActions.ts | 10 ++++++++++ tests/multi-sources.spec.ts | 18 ++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/features/viewer/useRecordingSourceActions.ts b/src/features/viewer/useRecordingSourceActions.ts index 5269053..307e68f 100644 --- a/src/features/viewer/useRecordingSourceActions.ts +++ b/src/features/viewer/useRecordingSourceActions.ts @@ -84,6 +84,16 @@ function notifyMergeWithUndo( const addedIds = new Set(result.addedItemIds); toast(offlineIntl.formatMessage({ id: 'viewer.mergeToast.message' }, { name: displayName }), { duration: 8000, + // Stack the message above the action instead of sonner's default + // side-by-side row, which reads as cramped once the message text wraps + // to more than one line. `!important` is needed because sonner's own + // (higher-specificity, attribute-selector) base styles set + // align-items/gap/margin directly; scoped to this toast only, so + // simple toast.error()/toast.success() calls elsewhere are unaffected. + classNames: { + toast: '!flex-col !items-stretch !gap-2.5', + actionButton: '!ml-0 !mr-0 !w-full !justify-center', + }, action: { label: offlineIntl.formatMessage({ id: 'viewer.mergeToast.action' }), onClick: () => { diff --git a/tests/multi-sources.spec.ts b/tests/multi-sources.spec.ts index 63fe06a..13053cf 100644 --- a/tests/multi-sources.spec.ts +++ b/tests/multi-sources.spec.ts @@ -97,10 +97,20 @@ test.describe('multi-source merge', () => { page.getByRole('button', { name: '/analysis/hand_pose_overlay/compressed', exact: true }), ).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText('Merged test_multi_incremental.mcap into the current session')).toBeVisible({ - timeout: 30_000, - }); - await expect(page.getByRole('button', { name: 'Switch to replace instead' })).toBeVisible(); + const message = page.getByText('Merged test_multi_incremental.mcap into the current session'); + await expect(message).toBeVisible({ timeout: 30_000 }); + const action = page.getByRole('button', { name: 'Switch to replace instead' }); + await expect(action).toBeVisible(); + + // Stacked (message on top, full-width action below) rather than sonner's + // default cramped side-by-side row: the action button spans (almost) the + // full toast width once the layout is vertical. + const toastRoot = page.locator('[data-sonner-toast]').first(); + await expect(async () => { + const toastBox = await toastRoot.boundingBox(); + const actionBox = await action.boundingBox(); + expect(toastBox && actionBox && actionBox.width > toastBox.width * 0.8).toBe(true); + }).toPass({ timeout: 5_000 }); }); test('clicking "switch to replace" detaches the newly-added file into its own session', async ({ page }) => { From 9409f2fae88ee184b50d16908c4cdae6a4bfbb59 Mon Sep 17 00:00:00 2001 From: puxiaokang <1726541+joaner@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:08:28 +0800 Subject: [PATCH 4/4] chore: bump version to 1.7.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6029301..2565d55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ioai/rosview", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ioai/rosview", - "version": "1.7.0", + "version": "1.7.1", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/package.json b/package.json index fa75263..a24531f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ioai/rosview", - "version": "1.7.0", + "version": "1.7.1", "description": "High-performance robotics data visualization for MCAP, ROS bag, ROS2 db3, HDF5 and BVH — embeddable React component and standalone SPA", "keywords": [ "ros",