diff --git a/src/components/os/Desktop.test.tsx b/src/components/os/Desktop.test.tsx
new file mode 100644
index 0000000..5c435a2
--- /dev/null
+++ b/src/components/os/Desktop.test.tsx
@@ -0,0 +1,165 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { generateSecretKey, nip19 } from 'nostr-tools';
+
+import { TestApp } from '@/test/TestApp';
+import { Desktop, FOLDER_ID_PREFIX } from './Desktop';
+import { WindowManagerProvider } from '@/os/WindowManagerProvider';
+import { useLoginActions } from '@/hooks/useLoginActions';
+import { folderStorageKey, loadFolderState } from '@/os/folders';
+import { iconLayoutStorageKey } from '@/os/iconLayout';
+
+function LoginProbe() {
+ const actions = useLoginActions();
+ return (
+
+ );
+}
+
+// Radix Popper (context menus) constructs `new ResizeObserver(cb)`, which the
+// vi.fn() mock in src/test/setup.ts does not support — same workaround as
+// WallpaperSection.test.tsx.
+beforeEach(() => {
+ global.ResizeObserver = class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+});
+
+async function renderDesktop(withLoginProbe = false) {
+ const result = render(
+
+
+
+ {withLoginProbe && }
+
+ ,
+ );
+ // NostrLoginProvider renders null while it reads logins from storage.
+ await screen.findByRole('main');
+ return result;
+}
+
+describe('Desktop folders', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('creates a folder around an app from the app context menu', async () => {
+ await renderDesktop();
+
+ fireEvent.contextMenu(screen.getByRole('button', { name: /^Feed —/ }));
+ const subTrigger = await screen.findByText('Move to folder');
+ fireEvent.keyDown(subTrigger, { key: 'ArrowRight' });
+ fireEvent.click(await screen.findByText('New folder with Feed…'));
+
+ const dialog = await screen.findByRole('dialog');
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Social' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }));
+
+ expect(dialog).not.toBeInTheDocument();
+ // The folder appears on the grid and the app icon leaves it.
+ expect(await screen.findByRole('button', { name: /Social — folder with 1 app/ })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /^Feed —/ })).not.toBeInTheDocument();
+
+ // The state persists under the anonymous user key (debounced save).
+ await waitFor(() => expect(loadFolderState(null, ['feed']).folders).toHaveLength(1));
+ const saved = loadFolderState(null, ['feed']);
+ expect(saved.folders[0].name).toBe('Social');
+ expect(saved.membership).toEqual({ feed: saved.folders[0].id });
+
+ // Nudging the layout persists a grid slot for the folder entry.
+ const folderIcon = screen.getByRole('button', { name: /Social — folder with 1 app/ });
+ folderIcon.focus();
+ fireEvent.keyDown(folderIcon, { key: ' ' });
+ fireEvent.keyDown(folderIcon, { key: 'ArrowRight' });
+ fireEvent.keyDown(folderIcon, { key: 'Enter' });
+
+ await waitFor(() => {
+ const layout = JSON.parse(localStorage.getItem(iconLayoutStorageKey) ?? '{}') as { desktop?: { id: string }[] };
+ expect(layout.desktop?.some((slot) => slot.id === `${FOLDER_ID_PREFIX}${saved.folders[0].id}`)).toBe(true);
+ });
+ });
+
+ it('opens a folder and moves an app back out of it', async () => {
+ await renderDesktop();
+
+ fireEvent.contextMenu(screen.getByRole('button', { name: /^Feed —/ }));
+ fireEvent.keyDown(await screen.findByText('Move to folder'), { key: 'ArrowRight' });
+ fireEvent.click(await screen.findByText('New folder with Feed…'));
+ fireEvent.change(await screen.findByLabelText('Name'), { target: { value: 'Social' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }));
+
+ fireEvent.doubleClick(await screen.findByRole('button', { name: /Social — folder with 1 app/ }));
+ expect(await screen.findByRole('dialog', { name: 'Folder Social' })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Remove Feed from Social' }));
+
+ // The app is back on the grid, and the removal persists (debounced).
+ expect(await screen.findByRole('button', { name: /^Feed —/ })).toBeInTheDocument();
+ await waitFor(() => {
+ const saved = loadFolderState(null, ['feed']);
+ expect(saved.folders).toHaveLength(1);
+ expect(saved.membership).toEqual({});
+ });
+ });
+
+ it('deletes an empty folder from its context menu without confirmation', async () => {
+ await renderDesktop();
+
+ fireEvent.contextMenu(screen.getByRole('main'));
+ fireEvent.click(await screen.findByText('New folder'));
+ fireEvent.change(await screen.findByLabelText('Name'), { target: { value: 'Empty one' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }));
+
+ const icon = await screen.findByRole('button', { name: /Empty one — folder with 0 apps/ });
+ fireEvent.contextMenu(icon);
+ fireEvent.click(await screen.findByText('Delete folder'));
+
+ expect(screen.queryByRole('button', { name: /Empty one/ })).not.toBeInTheDocument();
+ expect(loadFolderState(null, []).folders).toHaveLength(0);
+ });
+
+ it('loads folder state persisted under the user key', async () => {
+ // Pre-seed the anonymous key; the signed-out desktop must pick it up.
+ localStorage.setItem(
+ folderStorageKey(null),
+ JSON.stringify({
+ version: 1,
+ folders: [{ id: 'persisted', name: 'Kept' }],
+ membership: { feed: 'persisted' },
+ }),
+ );
+ await renderDesktop();
+ expect(await screen.findByRole('button', { name: /Kept — folder with 1 app/ })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /^Feed —/ })).not.toBeInTheDocument();
+ });
+
+ it('switches to the signed-in user’s folders on login and keeps the anonymous ones', async () => {
+ localStorage.setItem(
+ folderStorageKey(null),
+ JSON.stringify({
+ version: 1,
+ folders: [{ id: 'anon-f', name: 'Anon Folder' }],
+ membership: { feed: 'anon-f' },
+ }),
+ );
+ await renderDesktop(true);
+ expect(await screen.findByRole('button', { name: /Anon Folder — folder with 1 app/ })).toBeInTheDocument();
+
+ act(() => screen.getByTestId('login-probe').click());
+
+ // The anonymous folder leaves the grid; the fresh user starts empty.
+ await waitFor(() => {
+ expect(screen.queryByRole('button', { name: /Anon Folder/ })).not.toBeInTheDocument();
+ });
+ expect(screen.getByRole('button', { name: /^Feed —/ })).toBeInTheDocument();
+
+ // The anonymous state survives under its own key.
+ const anon = JSON.parse(localStorage.getItem(folderStorageKey(null)) ?? '{}') as { folders?: unknown[] };
+ expect(anon.folders).toHaveLength(1);
+ });
+});
diff --git a/src/components/os/Desktop.tsx b/src/components/os/Desktop.tsx
index cec1c84..7d14299 100644
--- a/src/components/os/Desktop.tsx
+++ b/src/components/os/Desktop.tsx
@@ -46,6 +46,16 @@ function geometryFor(width: number, height: number): GridGeometry {
};
}
+/**
+ * Context menus nest (icon menus inside the desktop menu), and Radix opens
+ * the menu of every trigger in the bubble path. Real right-click is handled
+ * by the innermost trigger; only touch long-presses bubble, so the inner
+ * trigger swallows them before the desktop menu would also open.
+ */
+function stopTouchContextMenu(event: React.MouseEvent) {
+ if ((event.nativeEvent as PointerEvent).pointerType === 'touch') event.stopPropagation();
+}
+
interface FolderDialogState {
open: boolean;
/** Set when renaming an existing folder. */
@@ -288,6 +298,7 @@ export function Desktop() {
pointerStart.current = { id: app.id, x: event.clientX, y: event.clientY, moved: false };
setSelected(app.id);
}}
+ onContextMenu={stopTouchContextMenu}
onKeyDown={(event) => iconKeyDown(app.id, event)}
onSelect={() => { if (!pointerStart.current?.moved) setSelected(app.id); }}
onOpen={() => openApp(app.id)}
@@ -344,6 +355,7 @@ export function Desktop() {
pointerStart.current = { id: iconId, x: event.clientX, y: event.clientY, moved: false };
setSelected(iconId);
}}
+ onContextMenu={stopTouchContextMenu}
onKeyDown={(event) => {
iconKeyDown(iconId, event);
if (event.defaultPrevented) return;
diff --git a/src/components/os/DesktopIcon.tsx b/src/components/os/DesktopIcon.tsx
index 1d878c0..921799a 100644
--- a/src/components/os/DesktopIcon.tsx
+++ b/src/components/os/DesktopIcon.tsx
@@ -7,6 +7,8 @@ interface DesktopIconProps {
onSelect: () => void;
onOpen: () => void;
onPointerDown?: (event: React.PointerEvent) => void;
+ /** Stops long-press context menus from bubbling to the desktop surface. */
+ onContextMenu?: (event: React.MouseEvent) => void;
onKeyDown?: (event: React.KeyboardEvent) => void;
tabIndex?: number;
dragging?: boolean;
@@ -20,6 +22,7 @@ export function DesktopIcon({
onSelect,
onOpen,
onPointerDown,
+ onContextMenu,
onKeyDown,
tabIndex,
dragging,
@@ -35,6 +38,7 @@ export function DesktopIcon({
tabIndex={tabIndex}
style={style}
onPointerDown={onPointerDown}
+ onContextMenu={onContextMenu}
onClick={onSelect}
onDoubleClick={onOpen}
onKeyDown={(event) => {
diff --git a/src/components/os/FolderIcon.tsx b/src/components/os/FolderIcon.tsx
index 820656e..9595b72 100644
--- a/src/components/os/FolderIcon.tsx
+++ b/src/components/os/FolderIcon.tsx
@@ -13,6 +13,8 @@ export interface FolderVisualProps {
onSelect: () => void;
onOpen: () => void;
onPointerDown?: (event: React.PointerEvent) => void;
+ /** Stops long-press context menus from bubbling to the desktop surface. */
+ onContextMenu?: (event: React.MouseEvent) => void;
onKeyDown?: (event: React.KeyboardEvent) => void;
tabIndex?: number;
dragging?: boolean;
@@ -21,6 +23,8 @@ export interface FolderVisualProps {
dropTarget?: boolean;
style?: CSSProperties;
className?: string;
+ /** Hit-target id used by drag-and-drop on the home screen. */
+ 'data-home-icon-id'?: string;
}
/**
@@ -35,6 +39,7 @@ export function FolderVisual({
onSelect,
onOpen,
onPointerDown,
+ onContextMenu,
onKeyDown,
tabIndex,
dragging,
@@ -42,13 +47,16 @@ export function FolderVisual({
dropTarget,
style,
className,
+ 'data-home-icon-id': homeIconId,
}: FolderVisualProps) {
return (