diff --git a/apps/web/src/auth/CoreKitProvider.tsx b/apps/web/src/auth/CoreKitProvider.tsx index 781ed1ac0..1c4cca308 100644 --- a/apps/web/src/auth/CoreKitProvider.tsx +++ b/apps/web/src/auth/CoreKitProvider.tsx @@ -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; } @@ -28,7 +35,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 +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); }; }, []); diff --git a/apps/web/src/auth/useAuth.ts b/apps/web/src/auth/useAuth.ts index b85e3cac7..1e127b686 100644 --- a/apps/web/src/auth/useAuth.ts +++ b/apps/web/src/auth/useAuth.ts @@ -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. */ @@ -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(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 +159,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..f656c3b9d 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; + /** The rows the delete will retire, named as one or counted as many. */ + rows: ListingRow[]; onClose: () => void; onConfirm: () => void; busy: boolean; @@ -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 ( - +
-

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

+

{message}

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

{failure} @@ -108,6 +155,7 @@ export function FileBrowserActions({ {(rows.length > 0 || showParentRow) && ( closeOnSuccess(actions.move(dialog.row.id, newParent))} + onConfirm={(newParent) => + closeOnBatch( + actions.move( + dialog.rows.map((row) => row.id), + newParent + ) + ) + } /> )} {dialog?.kind === 'delete' && ( closeOnSuccess(actions.remove(dialog.row.id))} + onConfirm={() => closeOnBatch(actions.remove(dialog.rows.map((row) => row.id)))} /> )} {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..07e4e1689 100644 --- a/apps/web/src/components/file-browser/FileList.tsx +++ b/apps/web/src/components/file-browser/FileList.tsx @@ -1,9 +1,12 @@ +import { useCallback } from 'react'; 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 +15,37 @@ 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; + // `indeterminate` is a DOM property with no attribute, so it is written here. + const markPartial = useCallback( + (node: HTMLInputElement | null) => { + if (node) node.indeterminate = partial; + }, + [partial] + ); + return (

+ [NAME]
@@ -29,7 +58,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..97453aa83 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. */ + /** The rows the move will relink, named as one or counted as many. */ + 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..9038c6866 100644 --- a/apps/web/src/components/file-browser/ParentDirRow.tsx +++ b/apps/web/src/components/file-browser/ParentDirRow.tsx @@ -18,6 +18,10 @@ export function ParentDirRow({ onActivate }: ParentDirRowProps) { data-testid="parent-dir-row" >

+ {/* `[..]` is not selectable, so it holds the column open instead. A + flex item, not padding: it inherits the row's gap rather than + restating it. */} +