From 3c863d653be2179c9fda8dc47d5bc6bbd38d04a6 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 6 Aug 2026 14:23:50 +0200 Subject: [PATCH 1/4] feat: add the vault browser selection model and bound the session check Two file-disjoint web items. Selection: a per-row and select-all selection model over the listing, a SelectionActionBar with batch download, move and delete, and one facade command per selected node dispatched through useVaultActions. Selection is held as hex keys and read back through the listing, so a row the engine has retired leaves the selection with it; a routed folder change starts over. The move and delete dialogs now take a set of rows instead of one, which is also how the row menu drives them, and the folder picker excludes every selected subtree rather than a single one. Session check: an unresolved Core Kit restore now has a deadline, so a tab deep-linking to /files while the provider never answers is returned to the front door instead of rendering CHECKING SESSION forever. A restore that lands after the deadline still promotes the tab back to ready, and the front door names why the check could not be made. The e2e assertion weakened by the hang is restored to its unconditional form. Closes #1091 Closes #1087 Part of #642 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WegkkQ3uhNREerTW4MMeY2 --- apps/web/src/auth/CoreKitProvider.tsx | 46 +++++-- apps/web/src/auth/useAuth.ts | 12 +- .../file-browser/ConfirmDeleteDialog.tsx | 22 ++-- .../file-browser/FileBrowserActions.test.tsx | 117 +++++++++++++++++- .../file-browser/FileBrowserActions.tsx | 46 +++++-- .../src/components/file-browser/FileList.tsx | 34 ++++- .../components/file-browser/FileListItem.tsx | 18 ++- .../components/file-browser/MoveDialog.tsx | 14 ++- .../components/file-browser/ParentDirRow.tsx | 2 + .../file-browser/SelectionActionBar.tsx | 79 ++++++++++++ apps/web/src/hooks/useFolderPicker.test.tsx | 2 +- apps/web/src/hooks/useFolderPicker.ts | 13 +- apps/web/src/hooks/useVaultActions.ts | 32 ++++- apps/web/src/routes/FilesPage.test.tsx | 62 ++++++++++ apps/web/src/routes/FilesPage.tsx | 6 +- apps/web/src/styles/vault-actions.css | 74 +++++++++++ apps/web/src/test/authFakes.tsx | 9 +- apps/web/src/vault/selection.ts | 73 +++++++++++ tests/web-e2e/README.md | 3 +- tests/web-e2e/page-objects/files.page.ts | 5 - tests/web-e2e/tests/smoke.spec.ts | 5 +- 21 files changed, 611 insertions(+), 63 deletions(-) create mode 100644 apps/web/src/components/file-browser/SelectionActionBar.tsx create mode 100644 apps/web/src/routes/FilesPage.test.tsx create mode 100644 apps/web/src/vault/selection.ts diff --git a/apps/web/src/auth/CoreKitProvider.tsx b/apps/web/src/auth/CoreKitProvider.tsx index 781ed1ac0..f58487b1b 100644 --- a/apps/web/src/auth/CoreKitProvider.tsx +++ b/apps/web/src/auth/CoreKitProvider.tsx @@ -2,12 +2,25 @@ 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. `unavailable` is a verdict, not a + * stage on the way to `ready`: a route gating on it has its answer. + */ +export type CoreKitStatus = 'checking' | 'ready' | 'unavailable'; + +/** + * How long the mount-time restore may run before the tab calls Core Kit + * unreachable. 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; } @@ -28,7 +41,7 @@ export interface CoreKitProviderProps { export function CoreKitProvider({ createSession, children }: CoreKitProviderProps) { const [value, setValue] = useState({ session: null, - isRestoring: true, + status: 'checking', error: null, }); const factory = useRef(createSession); @@ -41,20 +54,33 @@ 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; the deadline turns that silence into a verdict, and a + // restore that lands late still promotes the tab back to `ready`. + 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); }; }, []); diff --git a/apps/web/src/auth/useAuth.ts b/apps/web/src/auth/useAuth.ts index b85e3cac7..efa1be18a 100644 --- a/apps/web/src/auth/useAuth.ts +++ b/apps/web/src/auth/useAuth.ts @@ -17,6 +17,12 @@ 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. Gating a route on `isReady` alone hangs + * that tab forever on an answer that is not coming. + */ + isSignedOut: boolean; /** True while a restore, login, or logout is in flight. */ isBusy: boolean; /** The last failure, already stripped of anything secret-shaped. */ @@ -42,13 +48,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(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): Promise => { @@ -153,6 +160,7 @@ export function useAuth(): Auth { return { isAuthenticated, isReady, + isSignedOut, isBusy, error: error ?? coreKitError, loginWithGoogle, diff --git a/apps/web/src/components/file-browser/ConfirmDeleteDialog.tsx b/apps/web/src/components/file-browser/ConfirmDeleteDialog.tsx index ca413c032..f42d9abeb 100644 --- a/apps/web/src/components/file-browser/ConfirmDeleteDialog.tsx +++ b/apps/web/src/components/file-browser/ConfirmDeleteDialog.tsx @@ -1,8 +1,10 @@ import type { ListingRow } from '../../vault/listing'; +import { describeRows } from '../../vault/selection'; import { Modal } from '../ui/Modal'; interface ConfirmDeleteDialogProps { - row: ListingRow; + /** What the delete will retire; each row becomes a command of its own. */ + rows: ListingRow[]; onClose: () => void; onConfirm: () => void; busy: boolean; @@ -11,20 +13,24 @@ interface ConfirmDeleteDialogProps { } export function ConfirmDeleteDialog({ - row, + rows, onClose, onConfirm, busy, error, }: ConfirmDeleteDialogProps) { + const what = describeRows(rows); + // A single row is named, so it is quoted; a count is not a name. + const single = rows.length === 1; + const target = single ? `"${what}"` : what; + const inside = rows.some((row) => row.kind === 'folder') + ? ` and everything inside${single ? ' it' : ''}` + : ''; + return ( - +
-

