Skip to content
122 changes: 118 additions & 4 deletions apps/web/src/components/DesignFilesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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<unknown>).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<string> = new Set<ProjectFileKind>([
'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;
};
Expand Down Expand Up @@ -99,8 +163,27 @@ export function DesignFilesPanel({
const MENU_SAFE_PADDING = 8;
const [preview, setPreview] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<SortKey>('mtime');
const [sortDir, setSortDir] = useState<SortDir>('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<SortKey>(
() => isSortKey(savedViewState.current.sortKey) ? savedViewState.current.sortKey : DEFAULT_SORT_KEY,
);
const [sortDir, setSortDir] = useState<SortDir>(
() => isSortDir(savedViewState.current.sortDir) ? savedViewState.current.sortDir : DEFAULT_SORT_DIR,
);
const lastKeyPress = useRef<Map<string, number>>(new Map());
const [deleting, setDeleting] = useState(false);
const [installingFolder, setInstallingFolder] = useState<string | null>(null);
Expand All @@ -112,7 +195,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<Set<ProjectFileKind>>(() => new Set());
const [kindFilter, setKindFilter] = useState<Set<ProjectFileKind>>(() => {
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<HTMLDivElement | null>(null);

Expand All @@ -133,7 +222,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);
Expand Down Expand Up @@ -163,7 +257,9 @@ export function DesignFilesPanel({
}, [filteredFiles, sortKey, sortDir]);

const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<number | 'all'>(30);
const [pageSize, setPageSize] = useState<number | 'all'>(
() => 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));
Expand Down Expand Up @@ -204,6 +300,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),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Persisting every preference change under the real project key here makes the existing apps/web/tests/components/DesignFilesPanel.test.tsx suite stateful across cases, because its renderPanel() helper hardcodes projectId="test-project" and never clears od:design-files:view-state:v1:test-project between tests. On this head, pnpm --filter @open-design/web exec vitest run -c vitest.config.ts tests/components/DesignFilesPanel.test.tsx now fails 4 cases: after shows 60 rows when page size is changed to 60, the later defaults-based assertions start from the restored pageSize=60 instead of 30 (navigates pages... sees 60 rows, updates page info... sees 1–60 of 500, etc.). Because this PR introduces that persisted state, the existing regression suite now goes red at HEAD. Please isolate those tests from this storage key—for example by clearing it in afterEach, or by giving each case a unique projectId—so the pre-existing large-list assertions stay deterministic.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

}, [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(() => {
Expand Down Expand Up @@ -1030,6 +1143,7 @@ export function DesignFilesPanel({
<label>
{t('designFiles.perPage')}:
<select
data-testid="df-page-size-select"
value={pageSize === 'all' ? 'all' : pageSize}
onChange={(e) => {
const val = e.target.value;
Expand Down
191 changes: 191 additions & 0 deletions apps/web/tests/components/DesignFilesPanel.view-state-persist.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// @vitest-environment jsdom
//
// Red spec for bug #3a: view state (pageSize, sortKey, sortDir, kindFilter)
// resets on navigation because the component remounts via key={projectId}.
// These tests must go RED on origin/main and GREEN after the fix.

import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import type { ProjectFile, ProjectFileKind } from '../../src/types';

// Minimal localStorage stub mirroring the pattern in state/config.test.ts
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => {
store.clear();
}),
});

function file(name: string, kind: ProjectFileKind = 'html', mtime = Date.now()): ProjectFile {
return { path: name, name, type: 'file', size: 1024, mtime, kind, mime: 'text/html' };
}

function generateFiles(count: number): ProjectFile[] {
const kinds: ProjectFileKind[] = ['html', 'image', 'sketch', 'text', 'code', 'pdf'];
return Array.from({ length: count }, (_, i) => {
const kind = kinds[i % kinds.length]!;
return file(`file-${i + 1}.html`, kind, Date.now() - i * 60_000);
});
}

function renderPanel(
files: ProjectFile[],
projectId = 'proj-a',
) {
return render(
<DesignFilesPanel
projectId={projectId}
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
/>,
);
}

function getPerPageSelect(container: HTMLElement): HTMLSelectElement {
// The per-page select is the first select in the panel
return container.querySelector<HTMLSelectElement>('.df-pagination-start select')!;
}

function getSortBtn(container: HTMLElement, label: string): HTMLElement {
return Array.from(container.querySelectorAll<HTMLElement>('.df-th-btn')).find(
(el) => el.textContent?.trim().startsWith(label),
)!;
}

describe('DesignFilesPanel view-state persistence', () => {
beforeEach(() => {
store.clear();
vi.mocked(localStorage.getItem).mockClear();
vi.mocked(localStorage.setItem).mockClear();
});

afterEach(() => {
cleanup();
});

it('restores pageSize from localStorage after remount', () => {
const files = generateFiles(500);

// First mount: change page size to 60
const first = renderPanel(files);
const sel = getPerPageSelect(first.container);
fireEvent.change(sel, { target: { value: '60' } });
first.unmount();

// Second mount simulates navigation away and back (key={projectId} causes remount)
const second = renderPanel(files);
const restoredSel = getPerPageSelect(second.container);
expect(restoredSel.value).toBe('60');
});

it('restores sort key from localStorage after remount', () => {
const files = generateFiles(50);

// First mount: click "Name" header to sort by name
const first = renderPanel(files);
fireEvent.click(getSortBtn(first.container, 'Name'));
first.unmount();

// Second mount: "Name" column should show the sort arrow
const second = renderPanel(files);
const nameBtn = getSortBtn(second.container, 'Name');
expect(nameBtn.textContent).toContain('↑');
});

it('restores sort direction from localStorage after remount', () => {
const files = generateFiles(50);

// First mount: click "Name" twice to get desc
const first = renderPanel(files);
fireEvent.click(getSortBtn(first.container, 'Name'));
fireEvent.click(getSortBtn(first.container, 'Name'));
first.unmount();

// Second mount: Name column should show desc arrow
const second = renderPanel(files);
const nameBtn = getSortBtn(second.container, 'Name');
expect(nameBtn.textContent).toContain('↓');
});

it('restores kindFilter from localStorage after remount', () => {
// Files with mixed kinds so the filter button appears
const files = [
file('a.html', 'html'),
file('b.png', 'image'),
file('c.txt', 'text'),
];

// First mount: open the filter popover and check 'HTML page'
const first = renderPanel(files);
const filterTrigger = first.container.querySelector<HTMLElement>('.df-kind-filter-trigger');
expect(filterTrigger).not.toBeNull();
fireEvent.click(filterTrigger!);
const checkboxes = first.container.querySelectorAll<HTMLInputElement>(
'.df-kind-filter-list input[type="checkbox"]',
);
// Check the first checkbox (HTML)
if (checkboxes[0]) fireEvent.click(checkboxes[0]);
first.unmount();

// Second mount: filter button should show active state (a kind is selected)
const second = renderPanel(files);
const trigger = second.container.querySelector('.df-kind-filter-trigger');
expect(trigger?.classList.contains('active')).toBe(true);
});

it('does not bleed pageSize from one project into another', () => {
const files = generateFiles(500);

// Project A: set page size 60
const first = renderPanel(files, 'proj-a');
fireEvent.change(getPerPageSelect(first.container), { target: { value: '60' } });
first.unmount();

// Project B: should still have the default (30), not project A's setting
const second = renderPanel(files, 'proj-b');
expect(getPerPageSelect(second.container).value).toBe('30');
});

it('writes view state to localStorage on pageSize change', () => {
const files = generateFiles(500);
const { container } = renderPanel(files);

fireEvent.change(getPerPageSelect(container), { target: { value: '45' } });

expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith(
expect.stringMatching(/od:design-files:view-state/),
expect.any(String),
);
});

it('falls back to default pageSize when stored value is not a supported option', () => {
const files = generateFiles(500);

// Seed localStorage with an unsupported value (fractional, out-of-set integer)
for (const bad of [0.5, 17, 999, -1, 0]) {
vi.mocked(localStorage.getItem).mockReturnValueOnce(JSON.stringify({ pageSize: bad }));
const { container } = renderPanel(files);
expect(getPerPageSelect(container).value).toBe('30');
cleanup();
}
});
});
Loading