From d39a68b869311265389b2f3f844eb2f56100b82a Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 5 Aug 2026 12:44:52 +0200 Subject: [PATCH 1/4] feat(web): wire file upload through the facade write handles Drops and file picks now drive beginWrite / pushChunk / commitWrite: each file is sliced a chunk at a time and the buffer is transferred into the engine, so no plaintext copy lands in React state. Per-file rows key on the op id commitWrite returns and render the engine's own opProgress phases; a dead letter settles the row as terminal, where an uploadFailed is one attempt the drain retries. Cancel aborts a staging handle or issues cancelUpload once the op exists, and a refused write keeps the engine's stable code so an over-budget refusal reads apart from a terminal failure. Closes #873 --- .../components/file-browser/FileBrowser.tsx | 25 ++ .../file-browser/UploadListItem.test.tsx | 75 ++++ .../file-browser/UploadListItem.tsx | 107 +++++ .../file-browser/UploadZone.test.tsx | 63 +++ .../components/file-browser/UploadZone.tsx | 86 ++++ apps/web/src/hooks/useDropUpload.test.tsx | 401 ++++++++++++++++++ apps/web/src/hooks/useDropUpload.ts | 314 ++++++++++++++ apps/web/src/main.tsx | 1 + apps/web/src/styles/upload.css | 183 ++++++++ 9 files changed, 1255 insertions(+) create mode 100644 apps/web/src/components/file-browser/UploadListItem.test.tsx create mode 100644 apps/web/src/components/file-browser/UploadListItem.tsx create mode 100644 apps/web/src/components/file-browser/UploadZone.test.tsx create mode 100644 apps/web/src/components/file-browser/UploadZone.tsx create mode 100644 apps/web/src/hooks/useDropUpload.test.tsx create mode 100644 apps/web/src/hooks/useDropUpload.ts create mode 100644 apps/web/src/styles/upload.css diff --git a/apps/web/src/components/file-browser/FileBrowser.tsx b/apps/web/src/components/file-browser/FileBrowser.tsx index 0e2931d31..d548d825c 100644 --- a/apps/web/src/components/file-browser/FileBrowser.tsx +++ b/apps/web/src/components/file-browser/FileBrowser.tsx @@ -1,13 +1,19 @@ +import { isActiveUpload, useDropUpload } from '../../hooks/useDropUpload'; import { useFolderNavigation } from '../../vault/useFolderNavigation'; import { Breadcrumbs } from './Breadcrumbs'; import { EmptyState } from './EmptyState'; import { FileList } from './FileList'; +import { UploadListItem } from './UploadListItem'; +import { UploadZone } from './UploadZone'; /** 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 { uploads, upload, cancel, retry, dismiss } = useDropUpload(); const settled = !isLoading && error === null; + // The trail ends at the folder on screen, which is where a drop lands. + const folder = breadcrumbs.at(-1)?.id ?? null; return (
@@ -22,6 +28,25 @@ export function FileBrowser() { {'// LOADING VAULT...'}

)} + {folder !== null && ( + upload(files, folder)} + busy={uploads.some((entry) => isActiveUpload(entry.phase))} + /> + )} + {uploads.length > 0 && ( +
+ {uploads.map((entry) => ( + + ))} +
+ )} {/* 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('stays indeterminate while the engine has no block count to give', () => { + show(entry({ phase: 'staging' })); + + const bar = screen.getByRole('progressbar'); + expect(bar.getAttribute('aria-valuenow')).toBeNull(); + expect(bar.className).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 failed 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('marks an over-budget refusal apart from a terminal failure', () => { + 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--budget'); + }); +}); 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..9546f1703 --- /dev/null +++ b/apps/web/src/components/file-browser/UploadListItem.tsx @@ -0,0 +1,107 @@ +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; +} + +/** The phases whose bar can quote a fraction; the rest are indeterminate. */ +const MEASURED: readonly UploadPhase[] = ['uploading', 'uploaded']; + +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 measured = MEASURED.includes(phase); + const percent = Math.round(upload.progress * 100); + const settled = !isActiveUpload(phase); + + return ( +
+
+ +
+ {name} + {!settled && ( +
+
+
+ )} +
+
+
+ {formatBytes(upload.size)} + + + {phase === 'uploading' ? `${percent}%` : LABELS[phase]} + + {isActiveUpload(phase) && ( + + )} + {phase === 'failed' && ( + <> + + + + )} + +
+ {error !== null && ( +

+ {error} +

+ )} +
+ ); +} 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..7fe5af35d --- /dev/null +++ b/apps/web/src/components/file-browser/UploadZone.tsx @@ -0,0 +1,86 @@ +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, so a boolean alone + // would clear the highlight while the drag is still over the zone. + 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..0a97af8d6 --- /dev/null +++ b/apps/web/src/hooks/useDropUpload.test.tsx @@ -0,0 +1,401 @@ +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); + }); +}); + +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('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..b7058c3e2 --- /dev/null +++ b/apps/web/src/hooks/useDropUpload.ts @@ -0,0 +1,314 @@ +/** + * The upload path: `File` handles in, facade write handles out + * (blueprint/web-client.md "Content paths"). One slice of plaintext exists at a + * time and is transferred into the engine, 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'; + +/** Plaintext crosses to the engine one slice at a time; peak heap is one slice. */ +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. `uploaded` means the version's blocks are on the + * network, not that its record published; `stalled` is one attempt the drain + * will retry, where `failed` is terminal. + */ +export type UploadPhase = + | 'staging' + | 'queued' + | 'uploading' + | 'uploaded' + | 'stalled' + | 'cancelled' + | 'failed'; + +const ACTIVE_PHASES: readonly UploadPhase[] = ['staging', 'queued', 'uploading', 'stalled']; + +/** 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; +} + +/** 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 Set>()); + // 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()); + // A drain that reports an op before its `commitWrite` reply lands has no row + // to update yet; one slot covers it, because only one commit is ever open and + // the op id it is claimed under is unique. + const committing = useRef(false); + const unbound = useRef(null); + + const patch = useCallback((id: string, change: Partial) => { + setUploads((rows) => rows.map((row) => (row.id === id ? { ...row, ...change } : row))); + }, []); + + const forget = useCallback((id: string) => { + 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) => { + const timer = setTimeout(() => { + timers.current.delete(timer); + forget(id); + }, SETTLED_ROW_MILLIS); + timers.current.add(timer); + }, + [forget] + ); + + useEffect(() => { + const pending = timers.current; + return () => { + for (const timer of pending) clearTimeout(timer); + pending.clear(); + }; + }, []); + + useEffect(() => { + if (engine === null) return; + return engine.facade.subscribe((event) => { + const update = rowUpdate(event); + if (update === null) return; + const row = rowByOp.current.get(update.opId.toString()); + if (row === undefined) { + if (committing.current) unbound.current = update; + return; + } + patch(row, update.change); + if (update.change.phase === 'uploaded') retire(row); + }); + }, [engine, patch, retire]); + + /** 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 => { + 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(); + patch(id, { phase: 'cancelled', error: null }); + retire(id); + 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; + // Read and handed over in one step: the push detaches the buffer, so + // no plaintext slice outlives the call that consumed it. + 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; + + rowByOp.current.set(opId.toString(), id); + const early = unbound.current?.opId === opId ? unbound.current.change : undefined; + unbound.current = null; + patch(id, { opId, phase: 'queued', ...early }); + if (early?.phase === 'uploaded') retire(id); + // 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)) { + patch(id, { phase: 'cancelled', error: null }); + retire(id); + return; + } + patch(id, { + phase: 'failed', + error: errorMessage(error), + code: error instanceof EngineRequestError ? (error.code ?? null) : null, + }); + } + }, + [dropOp, engine, patch, retire] + ); + + const enqueue = useCallback( + (id: string) => { + queue.current = queue.current.then(() => run(id)); + }, + [run] + ); + + const upload = useCallback( + (files: readonly File[], parent: Uint8Array) => { + const started = files.map((file) => { + const id = `upload-${(sequence += 1)}`; + jobs.current.set(id, { file, parent }); + return { + id, + name: file.name, + size: file.size, + phase: 'staging', + progress: 0, + opId: null, + error: null, + code: null, + } satisfies UploadEntry; + }); + if (started.length === 0) return; + setUploads((rows) => [...rows, ...started]); + for (const row of started) enqueue(row.id); + }, + [enqueue] + ); + + const cancel = useCallback( + (id: string) => { + cancelled.current.add(id); + const opId = uploads.find((row) => row.id === id)?.opId; + // Still staging: the run loop reads the flag at its next chunk boundary. + if (opId != null) dropOp(id, opId); + }, + [dropOp, uploads] + ); + + const retry = useCallback( + (id: string) => { + if (!jobs.current.has(id)) return; + cancelled.current.delete(id); + unbind(rowByOp.current, id); + patch(id, { phase: 'staging', progress: 0, opId: null, error: null, code: null }); + enqueue(id); + }, + [enqueue, patch] + ); + + return { uploads, upload, cancel, retry, dismiss: forget }; +} + +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..bb5797173 --- /dev/null +++ b/apps/web/src/styles/upload.css @@ -0,0 +1,183 @@ +/* ========================================================================== + 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); +} + +.upload-row { + cursor: default; +} + +.upload-row:hover { + background-color: transparent; +} + +.upload-row--failed { + background-color: rgb(239 68 68 / 8%); +} + +.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; +} + +/* An indeterminate stage — sealing, queued, or waiting on a retry — animates + the track itself, so the fill has no width to jump back from. */ +@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:focus-visible { + outline: var(--border-thickness) solid var(--color-green-primary); + outline-offset: 1px; +} + +.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); +} + +/* An over-budget refusal is a ceiling, not a verdict: it reads as a warning + because the same write can be admitted once room frees. */ +.upload-row-error--budget { + color: var(--color-warning); +} + +@media (prefers-reduced-motion: reduce) { + .upload-row-fill, + .upload-row-track--indeterminate { + transition: none; + animation: none; + } +} From 450f78933b00c4bc3b0c664c83e7c150a109410e Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 5 Aug 2026 12:59:39 +0200 Subject: [PATCH 2/4] fix(web): clear every settled upload row and hold one a dead letter overtook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review gates on the upload wiring. Security: an engine-driven cancel left a row with no control at all — cancel was gated on the row being active and dismiss on it having failed — so the row and the File handle behind it were stranded for the life of the mount. Every settled row now retires itself and offers dismiss. The retire timer is also cancellable again: a dead letter can follow the blocks landing, and the timer the completed phase scheduled would otherwise sweep the failed row. The unbound-update slot is a map gated on an open commit, so a foreign op's report can neither accumulate nor clobber the update a commit is waiting for. Simplify: the upload surface moves into UploadPanel so a block-confirmed event no longer repaints the whole listing; cancel reads the op id off the job rather than render state; the drop target takes the folder id the snapshot reports instead of re-deriving it from the breadcrumb trail; and the shimmer runs only on the row actually being fed. A stopped attempt and an over-budget refusal now share the warning colour, since neither is the settled red of a row that will never publish. --- .../components/file-browser/FileBrowser.tsx | 29 +---- .../file-browser/UploadListItem.test.tsx | 36 +++++- .../file-browser/UploadListItem.tsx | 40 ++++--- .../components/file-browser/UploadPanel.tsx | 38 +++++++ .../components/file-browser/UploadZone.tsx | 3 +- apps/web/src/hooks/useDropUpload.test.tsx | 60 ++++++++++ apps/web/src/hooks/useDropUpload.ts | 104 +++++++++++------- apps/web/src/styles/upload.css | 22 ++-- apps/web/src/vault/useFolderNavigation.ts | 3 + 9 files changed, 230 insertions(+), 105 deletions(-) create mode 100644 apps/web/src/components/file-browser/UploadPanel.tsx diff --git a/apps/web/src/components/file-browser/FileBrowser.tsx b/apps/web/src/components/file-browser/FileBrowser.tsx index d548d825c..4d275ae4f 100644 --- a/apps/web/src/components/file-browser/FileBrowser.tsx +++ b/apps/web/src/components/file-browser/FileBrowser.tsx @@ -1,19 +1,14 @@ -import { isActiveUpload, useDropUpload } from '../../hooks/useDropUpload'; import { useFolderNavigation } from '../../vault/useFolderNavigation'; import { Breadcrumbs } from './Breadcrumbs'; import { EmptyState } from './EmptyState'; import { FileList } from './FileList'; -import { UploadListItem } from './UploadListItem'; -import { UploadZone } from './UploadZone'; +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 { uploads, upload, cancel, retry, dismiss } = useDropUpload(); const settled = !isLoading && error === null; - // The trail ends at the folder on screen, which is where a drop lands. - const folder = breadcrumbs.at(-1)?.id ?? null; return (
@@ -28,25 +23,7 @@ export function FileBrowser() { {'// LOADING VAULT...'}

)} - {folder !== null && ( - upload(files, folder)} - busy={uploads.some((entry) => isActiveUpload(entry.phase))} - /> - )} - {uploads.length > 0 && ( -
- {uploads.map((entry) => ( - - ))} -
- )} + {folder !== null && } {/* An empty non-root folder still lists, so `[..]` remains reachable. */} {settled && (rows.length > 0 || !isRoot) && ( { expect(screen.getByTestId('upload-row-status').textContent).toBe('50%'); }); - it('stays indeterminate while the engine has no block count to give', () => { + it('shimmers only while the client is feeding the engine', () => { show(entry({ phase: 'staging' })); const bar = screen.getByRole('progressbar'); @@ -40,6 +40,14 @@ describe('an upload row', () => { 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 })); @@ -53,7 +61,7 @@ describe('an upload row', () => { 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 failed 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'); @@ -61,7 +69,16 @@ describe('an upload row', () => { expect(screen.getByRole('alert').textContent).toBe('no reachable pin provider'); }); - it('marks an over-budget refusal apart from a terminal failure', () => { + 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', @@ -70,6 +87,17 @@ describe('an upload row', () => { }) ); - expect(screen.getByTestId('upload-row-error').className).toContain('upload-row-error--budget'); + 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 index 9546f1703..796818fb0 100644 --- a/apps/web/src/components/file-browser/UploadListItem.tsx +++ b/apps/web/src/components/file-browser/UploadListItem.tsx @@ -8,9 +8,6 @@ interface UploadListItemProps { onDismiss: (id: string) => void; } -/** The phases whose bar can quote a fraction; the rest are indeterminate. */ -const MEASURED: readonly UploadPhase[] = ['uploading', 'uploaded']; - const LABELS: Record = { staging: 'sealing', queued: 'queued', @@ -24,9 +21,15 @@ const LABELS: Record = { /** 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 measured = MEASURED.includes(phase); + const measured = phase === 'uploading' || phase === 'uploaded'; const percent = Math.round(upload.progress * 100); const settled = !isActiveUpload(phase); + // 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 (
{name} {!settled && (
{phase === 'uploading' ? `${percent}%` : LABELS[phase]} - {isActiveUpload(phase) && ( + {!settled && ( + {phase !== 'uploaded' && ( + + )} + {/* Every settled row is clearable, so none can strand its `File`. */}
{error !== null && (

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..ea0b3419b --- /dev/null +++ b/apps/web/src/components/file-browser/UploadPanel.tsx @@ -0,0 +1,38 @@ +import { isActiveUpload, useDropUpload } from '../../hooks/useDropUpload'; +import { UploadListItem } from './UploadListItem'; +import { UploadZone } from './UploadZone'; + +interface UploadPanelProps { + /** Where a drop lands: the folder on screen. */ + folder: Uint8Array; +} + +/** + * The upload surface for one folder. It owns the upload rows so a block-confirmed + * event repaints them alone, not the listing underneath. + */ +export function UploadPanel({ folder }: UploadPanelProps) { + const { uploads, upload, cancel, retry, dismiss } = useDropUpload(); + + return ( + <> + 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.tsx b/apps/web/src/components/file-browser/UploadZone.tsx index 7fe5af35d..50dc32d57 100644 --- a/apps/web/src/components/file-browser/UploadZone.tsx +++ b/apps/web/src/components/file-browser/UploadZone.tsx @@ -15,8 +15,7 @@ function carriesFiles(transfer: DataTransfer): boolean { /** 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, so a boolean alone - // would clear the highlight while the drag is still over the zone. + // `dragleave` fires for every child the pointer crosses. const depth = useRef(0); const picker = useRef(null); diff --git a/apps/web/src/hooks/useDropUpload.test.tsx b/apps/web/src/hooks/useDropUpload.test.tsx index 0a97af8d6..5cc8f1d2c 100644 --- a/apps/web/src/hooks/useDropUpload.test.tsx +++ b/apps/web/src/hooks/useDropUpload.test.tsx @@ -258,6 +258,37 @@ describe('reporting what the engine says about the op', () => { }); 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', () => { @@ -311,6 +342,35 @@ describe('cancelling', () => { 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('shows a refused cancel without moving the row off the upload', async () => { const engine = uploadEngine(); engine.facade.cancelUpload.mockRejectedValueOnce( diff --git a/apps/web/src/hooks/useDropUpload.ts b/apps/web/src/hooks/useDropUpload.ts index b7058c3e2..e2f6bd050 100644 --- a/apps/web/src/hooks/useDropUpload.ts +++ b/apps/web/src/hooks/useDropUpload.ts @@ -1,7 +1,7 @@ /** * The upload path: `File` handles in, facade write handles out - * (blueprint/web-client.md "Content paths"). One slice of plaintext exists at a - * time and is transferred into the engine, never copied through React state. + * (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). @@ -13,17 +13,13 @@ import type { EventDescriptor } from '@cipherbox/client'; import { errorMessage } from '../lib/errorMessage'; import { useEngine } from '../providers/EngineProvider'; -/** Plaintext crosses to the engine one slice at a time; peak heap is one slice. */ +/** 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. `uploaded` means the version's blocks are on the - * network, not that its record published; `stalled` is one attempt the drain - * will retry, where `failed` is terminal. - */ +/** Where an upload has got to; `staging` is the only rung the engine does not name. */ export type UploadPhase = | 'staging' | 'queued' @@ -35,6 +31,9 @@ export type UploadPhase = 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); @@ -70,6 +69,8 @@ export interface DropUpload { 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. */ @@ -86,21 +87,22 @@ export function useDropUpload(): DropUpload { const jobs = useRef(new Map()); const cancelled = useRef(new Set()); const rowByOp = useRef(new Map()); - const timers = useRef(new Set>()); + 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()); - // A drain that reports an op before its `commitWrite` reply lands has no row - // to update yet; one slot covers it, because only one commit is ever open and - // the op id it is claimed under is unique. + // 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(null); + 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); @@ -109,11 +111,11 @@ export function useDropUpload(): DropUpload { const retire = useCallback( (id: string) => { - const timer = setTimeout(() => { - timers.current.delete(timer); - forget(id); - }, SETTLED_ROW_MILLIS); - timers.current.add(timer); + stopRetire(timers.current, id); + timers.current.set( + id, + setTimeout(() => forget(id), SETTLED_ROW_MILLIS) + ); }, [forget] ); @@ -121,25 +123,38 @@ export function useDropUpload(): DropUpload { useEffect(() => { const pending = timers.current; return () => { - for (const timer of pending) clearTimeout(timer); + 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 row = rowByOp.current.get(update.opId.toString()); + const key = update.opId.toString(); + const row = rowByOp.current.get(key); if (row === undefined) { - if (committing.current) unbound.current = update; + if (committing.current) unbound.current.set(key, update.change); return; } - patch(row, update.change); - if (update.change.phase === 'uploaded') retire(row); + apply(row, update.change); }); - }, [engine, patch, retire]); + }, [apply, engine]); /** Asks the engine to drop a committed op; a refusal says why on the row. */ const dropOp = useCallback( @@ -172,8 +187,7 @@ export function useDropUpload(): DropUpload { const abandoned = async (): Promise => { if (!cancelled.current.has(id)) return false; await release(); - patch(id, { phase: 'cancelled', error: null }); - retire(id); + apply(id, { phase: 'cancelled', error: null }); return true; }; @@ -185,8 +199,6 @@ export function useDropUpload(): DropUpload { ); for (let offset = 0; offset < job.file.size; offset += CHUNK_BYTES) { if (await abandoned()) return; - // Read and handed over in one step: the push detaches the buffer, so - // no plaintext slice outlives the call that consumed it. await facade.pushChunk( handle, await job.file.slice(offset, offset + CHUNK_BYTES).arrayBuffer() @@ -202,19 +214,19 @@ export function useDropUpload(): DropUpload { committing.current = false; } handle = null; - + job.opId = opId; rowByOp.current.set(opId.toString(), id); - const early = unbound.current?.opId === opId ? unbound.current.change : undefined; - unbound.current = null; - patch(id, { opId, phase: 'queued', ...early }); - if (early?.phase === 'uploaded') retire(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)) { - patch(id, { phase: 'cancelled', error: null }); - retire(id); + apply(id, { phase: 'cancelled', error: null }); return; } patch(id, { @@ -224,7 +236,7 @@ export function useDropUpload(): DropUpload { }); } }, - [dropOp, engine, patch, retire] + [apply, dropOp, engine, patch, retire] ); const enqueue = useCallback( @@ -236,9 +248,10 @@ export function useDropUpload(): DropUpload { 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 }); + jobs.current.set(id, { file, parent, opId: null }); return { id, name: file.name, @@ -250,7 +263,6 @@ export function useDropUpload(): DropUpload { code: null, } satisfies UploadEntry; }); - if (started.length === 0) return; setUploads((rows) => [...rows, ...started]); for (const row of started) enqueue(row.id); }, @@ -260,18 +272,19 @@ export function useDropUpload(): DropUpload { const cancel = useCallback( (id: string) => { cancelled.current.add(id); - const opId = uploads.find((row) => row.id === id)?.opId; - // Still staging: the run loop reads the flag at its next chunk boundary. + const opId = jobs.current.get(id)?.opId; if (opId != null) dropOp(id, opId); }, - [dropOp, uploads] + [dropOp] ); const retry = useCallback( (id: string) => { - if (!jobs.current.has(id)) return; + const job = jobs.current.get(id); + if (job === undefined) return; cancelled.current.delete(id); unbind(rowByOp.current, id); + job.opId = null; patch(id, { phase: 'staging', progress: 0, opId: null, error: null, code: null }); enqueue(id); }, @@ -281,6 +294,13 @@ export function useDropUpload(): DropUpload { 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); diff --git a/apps/web/src/styles/upload.css b/apps/web/src/styles/upload.css index bb5797173..30ee345e0 100644 --- a/apps/web/src/styles/upload.css +++ b/apps/web/src/styles/upload.css @@ -60,16 +60,17 @@ margin-bottom: var(--spacing-sm); } -.upload-row { +/* Doubled up so the row's own affordances win wherever this sheet is loaded. */ +.file-list-item.upload-row { cursor: default; } -.upload-row:hover { +.file-list-item.upload-row:hover { background-color: transparent; } -.upload-row--failed { - background-color: rgb(239 68 68 / 8%); +.file-list-item.upload-row--failed { + background-color: color-mix(in srgb, var(--color-error) 8%, transparent); } .upload-row--failed .file-list-item-icon { @@ -102,8 +103,8 @@ transition: width 0.2s ease; } -/* An indeterminate stage — sealing, queued, or waiting on a retry — animates - the track itself, so the fill has no width to jump back from. */ +/* 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; @@ -151,11 +152,6 @@ color: var(--color-text-primary); } -.upload-row-button:focus-visible { - outline: var(--border-thickness) solid var(--color-green-primary); - outline-offset: 1px; -} - .upload-row-button--retry { color: var(--color-green-primary); } @@ -168,9 +164,7 @@ color: var(--color-error); } -/* An over-budget refusal is a ceiling, not a verdict: it reads as a warning - because the same write can be admitted once room frees. */ -.upload-row-error--budget { +.upload-row-error--transient { color: var(--color-warning); } 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), From 0039c02b0d58bac898ca5e697e43e22f4a3b4ab7 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 5 Aug 2026 20:02:53 +0200 Subject: [PATCH 3/4] fix: keep the upload panel mounted across a folder change and stop the retirement timer on retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An upload panel gated on a non-null folder unmounted whenever the routed folder had no snapshot yet, taking the in-flight rows, their cancel and retry controls, and the engine subscription with it. Mount it always and gate only the drop target, on a settled folder — which also stops a malformed node route from accepting drops into the root behind its own "that is not a folder id" error. `retry` moved a settled row back to `staging` through `patch`, leaving the retirement timer that phase had scheduled to delete the row and its job mid-run. Route it through `apply`, which stops the timer for a phase that is not retiring, and drop the stale `retire` dependency from `run`. Entire-Checkpoint: aa776a71af92 --- .../components/file-browser/FileBrowser.tsx | 4 +- .../file-browser/UploadPanel.test.tsx | 93 +++++++++++++++++++ .../components/file-browser/UploadPanel.tsx | 20 ++-- apps/web/src/hooks/useDropUpload.test.tsx | 38 ++++++++ apps/web/src/hooks/useDropUpload.ts | 10 +- .../src/vault/useFolderNavigation.test.tsx | 11 +++ 6 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/file-browser/UploadPanel.test.tsx diff --git a/apps/web/src/components/file-browser/FileBrowser.tsx b/apps/web/src/components/file-browser/FileBrowser.tsx index 4d275ae4f..67f6ddaeb 100644 --- a/apps/web/src/components/file-browser/FileBrowser.tsx +++ b/apps/web/src/components/file-browser/FileBrowser.tsx @@ -23,7 +23,9 @@ export function FileBrowser() { {'// LOADING VAULT...'}

)} - {folder !== null && } + {/* 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) && ( 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 index ea0b3419b..b0301b9ee 100644 --- a/apps/web/src/components/file-browser/UploadPanel.tsx +++ b/apps/web/src/components/file-browser/UploadPanel.tsx @@ -3,23 +3,27 @@ import { UploadListItem } from './UploadListItem'; import { UploadZone } from './UploadZone'; interface UploadPanelProps { - /** Where a drop lands: the folder on screen. */ - folder: Uint8Array; + /** Where a drop lands, or `null` when nothing on screen can take one. */ + folder: Uint8Array | null; } /** - * The upload surface for one folder. It owns the upload rows so a block-confirmed - * event repaints them alone, not the listing underneath. + * 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 ( <> - upload(files, folder)} - busy={uploads.some((entry) => isActiveUpload(entry.phase))} - /> + {folder !== null && ( + upload(files, folder)} + busy={uploads.some((entry) => isActiveUpload(entry.phase))} + /> + )} {uploads.length > 0 && (
{uploads.map((entry) => ( diff --git a/apps/web/src/hooks/useDropUpload.test.tsx b/apps/web/src/hooks/useDropUpload.test.tsx index 5cc8f1d2c..ec31813a5 100644 --- a/apps/web/src/hooks/useDropUpload.test.tsx +++ b/apps/web/src/hooks/useDropUpload.test.tsx @@ -371,6 +371,44 @@ describe('cancelling', () => { 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( diff --git a/apps/web/src/hooks/useDropUpload.ts b/apps/web/src/hooks/useDropUpload.ts index e2f6bd050..f62a1172b 100644 --- a/apps/web/src/hooks/useDropUpload.ts +++ b/apps/web/src/hooks/useDropUpload.ts @@ -180,6 +180,8 @@ export function useDropUpload(): DropUpload { 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; }; @@ -236,7 +238,7 @@ export function useDropUpload(): DropUpload { }); } }, - [apply, dropOp, engine, patch, retire] + [apply, dropOp, engine, patch] ); const enqueue = useCallback( @@ -285,10 +287,12 @@ export function useDropUpload(): DropUpload { cancelled.current.delete(id); unbind(rowByOp.current, id); job.opId = null; - patch(id, { phase: 'staging', progress: 0, opId: null, error: null, code: 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); }, - [enqueue, patch] + [apply, enqueue] ); return { uploads, upload, cancel, retry, dismiss: forget }; 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(); + }); }); From 9a0fd7d9aa88e17f9c530c0ecf1443d304ebc6fb Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 5 Aug 2026 20:06:51 +0200 Subject: [PATCH 4/4] refactor: narrow the upload row's measured-progress condition to the phase that reports one The progress track only renders while the row is active, and `uploaded` is not an active phase, so its arm of `measured` was unreachable. Entire-Checkpoint: 246c9b26ac95 --- apps/web/src/components/file-browser/UploadListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/file-browser/UploadListItem.tsx b/apps/web/src/components/file-browser/UploadListItem.tsx index 796818fb0..0c0cc23a5 100644 --- a/apps/web/src/components/file-browser/UploadListItem.tsx +++ b/apps/web/src/components/file-browser/UploadListItem.tsx @@ -21,9 +21,9 @@ const LABELS: Record = { /** 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 measured = phase === 'uploading' || phase === 'uploaded'; 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';