Add folder state module, hook, dialog, folder icon and folder panel components

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 15:04:22 +00:00
committed by GitHub
parent 9c42510584
commit 32fbada1cf
8 changed files with 708 additions and 11 deletions

11
package-lock.json generated
View File

@@ -6069,7 +6069,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6090,7 +6089,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6111,7 +6109,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6132,7 +6129,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6153,7 +6149,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6174,7 +6169,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6195,7 +6189,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6216,7 +6209,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6237,7 +6229,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6258,7 +6249,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6279,7 +6269,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [

View File

@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { FolderDialog } from './FolderDialog';
import type { Folder } from '@/os/folders';
const folders: Folder[] = [{ id: 'f1', name: 'Social' }];
function renderDialog(overrides: Partial<Parameters<typeof FolderDialog>[0]> = {}) {
const onSubmit = vi.fn();
const onOpenChange = vi.fn();
render(
<FolderDialog
open
folders={folders}
onSubmit={onSubmit}
onOpenChange={onOpenChange}
{...overrides}
/>,
);
return { onSubmit, onOpenChange };
}
describe('FolderDialog', () => {
it('creates a folder with the normalized name', () => {
const { onSubmit, onOpenChange } = renderDialog();
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' My Tools ' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(onSubmit).toHaveBeenCalledWith('My Tools');
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('shows an error when the name is empty', () => {
const { onSubmit } = renderDialog();
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('Give the folder a name.');
});
it('shows an error for a duplicate name', () => {
const { onSubmit } = renderDialog();
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'social' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('already exists');
});
it('allows keeping the current name when renaming', () => {
const { onSubmit } = renderDialog({ folder: folders[0] });
expect(screen.getByLabelText('Name')).toHaveValue('Social');
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
expect(onSubmit).toHaveBeenCalledWith('Social');
});
});

View File

@@ -0,0 +1,90 @@
import { useId, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { folderNameTaken, normalizeFolderName, type Folder } from '@/os/folders';
interface FolderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Existing folder names, for the duplicate check. */
folders: Folder[];
/** Set when renaming; omitted when creating. */
folder?: Folder;
onSubmit: (name: string) => void;
}
/** Small create/rename dialog with an inline duplicate-name error. */
export function FolderDialog({ open, onOpenChange, folders, folder, onSubmit }: FolderDialogProps) {
const errorId = useId();
// Keyed remount resets the input; this only seeds the initial value.
const [name, setName] = useState(() => folder?.name ?? '');
const [error, setError] = useState('');
const submit = (event: React.FormEvent) => {
event.preventDefault();
const normalized = normalizeFolderName(name);
if (!normalized) {
setError('Give the folder a name.');
return;
}
if (folderNameTaken(folders, normalized, folder?.id)) {
setError(`A folder named “${normalized}” already exists.`);
return;
}
onSubmit(normalized);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xs">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>{folder ? 'Rename folder' : 'New folder'}</DialogTitle>
<DialogDescription>
{folder
? 'Apps inside stay put when you rename it.'
: 'Group apps together on the desktop and the home screen.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5 py-4">
<Label htmlFor="folder-name">Name</Label>
<Input
id="folder-name"
value={name}
autoFocus
autoComplete="off"
placeholder="e.g. Social"
aria-invalid={Boolean(error) || undefined}
aria-describedby={error ? errorId : undefined}
onChange={(event) => {
setName(event.target.value);
if (error) setError('');
}}
/>
{error && (
<p id={errorId} role="alert" className="text-xs text-destructive">
{error}
</p>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit">{folder ? 'Rename' : 'Create'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,96 @@
import type { CSSProperties } from 'react';
import { Folder as FolderGlyph } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface FolderVisualProps {
/** The folder's display name. */
label: string;
/** Stacked behind the folder glyph, e.g. the icons of the apps inside. */
badges: React.ReactNode[];
/** How many apps are inside; announced to assistive tech. */
count: number;
selected: boolean;
onSelect: () => void;
onOpen: () => void;
onPointerDown?: (event: React.PointerEvent<HTMLButtonElement>) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
tabIndex?: number;
dragging?: boolean;
pickedUp?: boolean;
/** Highlighted while an app is dragged over the folder. */
dropTarget?: boolean;
style?: CSSProperties;
className?: string;
}
/**
* A folder on the desktop/home screen, styled after DesktopIcon: the glyph
* carries up to three mini-badges with the icons of the apps inside.
*/
export function FolderVisual({
label,
badges,
count,
selected,
onSelect,
onOpen,
onPointerDown,
onKeyDown,
tabIndex,
dragging,
pickedUp,
dropTarget,
style,
className,
}: FolderVisualProps) {
return (
<button
type="button"
tabIndex={tabIndex}
style={style}
onPointerDown={onPointerDown}
onClick={onSelect}
onDoubleClick={onOpen}
onKeyDown={onKeyDown}
aria-label={`${label} — folder with ${count} ${count === 1 ? 'app' : 'apps'}`}
aria-pressed={pickedUp || undefined}
aria-describedby={pickedUp ? 'icon-layout-status' : undefined}
className={cn(
'group flex w-20 flex-col items-center gap-1.5 rounded-lg p-2 text-center transition-[background-color,transform,box-shadow] motion-reduce:transition-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
selected || pickedUp ? 'bg-primary/15' : 'hover:bg-foreground/5',
dragging && 'scale-105 cursor-grabbing shadow-lg',
dropTarget && 'ring-2 ring-primary/60 bg-primary/10',
className,
)}
>
<span
className={cn(
'relative flex size-12 items-center justify-center rounded-xl border bg-background/80 shadow-sm transition-transform',
'group-hover:-translate-y-0.5 group-active:translate-y-0',
selected ? 'border-primary/40' : 'border-os-window-border',
)}
>
<FolderGlyph className="size-6 text-primary" aria-hidden />
{badges.length > 0 && (
<span className="absolute -bottom-1.5 left-1/2 flex -translate-x-1/2" aria-hidden>
{badges.slice(0, 3).map((badge, index) => (
<span
key={index}
className={cn(
'flex size-4 items-center justify-center rounded-full border border-os-window-border bg-background shadow-sm',
index > 0 && '-ml-1.5',
)}
>
{badge}
</span>
))}
</span>
)}
</span>
<span className="line-clamp-2 text-[11px] font-medium leading-tight text-foreground/80">
{label}
</span>
</button>
);
}

View File

@@ -0,0 +1,79 @@
import { FolderOpen, Folder as FolderGlyph, LogOut, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { getApp } from '@/os/registry';
import type { Folder } from '@/os/folders';
interface FolderWindowProps {
folder: Folder;
/** App ids inside this folder, in registry order. */
appIds: string[];
onOpenApp: (appId: string) => void;
onRemoveApp: (appId: string) => void;
onRename: () => void;
onDelete: () => void;
}
/**
* The contents of a desktop folder, shown as a floating panel below the
* folder icon while it is open. Not an OS window — folders are lightweight.
*/
export function FolderWindow({ folder, appIds, onOpenApp, onRemoveApp, onRename, onDelete }: FolderWindowProps) {
const apps = appIds
.map((id) => getApp(id))
.filter((app): app is NonNullable<typeof app> => Boolean(app));
return (
<div
role="dialog"
aria-label={`Folder ${folder.name}`}
className="w-64 rounded-xl border border-os-window-border bg-popover shadow-xl"
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<FolderGlyph className="size-4 shrink-0 text-primary" aria-hidden />
<span className="truncate text-[13px] font-semibold">{folder.name}</span>
<div className="ml-auto flex shrink-0 items-center">
<Button variant="ghost" size="icon-sm" aria-label={`Rename folder ${folder.name}`} onClick={onRename}>
<Pencil className="size-3.5" aria-hidden />
</Button>
<Button variant="ghost" size="icon-sm" aria-label={`Delete folder ${folder.name}`} onClick={onDelete}>
<Trash2 className="size-3.5" aria-hidden />
</Button>
</div>
</div>
{apps.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
This folder is empty. Drag an app onto the folder, or use its context menu to move it here.
</p>
) : (
<ul className="os-scroll max-h-64 overflow-y-auto p-1.5">
{apps.map((app) => (
<li key={app.id} className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-muted">
<button
type="button"
onClick={() => onOpenApp(app.id)}
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<app.icon className="size-4 shrink-0 text-primary" aria-hidden />
<span className="truncate text-[13px]">{app.title}</span>
</button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove ${app.title} from ${folder.name}`}
onClick={() => onRemoveApp(app.id)}
>
<LogOut className="size-3.5" aria-hidden />
</Button>
</li>
))}
</ul>
)}
<div className="flex items-center gap-1.5 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
<FolderOpen className="size-3" aria-hidden />
{apps.length} {apps.length === 1 ? 'app' : 'apps'}
</div>
</div>
);
}

157
src/os/folders.test.ts Normal file
View File

@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
EMPTY_FOLDERS,
MAX_FOLDER_NAME,
addFolder,
appsInFolder,
clearFolderState,
folderNameTaken,
folderStorageKey,
loadFolderState,
normalizeFolderName,
reconcileFolderState,
removeFolder,
renameFolder,
saveFolderState,
setAppFolder,
type FolderState,
} from './folders';
const stateWithFolder = (): { state: FolderState; folderId: string } => {
const { state, folder } = addFolder(EMPTY_FOLDERS, 'Tools');
return { state, folderId: folder.id };
};
describe('normalizeFolderName', () => {
it('trims and collapses whitespace', () => {
expect(normalizeFolderName(' My Folder ')).toBe('My Folder');
});
it('caps the length', () => {
expect(normalizeFolderName('x'.repeat(100))).toHaveLength(MAX_FOLDER_NAME);
});
});
describe('folderNameTaken', () => {
it('matches case-insensitively and ignores the excluded folder', () => {
const { state, folderId } = stateWithFolder();
expect(folderNameTaken(state.folders, 'tools')).toBe(true);
expect(folderNameTaken(state.folders, 'TOOLS')).toBe(true);
expect(folderNameTaken(state.folders, 'Tools', folderId)).toBe(false);
expect(folderNameTaken(state.folders, 'Other')).toBe(false);
});
});
describe('folder operations', () => {
it('creates folders with unique ids', () => {
const first = addFolder(EMPTY_FOLDERS, 'One');
const second = addFolder(first.state, 'Two');
expect(second.state.folders).toHaveLength(2);
expect(second.state.folders[0].id).not.toBe(second.state.folders[1].id);
});
it('renames a folder without touching the others', () => {
const { state, folderId } = stateWithFolder();
const renamed = renameFolder(state, folderId, 'Utilities');
expect(renamed.folders[0].name).toBe('Utilities');
});
it('assigns an app to a folder and lists it', () => {
const { state, folderId } = stateWithFolder();
const assigned = setAppFolder(state, 'feed', folderId);
expect(appsInFolder(assigned, folderId)).toEqual(['feed']);
});
it('moves an app between folders', () => {
const { state, folderId } = stateWithFolder();
const { state: withTwo, folder: other } = addFolder(state, 'More');
const assigned = setAppFolder(setAppFolder(withTwo, 'feed', folderId), 'feed', other.id);
expect(appsInFolder(assigned, folderId)).toEqual([]);
expect(appsInFolder(assigned, other.id)).toEqual(['feed']);
});
it('returns an app to the top level with null', () => {
const { state, folderId } = stateWithFolder();
const assigned = setAppFolder(state, 'feed', folderId);
const cleared = setAppFolder(assigned, 'feed', null);
expect(cleared.membership).toEqual({});
});
it('ignores assignments to unknown folders', () => {
const { state } = stateWithFolder();
const assigned = setAppFolder(state, 'feed', 'no-such-folder');
expect(assigned.membership).toEqual({});
});
it('deleting a folder returns its apps to the top level', () => {
const { state, folderId } = stateWithFolder();
const assigned = setAppFolder(state, 'feed', folderId);
const removed = removeFolder(assigned, folderId);
expect(removed.folders).toEqual([]);
expect(removed.membership).toEqual({});
});
});
describe('reconcileFolderState', () => {
it('drops membership for unknown folders and apps', () => {
const { state, folderId } = stateWithFolder();
const dirty: FolderState = {
folders: state.folders,
membership: { feed: folderId, ghost: folderId, settings: 'unknown-folder' },
};
const clean = reconcileFolderState(dirty, ['feed', 'settings']);
expect(clean.membership).toEqual({ feed: folderId });
});
it('drops duplicate folder ids and empty names', () => {
const { folderId } = stateWithFolder();
const dirty: FolderState = {
folders: [
{ id: folderId, name: 'One' },
{ id: folderId, name: 'Duplicate' },
{ id: 'blank', name: ' ' },
],
membership: { feed: folderId, notes: 'blank' },
};
const clean = reconcileFolderState(dirty, ['feed', 'notes']);
expect(clean.folders).toEqual([{ id: folderId, name: 'One' }]);
expect(clean.membership).toEqual({ feed: folderId });
});
});
describe('persistence', () => {
beforeEach(() => {
localStorage.clear();
});
it('scopes the storage key per user with an anonymous fallback', () => {
expect(folderStorageKey(null)).toBe('nostr:icon-layout');
expect(folderStorageKey(undefined)).toBe('nostr:icon-layout');
expect(folderStorageKey('abc123')).toBe('nostr:icon-layout:abc123');
});
it('round-trips a folder state through localStorage', () => {
const { state, folderId } = stateWithFolder();
const assigned = setAppFolder(state, 'feed', folderId);
saveFolderState(assigned, 'user-a');
expect(loadFolderState('user-a', ['feed'])).toEqual(assigned);
// A different user must not see it.
expect(loadFolderState('user-b', ['feed'])).toEqual(EMPTY_FOLDERS);
});
it('returns an empty state for corrupt payloads', () => {
localStorage.setItem(folderStorageKey(null), '{not json');
expect(loadFolderState(null, ['feed'])).toEqual(EMPTY_FOLDERS);
localStorage.setItem(folderStorageKey(null), JSON.stringify({ version: 99, folders: [] }));
expect(loadFolderState(null, ['feed'])).toEqual(EMPTY_FOLDERS);
});
it('clears only the requested user key', () => {
const { state } = stateWithFolder();
saveFolderState(state, 'user-a');
saveFolderState(state, null);
clearFolderState('user-a');
expect(loadFolderState('user-a', [])).toEqual(EMPTY_FOLDERS);
expect(loadFolderState(null, [])).toEqual(state);
});
});

138
src/os/folders.ts Normal file
View File

@@ -0,0 +1,138 @@
import { z } from 'zod';
const BASE_STORAGE_KEY = 'nostr:icon-layout';
const VERSION = 1;
export const MAX_FOLDERS = 32;
export const MAX_FOLDER_NAME = 40;
export interface Folder {
id: string;
name: string;
}
export interface FolderState {
/** Every folder, in creation order. */
folders: Folder[];
/** `appId -> folderId`; apps without an entry live at the top level. */
membership: Record<string, string>;
}
export const EMPTY_FOLDERS: FolderState = { folders: [], membership: {} };
const FolderSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(MAX_FOLDER_NAME),
});
const FolderStateSchema = z.object({
version: z.literal(VERSION),
folders: z.array(FolderSchema).max(MAX_FOLDERS),
membership: z.record(z.string().min(1), z.string().min(1)).refine(
(membership) => Object.keys(membership).length <= 500,
{ message: 'too many folder assignments' },
),
});
/** Per-user key when signed in, the shared anonymous key otherwise. */
export function folderStorageKey(userKey?: string | null): string {
return userKey ? `${BASE_STORAGE_KEY}:${userKey}` : BASE_STORAGE_KEY;
}
export function normalizeFolderName(input: string): string {
return input.trim().replace(/\s+/g, ' ').slice(0, MAX_FOLDER_NAME);
}
/** Case-insensitive duplicate check used by the create/rename dialog. */
export function folderNameTaken(folders: Folder[], name: string, excludeId?: string): boolean {
const needle = name.toLowerCase();
return folders.some((folder) => folder.id !== excludeId && folder.name.toLowerCase() === needle);
}
/** Drops assignments to folders/apps that no longer exist and dedupes folder ids. */
export function reconcileFolderState(state: FolderState, appIds: string[]): FolderState {
const seen = new Set<string>();
const folders: Folder[] = [];
for (const folder of state.folders) {
if (seen.has(folder.id)) continue;
const name = normalizeFolderName(folder.name);
if (!name) continue;
seen.add(folder.id);
folders.push({ id: folder.id, name });
}
const eligible = new Set(folders.map((folder) => folder.id));
const knownApps = new Set(appIds);
const membership: Record<string, string> = {};
for (const [appId, folderId] of Object.entries(state.membership)) {
if (eligible.has(folderId) && knownApps.has(appId)) membership[appId] = folderId;
}
return { folders, membership };
}
let counter = 0;
export function createFolderId(): string {
counter = (counter + 1) % 36 ** 4;
return `f${Date.now().toString(36)}${counter.toString(36)}`;
}
export function addFolder(state: FolderState, name: string): { state: FolderState; folder: Folder } {
const folder = { id: createFolderId(), name };
return { state: { ...state, folders: [...state.folders, folder] }, folder };
}
export function renameFolder(state: FolderState, folderId: string, name: string): FolderState {
return {
...state,
folders: state.folders.map((folder) => (folder.id === folderId ? { ...folder, name } : folder)),
};
}
/** Removes the folder; its apps return to the top level. */
export function removeFolder(state: FolderState, folderId: string): FolderState {
const membership: Record<string, string> = {};
for (const [appId, assigned] of Object.entries(state.membership)) {
if (assigned !== folderId) membership[appId] = assigned;
}
return { folders: state.folders.filter((folder) => folder.id !== folderId), membership };
}
/** Assigns an app to a folder, or back to the top level with `folderId: null`. */
export function setAppFolder(state: FolderState, appId: string, folderId: string | null): FolderState {
const membership = { ...state.membership };
if (folderId && state.folders.some((folder) => folder.id === folderId)) membership[appId] = folderId;
else delete membership[appId];
return { ...state, membership };
}
export function appsInFolder(state: FolderState, folderId: string): string[] {
return Object.entries(state.membership)
.filter(([, assigned]) => assigned === folderId)
.map(([appId]) => appId);
}
export function loadFolderState(userKey: string | null | undefined, appIds: string[]): FolderState {
try {
const raw = localStorage.getItem(folderStorageKey(userKey));
if (!raw) return EMPTY_FOLDERS;
return reconcileFolderState(FolderStateSchema.parse(JSON.parse(raw)), appIds);
} catch {
return EMPTY_FOLDERS;
}
}
export function saveFolderState(state: FolderState, userKey: string | null | undefined): void {
try {
localStorage.setItem(folderStorageKey(userKey), JSON.stringify({ version: VERSION, ...state }));
} catch {
// Storage can be unavailable in private browsing; the in-memory state remains usable.
}
}
export function clearFolderState(userKey: string | null | undefined): void {
try {
localStorage.removeItem(folderStorageKey(userKey));
} catch {
// The caller still resets its in-memory state.
}
}

94
src/os/useFolders.ts Normal file
View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
EMPTY_FOLDERS,
addFolder,
clearFolderState,
loadFolderState,
reconcileFolderState,
removeFolder,
renameFolder,
saveFolderState,
setAppFolder,
type FolderState,
} from './folders';
const SAVE_DELAY = 250;
/**
* Folder state for the desktop/home-screen icons. Persists per signed-in
* user (anonymous key when signed out) so the structure survives sign-in/out
* cycles for both identities.
*/
export function useFolders(appIds: string[]) {
const { user } = useCurrentUser();
const userKey = user?.pubkey ?? null;
const idsKey = appIds.join('|');
const stableIds = useMemo(() => (idsKey ? idsKey.split('|') : []), [idsKey]);
const [state, setState] = useState<FolderState>(() => loadFolderState(userKey, stableIds));
// Render-phase sync for a key change (sign-in/out): the same pattern
// useLocalStorage uses for its storageKey argument.
const [loadedKey, setLoadedKey] = useState(userKey);
if (loadedKey !== userKey) {
setLoadedKey(userKey);
setState(loadFolderState(userKey, stableIds));
}
const normalized = useMemo(() => reconcileFolderState(state, stableIds), [state, stableIds]);
const dirty = useRef(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
if (!dirty.current) return;
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
dirty.current = false;
saveFolderState(normalized, userKey);
}, SAVE_DELAY);
return () => clearTimeout(saveTimer.current);
}, [normalized, userKey]);
// A reset from another consumer (e.g. Settings) reloads the current key.
useEffect(() => {
const onReset = () => setState(loadFolderState(userKey, stableIds));
window.addEventListener('icon-layout-reset', onReset);
return () => window.removeEventListener('icon-layout-reset', onReset);
}, [userKey, stableIds]);
const update = useCallback((updater: (current: FolderState) => FolderState) => {
dirty.current = true;
setState((current) => updater(reconcileFolderState(current, stableIds)));
}, [stableIds]);
const createFolder = useCallback((name: string): string => {
const id = addFolder(normalized, name).folder.id;
update((current) => addFolder(current, name).state);
return id;
}, [normalized, update]);
const rename = useCallback((folderId: string, name: string) => {
update((current) => renameFolder(current, folderId, name));
}, [update]);
const remove = useCallback((folderId: string) => {
update((current) => removeFolder(current, folderId));
}, [update]);
const assign = useCallback((appId: string, folderId: string | null) => {
update((current) => setAppFolder(current, appId, folderId));
}, [update]);
const resetFolders = useCallback(() => {
clearFolderState(userKey);
dirty.current = false;
clearTimeout(saveTimer.current);
setState(EMPTY_FOLDERS);
window.dispatchEvent(new Event('icon-layout-reset'));
}, [userKey]);
return useMemo(
() => ({ folderState: normalized, createFolder, renameFolder: rename, removeFolder: remove, setAppFolder: assign, resetFolders }),
[normalized, createFolder, rename, remove, assign, resetFolders],
);
}