diff --git a/apps/web/src/components/DesignFilesPanel.tsx b/apps/web/src/components/DesignFilesPanel.tsx index b719f9b6fb..914a075c22 100644 --- a/apps/web/src/components/DesignFilesPanel.tsx +++ b/apps/web/src/components/DesignFilesPanel.tsx @@ -39,6 +39,70 @@ type DesignFilesGroupMode = 'kind' | 'modified'; type ModifiedSection = 'today' | 'yesterday' | 'previous7Days' | 'previous30Days' | 'older'; type SortKey = 'name' | 'kind' | 'mtime'; type SortDir = 'asc' | 'desc'; + +// Storage key for per-project view state. Bump the version suffix (v1 → v2) when +// removing or renaming a persisted field — just adding an optional field is safe +// without a version bump. No cleanup of old keys on project deletion; the keys +// are small preference blobs and orphan gracefully. +const VIEW_STATE_KEY_PREFIX = 'od:design-files:view-state:v1:'; + +const DEFAULT_SORT_KEY: SortKey = 'mtime'; +const DEFAULT_SORT_DIR: SortDir = 'desc'; +const DEFAULT_PAGE_SIZE: number | 'all' = 30; +const PAGE_SIZE_OPTIONS = [15, 30, 45, 60, 'all'] as const; + +interface PersistedViewState { + sortKey?: SortKey; + sortDir?: SortDir; + pageSize?: number | 'all'; + kindFilter?: string[]; +} + +function readViewState(projectId: string): PersistedViewState { + try { + if (typeof window === 'undefined') return {}; + const raw = localStorage.getItem(VIEW_STATE_KEY_PREFIX + projectId); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return parsed as PersistedViewState; + } catch { + return {}; + } +} + +function writeViewState(projectId: string, state: PersistedViewState): void { + try { + localStorage.setItem(VIEW_STATE_KEY_PREFIX + projectId, JSON.stringify(state)); + } catch { + // localStorage unavailable (private mode, quota exceeded) — silently skip + } +} + +function isSortKey(v: unknown): v is SortKey { + return v === 'name' || v === 'kind' || v === 'mtime'; +} + +function isSortDir(v: unknown): v is SortDir { + return v === 'asc' || v === 'desc'; +} + +function isPageSize(v: unknown): v is number | 'all' { + return (PAGE_SIZE_OPTIONS as ReadonlyArray).includes(v); +} + +// Validate that a value is one of the known ProjectFileKind literals. This +// guards against stored values that were valid under a previous schema but +// are no longer part of the union — they are silently dropped rather than +// poisoning the kindFilter state. +const VALID_KIND_SET: ReadonlySet = new Set([ + 'html', 'image', 'video', 'audio', 'sketch', 'text', + 'code', 'pdf', 'document', 'presentation', 'spreadsheet', 'binary', +]); + +function isProjectFileKind(v: unknown): v is ProjectFileKind { + return typeof v === 'string' && VALID_KIND_SET.has(v); +} type FileSystemEntryWithReader = FileSystemEntry & { createReader?: () => FileSystemDirectoryReader; }; @@ -102,8 +166,27 @@ export function DesignFilesPanel({ const MENU_SAFE_PADDING = 8; const [preview, setPreview] = useState(null); const [selected, setSelected] = useState>(new Set()); - const [sortKey, setSortKey] = useState('mtime'); - const [sortDir, setSortDir] = useState('desc'); + // Read once at mount; projectId is stable for this component instance + // (parent uses key={projectId} to remount on project switch). + const savedViewState = useRef(readViewState(projectId)); + // Guard for the persist useEffect: skip the initial write so we only + // flush to localStorage when the user actually changes a preference. + // Without this, every project the user opens gets a default-value entry + // written on first render, making stale-key garbage grow unbounded. + // Note: React 18 StrictMode (active in next dev) fires effects twice, + // keeping refs intact across the simulated remount. This means the guard + // fires on the first effect run, sets the ref true, and the second run + // then writes the defaults. The result is a harmless default-value entry + // for the project; subsequent user changes overwrite it correctly. The + // invariant ("no write without a user action") only holds in production + // builds where StrictMode is not active. + const viewStateHasMounted = useRef(false); + const [sortKey, setSortKey] = useState( + () => isSortKey(savedViewState.current.sortKey) ? savedViewState.current.sortKey : DEFAULT_SORT_KEY, + ); + const [sortDir, setSortDir] = useState( + () => isSortDir(savedViewState.current.sortDir) ? savedViewState.current.sortDir : DEFAULT_SORT_DIR, + ); const lastKeyPress = useRef>(new Map()); const [deleting, setDeleting] = useState(false); const [installingFolder, setInstallingFolder] = useState(null); @@ -115,7 +198,13 @@ export function DesignFilesPanel({ >(new Set()); const [renaming, setRenaming] = useState<{ name: string; draft: string; saving: boolean } | null>(null); const [dayBoundary, setDayBoundary] = useState(() => Date.now()); - const [kindFilter, setKindFilter] = useState>(() => new Set()); + const [kindFilter, setKindFilter] = useState>(() => { + const { kindFilter: kf } = savedViewState.current; + if (!Array.isArray(kf) || kf.length === 0) return new Set(); + // Validate each stored value against the current ProjectFileKind union so + // stale values from a prior schema (e.g. a renamed kind) are dropped silently. + return new Set(kf.filter(isProjectFileKind)); + }); const [filterMenuOpen, setFilterMenuOpen] = useState(false); const filterMenuRef = useRef(null); @@ -136,7 +225,12 @@ export function DesignFilesPanel({ // Drop any selected-filter kinds that no longer appear in the file list // (e.g. after a delete leaves the kind empty). Keeps the filter UI honest // and prevents a stale filter from silently hiding everything. + // Guard: skip when no kinds are available yet — availableKinds is empty only + // when files haven't loaded. Running cleanup against an empty set would + // clear a kindFilter that was correctly restored from localStorage before + // the async file list arrived. useEffect(() => { + if (availableKinds.length === 0) return; setKindFilter((prev) => { if (prev.size === 0) return prev; const present = new Set(availableKinds); @@ -166,7 +260,9 @@ export function DesignFilesPanel({ }, [filteredFiles, sortKey, sortDir]); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(30); + const [pageSize, setPageSize] = useState( + () => isPageSize(savedViewState.current.pageSize) ? savedViewState.current.pageSize : DEFAULT_PAGE_SIZE, + ); const effectivePageSize = pageSize === 'all' ? Math.max(1, sortedFiles.length) : pageSize; const totalPages = Math.max(1, Math.ceil(sortedFiles.length / effectivePageSize)); @@ -207,6 +303,23 @@ export function DesignFilesPanel({ setPage(0); }, [pageSize]); + // Persist view state so it survives navigation (the panel remounts via + // key={projectId} when the user tabs away and back). + // Skip the initial render: we only want to write when the user actually + // changes a preference, not on every project the user visits. + useEffect(() => { + if (!viewStateHasMounted.current) { + viewStateHasMounted.current = true; + return; + } + writeViewState(projectId, { + sortKey, + sortDir, + pageSize, + kindFilter: Array.from(kindFilter), + }); + }, [projectId, sortKey, sortDir, pageSize, kindFilter]); + // Reset to the first page when the filter changes — the previous page // index may no longer exist (or may now sit past the new totalPages). useEffect(() => { @@ -1040,6 +1153,7 @@ export function DesignFilesPanel({