diff --git a/apps/web/src/components/file-browser/FileBrowser.tsx b/apps/web/src/components/file-browser/FileBrowser.tsx
index 0e2931d31..67f6ddaeb 100644
--- a/apps/web/src/components/file-browser/FileBrowser.tsx
+++ b/apps/web/src/components/file-browser/FileBrowser.tsx
@@ -2,10 +2,11 @@ import { useFolderNavigation } from '../../vault/useFolderNavigation';
import { Breadcrumbs } from './Breadcrumbs';
import { EmptyState } from './EmptyState';
import { FileList } from './FileList';
+import { UploadPanel } from './UploadPanel';
/** 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 } =
+ const { rows, folder, breadcrumbs, isLoading, isRoot, error, navigateTo, navigateUp } =
useFolderNavigation();
const settled = !isLoading && error === null;
@@ -22,6 +23,9 @@ export function FileBrowser() {
{'// LOADING VAULT...'}
)}
+ {/* Mounted whatever the route says, so a running upload survives a folder
+ change; only its drop target waits for a folder that can take one. */}
+
{/* An empty non-root folder still lists, so `[..]` remains reachable. */}
{settled && (rows.length > 0 || !isRoot) && (
= {}): UploadEntry {
+ return {
+ id: 'upload-1',
+ name: 'report.pdf',
+ size: 2048,
+ phase: 'staging',
+ progress: 0,
+ opId: null,
+ error: null,
+ code: null,
+ ...overrides,
+ };
+}
+
+function show(upload: UploadEntry) {
+ const handlers = { onCancel: vi.fn(), onRetry: vi.fn(), onDismiss: vi.fn() };
+ render();
+ return handlers;
+}
+
+describe('an upload row', () => {
+ it('quotes the confirmed fraction once the drain reports blocks', () => {
+ show(entry({ phase: 'uploading', progress: 0.5, opId: 1n }));
+
+ const bar = screen.getByRole('progressbar');
+ expect(bar.getAttribute('aria-valuenow')).toBe('50');
+ expect(screen.getByTestId('upload-row-status').textContent).toBe('50%');
+ });
+
+ it('shimmers only while the client is feeding the engine', () => {
+ show(entry({ phase: 'staging' }));
+
+ const bar = screen.getByRole('progressbar');
+ expect(bar.getAttribute('aria-valuenow')).toBeNull();
+ expect(bar.className).toContain('upload-row-track--indeterminate');
+ });
+
+ it('leaves a queued row still, because nothing is moving yet', () => {
+ show(entry({ phase: 'queued', opId: 1n }));
+
+ const bar = screen.getByRole('progressbar');
+ expect(bar.getAttribute('aria-valuetext')).toBe('queued');
+ expect(bar.className).not.toContain('upload-row-track--indeterminate');
+ });
+
+ it('offers cancel while the engine still has work', () => {
+ const handlers = show(entry({ phase: 'uploading', opId: 1n }));
+
+ fireEvent.click(screen.getByLabelText('Cancel upload of report.pdf'));
+
+ expect(handlers.onCancel).toHaveBeenCalledWith('upload-1');
+ expect(screen.queryByLabelText('Retry upload of report.pdf')).toBeNull();
+ });
+
+ it('offers retry and dismiss once a row has failed for good', () => {
+ const handlers = show(entry({ phase: 'failed', error: 'no reachable pin provider' }));
+
+ fireEvent.click(screen.getByLabelText('Retry upload of report.pdf'));
+ fireEvent.click(screen.getByLabelText('Dismiss upload of report.pdf'));
+
+ expect(handlers.onRetry).toHaveBeenCalledWith('upload-1');
+ expect(handlers.onDismiss).toHaveBeenCalledWith('upload-1');
+ expect(screen.queryByRole('progressbar')).toBeNull();
+ expect(screen.getByRole('alert').textContent).toBe('no reachable pin provider');
+ });
+
+ it('lets a cancelled row be cleared, so none can strand its file', () => {
+ const handlers = show(entry({ phase: 'cancelled', opId: 1n }));
+
+ fireEvent.click(screen.getByLabelText('Dismiss upload of report.pdf'));
+
+ expect(handlers.onDismiss).toHaveBeenCalledWith('upload-1');
+ expect(screen.queryByLabelText('Cancel upload of report.pdf')).toBeNull();
+ });
+
+ it('marks an over-budget refusal apart from a failure that will never clear', () => {
+ show(
+ entry({
+ phase: 'failed',
+ code: 'overBudget',
+ error: 'this write needs 900 bytes but only 100 are free',
+ })
+ );
+
+ expect(screen.getByTestId('upload-row-error').className).toContain(
+ 'upload-row-error--transient'
+ );
+ });
+
+ it('marks a stopped attempt as retryable, not settled', () => {
+ show(entry({ phase: 'stalled', opId: 1n, error: 'no reachable pin provider' }));
+
+ expect(screen.getByTestId('upload-row-error').className).toContain(
+ 'upload-row-error--transient'
+ );
+ expect(screen.getByTestId('upload-row-error').getAttribute('role')).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/file-browser/UploadListItem.tsx b/apps/web/src/components/file-browser/UploadListItem.tsx
new file mode 100644
index 000000000..0c0cc23a5
--- /dev/null
+++ b/apps/web/src/components/file-browser/UploadListItem.tsx
@@ -0,0 +1,113 @@
+import { isActiveUpload, type UploadEntry, type UploadPhase } from '../../hooks/useDropUpload';
+import { formatBytes } from '../../utils/format';
+
+interface UploadListItemProps {
+ upload: UploadEntry;
+ onCancel: (id: string) => void;
+ onRetry: (id: string) => void;
+ onDismiss: (id: string) => void;
+}
+
+const LABELS: Record = {
+ staging: 'sealing',
+ queued: 'queued',
+ uploading: 'uploading',
+ uploaded: 'done',
+ stalled: 'retrying',
+ cancelled: 'cancelled',
+ failed: 'failed',
+};
+
+/** One in-flight upload, in the columns the listing below it uses. */
+export function UploadListItem({ upload, onCancel, onRetry, onDismiss }: UploadListItemProps) {
+ const { id, name, phase, error } = upload;
+ const percent = Math.round(upload.progress * 100);
+ const settled = !isActiveUpload(phase);
+ const measured = phase === 'uploading';
+ // Only the row the client is actually feeding animates; a queued or retrying
+ // one has nothing moving to report.
+ const indeterminate = phase === 'staging';
+ // An over-budget refusal is a ceiling and a stopped attempt is retried, so
+ // neither reads as the settled red of a row that will never publish.
+ const transient = phase === 'stalled' || upload.code === 'overBudget';
+
+ return (
+
+
+
+ {phase === 'failed' ? '[!]' : '[^]'}
+
+
+
{name}
+ {!settled && (
+
+ )}
+
+
+
+ {formatBytes(upload.size)}
+
+
+ {phase === 'uploading' ? `${percent}%` : LABELS[phase]}
+
+ {!settled && (
+
+ )}
+ {settled && (
+ <>
+ {phase !== 'uploaded' && (
+
+ )}
+ {/* Every settled row is clearable, so none can strand its `File`. */}
+
+ >
+ )}
+
+
+ {error !== null && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/file-browser/UploadPanel.test.tsx b/apps/web/src/components/file-browser/UploadPanel.test.tsx
new file mode 100644
index 000000000..e57af6902
--- /dev/null
+++ b/apps/web/src/components/file-browser/UploadPanel.test.tsx
@@ -0,0 +1,93 @@
+import type { EngineClient, EventDescriptor } from '@cipherbox/client';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { EngineProvider } from '../../providers/EngineProvider';
+import { UploadPanel } from './UploadPanel';
+
+const FOLDER = new Uint8Array(16).fill(7);
+
+/** The write-handle surface `useDropUpload` drives, held open at the commit. */
+function uploadEngine() {
+ let settle = () => undefined as void;
+ const facade = {
+ subscribe: (_listener: (event: EventDescriptor) => void) => () => undefined,
+ snapshot: () => new Promise(() => undefined),
+ setFocus: () => Promise.resolve(),
+ beginWrite: vi.fn(() => Promise.resolve(1n)),
+ pushChunk: vi.fn(() => Promise.resolve()),
+ commitWrite: vi.fn(
+ () =>
+ new Promise((resolve) => {
+ settle = () => resolve(1n);
+ })
+ ),
+ abortWrite: vi.fn(() => Promise.resolve()),
+ cancelUpload: vi.fn(() => Promise.resolve()),
+ };
+ const client = {
+ facade,
+ reportFocus: () => undefined,
+ dispose: () => Promise.resolve(),
+ } as unknown as EngineClient;
+ return { client, facade, settle: () => settle() };
+}
+
+function draw(client: EngineClient, folder: Uint8Array | null) {
+ return render(
+ client}>
+
+
+ );
+}
+
+describe('the upload panel', () => {
+ it('offers no drop target when no folder can take one', () => {
+ draw(uploadEngine().client, null);
+ expect(screen.queryByTestId('upload-zone')).toBeNull();
+ });
+
+ it('keeps a running upload on screen across a folder change', async () => {
+ const engine = uploadEngine();
+ const { rerender } = draw(engine.client, FOLDER);
+
+ fireEvent.change(screen.getByLabelText('Choose files to upload'), {
+ target: { files: [new File(['x'], 'notes.txt')] },
+ });
+ await waitFor(() => expect(screen.getByTestId('upload-row')).toBeTruthy());
+
+ // The next folder's snapshot has not landed, so there is nowhere to drop.
+ rerender(
+ engine.client}>
+
+
+ );
+
+ expect(screen.queryByTestId('upload-zone')).toBeNull();
+ expect(screen.getByTestId('upload-row')).toBeTruthy();
+ expect(screen.getByLabelText('Cancel upload of notes.txt')).toBeTruthy();
+ // The write kept its handle rather than being torn down with the zone.
+ expect(engine.facade.abortWrite).not.toHaveBeenCalled();
+ await act(async () => {
+ engine.settle();
+ });
+ });
+
+ it('drops files into the folder on screen', async () => {
+ const engine = uploadEngine();
+ draw(engine.client, FOLDER);
+
+ fireEvent.drop(screen.getByTestId('upload-zone'), {
+ dataTransfer: { files: [new File(['x'], 'notes.txt')], types: ['Files'], dropEffect: 'none' },
+ });
+
+ await waitFor(() =>
+ expect(engine.facade.beginWrite).toHaveBeenCalledWith(
+ { parent: FOLDER, name: 'notes.txt' },
+ 1
+ )
+ );
+ await act(async () => {
+ engine.settle();
+ });
+ });
+});
diff --git a/apps/web/src/components/file-browser/UploadPanel.tsx b/apps/web/src/components/file-browser/UploadPanel.tsx
new file mode 100644
index 000000000..b0301b9ee
--- /dev/null
+++ b/apps/web/src/components/file-browser/UploadPanel.tsx
@@ -0,0 +1,42 @@
+import { isActiveUpload, useDropUpload } from '../../hooks/useDropUpload';
+import { UploadListItem } from './UploadListItem';
+import { UploadZone } from './UploadZone';
+
+interface UploadPanelProps {
+ /** Where a drop lands, or `null` when nothing on screen can take one. */
+ folder: Uint8Array | null;
+}
+
+/**
+ * The upload surface. It owns the upload rows so a block-confirmed event
+ * repaints them alone, not the listing underneath, and it outlives a folder
+ * change so a running upload keeps its row, its controls, and its subscription
+ * to the engine's reports — only the drop target follows the folder.
+ */
+export function UploadPanel({ folder }: UploadPanelProps) {
+ const { uploads, upload, cancel, retry, dismiss } = useDropUpload();
+
+ return (
+ <>
+ {folder !== null && (
+ upload(files, folder)}
+ busy={uploads.some((entry) => isActiveUpload(entry.phase))}
+ />
+ )}
+ {uploads.length > 0 && (
+
+ {uploads.map((entry) => (
+
+ ))}
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/components/file-browser/UploadZone.test.tsx b/apps/web/src/components/file-browser/UploadZone.test.tsx
new file mode 100644
index 000000000..59e5663ef
--- /dev/null
+++ b/apps/web/src/components/file-browser/UploadZone.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { UploadZone } from './UploadZone';
+
+const dropped = (files: File[]) => ({
+ dataTransfer: { files, types: ['Files'], dropEffect: 'none' },
+});
+
+describe('the upload drop zone', () => {
+ it('hands dropped files to its caller', () => {
+ const onFiles = vi.fn();
+ render();
+ const zone = screen.getByTestId('upload-zone');
+ const file = new File(['x'], 'notes.txt');
+
+ fireEvent.drop(zone, dropped([file]));
+
+ expect(onFiles).toHaveBeenCalledWith([file]);
+ });
+
+ it('ignores a drag that carries no files', () => {
+ const onFiles = vi.fn();
+ render();
+ const zone = screen.getByTestId('upload-zone');
+
+ fireEvent.dragEnter(zone, { dataTransfer: { files: [], types: ['text/plain'] } });
+ expect(zone.className).not.toContain('upload-zone--dragging');
+
+ fireEvent.drop(zone, { dataTransfer: { files: [], types: ['text/plain'] } });
+ expect(onFiles).not.toHaveBeenCalled();
+ });
+
+ it('highlights only while the drag is still over the zone', () => {
+ render();
+ const zone = screen.getByTestId('upload-zone');
+
+ fireEvent.dragEnter(zone, dropped([]));
+ // Crossing a child fires enter/leave pairs the highlight must survive.
+ fireEvent.dragEnter(zone, dropped([]));
+ fireEvent.dragLeave(zone);
+ expect(zone.className).toContain('upload-zone--dragging');
+
+ fireEvent.dragLeave(zone);
+ expect(zone.className).not.toContain('upload-zone--dragging');
+ });
+
+ it('hands picked files over and clears the picker so the same file can repeat', () => {
+ const onFiles = vi.fn();
+ render();
+ const picker = screen.getByLabelText('Choose files to upload') as HTMLInputElement;
+ const file = new File(['x'], 'notes.txt');
+
+ fireEvent.change(picker, { target: { files: [file] } });
+
+ expect(onFiles).toHaveBeenCalledWith([file]);
+ expect(picker.value).toBe('');
+ });
+
+ it('says so while an upload is running', () => {
+ render();
+ expect(screen.getByTestId('upload-zone-pick').textContent).toContain('UPLOADING');
+ });
+});
diff --git a/apps/web/src/components/file-browser/UploadZone.tsx b/apps/web/src/components/file-browser/UploadZone.tsx
new file mode 100644
index 000000000..50dc32d57
--- /dev/null
+++ b/apps/web/src/components/file-browser/UploadZone.tsx
@@ -0,0 +1,85 @@
+import { useRef, useState, type DragEvent } from 'react';
+
+interface UploadZoneProps {
+ /** Handed the dropped or picked files; never called with an empty list. */
+ onFiles: (files: File[]) => void;
+ /** True while the engine still has an upload in hand. */
+ busy: boolean;
+}
+
+/** Whether a drag carries files from outside the page rather than a page value. */
+function carriesFiles(transfer: DataTransfer): boolean {
+ return Array.from(transfer.types).includes('Files');
+}
+
+/** Where files enter the vault: a drop target that doubles as a file picker. */
+export function UploadZone({ onFiles, busy }: UploadZoneProps) {
+ const [dragging, setDragging] = useState(false);
+ // `dragleave` fires for every child the pointer crosses.
+ const depth = useRef(0);
+ const picker = useRef(null);
+
+ const enter = (event: DragEvent) => {
+ if (!carriesFiles(event.dataTransfer)) return;
+ depth.current += 1;
+ setDragging(true);
+ };
+
+ const leave = () => {
+ depth.current = Math.max(depth.current - 1, 0);
+ if (depth.current === 0) setDragging(false);
+ };
+
+ const over = (event: DragEvent) => {
+ if (!carriesFiles(event.dataTransfer)) return;
+ // Without this the browser navigates to the dropped file instead.
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ };
+
+ const drop = (event: DragEvent) => {
+ event.preventDefault();
+ depth.current = 0;
+ setDragging(false);
+ const files = Array.from(event.dataTransfer.files);
+ if (files.length > 0) onFiles(files);
+ };
+
+ return (
+
+
+ {
+ const files = Array.from(event.target.files ?? []);
+ // Cleared so picking the same file twice in a row still fires.
+ event.target.value = '';
+ if (files.length > 0) onFiles(files);
+ }}
+ />
+
+ );
+}
diff --git a/apps/web/src/hooks/useDropUpload.test.tsx b/apps/web/src/hooks/useDropUpload.test.tsx
new file mode 100644
index 000000000..ec31813a5
--- /dev/null
+++ b/apps/web/src/hooks/useDropUpload.test.tsx
@@ -0,0 +1,499 @@
+import type { ReactNode } from 'react';
+import { EngineRequestError } from '@cipherbox/client';
+import type { EngineClient, EventDescriptor, WriteTarget } from '@cipherbox/client';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { EngineProvider } from '../providers/EngineProvider';
+import { useDropUpload } from './useDropUpload';
+
+const PARENT = new Uint8Array(16).fill(4);
+const CHUNK_BYTES = 1024 * 1024;
+
+/** The write-handle surface the hook drives, with every call recorded in order. */
+function uploadEngine() {
+ const listeners = new Set<(event: EventDescriptor) => void>();
+ const log: string[] = [];
+ let handles = 0n;
+ let ops = 0n;
+
+ const facade = {
+ subscribe(listener: (event: EventDescriptor) => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ snapshot: () => new Promise(() => undefined),
+ setFocus: () => Promise.resolve(),
+ beginWrite: vi.fn((target: WriteTarget, size: number) => {
+ log.push(`begin:${'name' in target ? target.name : 'version'}:${size}`);
+ handles += 1n;
+ return Promise.resolve(handles);
+ }),
+ pushChunk: vi.fn((_handle: bigint, chunk: ArrayBuffer) => {
+ log.push(`push:${chunk.byteLength}`);
+ return Promise.resolve();
+ }),
+ commitWrite: vi.fn(() => {
+ log.push('commit');
+ ops += 1n;
+ return Promise.resolve(ops);
+ }),
+ abortWrite: vi.fn(() => {
+ log.push('abort');
+ return Promise.resolve();
+ }),
+ cancelUpload: vi.fn((opId: bigint) => {
+ log.push(`cancelUpload:${opId}`);
+ return Promise.resolve();
+ }),
+ };
+
+ const client = {
+ facade,
+ reportFocus: () => undefined,
+ dispose: () => Promise.resolve(),
+ } as unknown as EngineClient;
+
+ return {
+ client,
+ facade,
+ log,
+ emit: (event: EventDescriptor) => {
+ for (const listener of listeners) listener(event);
+ },
+ };
+}
+
+function mount(client: EngineClient) {
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ client}>{children}
+ );
+ return renderHook(() => useDropUpload(), { wrapper });
+}
+
+function file(name: string, bytes: number): File {
+ return new File([new Uint8Array(bytes)], name);
+}
+
+const progress = (opId: bigint, confirmed: number, total: number): EventDescriptor => ({
+ kind: 'opProgress',
+ opId,
+ node: new Uint8Array(16),
+ phase: 'uploadProgress',
+ blocksConfirmed: confirmed,
+ blocksTotal: total,
+ error: null,
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('driving an upload through the facade write handles', () => {
+ it('slices the file at the chunk boundary and commits one op', async () => {
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('report.pdf', CHUNK_BYTES + 100)], PARENT);
+ });
+
+ await waitFor(() => expect(result.current.uploads[0].phase).toBe('queued'));
+ expect(engine.log).toEqual([
+ `begin:report.pdf:${CHUNK_BYTES + 100}`,
+ `push:${CHUNK_BYTES}`,
+ 'push:100',
+ 'commit',
+ ]);
+ expect(engine.facade.beginWrite.mock.calls[0][0]).toEqual({
+ parent: PARENT,
+ name: 'report.pdf',
+ });
+ expect(result.current.uploads[0].opId).toBe(1n);
+ });
+
+ it('commits an empty file without pushing a chunk', async () => {
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('empty.txt', 0)], PARENT);
+ });
+
+ await waitFor(() => expect(result.current.uploads[0].phase).toBe('queued'));
+ expect(engine.log).toEqual(['begin:empty.txt:0', 'commit']);
+ });
+
+ it('runs queued files one at a time', async () => {
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10), file('b.bin', 20)], PARENT);
+ });
+
+ await waitFor(() => expect(result.current.uploads[1].phase).toBe('queued'));
+ expect(engine.log).toEqual([
+ 'begin:a.bin:10',
+ 'push:10',
+ 'commit',
+ 'begin:b.bin:20',
+ 'push:20',
+ 'commit',
+ ]);
+ });
+});
+
+describe('reporting what the engine says about the op', () => {
+ it('tracks confirmed blocks as the drain reports them', async () => {
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].opId).toBe(1n));
+
+ act(() => engine.emit(progress(1n, 3, 4)));
+
+ expect(result.current.uploads[0].phase).toBe('uploading');
+ expect(result.current.uploads[0].progress).toBeCloseTo(0.75);
+ });
+
+ it('binds an event that landed before commitWrite answered', async () => {
+ const engine = uploadEngine();
+ engine.facade.commitWrite.mockImplementationOnce(() => {
+ // The drain can report the op before the commit reply crosses back.
+ engine.emit(progress(1n, 2, 2));
+ return Promise.resolve(1n);
+ });
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+
+ await waitFor(() => expect(result.current.uploads[0].phase).toBe('uploading'));
+ expect(result.current.uploads[0].progress).toBe(1);
+ });
+
+ it('ignores op progress for an op this tab never opened', async () => {
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].opId).toBe(1n));
+
+ act(() => engine.emit(progress(99n, 1, 2)));
+
+ expect(result.current.uploads[0].phase).toBe('queued');
+ });
+
+ it('holds a failed attempt open, because the drain retries it', async () => {
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].opId).toBe(1n));
+
+ act(() =>
+ engine.emit({
+ kind: 'opProgress',
+ opId: 1n,
+ node: new Uint8Array(16),
+ phase: 'uploadFailed',
+ blocksConfirmed: null,
+ blocksTotal: null,
+ error: 'no reachable pin provider',
+ })
+ );
+
+ expect(result.current.uploads[0].phase).toBe('stalled');
+ expect(result.current.uploads[0].error).toBe('no reachable pin provider');
+ });
+
+ it('settles a dead-lettered op as terminal', async () => {
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].opId).toBe(1n));
+
+ act(() => engine.emit({ kind: 'deadLetter', opId: 1n, reason: 'targetGone' }));
+
+ expect(result.current.uploads[0].phase).toBe('failed');
+ expect(result.current.uploads[0].error).toContain('targetGone');
+ });
+
+ it('retires a published row on its own', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].opId).toBe(1n));
+
+ act(() =>
+ engine.emit({
+ kind: 'opProgress',
+ opId: 1n,
+ node: new Uint8Array(16),
+ phase: 'uploadCompleted',
+ blocksConfirmed: 2,
+ blocksTotal: 2,
+ error: null,
+ })
+ );
+ expect(result.current.uploads[0].phase).toBe('uploaded');
+
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(result.current.uploads).toHaveLength(0);
+ });
+
+ it('keeps a row a dead letter overtook, rather than sweeping it on the old timer', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].opId).toBe(1n));
+
+ act(() =>
+ engine.emit({
+ kind: 'opProgress',
+ opId: 1n,
+ node: new Uint8Array(16),
+ phase: 'uploadCompleted',
+ blocksConfirmed: 2,
+ blocksTotal: 2,
+ error: null,
+ })
+ );
+ // The record still has to publish, so a dead letter can follow the blocks.
+ act(() => engine.emit({ kind: 'deadLetter', opId: 1n, reason: 'targetGone' }));
+
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(result.current.uploads).toHaveLength(1);
+ expect(result.current.uploads[0].phase).toBe('failed');
+ });
+});
+
+describe('cancelling', () => {
+ it('aborts a staging write instead of committing it', async () => {
+ const engine = uploadEngine();
+ let admit = () => undefined as void;
+ engine.facade.beginWrite.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ admit = () => resolve(1n);
+ })
+ );
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ act(() => result.current.cancel(result.current.uploads[0].id));
+ await act(async () => {
+ admit();
+ });
+
+ await waitFor(() => expect(result.current.uploads[0].phase).toBe('cancelled'));
+ expect(engine.facade.commitWrite).not.toHaveBeenCalled();
+ expect(engine.facade.abortWrite).toHaveBeenCalledWith(1n);
+ });
+
+ it('asks the engine to drop an op that has already committed', async () => {
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].opId).toBe(1n));
+
+ act(() => result.current.cancel(result.current.uploads[0].id));
+ expect(engine.facade.cancelUpload).toHaveBeenCalledWith(1n);
+
+ act(() =>
+ engine.emit({
+ kind: 'opProgress',
+ opId: 1n,
+ node: new Uint8Array(16),
+ phase: 'uploadCancelled',
+ blocksConfirmed: null,
+ blocksTotal: null,
+ error: null,
+ })
+ );
+ expect(result.current.uploads[0].phase).toBe('cancelled');
+ });
+
+ it('retires a row the engine cancelled, rather than stranding it', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].opId).toBe(1n));
+
+ act(() => result.current.cancel(result.current.uploads[0].id));
+ act(() =>
+ engine.emit({
+ kind: 'opProgress',
+ opId: 1n,
+ node: new Uint8Array(16),
+ phase: 'uploadCancelled',
+ blocksConfirmed: null,
+ blocksTotal: null,
+ error: null,
+ })
+ );
+
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(result.current.uploads).toHaveLength(0);
+ });
+
+ it('keeps a retried row that the cancel retirement timer would have swept', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const engine = uploadEngine();
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].opId).toBe(1n));
+
+ act(() => result.current.cancel(result.current.uploads[0].id));
+ act(() =>
+ engine.emit({
+ kind: 'opProgress',
+ opId: 1n,
+ node: new Uint8Array(16),
+ phase: 'uploadCancelled',
+ blocksConfirmed: null,
+ blocksTotal: null,
+ error: null,
+ })
+ );
+ expect(result.current.uploads[0].phase).toBe('cancelled');
+
+ // The retry button is on screen for the whole retirement window.
+ await act(async () => {
+ result.current.retry(result.current.uploads[0].id);
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(result.current.uploads).toHaveLength(1);
+ expect(result.current.uploads[0].phase).toBe('queued');
+ expect(result.current.uploads[0].opId).toBe(2n);
+ expect(engine.facade.commitWrite).toHaveBeenCalledTimes(2);
+ });
+
+ it('shows a refused cancel without moving the row off the upload', async () => {
+ const engine = uploadEngine();
+ engine.facade.cancelUpload.mockRejectedValueOnce(
+ new EngineRequestError('the version is already publishing', 'tooLateToCancel')
+ );
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].opId).toBe(1n));
+
+ await act(async () => {
+ result.current.cancel(result.current.uploads[0].id);
+ });
+
+ expect(result.current.uploads[0].phase).toBe('queued');
+ expect(result.current.uploads[0].error).toBe('the version is already publishing');
+ });
+});
+
+describe('refused writes', () => {
+ it('keeps the engine code so the caller can classify an over-budget refusal', async () => {
+ const engine = uploadEngine();
+ engine.facade.beginWrite.mockRejectedValueOnce(
+ new EngineRequestError('this write needs 900 bytes but only 100 are free', 'overBudget')
+ );
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('big.bin', 900)], PARENT);
+ });
+
+ await waitFor(() => expect(result.current.uploads[0].phase).toBe('failed'));
+ expect(result.current.uploads[0].code).toBe('overBudget');
+ expect(result.current.uploads[0].error).toContain('only 100 are free');
+ expect(engine.facade.commitWrite).not.toHaveBeenCalled();
+ });
+
+ it('releases the handle when a chunk is refused', async () => {
+ const engine = uploadEngine();
+ engine.facade.pushChunk.mockRejectedValueOnce(
+ new EngineRequestError('pushed 3 of 5 bytes', 'contentSizeMismatch')
+ );
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+
+ await waitFor(() => expect(result.current.uploads[0].phase).toBe('failed'));
+ expect(engine.facade.abortWrite).toHaveBeenCalledWith(1n);
+ });
+
+ it('re-runs a failed row from the file it was dropped with', async () => {
+ const engine = uploadEngine();
+ engine.facade.beginWrite.mockRejectedValueOnce(new Error('nope'));
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].phase).toBe('failed'));
+
+ await act(async () => {
+ result.current.retry(result.current.uploads[0].id);
+ });
+
+ await waitFor(() => expect(result.current.uploads[0].phase).toBe('queued'));
+ expect(result.current.uploads[0].error).toBeNull();
+ expect(result.current.uploads[0].code).toBeNull();
+ });
+
+ it('drops a dismissed row', async () => {
+ const engine = uploadEngine();
+ engine.facade.beginWrite.mockRejectedValueOnce(new Error('nope'));
+ const { result } = mount(engine.client);
+
+ await act(async () => {
+ result.current.upload([file('a.bin', 10)], PARENT);
+ });
+ await waitFor(() => expect(result.current.uploads[0].phase).toBe('failed'));
+
+ act(() => result.current.dismiss(result.current.uploads[0].id));
+
+ expect(result.current.uploads).toHaveLength(0);
+ });
+});
diff --git a/apps/web/src/hooks/useDropUpload.ts b/apps/web/src/hooks/useDropUpload.ts
new file mode 100644
index 000000000..f62a1172b
--- /dev/null
+++ b/apps/web/src/hooks/useDropUpload.ts
@@ -0,0 +1,338 @@
+/**
+ * The upload path: `File` handles in, facade write handles out
+ * (blueprint/web-client.md "Content paths"). A slice of plaintext is read and
+ * transferred into the engine in one step and never copied through React state.
+ *
+ * Rows are transient UI state keyed on the upload's op id; what the vault holds
+ * stays the snapshot store's word alone (UI state law).
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { EngineRequestError } from '@cipherbox/client';
+import type { EventDescriptor } from '@cipherbox/client';
+import { errorMessage } from '../lib/errorMessage';
+import { useEngine } from '../providers/EngineProvider';
+
+/** Peak heap is one slice, however large the file. */
+const CHUNK_BYTES = 1024 * 1024;
+
+/** How long a settled row stays on screen before it retires itself. */
+const SETTLED_ROW_MILLIS = 1500;
+
+/** Where an upload has got to; `staging` is the only rung the engine does not name. */
+export type UploadPhase =
+ | 'staging'
+ | 'queued'
+ | 'uploading'
+ | 'uploaded'
+ | 'stalled'
+ | 'cancelled'
+ | 'failed';
+
+const ACTIVE_PHASES: readonly UploadPhase[] = ['staging', 'queued', 'uploading', 'stalled'];
+
+/** Settled with nothing left to say, so the row clears itself. */
+const RETIRING_PHASES: readonly UploadPhase[] = ['uploaded', 'cancelled'];
+
+/** Whether the engine still has work for this row. */
+export function isActiveUpload(phase: UploadPhase): boolean {
+ return ACTIVE_PHASES.includes(phase);
+}
+
+export interface UploadEntry {
+ /** Row identity from the drop; the op id only exists once the write commits. */
+ id: string;
+ name: string;
+ size: number;
+ phase: UploadPhase;
+ /** Blocks confirmed as a fraction, once the drain reports them. */
+ progress: number;
+ opId: bigint | null;
+ /** The engine's diagnostic for the current phase, or `null`. */
+ error: string | null;
+ /** The engine's stable code for a refused write, so a caller classifies it. */
+ code: string | null;
+}
+
+export interface DropUpload {
+ uploads: readonly UploadEntry[];
+ /** Stages and commits each file into `parent`, one at a time. */
+ upload(files: readonly File[], parent: Uint8Array): void;
+ /** Aborts a staging write, or asks the engine to drop a committed op. */
+ cancel(id: string): void;
+ /** Re-runs a settled row from the `File` it was dropped with. */
+ retry(id: string): void;
+ /** Clears a settled row. */
+ dismiss(id: string): void;
+}
+
+interface Job {
+ file: File;
+ parent: Uint8Array;
+ /** Set once the write commits, so a cancel names the op without a render read. */
+ opId: bigint | null;
+}
+
+/** What one engine event says about the row holding its op. */
+interface RowUpdate {
+ opId: bigint;
+ change: Partial;
+}
+
+let sequence = 0;
+
+export function useDropUpload(): DropUpload {
+ const engine = useEngine();
+ const [uploads, setUploads] = useState([]);
+ const jobs = useRef(new Map());
+ const cancelled = useRef(new Set());
+ const rowByOp = useRef(new Map());
+ const timers = useRef(new Map>());
+ // Uploads run one at a time: `beginWrite` reserves the whole version against
+ // the staging budget, so files started together contend for room only one has.
+ const queue = useRef>(Promise.resolve());
+ // Replies and events cross on different channels, so an op can be reported
+ // before its `commitWrite` reply lands. Only events from inside a commit
+ // window may claim a slot, so a foreign op's report cannot accumulate here.
+ const committing = useRef(false);
+ const unbound = useRef(new Map>());
+
+ const patch = useCallback((id: string, change: Partial) => {
+ setUploads((rows) => rows.map((row) => (row.id === id ? { ...row, ...change } : row)));
+ }, []);
+
+ const forget = useCallback((id: string) => {
+ stopRetire(timers.current, id);
+ jobs.current.delete(id);
+ cancelled.current.delete(id);
+ unbind(rowByOp.current, id);
+ setUploads((rows) => rows.filter((row) => row.id !== id));
+ }, []);
+
+ const retire = useCallback(
+ (id: string) => {
+ stopRetire(timers.current, id);
+ timers.current.set(
+ id,
+ setTimeout(() => forget(id), SETTLED_ROW_MILLIS)
+ );
+ },
+ [forget]
+ );
+
+ useEffect(() => {
+ const pending = timers.current;
+ return () => {
+ for (const timer of pending.values()) clearTimeout(timer);
+ pending.clear();
+ };
+ }, []);
+
+ /** Lands one engine update on a row, retiring it once nothing is left to do. */
+ const apply = useCallback(
+ (id: string, change: Partial) => {
+ patch(id, change);
+ if (change.phase === undefined) return;
+ // A dead letter can follow the blocks landing, so a row that moves on must
+ // not be swept by the timer its earlier phase scheduled.
+ if (RETIRING_PHASES.includes(change.phase)) retire(id);
+ else stopRetire(timers.current, id);
+ },
+ [patch, retire]
+ );
+
+ useEffect(() => {
+ if (engine === null) return;
+ return engine.facade.subscribe((event) => {
+ const update = rowUpdate(event);
+ if (update === null) return;
+ const key = update.opId.toString();
+ const row = rowByOp.current.get(key);
+ if (row === undefined) {
+ if (committing.current) unbound.current.set(key, update.change);
+ return;
+ }
+ apply(row, update.change);
+ });
+ }, [apply, engine]);
+
+ /** Asks the engine to drop a committed op; a refusal says why on the row. */
+ const dropOp = useCallback(
+ (id: string, opId: bigint): void => {
+ const facade = engine?.facade;
+ if (facade === undefined) return;
+ facade.cancelUpload(opId).catch((error: unknown) => {
+ patch(id, { error: errorMessage(error) });
+ });
+ },
+ [engine, patch]
+ );
+
+ const run = useCallback(
+ async (id: string) => {
+ const job = jobs.current.get(id);
+ if (job === undefined) return;
+ const facade = engine?.facade;
+ if (facade === undefined) {
+ patch(id, { phase: 'failed', error: 'the engine is not running yet' });
+ return;
+ }
+
+ let handle: bigint | null = null;
+ const release = async (): Promise => {
+ // A refused abort has nothing to add: the row is already about to report
+ // the cancel or the error that brought it here.
+ if (handle !== null) await facade.abortWrite(handle).catch(() => undefined);
+ handle = null;
+ };
+ /** True once the row is cancelled, having released what it held. */
+ const abandoned = async (): Promise => {
+ if (!cancelled.current.has(id)) return false;
+ await release();
+ apply(id, { phase: 'cancelled', error: null });
+ return true;
+ };
+
+ try {
+ if (await abandoned()) return;
+ handle = await facade.beginWrite(
+ { parent: job.parent, name: job.file.name },
+ job.file.size
+ );
+ for (let offset = 0; offset < job.file.size; offset += CHUNK_BYTES) {
+ if (await abandoned()) return;
+ await facade.pushChunk(
+ handle,
+ await job.file.slice(offset, offset + CHUNK_BYTES).arrayBuffer()
+ );
+ }
+ if (await abandoned()) return;
+
+ committing.current = true;
+ let opId: bigint;
+ try {
+ opId = await facade.commitWrite(handle);
+ } finally {
+ committing.current = false;
+ }
+ handle = null;
+ job.opId = opId;
+ rowByOp.current.set(opId.toString(), id);
+ patch(id, { opId, phase: 'queued' });
+
+ const early = unbound.current.get(opId.toString());
+ unbound.current.clear();
+ if (early !== undefined) apply(id, early);
+ // A cancel that arrived mid-commit has an op to name now.
+ if (cancelled.current.has(id)) dropOp(id, opId);
+ } catch (error) {
+ await release();
+ if (cancelled.current.has(id)) {
+ apply(id, { phase: 'cancelled', error: null });
+ return;
+ }
+ patch(id, {
+ phase: 'failed',
+ error: errorMessage(error),
+ code: error instanceof EngineRequestError ? (error.code ?? null) : null,
+ });
+ }
+ },
+ [apply, dropOp, engine, patch]
+ );
+
+ const enqueue = useCallback(
+ (id: string) => {
+ queue.current = queue.current.then(() => run(id));
+ },
+ [run]
+ );
+
+ const upload = useCallback(
+ (files: readonly File[], parent: Uint8Array) => {
+ if (files.length === 0) return;
+ const started = files.map((file) => {
+ const id = `upload-${(sequence += 1)}`;
+ jobs.current.set(id, { file, parent, opId: null });
+ return {
+ id,
+ name: file.name,
+ size: file.size,
+ phase: 'staging',
+ progress: 0,
+ opId: null,
+ error: null,
+ code: null,
+ } satisfies UploadEntry;
+ });
+ setUploads((rows) => [...rows, ...started]);
+ for (const row of started) enqueue(row.id);
+ },
+ [enqueue]
+ );
+
+ const cancel = useCallback(
+ (id: string) => {
+ cancelled.current.add(id);
+ const opId = jobs.current.get(id)?.opId;
+ if (opId != null) dropOp(id, opId);
+ },
+ [dropOp]
+ );
+
+ const retry = useCallback(
+ (id: string) => {
+ const job = jobs.current.get(id);
+ if (job === undefined) return;
+ cancelled.current.delete(id);
+ unbind(rowByOp.current, id);
+ job.opId = null;
+ // Through `apply`, so the retirement timer the settled phase scheduled is
+ // stopped before it sweeps the row out from under the run about to start.
+ apply(id, { phase: 'staging', progress: 0, opId: null, error: null, code: null });
+ enqueue(id);
+ },
+ [apply, enqueue]
+ );
+
+ return { uploads, upload, cancel, retry, dismiss: forget };
+}
+
+function stopRetire(timers: Map>, id: string): void {
+ const timer = timers.get(id);
+ if (timer === undefined) return;
+ clearTimeout(timer);
+ timers.delete(id);
+}
+
+function unbind(rowByOp: Map, id: string): void {
+ for (const [op, row] of rowByOp) {
+ if (row === id) rowByOp.delete(op);
+ }
+}
+
+function rowUpdate(event: EventDescriptor): RowUpdate | null {
+ if (event.kind === 'deadLetter') {
+ const error = `${event.reason}, so this upload will never publish`;
+ return { opId: event.opId, change: { phase: 'failed', error } };
+ }
+ if (event.kind !== 'opProgress' || event.opId === null) return null;
+ switch (event.phase) {
+ case 'uploadStarted':
+ case 'uploadProgress':
+ return { opId: event.opId, change: { phase: 'uploading', progress: fraction(event) } };
+ case 'uploadCompleted':
+ return { opId: event.opId, change: { phase: 'uploaded', progress: 1, error: null } };
+ case 'uploadFailed':
+ return { opId: event.opId, change: { phase: 'stalled', error: event.error } };
+ case 'uploadCancelled':
+ return { opId: event.opId, change: { phase: 'cancelled', error: null } };
+ default:
+ return null;
+ }
+}
+
+function fraction(event: { blocksConfirmed: number | null; blocksTotal: number | null }): number {
+ const total = event.blocksTotal ?? 0;
+ return total > 0 ? Math.min((event.blocksConfirmed ?? 0) / total, 1) : 0;
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index f363f9b75..32e804982 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -4,6 +4,7 @@ import './index.css';
import './styles/login.css';
import './styles/layout.css';
import './styles/file-browser.css';
+import './styles/upload.css';
import './styles/breadcrumbs.css';
import './styles/responsive.css';
diff --git a/apps/web/src/styles/upload.css b/apps/web/src/styles/upload.css
new file mode 100644
index 000000000..30ee345e0
--- /dev/null
+++ b/apps/web/src/styles/upload.css
@@ -0,0 +1,177 @@
+/* ==========================================================================
+ Upload - Terminal Aesthetic
+ ========================================================================== */
+
+.upload-zone {
+ display: flex;
+ margin-bottom: var(--spacing-sm);
+ border: var(--border-thickness) dashed var(--color-border-dim);
+ transition:
+ border-color 0.15s ease,
+ background-color 0.15s ease;
+}
+
+.upload-zone:hover {
+ border-color: var(--color-green-primary);
+}
+
+.upload-zone--dragging {
+ border-style: solid;
+ border-color: var(--color-green-primary);
+ background-color: var(--color-green-darker);
+ box-shadow: var(--glow-green);
+}
+
+.upload-zone-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: 1;
+ padding: var(--spacing-xs) var(--spacing-md);
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+}
+
+.upload-zone-button:focus-visible {
+ outline: var(--border-thickness) solid var(--color-green-primary);
+ outline-offset: -2px;
+}
+
+.upload-zone-icon {
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+}
+
+.upload-zone-input {
+ display: none;
+}
+
+/* ==========================================================================
+ Upload Rows
+ ========================================================================== */
+
+.upload-list {
+ border: var(--border-thickness) solid var(--color-border-dim);
+ border-bottom: none;
+ margin-bottom: var(--spacing-sm);
+}
+
+/* Doubled up so the row's own affordances win wherever this sheet is loaded. */
+.file-list-item.upload-row {
+ cursor: default;
+}
+
+.file-list-item.upload-row:hover {
+ background-color: transparent;
+}
+
+.file-list-item.upload-row--failed {
+ background-color: color-mix(in srgb, var(--color-error) 8%, transparent);
+}
+
+.upload-row--failed .file-list-item-icon {
+ color: var(--color-error);
+}
+
+.upload-row--cancelled,
+.upload-row--uploaded {
+ opacity: 0.6;
+}
+
+.upload-row-name {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1;
+ min-width: 0;
+}
+
+.upload-row-track {
+ height: 3px;
+ width: 100%;
+ overflow: hidden;
+ background-color: var(--color-border-dim);
+}
+
+.upload-row-fill {
+ height: 100%;
+ background-color: var(--color-green-primary);
+ transition: width 0.2s ease;
+}
+
+/* Sealing animates the track itself, so the fill has no width to jump back from
+ when the drain's first block lands. */
+@keyframes upload-row-shimmer {
+ from {
+ background-position: -200% 0;
+ }
+
+ to {
+ background-position: 200% 0;
+ }
+}
+
+.upload-row-track--indeterminate {
+ background: linear-gradient(
+ 90deg,
+ var(--color-border-dim) 30%,
+ var(--color-green-primary) 50%,
+ var(--color-border-dim) 70%
+ );
+ background-size: 200% 100%;
+ animation: upload-row-shimmer 1.5s ease-in-out infinite;
+}
+
+.upload-row-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--spacing-xs);
+}
+
+.upload-row-status {
+ font-size: var(--font-size-xs);
+ color: var(--color-text-secondary);
+}
+
+.upload-row-button {
+ padding: 2px var(--spacing-xs);
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-xs);
+ color: var(--color-text-secondary);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+}
+
+.upload-row-button:hover {
+ color: var(--color-text-primary);
+}
+
+.upload-row-button--retry {
+ color: var(--color-green-primary);
+}
+
+.upload-row-error {
+ grid-column: 1 / -1;
+ margin: 4px 0 0;
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-xs);
+ color: var(--color-error);
+}
+
+.upload-row-error--transient {
+ color: var(--color-warning);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .upload-row-fill,
+ .upload-row-track--indeterminate {
+ transition: none;
+ animation: none;
+ }
+}
diff --git a/apps/web/src/vault/useFolderNavigation.test.tsx b/apps/web/src/vault/useFolderNavigation.test.tsx
index f8be06172..af3544f56 100644
--- a/apps/web/src/vault/useFolderNavigation.test.tsx
+++ b/apps/web/src/vault/useFolderNavigation.test.tsx
@@ -268,4 +268,15 @@ describe('the vault browser read path', () => {
expect(screen.getByTestId('file-browser-error').textContent).toBe('that is not a folder id');
expect(engine.focus).toEqual([]);
});
+
+ it('takes no drop for a route that is not a folder, whatever the store still holds', async () => {
+ const engine = fakeEngine();
+ renderBrowser(engine, '/files/not-a-node');
+
+ // The root view outlives the bad route, and must not stand in for it.
+ await landSnapshot(engine, folderView());
+
+ expect(screen.getByTestId('file-browser-error').textContent).toBe('that is not a folder id');
+ expect(screen.queryByTestId('upload-zone')).toBeNull();
+ });
});
diff --git a/apps/web/src/vault/useFolderNavigation.ts b/apps/web/src/vault/useFolderNavigation.ts
index 63655cc67..81fac88e0 100644
--- a/apps/web/src/vault/useFolderNavigation.ts
+++ b/apps/web/src/vault/useFolderNavigation.ts
@@ -19,6 +19,8 @@ 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[];
+ /** The folder the snapshot listed, or `null` until one lands for this route. */
+ folder: Uint8Array | null;
/** Root-first trail, ending at the folder on screen. */
breadcrumbs: BreadcrumbDescriptor[];
/** True until the engine reports a snapshot *of the routed folder*. */
@@ -75,6 +77,7 @@ export function useFolderNavigation(): FolderNavigation {
return {
rows,
+ folder: listed?.folder ?? null,
breadcrumbs,
isLoading: route.kind !== 'invalid' && listed === null && error === null,
isRoot: listed !== null && sameNode(listed.folder, listed.root),