- {row.kind === 'folder' - ? `delete "${row.name}" and everything inside it?` - : `delete "${row.name}"?`} -

+

{`delete ${target}${inside}?`}

+ void downloadSelection()} + onMove={() => setDialog({ kind: 'move', rows: selection.rows })} + onDelete={() => setDialog({ kind: 'delete', rows: selection.rows })} + /> {failure !== null && (

{failure} @@ -108,6 +131,7 @@ export function FileBrowserActions({ {(rows.length > 0 || showParentRow) && ( closeOnSuccess(actions.move(dialog.row.id, newParent))} + onConfirm={(newParent) => + closeOnSuccess(actions.move(dialog.rows.map(toNodeId), newParent), selection.clear) + } /> )} {dialog?.kind === 'delete' && ( closeOnSuccess(actions.remove(dialog.row.id))} + onConfirm={() => + closeOnSuccess(actions.remove(dialog.rows.map(toNodeId)), selection.clear) + } /> )} {dialog?.kind === 'details' && } diff --git a/apps/web/src/components/file-browser/FileList.tsx b/apps/web/src/components/file-browser/FileList.tsx index 3cf4f6472..12e3df8da 100644 --- a/apps/web/src/components/file-browser/FileList.tsx +++ b/apps/web/src/components/file-browser/FileList.tsx @@ -1,9 +1,11 @@ import type { ListingRow } from '../../vault/listing'; +import type { Selection } from '../../vault/selection'; import { FileListItem } from './FileListItem'; import { ParentDirRow } from './ParentDirRow'; interface FileListProps { rows: ListingRow[]; + selection: Selection; /** False at the vault root, which has no parent to step up to. */ showParentRow: boolean; onOpen: (node: Uint8Array) => void; @@ -12,11 +14,32 @@ interface FileListProps { } /** The routed folder's direct children, in columns. */ -export function FileList({ rows, showParentRow, onOpen, onNavigateUp, onRowMenu }: FileListProps) { +export function FileList({ + rows, + selection, + showParentRow, + onOpen, + onNavigateUp, + onRowMenu, +}: FileListProps) { + const partial = selection.rows.length > 0 && !selection.allSelected; + return (

+ { + if (node) node.indeterminate = partial; + }} + disabled={rows.length === 0} + aria-label="select all" + data-testid="select-all" + onChange={selection.toggleAll} + /> [NAME]
@@ -29,7 +52,14 @@ export function FileList({ rows, showParentRow, onOpen, onNavigateUp, onRowMenu
{showParentRow && } {rows.map((row) => ( - + ))}
diff --git a/apps/web/src/components/file-browser/FileListItem.tsx b/apps/web/src/components/file-browser/FileListItem.tsx index c932a2f79..2a5f6d959 100644 --- a/apps/web/src/components/file-browser/FileListItem.tsx +++ b/apps/web/src/components/file-browser/FileListItem.tsx @@ -2,6 +2,9 @@ import type { ListingRow } from '../../vault/listing'; interface FileListItemProps { row: ListingRow; + selected: boolean; + /** Adds or drops this row from the batch selection. */ + onToggle: (key: string) => void; /** Opens a folder. */ onOpen: (node: Uint8Array) => void; /** Raises the row's action menu, anchored on the event that asked for it. */ @@ -9,7 +12,7 @@ interface FileListItemProps { } /** One direct child: kind marker, name, size, mtime, and its queue status. */ -export function FileListItem({ row, onOpen, onRowMenu }: FileListItemProps) { +export function FileListItem({ row, selected, onToggle, onOpen, onRowMenu }: FileListItemProps) { const isFolder = row.kind === 'folder'; const open = () => { if (isFolder) onOpen(row.id); @@ -17,7 +20,7 @@ export function FileListItem({ row, onOpen, onRowMenu }: FileListItemProps) { return (
+ {/* Its own click, not the row's: selecting must not also open. */} + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onChange={() => onToggle(row.key)} + /> diff --git a/apps/web/src/components/file-browser/MoveDialog.tsx b/apps/web/src/components/file-browser/MoveDialog.tsx index d3442250d..4d4227f36 100644 --- a/apps/web/src/components/file-browser/MoveDialog.tsx +++ b/apps/web/src/components/file-browser/MoveDialog.tsx @@ -1,10 +1,13 @@ +import { useMemo } from 'react'; import { useFolderPicker } from '../../hooks/useFolderPicker'; import type { ListingRow } from '../../vault/listing'; +import { describeRows } from '../../vault/selection'; import { Modal } from '../ui/Modal'; interface MoveDialogProps { - row: ListingRow; - /** The folder the row is in today; moving into it would be a no-op. */ + /** What the move will relink; each row becomes a command of its own. */ + rows: ListingRow[]; + /** The folder the rows are in today; moving into it would be a no-op. */ parent: Uint8Array | null; onClose: () => void; onConfirm: (newParent: Uint8Array) => void; @@ -14,13 +17,14 @@ interface MoveDialogProps { } /** Picks a destination by walking the vault one folder at a time. */ -export function MoveDialog({ row, parent, onClose, onConfirm, busy, error }: MoveDialogProps) { - const picker = useFolderPicker(parent, row.key); +export function MoveDialog({ rows, parent, onClose, onConfirm, busy, error }: MoveDialogProps) { + const excluded = useMemo(() => new Set(rows.map((row) => row.key)), [rows]); + const picker = useFolderPicker(parent, excluded); const destination = picker.destination; const canMove = !busy && destination !== null && !picker.atHome; return ( - +

{'destination: '} diff --git a/apps/web/src/components/file-browser/ParentDirRow.tsx b/apps/web/src/components/file-browser/ParentDirRow.tsx index e8ef8ded9..dcd30919b 100644 --- a/apps/web/src/components/file-browser/ParentDirRow.tsx +++ b/apps/web/src/components/file-browser/ParentDirRow.tsx @@ -18,6 +18,8 @@ export function ParentDirRow({ onActivate }: ParentDirRowProps) { data-testid="parent-dir-row" >

+ {/* Holds the selection column open: `[..]` is not a selectable row. */} +