From 32d521a36f4a31c6e2b4e6a3b614caf734ce5b79 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Sun, 2 Aug 2026 21:46:27 +0200 Subject: [PATCH 1/2] feat(web): harvest the v1 vault browser read path over the snapshot adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings back the v1 vault browser — app shell, breadcrumbs, folder listing, navigation — sourced entirely from the engine's snapshot adapter. `useFolderNavigation` reads the snapshot store, keys on the routed node id, and moves the engine's focus window on every navigation. A view is only listed once its `folder` matches the route, so a pull for the folder just left cannot paint under the new one. Listings hydrate progressively: name and kind render from the child ref, size and mtime fill in when the projection lands. Breadcrumbs walk the ancestor trail the snapshot carries. That trail starts at the folder's parent, so `SnapshotView` now also carries the listed folder's own name — a host cannot recover it from `ancestors` and must not cache a name across a navigation. The sidebar keeps /files live and renders /shared, /bin and /settings inert with a coming-soon affordance for #643. Closes #805 --- apps/web/src/App.test.tsx | 21 +- .../components/file-browser/Breadcrumbs.tsx | 35 ++ .../components/file-browser/EmptyState.tsx | 20 ++ .../components/file-browser/FileBrowser.tsx | 37 ++ .../src/components/file-browser/FileList.tsx | 36 ++ .../components/file-browser/FileListItem.tsx | 64 ++++ .../components/file-browser/ParentDirRow.tsx | 36 ++ apps/web/src/components/layout/AppFooter.tsx | 25 ++ apps/web/src/components/layout/AppHeader.tsx | 16 + apps/web/src/components/layout/AppShell.tsx | 24 ++ apps/web/src/components/layout/AppSidebar.tsx | 18 + apps/web/src/components/layout/NavItem.tsx | 101 ++++++ .../src/components/layout/StatusIndicator.tsx | 26 ++ apps/web/src/components/layout/UserMenu.tsx | 39 ++ apps/web/src/engine/snapshotStore.ts | 7 +- apps/web/src/engine/testFakes.ts | 1 + apps/web/src/lib/nodeId.test.ts | 47 +++ apps/web/src/lib/nodeId.ts | 35 ++ apps/web/src/main.tsx | 4 + apps/web/src/routes/FilesPage.tsx | 36 +- apps/web/src/styles/breadcrumbs.css | 55 +++ apps/web/src/styles/file-browser.css | 186 ++++++++++ apps/web/src/styles/layout.css | 340 ++++++++++++++++++ apps/web/src/styles/responsive.css | 108 ++++++ apps/web/src/utils/format.ts | 23 ++ apps/web/src/vault/listing.test.ts | 69 ++++ apps/web/src/vault/listing.ts | 63 ++++ .../src/vault/useFolderNavigation.test.tsx | 242 +++++++++++++ apps/web/src/vault/useFolderNavigation.ts | 85 +++++ crates/engine/src/facade.rs | 14 + crates/wasm/src/lib.rs | 8 + crates/wasm/tests/boundary.rs | 7 + .../client/src/broadcastTransport.test.ts | 1 + packages/client/src/index.ts | 4 +- packages/client/src/testkit.ts | 1 + .../client/src/worker/commandCodec.test.ts | 3 + packages/client/src/worker/commandCodec.ts | 1 + packages/client/src/worker/engineWasm.ts | 1 + packages/client/src/worker/protocol.ts | 2 + 39 files changed, 1818 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/file-browser/Breadcrumbs.tsx create mode 100644 apps/web/src/components/file-browser/EmptyState.tsx create mode 100644 apps/web/src/components/file-browser/FileBrowser.tsx create mode 100644 apps/web/src/components/file-browser/FileList.tsx create mode 100644 apps/web/src/components/file-browser/FileListItem.tsx create mode 100644 apps/web/src/components/file-browser/ParentDirRow.tsx create mode 100644 apps/web/src/components/layout/AppFooter.tsx create mode 100644 apps/web/src/components/layout/AppHeader.tsx create mode 100644 apps/web/src/components/layout/AppShell.tsx create mode 100644 apps/web/src/components/layout/AppSidebar.tsx create mode 100644 apps/web/src/components/layout/NavItem.tsx create mode 100644 apps/web/src/components/layout/StatusIndicator.tsx create mode 100644 apps/web/src/components/layout/UserMenu.tsx create mode 100644 apps/web/src/lib/nodeId.test.ts create mode 100644 apps/web/src/lib/nodeId.ts create mode 100644 apps/web/src/styles/breadcrumbs.css create mode 100644 apps/web/src/styles/file-browser.css create mode 100644 apps/web/src/styles/layout.css create mode 100644 apps/web/src/styles/responsive.css create mode 100644 apps/web/src/utils/format.ts create mode 100644 apps/web/src/vault/listing.test.ts create mode 100644 apps/web/src/vault/listing.ts create mode 100644 apps/web/src/vault/useFolderNavigation.test.tsx create mode 100644 apps/web/src/vault/useFolderNavigation.ts diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index cc609b905..4acdf93a8 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { App } from './App'; +import { authStore } from './stores/auth.store'; import { fakeCoreKitSession, fakeEngineClient, pageWrapper } from './test/authFakes'; function renderAt(path: string) { @@ -15,6 +16,8 @@ function renderAt(path: string) { ); } +afterEach(() => authStore.signedOut()); + describe('App routes', () => { it('renders the login page at the root', () => { renderAt('/'); @@ -22,13 +25,23 @@ describe('App routes', () => { }); it('renders the vault browser for the vault root when no node id is routed', () => { + authStore.signedIn('google', 'user@example.test'); renderAt('/files'); - expect(screen.getByTestId('files-node').textContent).toBe('root'); + expect(screen.getByTestId('app-shell')).toBeDefined(); + expect(screen.getByTestId('file-browser')).toBeDefined(); }); it('keys the vault browser on the routed node id', () => { - renderAt('/files/0a1b2c'); - expect(screen.getByTestId('files-node').textContent).toBe('0a1b2c'); + authStore.signedIn('google', 'user@example.test'); + renderAt(`/files/${'0a'.repeat(16)}`); + expect(screen.getByTestId('file-browser')).toBeDefined(); + }); + + it('sends a signed-out tab away from the vault browser', async () => { + renderAt('/files'); + // The redirect waits on the Core Kit restore: a tab that is still deciding + // must not be thrown out of its own vault. + expect(await screen.findByRole('heading', { name: 'CipherBox' })).toBeDefined(); }); it('sends an unknown path back to login', () => { diff --git a/apps/web/src/components/file-browser/Breadcrumbs.tsx b/apps/web/src/components/file-browser/Breadcrumbs.tsx new file mode 100644 index 000000000..ff6af7c35 --- /dev/null +++ b/apps/web/src/components/file-browser/Breadcrumbs.tsx @@ -0,0 +1,35 @@ +import { Fragment } from 'react'; +import type { BreadcrumbDescriptor } from '@cipherbox/client'; +import { toHex } from '@cipherbox/client'; + +interface BreadcrumbsProps { + /** Root-first trail, ending at the folder on screen. */ + crumbs: BreadcrumbDescriptor[]; + onNavigate: (node: Uint8Array) => void; +} + +/** The vault's current location as a terminal path: `~/root/documents`. */ +export function Breadcrumbs({ crumbs, onNavigate }: BreadcrumbsProps) { + return ( + + ); +} diff --git a/apps/web/src/components/file-browser/EmptyState.tsx b/apps/web/src/components/file-browser/EmptyState.tsx new file mode 100644 index 000000000..af073e3f3 --- /dev/null +++ b/apps/web/src/components/file-browser/EmptyState.tsx @@ -0,0 +1,20 @@ +/** Terminal window running `ls -la` over nothing, in box-drawing characters. */ +const TERMINAL_ART = `┌──────────────────────┐ +│ $ ls -la │ +│ total 0 │ +│ $ █ │ +└──────────────────────┘`; + +/** What a folder with no children shows. */ +export function EmptyState() { + return ( +
+
+ +

