Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions apps/web/src/auth/CoreKitProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode
import { errorMessage } from '../lib/errorMessage';
import type { CoreKitSession } from './coreKit';

/** Whether this tab knows if it has a session. */
export type CoreKitStatus = 'checking' | 'ready' | 'unavailable';

/** Generous, so a slow-but-working restore still lands `ready`. */
const RESTORE_DEADLINE_MS = 10_000;

const UNREACHABLE = 'the login provider is not responding — check your connection and reload';

export interface CoreKitContextValue {
/** `null` until the session is built and its restore attempt has settled. */
session: CoreKitSession | null;
/** True while the mount-time session restore is still in flight. */
isRestoring: boolean;
/** Why Core Kit is unusable at all — a missing or rejected build config. */
status: CoreKitStatus;
/** Why Core Kit is unusable at all — a bad build config, or silence. */
error: string | null;
}

Expand All @@ -28,7 +35,7 @@ export interface CoreKitProviderProps {
export function CoreKitProvider({ createSession, children }: CoreKitProviderProps) {
const [value, setValue] = useState<CoreKitContextValue>({
session: null,
isRestoring: true,
status: 'checking',
error: null,
});
const factory = useRef(createSession);
Expand All @@ -41,20 +48,32 @@ export function CoreKitProvider({ createSession, children }: CoreKitProviderProp
session.current ??= factory.current();
restore.current ??= session.current.restore();
} catch (error) {
setValue({ session: null, isRestoring: false, error: errorMessage(error) });
setValue({ session: null, status: 'unavailable', error: errorMessage(error) });
return;
}

const settled = { session: session.current, isRestoring: false, error: null };
// A restore that never settles would hold every route gating on this at
// `checking` forever, so silence past the deadline is a verdict.
const deadline = setTimeout(() => {
if (live) setValue({ session: null, status: 'unavailable', error: UNREACHABLE });
}, RESTORE_DEADLINE_MS);

const settled: CoreKitContextValue = {
session: session.current,
status: 'ready',
error: null,
};
// A failed restore just means there is no session to resume; the methods
// below still work, and a real breakage surfaces when one is used.
restore.current.then(
() => live && setValue(settled),
() => live && setValue(settled)
);
const settle = () => {
clearTimeout(deadline);
if (live) setValue(settled);
};
restore.current.then(settle, settle);

return () => {
live = false;
clearTimeout(deadline);
};
}, []);

Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/auth/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export interface Auth {
isAuthenticated: boolean;
/** True while the tab is still assembling its engine or Core Kit session. */
isReady: boolean;
/**
* True once the tab knows it has no session — the check settled signed out,
* or Core Kit could never answer it.
*/
isSignedOut: boolean;
/** True while a restore, login, or logout is in flight. */
isBusy: boolean;
/** The last failure, already stripped of anything secret-shaped. */
Expand All @@ -42,13 +47,14 @@ export function useAuth(): Auth {
const client = useEngine();
const secrets = useLoginSecretSource();
const rebuildEngine = useRebuildEngine();
const { session, isRestoring, error: coreKitError } = useCoreKit();
const { session, status, error: coreKitError } = useCoreKit();
const { isAuthenticated } = useAuthState();

const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

const isReady = client !== null && session !== null && !isRestoring;
const isReady = client !== null && session !== null && status === 'ready';
const isSignedOut = !isAuthenticated && (isReady || status === 'unavailable');

/** Serializes the auth transitions; a collision rejects rather than no-ops. */
const exclusively = useCallback(async (step: () => Promise<void>): Promise<void> => {
Expand Down Expand Up @@ -153,6 +159,7 @@ export function useAuth(): Auth {
return {
isAuthenticated,
isReady,
isSignedOut,
isBusy,
error: error ?? coreKitError,
loginWithGoogle,
Expand Down
21 changes: 13 additions & 8 deletions apps/web/src/components/file-browser/ConfirmDeleteDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { ListingRow } from '../../vault/listing';
import { describeRows } from '../../vault/selection';
import { Modal } from '../ui/Modal';

interface ConfirmDeleteDialogProps {
row: ListingRow;
/** The rows the delete will retire, named as one or counted as many. */
rows: ListingRow[];
onClose: () => void;
onConfirm: () => void;
busy: boolean;
Expand All @@ -11,20 +13,23 @@ interface ConfirmDeleteDialogProps {
}

export function ConfirmDeleteDialog({
row,
rows,
onClose,
onConfirm,
busy,
error,
}: ConfirmDeleteDialogProps) {
const what = describeRows(rows);
const recursive = rows.some((row) => row.kind === 'folder');
const message =
rows.length === 1
? `delete "${what}"${recursive ? ' and everything inside it' : ''}?`
: `delete ${what}${recursive ? ' and everything inside' : ''}?`;

return (
<Modal onClose={onClose} title={`delete ${row.name}`} error={error} busy={busy}>
<Modal onClose={onClose} title={`delete ${what}`} error={error} busy={busy}>
<div className="dialog-content" data-testid="delete-dialog">
<p className="dialog-message">
{row.kind === 'folder'
? `delete "${row.name}" and everything inside it?`
: `delete "${row.name}"?`}
</p>
<p className="dialog-message">{message}</p>
<div className="dialog-actions">
<button type="button" className="dialog-button" onClick={onClose} disabled={busy}>
cancel
Expand Down
199 changes: 198 additions & 1 deletion apps/web/src/components/file-browser/FileBrowserActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ describe('the vault browser write path', () => {
const frames: { atHome: boolean; destination: string | null }[] = [];

function Probe() {
const picker = useFolderPicker(DOCS, toHex(NOTE));
const picker = useFolderPicker(DOCS, new Set([toHex(NOTE)]));
frames.push({
atHome: picker.atHome,
destination: picker.destination === null ? null : toHex(picker.destination),
Expand Down Expand Up @@ -364,6 +364,203 @@ describe('the vault browser write path', () => {
});
});

describe('the vault browser selection', () => {
const select = (name: string) => fireEvent.click(screen.getByLabelText(`select ${name}`));
const count = () => screen.queryByTestId('selection-count')?.textContent ?? null;

it('counts the rows toggled on, and empties on clear', async () => {
const engine = fakeEngine();
renderBrowser(engine);
await landSnapshot(engine, listing());

expect(screen.queryByTestId('selection-action-bar')).toBeNull();

select('notes.txt');
expect(count()).toBe('notes.txt selected');
select('documents');
expect(count()).toBe('1 file, 1 folder selected');
select('notes.txt');
expect(count()).toBe('documents selected');

fireEvent.click(screen.getByTestId('selection-clear'));
expect(screen.queryByTestId('selection-action-bar')).toBeNull();
});

it('takes the whole listing from the header, and gives it back', async () => {
const engine = fakeEngine();
renderBrowser(engine);
await landSnapshot(engine, listing());

fireEvent.click(screen.getByTestId('select-all'));
expect(count()).toBe('1 file, 1 folder selected');

fireEvent.click(screen.getByTestId('select-all'));
expect(screen.queryByTestId('selection-action-bar')).toBeNull();
});

it('dispatches one delete per selected node', async () => {
const engine = fakeEngine();
renderBrowser(engine);
await landSnapshot(engine, listing());

fireEvent.click(screen.getByTestId('select-all'));
fireEvent.click(screen.getByTestId('selection-delete'));
fireEvent.click(screen.getByTestId('delete-confirm'));

await waitFor(() => expect(engine.facade.delete).toHaveBeenCalledTimes(2));
expect(engine.facade.delete.mock.calls).toEqual([[DOCS], [NOTE]]);
await waitFor(() => expect(screen.queryByTestId('selection-action-bar')).toBeNull());
});

it('dispatches one relink per selected node, all to the picked destination', async () => {
const engine = fakeEngine();
renderBrowser(engine);
await landSnapshot(
engine,
folderView({ children: [file(NOTE, 'notes.txt'), file(PICTURE, 'shot.png')] })
);

fireEvent.click(screen.getByTestId('select-all'));
fireEvent.click(screen.getByTestId('selection-move'));
await settlePickerRead(engine, listing());

fireEvent.click(screen.getByTestId('move-dialog-folder'));
await settlePickerRead(
engine,
folderView({ folder: DOCS, folderName: 'documents', ancestors: [{ id: ROOT, name: '' }] })
);
fireEvent.click(screen.getByTestId('move-confirm'));

await waitFor(() => expect(engine.facade.relink).toHaveBeenCalledTimes(2));
expect(engine.facade.relink.mock.calls).toEqual([
[NOTE, DOCS],
[PICTURE, DOCS],
]);
});

it('reads every selected file and leaves the folders alone', async () => {
const engine = fakeEngine();
renderBrowser(engine);
await landSnapshot(
engine,
folderView({
children: [folder(DOCS, 'documents'), file(NOTE, 'notes.txt'), file(PICTURE, 'shot.png')],
})
);

fireEvent.click(screen.getByTestId('select-all'));
fireEvent.click(screen.getByTestId('selection-download'));

await waitFor(() => expect(engine.facade.download).toHaveBeenCalledTimes(2));
expect(engine.facade.download.mock.calls).toEqual([[NOTE], [PICTURE]]);
});

it('runs one batch download at a time, however often the button is clicked', async () => {
const pending: ((bytes: ArrayBuffer) => void)[] = [];
const engine = fakeEngine(() => new Promise<ArrayBuffer>((resolve) => pending.push(resolve)));
renderBrowser(engine);
await landSnapshot(
engine,
folderView({ children: [file(NOTE, 'notes.txt'), file(PICTURE, 'shot.png')] })
);

fireEvent.click(screen.getByTestId('select-all'));
fireEvent.click(screen.getByTestId('selection-download'));
await act(async () => {
await Promise.resolve();
});

// A second loop would hand the user a duplicate of every file, and would
// let a move or delete land between two reads of the same batch.
const bar = screen.getByTestId('selection-action-bar');
for (const action of ['download', 'move', 'delete']) {
expect((screen.getByTestId(`selection-${action}`) as HTMLButtonElement).disabled).toBe(true);
}
fireEvent.click(screen.getByTestId('selection-download'));
expect(engine.facade.download.mock.calls).toEqual([[NOTE]]);
expect(bar).toBeDefined();

await act(async () => {
pending[0](new ArrayBuffer(0));
await Promise.resolve();
});
await waitFor(() => expect(pending).toHaveLength(2));
await act(async () => {
pending[1](new ArrayBuffer(0));
await Promise.resolve();
});

await waitFor(() =>
expect((screen.getByTestId('selection-download') as HTMLButtonElement).disabled).toBe(false)
);
expect(engine.facade.download.mock.calls).toEqual([[NOTE], [PICTURE]]);
});

it('retires only the nodes a partly refused batch was accepted for', async () => {
const engine = fakeEngine();
engine.facade.delete
.mockImplementationOnce(() => Promise.resolve())
.mockImplementationOnce(() => Promise.reject(new Error('the op queue is full')));
renderBrowser(engine);
await landSnapshot(engine, listing());

fireEvent.click(screen.getByTestId('select-all'));
fireEvent.click(screen.getByTestId('selection-delete'));
fireEvent.click(screen.getByTestId('delete-confirm'));

await waitFor(() =>
expect(screen.getByTestId('dialog-error').textContent).toBe('the op queue is full')
);
// The dialog stays up over what was refused only: retrying it must not
// journal a second delete for the node the engine already took.
expect(count()).toBe('notes.txt selected');
expect(screen.getByTestId('delete-dialog').textContent).toContain('"notes.txt"');

fireEvent.click(screen.getByTestId('delete-confirm'));
await waitFor(() => expect(engine.facade.delete.mock.calls).toEqual([[DOCS], [NOTE], [NOTE]]));
await waitFor(() => expect(screen.queryByTestId('delete-dialog')).toBeNull());
expect(screen.queryByTestId('selection-action-bar')).toBeNull();
});

it('retires only the rows a command acted on, not the whole selection', async () => {
const engine = fakeEngine();
renderBrowser(engine);
await landSnapshot(engine, listing());

fireEvent.click(screen.getByTestId('select-all'));
// A command raised from one row's own menu is about that row, not the batch.
openRowMenu('notes.txt');
chooseMenuItem('delete');
fireEvent.click(screen.getByTestId('delete-confirm'));

await waitFor(() => expect(engine.facade.delete.mock.calls).toEqual([[NOTE]]));
await waitFor(() => expect(count()).toBe('documents selected'));
});

it('starts over when the route moves to another folder', async () => {
const engine = fakeEngine();
renderBrowser(engine);
await landSnapshot(engine, listing());

fireEvent.click(screen.getByTestId('select-all'));
expect(count()).toBe('1 file, 1 folder selected');

fireEvent.doubleClick(screen.getByText('documents'));
await landSnapshot(
engine,
folderView({
folder: DOCS,
folderName: 'documents',
children: [file(PICTURE, 'shot.png')],
ancestors: [{ id: ROOT, name: '' }],
})
);

expect(screen.getByText('shot.png')).toBeDefined();
expect(screen.queryByTestId('selection-action-bar')).toBeNull();
});
});

describe('the vault browser read path over the facade', () => {
const originalCreate = URL.createObjectURL;
const originalRevoke = URL.revokeObjectURL;
Expand Down
Loading