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 ( +
+
+ +
+ {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),