Persist File Manager prefs through one hook

Six view preferences each had a lazy localStorage read plus a copied
mirroring effect. usePersistentState does both and tolerates storage
that throws, so the page no longer crashes on it; the panel as a whole
still needs storage for its auth tokens.
This commit is contained in:
Juan Denis committed 2026-10-10 18:53:01 -04:00
1 parent 166e9acabf
commit dac348596a
3 files changed
+72 -26

No files matched your search

+16
View File
@@ -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();
+36
View File
@@ -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];
}
+20 -26
View File
@@ -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(() => {