diff --git a/frontend/scripts/file-manager-browser.mjs b/frontend/scripts/file-manager-browser.mjs index 09fa42bd..abce2a3e 100644 --- a/frontend/scripts/file-manager-browser.mjs +++ b/frontend/scripts/file-manager-browser.mjs @@ -79,6 +79,22 @@ try { assert.equal(await page.getByText('projects', { exact: true }).count() > 0, true); assert.deepEqual(errors, []); console.log('PASS a slow listing for a folder the user left does not pull them back'); + + // Blocked site data: every localStorage access throws. The view prefs + // used to read it unguarded during render, which took the page down. + const blocked = await browser.newPage(); + const blockedErrors = []; + blocked.on('pageerror', error => blockedErrors.push(error.message)); + await blocked.addInitScript(() => { + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { throw new DOMException('The operation is insecure.', 'SecurityError'); }, + }); + }); + await blocked.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`); + await blocked.getByText('projects', { exact: true }).first().waitFor(); + assert.deepEqual(blockedErrors, []); + console.log('PASS the File Manager renders and browses with storage blocked'); } finally { await browser?.close(); await server.close(); diff --git a/frontend/src/hooks/usePersistentState.js b/frontend/src/hooks/usePersistentState.js new file mode 100644 index 00000000..6019a161 --- /dev/null +++ b/frontend/src/hooks/usePersistentState.js @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; + +// Storage can be unavailable (blocked site data, some private modes) and then +// throws on access. A preference is a convenience: it must never crash a page. +export function readStorage(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +export function writeStorage(key, value) { + try { + localStorage.setItem(key, value); + } catch { + // Unavailable storage: the preference just isn't remembered. + } +} + +/** + * useState mirrored into localStorage. + * + * @param {string} key storage key + * @param {(stored: string|null) => any} init builds the initial value from + * the stored string (null when nothing is stored or storage is unavailable) + * @param {(value: any) => string} [serialize] must be stable (module-level), + * it is an effect dependency + */ +export default function usePersistentState(key, init, serialize = String) { + const [value, setValue] = useState(() => init(readStorage(key))); + useEffect(() => { + writeStorage(key, serialize(value)); + }, [key, value, serialize]); + return [value, setValue]; +} diff --git a/frontend/src/pages/FileManager.jsx b/frontend/src/pages/FileManager.jsx index fbd1dbc5..52293b85 100644 --- a/frontend/src/pages/FileManager.jsx +++ b/frontend/src/pages/FileManager.jsx @@ -32,6 +32,7 @@ import { useClipboard } from '@/hooks/useClipboard'; import { useTranslation } from 'react-i18next'; import { useTopbarActions } from '@/hooks/useTopbarActions'; import { errorReason } from '@/utils/errorMessage'; +import usePersistentState from '@/hooks/usePersistentState'; // Demo rail shortcuts (Quick access) — one-click jumps to the paths people // actually visit on a ServerKit host. "Stack" starts at the default install @@ -119,6 +120,17 @@ const STORAGE = { sortDir: 'serverkit-fm-sort-dir', }; +const serializeExpanded = (set) => JSON.stringify([...set]); +const parseExpanded = (stored) => { + try { + return stored ? new Set(JSON.parse(stored)) : new Set(); + } catch { + return new Set(); + } +}; +const isNarrowScreen = () => typeof window !== 'undefined' + && window.matchMedia('(max-width: 1024px)').matches; + const FILTER_OPTIONS = [ { id: 'all', labelKey: 'common.labels.all', label: 'All' }, { id: 'folder', labelKey: 'app.fileManager.folders', label: 'Folders' }, @@ -252,27 +264,19 @@ function FileManager() { const dragCounter = useRef(0); // ─── view prefs ────────────────────────────────────── - const [viewMode, setViewMode] = useState(() => localStorage.getItem(STORAGE.viewMode) || 'list'); + const [viewMode, setViewMode] = usePersistentState(STORAGE.viewMode, (v) => v || 'list'); const gridSize = 'md'; - const [sortBy, setSortBy] = useState(() => localStorage.getItem(STORAGE.sortBy) || 'name'); - const [sortDir, setSortDir] = useState(() => localStorage.getItem(STORAGE.sortDir) || 'asc'); + const [sortBy, setSortBy] = usePersistentState(STORAGE.sortBy, (v) => v || 'name'); + const [sortDir, setSortDir] = usePersistentState(STORAGE.sortDir, (v) => v || 'asc'); const [activeFilter, setActiveFilter] = useState('all'); // ─── left sidebar ──────────────────────────────────── - const [sidebarVisible, setSidebarVisible] = useState(() => { - if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1024px)').matches) return false; - const v = localStorage.getItem(STORAGE.sidebar); - return v !== null ? v === 'true' : true; - }); - const [treeCollapsed, setTreeCollapsed] = useState(() => localStorage.getItem(STORAGE.treeCollapsed) === 'true'); + const [sidebarVisible, setSidebarVisible] = usePersistentState( + STORAGE.sidebar, (v) => (isNarrowScreen() ? false : v !== null ? v === 'true' : true)); + const [treeCollapsed, setTreeCollapsed] = usePersistentState(STORAGE.treeCollapsed, (v) => v === 'true'); // ─── folder tree state ─────────────────────────────── - const [treeExpanded, setTreeExpanded] = useState(() => { - try { - const stored = localStorage.getItem(STORAGE.expanded); - return stored ? new Set(JSON.parse(stored)) : new Set(); - } catch { return new Set(); } - }); + const [treeExpanded, setTreeExpanded] = usePersistentState(STORAGE.expanded, parseExpanded, serializeExpanded); const [treeCache, setTreeCache] = useState(new Map()); const [treeLoading, setTreeLoading] = useState(new Set()); @@ -295,16 +299,6 @@ function FileManager() { const toast = useToast(); - // ─── persistence ───────────────────────────────────── - useEffect(() => { localStorage.setItem(STORAGE.sidebar, sidebarVisible); }, [sidebarVisible]); - useEffect(() => { localStorage.setItem(STORAGE.treeCollapsed, treeCollapsed); }, [treeCollapsed]); - useEffect(() => { localStorage.setItem(STORAGE.viewMode, viewMode); }, [viewMode]); - useEffect(() => { localStorage.setItem(STORAGE.sortBy, sortBy); }, [sortBy]); - useEffect(() => { localStorage.setItem(STORAGE.sortDir, sortDir); }, [sortDir]); - useEffect(() => { - localStorage.setItem(STORAGE.expanded, JSON.stringify([...treeExpanded])); - }, [treeExpanded]); - useEffect(() => { const pathFromUrl = searchParams.get('path') || '/home'; if (pathFromUrl !== currentPath) { @@ -483,7 +477,7 @@ function FileManager() { } } setTreeExpanded((s) => { const n = new Set(s); n.add(path); return n; }); - }, [treeExpanded, treeCache, fileApi]); + }, [treeExpanded, treeCache, fileApi, setTreeExpanded]); // Auto-expand the tree along the current path so the active row is visible. useEffect(() => {