// EMPTY DIRECTORY

+
+
+ ); +} diff --git a/apps/web/src/components/file-browser/FileBrowser.tsx b/apps/web/src/components/file-browser/FileBrowser.tsx new file mode 100644 index 000000000..0e2931d31 --- /dev/null +++ b/apps/web/src/components/file-browser/FileBrowser.tsx @@ -0,0 +1,37 @@ +import { useFolderNavigation } from '../../vault/useFolderNavigation'; +import { Breadcrumbs } from './Breadcrumbs'; +import { EmptyState } from './EmptyState'; +import { FileList } from './FileList'; + +/** The vault browser: where you are, what is in it, and how to move. */ +export function FileBrowser() { + const { rows, breadcrumbs, isLoading, isRoot, error, navigateTo, navigateUp } = + useFolderNavigation(); + const settled = !isLoading && error === null; + + return ( +
+ + {error && ( +

+ {error.message} +

+ )} + {isLoading && ( +

+ {'// LOADING VAULT...'} +

+ )} + {/* An empty non-root folder still lists, so `[..]` remains reachable. */} + {settled && (rows.length > 0 || !isRoot) && ( + + )} + {settled && rows.length === 0 && } +
+ ); +} diff --git a/apps/web/src/components/file-browser/FileList.tsx b/apps/web/src/components/file-browser/FileList.tsx new file mode 100644 index 000000000..d4c73d5b8 --- /dev/null +++ b/apps/web/src/components/file-browser/FileList.tsx @@ -0,0 +1,36 @@ +import type { ListingRow } from '../../vault/listing'; +import { FileListItem } from './FileListItem'; +import { ParentDirRow } from './ParentDirRow'; + +interface FileListProps { + rows: ListingRow[]; + /** False at the vault root, which has no parent to step up to. */ + showParentRow: boolean; + onOpen: (node: Uint8Array) => void; + onNavigateUp: () => void; +} + +/** The routed folder's direct children, in columns. */ +export function FileList({ rows, showParentRow, onOpen, onNavigateUp }: FileListProps) { + return ( +
+
+
+ [NAME] +
+
+ [SIZE] +
+
+ [MODIFIED] +
+
+
+ {showParentRow && } + {rows.map((row) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/file-browser/FileListItem.tsx b/apps/web/src/components/file-browser/FileListItem.tsx new file mode 100644 index 000000000..75d7ae648 --- /dev/null +++ b/apps/web/src/components/file-browser/FileListItem.tsx @@ -0,0 +1,64 @@ +import type { ListingRow } from '../../vault/listing'; + +interface FileListItemProps { + row: ListingRow; + /** Opens a folder. Files have no read action until #808 lands. */ + onOpen: (node: Uint8Array) => void; +} + +/** One direct child: kind marker, name, size, mtime, and its queue status. */ +export function FileListItem({ row, onOpen }: FileListItemProps) { + const isFolder = row.kind === 'folder'; + const open = () => { + if (isFolder) onOpen(row.id); + }; + + return ( +
{ + if (event.key !== 'Enter') return; + event.preventDefault(); + open(); + }} + > +
+ + {row.name} + +
+
+ + {row.size} + + + {row.modified} + +
+
+ ); +} + +/** The engine's per-node queue flags, rendered as the engine reports them. */ +function ItemStatus({ row }: { row: ListingRow }) { + if (row.deadLetter) { + return ( + + [!] + + ); + } + if (row.pending === 'none') return null; + return ( + + [~] + + ); +} diff --git a/apps/web/src/components/file-browser/ParentDirRow.tsx b/apps/web/src/components/file-browser/ParentDirRow.tsx new file mode 100644 index 000000000..e8ef8ded9 --- /dev/null +++ b/apps/web/src/components/file-browser/ParentDirRow.tsx @@ -0,0 +1,36 @@ +interface ParentDirRowProps { + onActivate: () => void; +} + +/** The `[..]` row that opens the parent folder, first in every non-root list. */ +export function ParentDirRow({ onActivate }: ParentDirRowProps) { + return ( +
{ + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onActivate(); + }} + data-testid="parent-dir-row" + > +
+ + PARENT_DIR +
+
+ + - + + + - + +
+
+ ); +} diff --git a/apps/web/src/components/layout/AppFooter.tsx b/apps/web/src/components/layout/AppFooter.tsx new file mode 100644 index 000000000..9d87dec3c --- /dev/null +++ b/apps/web/src/components/layout/AppFooter.tsx @@ -0,0 +1,25 @@ +import { StatusIndicator } from './StatusIndicator'; + +/** Chrome: attribution, outbound links, and the staleness rung. */ +export function AppFooter() { + return ( + + ); +} diff --git a/apps/web/src/components/layout/AppHeader.tsx b/apps/web/src/components/layout/AppHeader.tsx new file mode 100644 index 000000000..a65ed4508 --- /dev/null +++ b/apps/web/src/components/layout/AppHeader.tsx @@ -0,0 +1,16 @@ +import { UserMenu } from './UserMenu'; + +/** Wordmark and account menu. */ +export function AppHeader() { + return ( +
+
+ > + CIPHERBOX +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx new file mode 100644 index 000000000..bd3a24620 --- /dev/null +++ b/apps/web/src/components/layout/AppShell.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { StagingBanner } from '../StagingBanner'; +import { AppFooter } from './AppFooter'; +import { AppHeader } from './AppHeader'; +import { AppSidebar } from './AppSidebar'; + +interface AppShellProps { + children: ReactNode; +} + +/** The signed-in frame: header, sidebar, scrollable main, footer. */ +export function AppShell({ children }: AppShellProps) { + return ( +
+ +
+ + +
{children}
+ +
+
+ ); +} diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx new file mode 100644 index 000000000..b3f3eafc7 --- /dev/null +++ b/apps/web/src/components/layout/AppSidebar.tsx @@ -0,0 +1,18 @@ +import { useLocation } from 'react-router-dom'; +import { NavItem } from './NavItem'; + +/** Vault navigation. Only `/files` is served in this build; the rest is #643. */ +export function AppSidebar() { + const { pathname } = useLocation(); + + return ( + + ); +} diff --git a/apps/web/src/components/layout/NavItem.tsx b/apps/web/src/components/layout/NavItem.tsx new file mode 100644 index 000000000..31503f0ce --- /dev/null +++ b/apps/web/src/components/layout/NavItem.tsx @@ -0,0 +1,101 @@ +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; + +export type NavIcon = 'folder' | 'shared' | 'bin' | 'settings'; + +interface NavItemProps { + to: string; + icon: NavIcon; + label: string; + active: boolean; + /** A destination this build does not serve yet; renders inert. */ + comingSoon?: boolean; +} + +const ICONS: Record = { + folder: ( + + ), + shared: ( + <> + + + + + ), + bin: ( + + ), + settings: ( + <> + + + + ), +}; + +/** One sidebar destination. */ +export function NavItem({ to, icon, label, active, comingSoon = false }: NavItemProps) { + const testId = `nav-item-${label.toLowerCase()}`; + const body = ( + <> + + + + {label} + + ); + + if (comingSoon) { + return ( + + {body} + soon + + ); + } + + return ( + + {body} + + ); +} diff --git a/apps/web/src/components/layout/StatusIndicator.tsx b/apps/web/src/components/layout/StatusIndicator.tsx new file mode 100644 index 000000000..e9bf99733 --- /dev/null +++ b/apps/web/src/components/layout/StatusIndicator.tsx @@ -0,0 +1,26 @@ +import { useStaleness } from '../../engine/useStaleness'; + +/** The staleness ladder's rungs, as the footer renders them (#33 D4). */ +const RUNGS = { + fresh: { label: 'synced', className: 'status-indicator--fresh' }, + reconciling: { label: 'syncing...', className: 'status-indicator--reconciling' }, + stale: { label: 'stale', className: 'status-indicator--stale' }, + offline: { label: 'offline', className: 'status-indicator--offline' }, +} as const; + +/** Where the vault sits on the staleness ladder. */ +export function StatusIndicator() { + const staleness = useStaleness(); + const rung = RUNGS[staleness]; + + return ( + + + ); +} diff --git a/apps/web/src/components/layout/UserMenu.tsx b/apps/web/src/components/layout/UserMenu.tsx new file mode 100644 index 000000000..29a039a10 --- /dev/null +++ b/apps/web/src/components/layout/UserMenu.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import { LogoutButton } from '../auth/LogoutButton'; +import { useAuthState } from '../../stores/auth.store'; + +/** Who is signed in, and the way out. */ +export function UserMenu() { + const { email } = useAuthState(); + const [isOpen, setIsOpen] = useState(false); + + return ( +
setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + > + + + {isOpen && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/engine/snapshotStore.ts b/apps/web/src/engine/snapshotStore.ts index 8ea639376..dcef6e689 100644 --- a/apps/web/src/engine/snapshotStore.ts +++ b/apps/web/src/engine/snapshotStore.ts @@ -7,6 +7,7 @@ import { EngineRequestError } from '@cipherbox/client'; import type { EngineClient, SnapshotDescriptor, Staleness } from '@cipherbox/client'; +import { sameNode } from '../lib/nodeId'; /** A failed pull, carrying the engine's stable code so the UI can classify it. */ export interface SnapshotError { @@ -153,9 +154,3 @@ function describe(error: unknown): SnapshotError { if (error instanceof EngineRequestError) return { message: error.message, code: error.code }; return { message: error instanceof Error ? error.message : String(error) }; } - -/** Node ids compare by value: callers hand in a fresh array per render. */ -function sameNode(a: Uint8Array | null, b: Uint8Array | null): boolean { - if (a === null || b === null) return a === b; - return a.length === b.length && a.every((byte, i) => byte === b[i]); -} diff --git a/apps/web/src/engine/testFakes.ts b/apps/web/src/engine/testFakes.ts index 3468a7cd0..ad19c05b6 100644 --- a/apps/web/src/engine/testFakes.ts +++ b/apps/web/src/engine/testFakes.ts @@ -21,6 +21,7 @@ export function view( return { root: ROOT_ID, folder, + folderName: '', children: Array.from({ length: children }, (_, i) => ({ id: new Uint8Array(16).fill(i + 1), name: `child-${i}`, diff --git a/apps/web/src/lib/nodeId.test.ts b/apps/web/src/lib/nodeId.test.ts new file mode 100644 index 000000000..a55b15859 --- /dev/null +++ b/apps/web/src/lib/nodeId.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { folderPath, folderRoute, sameNode } from './nodeId'; + +const NODE = new Uint8Array(16).fill(0xab); + +describe('folderRoute', () => { + it('reads an absent param as the vault root', () => { + expect(folderRoute(undefined)).toEqual({ kind: 'root' }); + }); + + it('round-trips a node id through its route', () => { + const route = folderRoute(folderPath(NODE).replace('/files/', '')); + expect(route).toEqual({ kind: 'node', id: NODE }); + }); + + it.each([ + ['too short', 'ab'], + ['too long', 'ab'.repeat(17)], + ['not hex', 'zz'.repeat(16)], + ['empty', ''], + ])('rejects a param that is %s', (_case, param) => { + expect(folderRoute(param)).toEqual({ kind: 'invalid' }); + }); +}); + +describe('folderPath', () => { + it('addresses the current root without a node id', () => { + expect(folderPath(null)).toBe('/files'); + }); + + it('addresses a node by lowercase hex', () => { + expect(folderPath(NODE)).toBe(`/files/${'ab'.repeat(16)}`); + }); +}); + +describe('sameNode', () => { + it('compares by value, not identity', () => { + expect(sameNode(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true); + expect(sameNode(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false); + expect(sameNode(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false); + }); + + it('treats null as the root, matching only itself', () => { + expect(sameNode(null, null)).toBe(true); + expect(sameNode(null, new Uint8Array([1]))).toBe(false); + }); +}); diff --git a/apps/web/src/lib/nodeId.ts b/apps/web/src/lib/nodeId.ts new file mode 100644 index 000000000..0b0a5b4f2 --- /dev/null +++ b/apps/web/src/lib/nodeId.ts @@ -0,0 +1,35 @@ +/** + * Node-id addressing for the UI. Routes and UI-owned state key on the stable + * node id (blueprint/web-client.md "UI state law"), carried in a URL as + * lowercase hex through the client package's one hex codec. + */ + +import { fromHex, toHex } from '@cipherbox/client'; + +/** A node id is 16 raw bytes (`crates/core` `NodeId`). */ +const NODE_ID_BYTES = 16; + +/** What the `/files/:nodeId?` param addresses. */ +export type FolderRoute = { kind: 'root' } | { kind: 'node'; id: Uint8Array } | { kind: 'invalid' }; + +/** Resolves a route param; anything that is not a node id resolves invalid. */ +export function folderRoute(param: string | undefined): FolderRoute { + if (param === undefined) return { kind: 'root' }; + if (param.length !== NODE_ID_BYTES * 2) return { kind: 'invalid' }; + try { + return { kind: 'node', id: fromHex(param) }; + } catch { + return { kind: 'invalid' }; + } +} + +/** The vault-browser route for a node id; `null` addresses the current root. */ +export function folderPath(id: Uint8Array | null): string { + return id === null ? '/files' : `/files/${toHex(id)}`; +} + +/** Node ids compare by value: callers hand in a fresh array per render. */ +export function sameNode(a: Uint8Array | null, b: Uint8Array | null): boolean { + if (a === null || b === null) return a === b; + return a.length === b.length && a.every((byte, i) => byte === b[i]); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4b3324700..f363f9b75 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,6 +2,10 @@ import './polyfills'; import './index.css'; import './styles/login.css'; +import './styles/layout.css'; +import './styles/file-browser.css'; +import './styles/breadcrumbs.css'; +import './styles/responsive.css'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; diff --git a/apps/web/src/routes/FilesPage.tsx b/apps/web/src/routes/FilesPage.tsx index 59c0d43c8..fa947bb40 100644 --- a/apps/web/src/routes/FilesPage.tsx +++ b/apps/web/src/routes/FilesPage.tsx @@ -1,16 +1,30 @@ -import { useParams } from 'react-router-dom'; -import { LogoutButton } from '../components/auth/LogoutButton'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth/useAuth'; +import { FileBrowser } from '../components/file-browser/FileBrowser'; +import { AppShell } from '../components/layout/AppShell'; -/** Placeholder for the vault browser (#805). */ +/** + * The vault browser. No route guard framework: an unauthenticated tab redirects + * on facade auth state (blueprint/web-client.md "Composition"). + */ export function FilesPage() { - // Routes key on the stable node id; an absent id is the vault's current root - // (blueprint/web-client.md "UI state law"). - const { nodeId } = useParams<{ nodeId: string }>(); + const { isAuthenticated, isReady } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (isReady && !isAuthenticated) navigate('/'); + }, [isAuthenticated, isReady, navigate]); + return ( -
-

Files

-

{nodeId ?? 'root'}

- -
+ + {isAuthenticated ? ( + + ) : ( +

+ {'// CHECKING SESSION...'} +

+ )} +
); } diff --git a/apps/web/src/styles/breadcrumbs.css b/apps/web/src/styles/breadcrumbs.css new file mode 100644 index 000000000..9d2cdc55f --- /dev/null +++ b/apps/web/src/styles/breadcrumbs.css @@ -0,0 +1,55 @@ +/* ========================================================================== + Breadcrumb Navigation - Terminal Aesthetic + ========================================================================== */ + +.breadcrumb-nav { + display: flex; + align-items: center; + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + padding: var(--spacing-xs) 0; + min-height: 2.5rem; +} + +.breadcrumb-prefix { + color: var(--color-text-secondary); +} + +.breadcrumb-separator { + color: var(--color-text-secondary); + margin: 0 2px; +} + +.breadcrumb-item { + background: transparent; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: 2px 4px; + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-normal); + border-radius: 0; + transition: color 0.15s ease; +} + +.breadcrumb-item:hover { + color: var(--color-text-primary); +} + +.breadcrumb-item:focus { + outline: none; + background-color: var(--color-green-darker); +} + +.breadcrumb-item:focus-visible { + outline: 1px solid var(--color-green-primary); + outline-offset: 1px; +} + +/* The folder on screen - slightly more prominent than its ancestors. */ +.breadcrumb-item--current { + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} diff --git a/apps/web/src/styles/file-browser.css b/apps/web/src/styles/file-browser.css new file mode 100644 index 000000000..b90fdf9e4 --- /dev/null +++ b/apps/web/src/styles/file-browser.css @@ -0,0 +1,186 @@ +/* ========================================================================== + File Browser - Terminal Aesthetic + ========================================================================== */ + +.file-browser { + display: flex; + flex-direction: column; + height: 100%; + padding: var(--spacing-sm) 20px var(--spacing-md); + overflow-x: hidden; + overflow-y: auto; + background-color: var(--color-background); + position: relative; +} + +.file-browser-error { + margin-bottom: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-error); + background-color: rgb(239 68 68 / 8%); + border: var(--border-thickness) solid rgb(239 68 68 / 20%); +} + +.file-browser-loading { + padding: var(--spacing-sm) 0; + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +/* ========================================================================== + File List + ========================================================================== */ + +.file-list { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + border: var(--border-thickness) solid var(--color-green-primary); +} + +.file-list-header { + display: grid; + grid-template-columns: 1fr 120px 180px; + gap: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + border-bottom: var(--border-thickness) solid var(--color-green-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-text-primary); + background-color: var(--color-background); +} + +.file-list-header-name, +.file-list-header-size, +.file-list-header-date { + display: flex; + align-items: center; +} + +.file-list-body { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +/* Desktop is a 3-column grid; the two row wrappers collapse to lines on mobile. */ +.file-list-item { + display: grid; + grid-template-columns: 1fr 120px 180px; + grid-template-areas: 'name size date'; + gap: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + transition: background-color 0.15s ease; + border-bottom: var(--border-thickness) solid var(--color-border-dim); + font-family: var(--font-family-mono); +} + +.file-list-item:last-child { + border-bottom: none; +} + +.file-list-item:hover, +.file-list-item:focus-visible { + background-color: var(--color-green-darker); +} + +.file-list-item--parent { + cursor: pointer; +} + +.file-list-item-row-top { + grid-area: name; + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +/* Children participate directly in the parent grid on desktop. */ +.file-list-item-row-bottom { + display: contents; +} + +.file-list-item-icon { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + flex-shrink: 0; + color: var(--color-text-primary); +} + +.file-list-item-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text-primary); + font-size: var(--font-size-sm); +} + +.file-list-item-size { + grid-area: size; + display: flex; + align-items: center; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.file-list-item-date { + grid-area: date; + display: flex; + align-items: center; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +/* Queue marker after the name: [~] unpublished, [!] dead-lettered. */ +.file-list-item-status { + flex-shrink: 0; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.file-list-item-status--dead { + color: var(--color-error); +} + +/* ========================================================================== + Empty State + ========================================================================== */ + +.empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; + margin: var(--spacing-lg); +} + +.empty-state-content { + text-align: center; + padding: var(--spacing-lg); +} + +.empty-state-ascii { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + line-height: 1.2; + color: var(--color-green-primary); + margin: 0 0 var(--spacing-md); + white-space: pre; +} + +.empty-state-text { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + margin: 0; + color: #8b9a8f; +} diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css new file mode 100644 index 000000000..8f75d14de --- /dev/null +++ b/apps/web/src/styles/layout.css @@ -0,0 +1,340 @@ +/* ========================================================================== + App Shell Layout - CSS Grid System + ========================================================================== */ + +.app-frame { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* The banner takes a row of the frame rather than overlaying the header. */ +.app-frame .staging-banner { + position: static; + flex-shrink: 0; +} + +.app-shell { + display: grid; + grid-template-rows: auto 1fr auto; + grid-template-columns: 180px 1fr; + grid-template-areas: + 'header header' + 'sidebar main' + 'footer footer'; + height: auto; + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; +} + +/* ========================================================================== + Header + ========================================================================== */ + +.app-header { + grid-area: header; + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 24px; + background-color: rgb(0 0 0 / 50%); + border-bottom: var(--border-thickness) solid var(--color-border-dim); + position: relative; + z-index: 2; +} + +.header-left { + display: flex; + align-items: center; + gap: var(--spacing-xs); +} + +.header-prompt { + font-size: 20px; + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +.header-logo { + font-size: 14px; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + letter-spacing: 1px; +} + +.header-right { + display: flex; + align-items: center; + gap: var(--spacing-xs); +} + +/* ========================================================================== + User Menu + ========================================================================== */ + +.user-menu { + position: relative; +} + +.user-menu-trigger { + display: flex; + align-items: center; + gap: 4px; + padding: 4px var(--spacing-xs); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + background: none; + border: none; + cursor: pointer; + transition: color 0.15s ease; +} + +.user-menu-trigger:hover { + color: var(--color-text-primary); +} + +.user-menu-trigger:focus-visible { + outline: 1px solid var(--color-green-primary); + outline-offset: 1px; +} + +.user-menu-email { + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-menu-caret { + font-size: 8px; +} + +.user-menu-dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 120px; + background-color: var(--color-background); + border: var(--border-thickness) solid var(--color-border-dim); + padding: 4px 0; + z-index: 100; +} + +/* Invisible bridge over the 4px gap so the pointer never leaves .user-menu. */ +.user-menu-dropdown::before { + content: ''; + position: absolute; + top: -4px; + left: 0; + right: 0; + height: 4px; +} + +/* The shared .logout-link is an inline control; a menu row must fill the sheet. */ +.user-menu-dropdown .logout-link { + display: block; + width: 100%; + padding: var(--spacing-xs) var(--spacing-sm); + text-align: left; + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +.user-menu-dropdown .logout-link:hover:not(:disabled) { + background-color: #001a11; +} + +/* ========================================================================== + Sidebar + ========================================================================== */ + +.app-sidebar { + grid-area: sidebar; + display: flex; + flex-direction: column; + justify-content: space-between; + background-color: rgb(0 0 0 / 50%); + border-right: var(--border-thickness) solid var(--color-border-dim); + overflow-y: auto; + position: relative; + z-index: 1; +} + +.sidebar-nav { + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: 4px; +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-normal); + color: var(--color-text-secondary); + text-decoration: none; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.nav-item:hover { + color: var(--color-text-primary); + text-decoration: none; +} + +.nav-item--active { + background-color: #001a11; + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); +} + +.nav-item--disabled, +.nav-item--disabled:hover { + color: var(--color-text-dim); + background-color: transparent; + cursor: default; +} + +.nav-item-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.nav-item-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-item-badge { + margin-left: auto; + flex-shrink: 0; + padding: 0 3px; + font-size: var(--font-size-xxs); + color: var(--color-text-dim); + border: var(--border-thickness) solid var(--color-border-dim); + text-transform: lowercase; +} + +/* ========================================================================== + Main Content Area + ========================================================================== */ + +.app-main { + grid-area: main; + overflow-y: auto; + background-color: rgb(0 0 0 / 90%); + position: relative; + z-index: 1; +} + +/* ========================================================================== + Footer + ========================================================================== */ + +.app-footer { + grid-area: footer; + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-xs) var(--spacing-lg); + background-color: rgb(0 0 0 / 50%); + border-top: var(--border-thickness) solid var(--color-border-dim); + position: relative; + z-index: 1; +} + +.footer-left, +.footer-center, +.footer-right { + display: flex; + align-items: center; +} + +.footer-center { + gap: var(--spacing-md); +} + +.footer-copyright { + font-size: var(--font-size-xxs); + color: var(--color-border-dim); +} + +.footer-link { + font-size: var(--font-size-xxs); + color: var(--color-text-secondary); + text-decoration: none; + transition: color 0.15s ease; +} + +.footer-link:hover { + color: var(--color-text-primary); + text-decoration: none; +} + +/* ========================================================================== + Status Indicator - the staleness ladder's rungs + ========================================================================== */ + +.status-indicator { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.status-indicator-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--color-text-secondary); +} + +.status-indicator--fresh .status-indicator-dot { + background-color: var(--color-green-primary); + box-shadow: var(--glow-green); +} + +.status-indicator--reconciling .status-indicator-dot { + background-color: var(--color-text-secondary); + animation: pulse 1.5s ease-in-out infinite; +} + +.status-indicator--stale .status-indicator-dot { + background-color: var(--color-warning); +} + +.status-indicator--offline .status-indicator-dot { + background-color: var(--color-error); +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} + +@media (prefers-reduced-motion: reduce) { + .status-indicator--reconciling .status-indicator-dot { + animation: none; + } +} diff --git a/apps/web/src/styles/responsive.css b/apps/web/src/styles/responsive.css new file mode 100644 index 000000000..33c326ba0 --- /dev/null +++ b/apps/web/src/styles/responsive.css @@ -0,0 +1,108 @@ +/* ========================================================================== + Responsive Styles - breakpoint 768px + ========================================================================== */ + +@media (max-width: 768px) { + /* Shell collapses to a single column; the sidebar has no mobile overlay. */ + .app-shell { + grid-template-columns: 1fr; + grid-template-areas: + 'header' + 'main' + 'footer'; + } + + .app-sidebar { + display: none; + } + + .app-header { + padding: var(--spacing-xs) var(--spacing-sm); + } + + .app-footer { + padding: var(--spacing-xs) var(--spacing-sm); + justify-content: center; + gap: var(--spacing-sm); + } + + .footer-center { + display: none; + } + + .footer-left, + .footer-right { + flex-shrink: 0; + } + + .user-menu-email { + max-width: 120px; + } + + .file-browser { + padding: 0 var(--spacing-sm) var(--spacing-sm); + } + + .breadcrumb-nav { + padding: var(--spacing-xs); + min-width: 0; + overflow: hidden; + } + + .breadcrumb-item { + max-width: 100px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* Rows become two lines: name on top, size and date beneath it. */ + .file-list-header { + grid-template-columns: 1fr; + } + + .file-list-header-size, + .file-list-header-date { + display: none; + } + + .file-list-item { + grid-template-columns: 1fr; + grid-template-areas: + 'name' + 'meta'; + gap: 4px; + } + + .file-list-item-row-bottom { + grid-area: meta; + display: flex; + gap: var(--spacing-sm); + } + + .file-list-item-size, + .file-list-item-date { + grid-area: auto; + } + + .empty-state { + margin: var(--spacing-md); + min-height: 200px; + } +} + +/* ========================================================================== + Touch Interactions + ========================================================================== */ + +.file-list-item { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +@media (pointer: coarse) { + .file-list-item:active { + background-color: var(--color-green-darker); + } +} diff --git a/apps/web/src/utils/format.ts b/apps/web/src/utils/format.ts new file mode 100644 index 000000000..aaac774a6 --- /dev/null +++ b/apps/web/src/utils/format.ts @@ -0,0 +1,23 @@ +/** Display formatting for the vault browser. */ + +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const; + +/** Human-readable byte count, at most one decimal place. */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1); + const value = bytes / 1024 ** exponent; + const rounded = value % 1 === 0 ? value.toString() : value.toFixed(1); + + return `${rounded} ${UNITS[exponent]}`; +} + +/** Locale-aware date for a Unix-millisecond timestamp. */ +export function formatDate(timestampMillis: number): string { + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(timestampMillis)); +} diff --git a/apps/web/src/vault/listing.test.ts b/apps/web/src/vault/listing.test.ts new file mode 100644 index 000000000..3f6e86cf5 --- /dev/null +++ b/apps/web/src/vault/listing.test.ts @@ -0,0 +1,69 @@ +import type { SnapshotChildDescriptor } from '@cipherbox/client'; +import { describe, expect, it } from 'vitest'; +import { listingRows } from './listing'; + +function child(overrides: Partial = {}): SnapshotChildDescriptor { + return { + id: new Uint8Array(16).fill(1), + name: 'notes.txt', + kind: 'file', + size: null, + mtime: null, + pending: 'none', + deadLetter: false, + contentVersion: null, + ...overrides, + }; +} + +describe('listingRows', () => { + it('puts folders first, then sorts by name case-insensitively', () => { + const rows = listingRows([ + child({ id: new Uint8Array(16).fill(1), name: 'beta.txt' }), + child({ id: new Uint8Array(16).fill(2), name: 'Alpha.txt' }), + child({ id: new Uint8Array(16).fill(3), name: 'zeta', kind: 'folder' }), + child({ id: new Uint8Array(16).fill(4), name: 'archive', kind: 'folder' }), + ]); + + expect(rows.map((row) => row.name)).toEqual(['archive', 'zeta', 'Alpha.txt', 'beta.txt']); + }); + + it('renders name and kind before the projection lands', () => { + const [row] = listingRows([child({ name: 'holiday.jpg' })]); + + expect(row.name).toBe('holiday.jpg'); + expect(row.icon).toBe('[FILE]'); + expect(row.size).toBe('...'); + expect(row.modified).toBe('...'); + }); + + it('renders size and mtime once the projection lands', () => { + const [row] = listingRows([child({ size: 1536n, mtime: 1_700_000_000_000n })]); + + expect(row.size).toBe('1.5 KB'); + expect(row.modified).not.toBe('...'); + }); + + it('has no size column for a folder', () => { + const [row] = listingRows([child({ kind: 'folder', name: 'docs' })]); + + expect(row.icon).toBe('[DIR]'); + expect(row.size).toBe('-'); + }); + + it('carries the engine queue flags verbatim', () => { + const rows = listingRows([ + child({ id: new Uint8Array(16).fill(1), name: 'a', pending: 'content' }), + child({ id: new Uint8Array(16).fill(2), name: 'b', deadLetter: true }), + ]); + + expect(rows[0].pending).toBe('content'); + expect(rows[1].deadLetter).toBe(true); + }); + + it('keys each row by its hex node id', () => { + const [row] = listingRows([child({ id: new Uint8Array(16).fill(0xcd) })]); + + expect(row.key).toBe('cd'.repeat(16)); + }); +}); diff --git a/apps/web/src/vault/listing.ts b/apps/web/src/vault/listing.ts new file mode 100644 index 000000000..c6f919d37 --- /dev/null +++ b/apps/web/src/vault/listing.ts @@ -0,0 +1,63 @@ +/** + * The engine's direct-children projection as list rows. Ordering and labels are + * the UI's; every value is the engine's word verbatim + * (blueprint/web-client.md "UI state law"). + */ + +import { toHex } from '@cipherbox/client'; +import type { NodeKind, PendingClass, SnapshotChildDescriptor } from '@cipherbox/client'; +import { formatBytes, formatDate } from '../utils/format'; + +/** Stands in for a projection the child ref does not carry yet (#27 D7). */ +const UNRESOLVED = '...'; + +/** A column with nothing to show for this kind of node. */ +const NOT_APPLICABLE = '-'; + +export interface ListingRow { + id: Uint8Array; + /** Hex node id: React key, route target, and `data-node-id`. */ + key: string; + name: string; + kind: NodeKind; + /** Terminal-style kind marker. */ + icon: string; + /** Formatted size, or `...` while the projection is still resolving. */ + size: string; + /** Formatted mtime, or `...` while the projection is still resolving. */ + modified: string; + pending: PendingClass; + deadLetter: boolean; +} + +/** + * Sorts folders first, then by name, and labels each row. The engine orders + * children by node id, which is stable but meaningless to a reader. + */ +export function listingRows(children: readonly SnapshotChildDescriptor[]): ListingRow[] { + return children.map(toRow).sort(byKindThenName); +} + +function toRow(child: SnapshotChildDescriptor): ListingRow { + const isFolder = child.kind === 'folder'; + return { + id: child.id, + key: toHex(child.id), + name: child.name, + kind: child.kind, + icon: isFolder ? '[DIR]' : '[FILE]', + size: isFolder ? NOT_APPLICABLE : projected(child.size, formatBytes), + modified: projected(child.mtime, formatDate), + pending: child.pending, + deadLetter: child.deadLetter, + }; +} + +function projected(value: bigint | null, format: (value: number) => string): string { + return value === null ? UNRESOLVED : format(Number(value)); +} + +function byKindThenName(a: ListingRow, b: ListingRow): number { + if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); +} diff --git a/apps/web/src/vault/useFolderNavigation.test.tsx b/apps/web/src/vault/useFolderNavigation.test.tsx new file mode 100644 index 000000000..920e99814 --- /dev/null +++ b/apps/web/src/vault/useFolderNavigation.test.tsx @@ -0,0 +1,242 @@ +import type { SnapshotDescriptor } from '@cipherbox/client'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; +import { FileBrowser } from '../components/file-browser/FileBrowser'; +import { fakeEngine } from '../engine/testFakes'; +import { folderPath } from '../lib/nodeId'; +import { EngineProvider } from '../providers/EngineProvider'; + +const ROOT = new Uint8Array(16).fill(0); +const DOCS = new Uint8Array(16).fill(7); +const SUB = new Uint8Array(16).fill(9); +const NOTE = new Uint8Array(16).fill(3); + +function folderView(overrides: Partial = {}): SnapshotDescriptor { + return { + root: ROOT, + folder: ROOT, + folderName: '', + children: [], + ancestors: [], + deadLetters: [], + blocked: null, + retainedRecords: 0, + staleness: 'fresh', + ...overrides, + }; +} + +function renderBrowser(engine: ReturnType, path = '/files') { + render( + engine.client}> + + + } /> + + + + ); +} + +/** Settles the pull the engine's `snapshotUpdated` event triggers. */ +async function landSnapshot( + engine: ReturnType, + view: SnapshotDescriptor +): Promise { + await act(async () => { + engine.emit({ kind: 'snapshotUpdated' }); + engine.pulls[engine.pulls.length - 1].resolve(view); + await Promise.resolve(); + }); +} + +const rowNames = () => + screen.getAllByTestId('file-list-item').map((row) => row.querySelector('.file-list-item-name')!); + +describe('the vault browser read path', () => { + it('renders the routed folder from the snapshot, folders first', async () => { + const engine = fakeEngine(); + renderBrowser(engine); + + expect(screen.getByTestId('file-browser-loading')).toBeDefined(); + + await landSnapshot( + engine, + folderView({ + children: [ + { + id: NOTE, + name: 'notes.txt', + kind: 'file', + size: 1024n, + mtime: 1_700_000_000_000n, + pending: 'none', + deadLetter: false, + contentVersion: 1n, + }, + { + id: DOCS, + name: 'documents', + kind: 'folder', + size: null, + mtime: null, + pending: 'none', + deadLetter: false, + contentVersion: null, + }, + ], + }) + ); + + expect(rowNames().map((cell) => cell.textContent)).toEqual(['documents', 'notes.txt']); + expect(screen.queryByTestId('parent-dir-row')).toBeNull(); + }); + + it('shows the empty state for a folder with no children', async () => { + const engine = fakeEngine(); + renderBrowser(engine); + await landSnapshot(engine, folderView()); + + expect(screen.getByTestId('empty-state')).toBeDefined(); + }); + + it('renders name and kind before size and mtime resolve, then repaints', async () => { + const engine = fakeEngine(); + renderBrowser(engine); + + const unresolved = { + id: NOTE, + name: 'holiday.jpg', + kind: 'file' as const, + size: null, + mtime: null, + pending: 'none' as const, + deadLetter: false, + contentVersion: null, + }; + await landSnapshot(engine, folderView({ children: [unresolved] })); + + const row = screen.getByTestId('file-list-item'); + expect(row.querySelector('.file-list-item-name')?.textContent).toBe('holiday.jpg'); + expect(row.querySelector('.file-list-item-size')?.textContent).toBe('...'); + + await landSnapshot( + engine, + folderView({ children: [{ ...unresolved, size: 2048n, mtime: 1_700_000_000_000n }] }) + ); + + expect( + screen.getByTestId('file-list-item').querySelector('.file-list-item-size')?.textContent + ).toBe('2 KB'); + }); + + it('moves the engine focus window when a folder is opened', async () => { + const engine = fakeEngine(); + renderBrowser(engine); + await landSnapshot( + engine, + folderView({ + children: [ + { + id: DOCS, + name: 'documents', + kind: 'folder', + size: null, + mtime: null, + pending: 'none', + deadLetter: false, + contentVersion: null, + }, + ], + }) + ); + + await act(async () => { + fireEvent.doubleClick(screen.getByTestId('file-list-item')); + await Promise.resolve(); + }); + + expect(engine.focus).toEqual([DOCS]); + expect(engine.reported).toEqual([DOCS]); + + await act(async () => { + engine.ackFocus(); + await Promise.resolve(); + engine.pulls[engine.pulls.length - 1].resolve( + folderView({ + folder: DOCS, + folderName: 'documents', + ancestors: [{ id: ROOT, name: '' }], + }) + ); + await Promise.resolve(); + }); + + expect(screen.getByTestId('parent-dir-row')).toBeDefined(); + }); + + it('builds the breadcrumb chain from the ancestor trail, root first', async () => { + const engine = fakeEngine(); + renderBrowser(engine, folderPath(SUB)); + + await act(async () => { + engine.ackFocus(); + await Promise.resolve(); + engine.pulls[engine.pulls.length - 1].resolve( + folderView({ + folder: SUB, + folderName: 'sub', + ancestors: [ + { id: DOCS, name: 'documents' }, + { id: ROOT, name: '' }, + ], + }) + ); + await Promise.resolve(); + }); + + const crumbs = screen.getByTestId('breadcrumbs').querySelectorAll('.breadcrumb-item'); + expect([...crumbs].map((crumb) => crumb.textContent)).toEqual(['root', 'documents', 'sub']); + expect(crumbs[crumbs.length - 1].getAttribute('aria-current')).toBe('page'); + }); + + it('does not list a folder the routed id does not match', async () => { + const engine = fakeEngine(); + renderBrowser(engine, folderPath(SUB)); + + // The folder just left answers late; it must not paint under the new route. + await act(async () => { + engine.ackFocus(); + await Promise.resolve(); + engine.pulls[engine.pulls.length - 1].resolve( + folderView({ + children: [ + { + id: NOTE, + name: 'stale.txt', + kind: 'file', + size: null, + mtime: null, + pending: 'none', + deadLetter: false, + contentVersion: null, + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(screen.queryByTestId('file-list-item')).toBeNull(); + expect(screen.getByTestId('file-browser-loading')).toBeDefined(); + }); + + it('refuses a route param that is not a node id', () => { + const engine = fakeEngine(); + renderBrowser(engine, '/files/not-a-node'); + + expect(screen.getByTestId('file-browser-error').textContent).toBe('that is not a folder id'); + expect(engine.focus).toEqual([]); + }); +}); diff --git a/apps/web/src/vault/useFolderNavigation.ts b/apps/web/src/vault/useFolderNavigation.ts new file mode 100644 index 000000000..63655cc67 --- /dev/null +++ b/apps/web/src/vault/useFolderNavigation.ts @@ -0,0 +1,85 @@ +/** + * The vault browser's read path: the routed folder, the snapshot the engine + * reports for it, and the navigation that moves both. The engine's focus window + * follows the route — there is no client-side tree to walk, only the direct + * children and the ancestor trail the snapshot carries. + */ + +import { useCallback, useEffect, useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import type { BreadcrumbDescriptor } from '@cipherbox/client'; +import type { SnapshotError } from '../engine/snapshotStore'; +import { useSnapshot } from '../engine/useSnapshot'; +import { folderPath, folderRoute, sameNode } from '../lib/nodeId'; +import { useSnapshotStore } from '../providers/EngineProvider'; +import { listingRows, type ListingRow } from './listing'; + +const NOT_A_FOLDER: SnapshotError = { message: 'that is not a folder id' }; + +export interface FolderNavigation { + /** Direct children of the routed folder, folders first. */ + rows: ListingRow[]; + /** Root-first trail, ending at the folder on screen. */ + breadcrumbs: BreadcrumbDescriptor[]; + /** True until the engine reports a snapshot *of the routed folder*. */ + isLoading: boolean; + isRoot: boolean; + error: SnapshotError | null; + navigateTo(node: Uint8Array): void; + /** Steps to the nearest ancestor; a no-op at the root. */ + navigateUp(): void; +} + +export function useFolderNavigation(): FolderNavigation { + const { nodeId } = useParams<{ nodeId: string }>(); + const navigate = useNavigate(); + const store = useSnapshotStore(); + const { view, error } = useSnapshot(); + + const route = useMemo(() => folderRoute(nodeId), [nodeId]); + + useEffect(() => { + if (route.kind === 'invalid') return; + store.setFocus(route.kind === 'node' ? route.id : null); + }, [route, store]); + + // A pull for the folder just left still lands under the new route, so the + // view is only this route's answer once its folder matches. + const listed = + view !== null && sameNode(view.folder, route.kind === 'node' ? route.id : view.root) + ? view + : null; + + const navigateTo = useCallback( + (node: Uint8Array) => { + // The root is addressable as itself, but `/files` survives a root cut. + navigate(folderPath(listed !== null && sameNode(node, listed.root) ? null : node)); + }, + [listed, navigate] + ); + + const navigateUp = useCallback(() => { + const parent = listed?.ancestors[0]; + if (parent) navigateTo(parent.id); + }, [listed, navigateTo]); + + const breadcrumbs = useMemo( + () => + listed === null + ? [] + : [...listed.ancestors].reverse().concat({ id: listed.folder, name: listed.folderName }), + [listed] + ); + + const rows = useMemo(() => (listed === null ? [] : listingRows(listed.children)), [listed]); + + return { + rows, + breadcrumbs, + isLoading: route.kind !== 'invalid' && listed === null && error === null, + isRoot: listed !== null && sameNode(listed.folder, listed.root), + error: route.kind === 'invalid' ? NOT_A_FOLDER : error, + navigateTo, + navigateUp, + }; +} diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs index d79de189c..38c80cd39 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -233,6 +233,10 @@ pub struct SnapshotView { pub root: NodeId, /// The folder this view lists. pub folder: NodeId, + /// That folder's own name, empty at the root. A host cannot recover it from + /// `ancestors` (which starts at the parent) and must not cache a name across + /// a navigation, so the view carries it. + pub folder_name: String, /// Direct children, deterministically ordered by node id. pub children: Vec, /// Ancestor trail from the folder's parent up to and including the root, @@ -2350,6 +2354,10 @@ impl Engine { .unwrap_or_default(), }) .collect(); + let folder_name = rendered + .node(folder) + .map(|meta| meta.name.clone()) + .unwrap_or_default(); let dead_letters = dead .iter() .map(|(op_id, (_, reason))| DeadLetter { @@ -2368,6 +2376,7 @@ impl Engine { Ok(SnapshotView { root: rendered.root, folder, + folder_name, children, ancestors, dead_letters, @@ -3439,6 +3448,7 @@ mod tests { assert!(view.dead_letters.is_empty()); assert_eq!(view.retained_records, 0); assert!(view.ancestors.is_empty(), "the root has no ancestors"); + assert!(view.folder_name.is_empty(), "the root has no name"); } #[test] @@ -3574,6 +3584,10 @@ mod tests { ], "nearest first, ending at the root" ); + assert_eq!( + view.folder_name, "sub", + "the listed folder names itself; the trail starts at its parent" + ); } #[test] diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 452972424..fd812479e 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -419,6 +419,12 @@ impl SnapshotView { self.inner.folder.0.to_vec() } + /// The listed folder's own name, empty at the root. + #[wasm_bindgen(getter, js_name = folderName)] + pub fn folder_name(&self) -> String { + self.inner.folder_name.clone() + } + /// Direct children, deterministically ordered by node id. #[wasm_bindgen(getter)] pub fn children(&self) -> Vec { @@ -953,6 +959,7 @@ mod tests { let view = SnapshotView::from_facade(facade::SnapshotView { root: facade::NodeId([1u8; 16]), folder: facade::NodeId([2u8; 16]), + folder_name: "holiday".into(), children: vec![ facade::SnapshotChild { id: facade::NodeId([3u8; 16]), @@ -1000,6 +1007,7 @@ mod tests { assert_eq!(view.root(), vec![1u8; 16]); assert_eq!(view.folder(), vec![2u8; 16]); + assert_eq!(view.folder_name(), "holiday"); let dead_letters = view.dead_letters(); assert_eq!( dead_letters diff --git a/crates/wasm/tests/boundary.rs b/crates/wasm/tests/boundary.rs index 09f69e801..e6d85af21 100644 --- a/crates/wasm/tests/boundary.rs +++ b/crates/wasm/tests/boundary.rs @@ -222,6 +222,7 @@ fn snapshot_view_getters_cross_with_boundary_shapes() { let view: JsValue = SnapshotView::from_facade(facade::SnapshotView { root: facade::NodeId([1u8; 16]), folder: facade::NodeId([2u8; 16]), + folder_name: "holiday".into(), children: vec![ facade::SnapshotChild { id: facade::NodeId([3u8; 16]), @@ -275,6 +276,12 @@ fn snapshot_view_getters_cross_with_boundary_shapes() { vec![2u8; 16] ); + assert_eq!( + get(&view, "folderName").as_string().as_deref(), + Some("holiday"), + "folderName must cross under that JS name" + ); + assert_eq!( get(&view, "retainedRecords").as_f64(), Some(0.0), diff --git a/packages/client/src/broadcastTransport.test.ts b/packages/client/src/broadcastTransport.test.ts index 5efcc5492..591ae710b 100644 --- a/packages/client/src/broadcastTransport.test.ts +++ b/packages/client/src/broadcastTransport.test.ts @@ -180,6 +180,7 @@ describe('broadcast transport ↔ leader relay', () => { const view: SnapshotDescriptor = { root: new Uint8Array(16).fill(1), folder: new Uint8Array(16).fill(2), + folderName: 'holiday', children: [ { id: new Uint8Array(16).fill(3), diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index afaf30551..f2862534b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -36,8 +36,8 @@ export { MediaService } from './media/service.js'; export type { MediaReader } from './media/broker.js'; // The one hex codec in TypeScript, for hosts that receive hex-encoded bytes -// from a third-party SDK. -export { fromHex } from './seams/bytes.js'; +// from a third-party SDK or address opaque engine byte strings by string key. +export { fromHex, toHex } from './seams/bytes.js'; // The wire descriptors the UI exchanges with the engine over the transport. export type { diff --git a/packages/client/src/testkit.ts b/packages/client/src/testkit.ts index 46790efdb..8fb1e03bf 100644 --- a/packages/client/src/testkit.ts +++ b/packages/client/src/testkit.ts @@ -58,6 +58,7 @@ export function emptySnapshot(folder: Uint8Array = new Uint8Array(16)): Snapshot return { root: new Uint8Array(16), folder, + folderName: '', children: [], ancestors: [], deadLetters: [], diff --git a/packages/client/src/worker/commandCodec.test.ts b/packages/client/src/worker/commandCodec.test.ts index ce8a1d8eb..f93b55e34 100644 --- a/packages/client/src/worker/commandCodec.test.ts +++ b/packages/client/src/worker/commandCodec.test.ts @@ -165,6 +165,7 @@ function baseView(): WasmSnapshotView { return { root: new Uint8Array(16), folder: new Uint8Array(16), + folderName: '', children: [], ancestors: [], deadLetters: [], @@ -178,6 +179,7 @@ describe('readSnapshot', () => { const view: WasmSnapshotView = { root: new Uint8Array(16).fill(1), folder: new Uint8Array(16).fill(2), + folderName: 'holiday', children: [ { id: new Uint8Array(16).fill(3), @@ -221,6 +223,7 @@ describe('readSnapshot', () => { expect(readSnapshot(fakeWasm, view)).toEqual({ root: new Uint8Array(16).fill(1), folder: new Uint8Array(16).fill(2), + folderName: 'holiday', children: [ { id: new Uint8Array(16).fill(3), diff --git a/packages/client/src/worker/commandCodec.ts b/packages/client/src/worker/commandCodec.ts index 245b79f60..79e000284 100644 --- a/packages/client/src/worker/commandCodec.ts +++ b/packages/client/src/worker/commandCodec.ts @@ -245,6 +245,7 @@ export function readSnapshot(wasm: EngineWasm, view: WasmSnapshotView): Snapshot return { root: view.root, folder: view.folder, + folderName: view.folderName, children: view.children.map((child) => ({ id: child.id, name: child.name, diff --git a/packages/client/src/worker/engineWasm.ts b/packages/client/src/worker/engineWasm.ts index fb949e6ef..ab6a69168 100644 --- a/packages/client/src/worker/engineWasm.ts +++ b/packages/client/src/worker/engineWasm.ts @@ -69,6 +69,7 @@ export interface WasmBlockedOp { export interface WasmSnapshotView { readonly root: Uint8Array; readonly folder: Uint8Array; + readonly folderName: string; readonly children: WasmSnapshotChild[]; readonly ancestors: WasmBreadcrumb[]; readonly deadLetters: readonly WasmDeadLetter[]; diff --git a/packages/client/src/worker/protocol.ts b/packages/client/src/worker/protocol.ts index 050c04f8b..0da037a29 100644 --- a/packages/client/src/worker/protocol.ts +++ b/packages/client/src/worker/protocol.ts @@ -97,6 +97,8 @@ export interface SnapshotChildDescriptor { export interface SnapshotDescriptor { root: Uint8Array; folder: Uint8Array; + /** The listed folder's own name, empty at the root. */ + folderName: string; children: SnapshotChildDescriptor[]; ancestors: BreadcrumbDescriptor[]; deadLetters: DeadLetterDescriptor[]; From 2d3a75b82ad1796d9b003ed8b6b5cace13071d85 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Mon, 3 Aug 2026 20:59:40 +0200 Subject: [PATCH 2/2] fix(web): guard an out-of-range mtime and widen keyboard activation The engine's mtime is a u64, but JS `Date` tops out at 8.64e15 ms. Past that `Intl.DateTimeFormat` throws `RangeError: Invalid time value`, so a single child ref authored elsewhere blanked the whole listing. Render those as `-`. Also activate a file row on Space, matching the `[..]` row beside it, and move the user menu's Escape handler onto the container so it still closes once focus moves into the dropdown. --- .../components/file-browser/FileListItem.tsx | 2 +- .../src/components/layout/UserMenu.test.tsx | 40 +++++++++++++++++++ apps/web/src/components/layout/UserMenu.tsx | 11 +++-- apps/web/src/vault/listing.test.ts | 13 ++++++ apps/web/src/vault/listing.ts | 17 ++++++-- .../src/vault/useFolderNavigation.test.tsx | 29 ++++++++++++++ 6 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/layout/UserMenu.test.tsx diff --git a/apps/web/src/components/file-browser/FileListItem.tsx b/apps/web/src/components/file-browser/FileListItem.tsx index 75d7ae648..ff90386ab 100644 --- a/apps/web/src/components/file-browser/FileListItem.tsx +++ b/apps/web/src/components/file-browser/FileListItem.tsx @@ -22,7 +22,7 @@ export function FileListItem({ row, onOpen }: FileListItemProps) { tabIndex={0} onDoubleClick={open} onKeyDown={(event) => { - if (event.key !== 'Enter') return; + if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); open(); }} diff --git a/apps/web/src/components/layout/UserMenu.test.tsx b/apps/web/src/components/layout/UserMenu.test.tsx new file mode 100644 index 000000000..2b29b6f86 --- /dev/null +++ b/apps/web/src/components/layout/UserMenu.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it } from 'vitest'; +import { UserMenu } from './UserMenu'; +import { authStore } from '../../stores/auth.store'; +import { fakeCoreKitSession, fakeEngineClient, pageWrapper } from '../../test/authFakes'; + +function renderMenu() { + const Providers = pageWrapper(fakeEngineClient().client, fakeCoreKitSession().session); + return render( + + + + + + ); +} + +afterEach(() => authStore.signedOut()); + +describe('UserMenu', () => { + it('closes on Escape pressed inside the dropdown, not just on the trigger', () => { + authStore.signedIn('google', 'user@example.test'); + renderMenu(); + + fireEvent.click(screen.getByRole('button', { expanded: false })); + const logout = screen.getByTestId('logout-button'); + + fireEvent.keyDown(logout, { key: 'Escape' }); + + expect(screen.queryByTestId('logout-button')).toBeNull(); + }); + + it('names a wallet login that carries no email', () => { + authStore.signedIn('wallet', null); + renderMenu(); + + expect(screen.getByTestId('user-menu').textContent).toContain('[an0n]'); + }); +}); diff --git a/apps/web/src/components/layout/UserMenu.tsx b/apps/web/src/components/layout/UserMenu.tsx index 29a039a10..712d92a43 100644 --- a/apps/web/src/components/layout/UserMenu.tsx +++ b/apps/web/src/components/layout/UserMenu.tsx @@ -2,7 +2,10 @@ import { useState } from 'react'; import { LogoutButton } from '../auth/LogoutButton'; import { useAuthState } from '../../stores/auth.store'; -/** Who is signed in, and the way out. */ +/** + * Who is signed in, and the way out. Escape is handled on the container, not the + * trigger, so it still closes once focus moves into the dropdown. + */ export function UserMenu() { const { email } = useAuthState(); const [isOpen, setIsOpen] = useState(false); @@ -13,6 +16,9 @@ export function UserMenu() { data-testid="user-menu" onMouseEnter={() => setIsOpen(true)} onMouseLeave={() => setIsOpen(false)} + onKeyDown={(event) => { + if (event.key === 'Escape') setIsOpen(false); + }} >