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..ff90386ab --- /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' && event.key !== ' ') 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.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 new file mode 100644 index 000000000..712d92a43 --- /dev/null +++ b/apps/web/src/components/layout/UserMenu.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { LogoutButton } from '../auth/LogoutButton'; +import { useAuthState } from '../../stores/auth.store'; + +/** + * 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); + + return ( +
setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + onKeyDown={(event) => { + if (event.key === 'Escape') 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..749dadf3a --- /dev/null +++ b/apps/web/src/vault/listing.test.ts @@ -0,0 +1,82 @@ +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('renders an mtime past the Date range rather than throwing out of Intl', () => { + // A u64 mtime authored elsewhere must not blank the listing. + const [row] = listingRows([child({ mtime: 8_640_000_000_000_001n })]); + + expect(row.modified).toBe('-'); + }); + + it('renders the largest mtime the Date range still admits', () => { + const [row] = listingRows([child({ mtime: 8_640_000_000_000_000n })]); + + 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..9eed03408 --- /dev/null +++ b/apps/web/src/vault/listing.ts @@ -0,0 +1,72 @@ +/** + * 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 = '-'; + +/** JS `Date` tops out here; the engine's mtime is a u64 and can exceed it. */ +const MAX_DATE_MILLIS = 8_640_000_000_000_000n; + +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 : projectedSize(child.size), + modified: projectedDate(child.mtime), + pending: child.pending, + deadLetter: child.deadLetter, + }; +} + +function projectedSize(value: bigint | null): string { + return value === null ? UNRESOLVED : formatBytes(Number(value)); +} + +/** Out of range, `Intl` throws on the `Date` and takes the whole listing with it. */ +function projectedDate(value: bigint | null): string { + if (value === null) return UNRESOLVED; + return value > MAX_DATE_MILLIS ? NOT_APPLICABLE : formatDate(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..f8be06172 --- /dev/null +++ b/apps/web/src/vault/useFolderNavigation.test.tsx @@ -0,0 +1,271 @@ +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.each([['Enter'], [' ']])('opens a folder from the keyboard with %j', async (key) => { + 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.keyDown(screen.getByTestId('file-list-item'), { key }); + await Promise.resolve(); + }); + + expect(engine.focus).toEqual([DOCS]); + }); + + 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[];