Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './storage_browser_view.module.css';
3 :
4 : import { List, useListCallbackRef } from 'react-window';
5 : import type { RowComponentProps } from 'react-window';
6 :
7 : import {
8 : BroomIcon,
9 : FileDirectoryFillIcon,
10 : FileDirectoryIcon,
11 : FileIcon,
12 : SyncIcon,
13 : XIcon,
14 : } from '../../ui/foundations/symbol_icon.js';
15 : import { Button, ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
16 : import { TreeEntry, TreeEntryName } from '../../ui/foundations/tree_entry.js';
17 : import { VerticalScrollShadows } from '../../ui/foundations/vertical_scroll_shadows.js';
18 : import { observeSize } from '../../ui/foundations/size_observer.js';
19 : import { classNames } from '../../utils/classnames.js';
20 : import { formatBytes } from '../../utils/format.js';
21 : import { formatRelativeTime } from '../../utils/time_format.js';
22 : import { useLogger } from '../../platform/logger/logger_provider.js';
23 : import { stringifyError } from '../../platform/logger/logger.js';
24 : import { useStorageReader, useStorageWriter } from '../../storage/storage_provider.js';
25 : import {
26 : type OPFSCleanupCandidate,
27 : type OPFSCleanupResult,
28 : findOPFSCleanupCandidates,
29 : removeOPFSCleanupCandidates,
30 : } from '../../storage/opfs_cleanup.js';
31 : import {
32 : OPFS_TREE_ROOT_KEY,
33 : StorageTreeEntryKind,
34 : StorageTreeStatCache,
35 : listStorageTreeRoots,
36 : type StorageTreeEntry,
37 : type StorageTreeFileStat,
38 : type StorageTreeRoot,
39 : } from '../../storage/storage_tree.js';
40 : import { getOPFSRoot } from '../../platform/opfs_root.js';
41 :
42 1 : const LOG_CTX = 'storage_browser';
43 : /// The height of a row: a 32px entry with 1px of space above and below, as in the catalog tree
44 1 : const ROW_HEIGHT = 34;
45 : /// The space above the first row, as in the catalog tree
46 1 : const FIRST_ROW_PADDING_PX = 8;
47 :
48 : /// The height of a row, the first row also holds the space above the list
49 1 : function rowHeight(index: number): number {
50 1 : return ROW_HEIGHT + (index === 0 ? FIRST_ROW_PADDING_PX : 0);
51 1 : }
52 :
53 : /// The class of a row container, the first row is padded at the top
54 1 : function rowContainerClass(index: number): string {
55 1 : return classNames(styles.row_container, index === 0 && styles.row_container_first);
56 1 : }
57 :
58 : /// The listing of a directory
59 : export type StorageTreeListing =
60 : | { status: 'loading' }
61 : | { status: 'ready'; entries: StorageTreeEntry[] }
62 : | { status: 'error'; message: string };
63 :
64 : /// The key of a node: the root key and the slash-joined path, separated by a NUL that no path contains
65 1 : export function storageTreeNodeKey(root: StorageTreeRoot, path: string): string {
66 1 : return `${root.key}\0${path}`;
67 1 : }
68 :
69 : /// The path segments of a slash-joined path
70 1 : function splitPath(path: string): string[] {
71 1 : return path === '' ? [] : path.split('/');
72 1 : }
73 :
74 : /// A row of the flattened tree
75 : export type StorageTreeRow =
76 : | { kind: 'root'; key: string; depth: number; root: StorageTreeRoot; expanded: boolean }
77 : | { kind: 'directory'; key: string; depth: number; root: StorageTreeRoot; path: string; name: string; expanded: boolean }
78 : | { kind: 'file'; key: string; depth: number; root: StorageTreeRoot; path: string; name: string }
79 : /// A row in place of the entries of a directory, `load` is set when the listing still has to be requested
80 : | { kind: 'status'; key: string; depth: number; text: string; error: boolean; load: { root: StorageTreeRoot; path: string } | null };
81 :
82 : /// Flatten the expanded and loaded part of the tree into rows
83 1 : export function flattenStorageTree(
84 1 : roots: readonly StorageTreeRoot[],
85 1 : expanded: ReadonlySet<string>,
86 1 : listings: ReadonlyMap<string, StorageTreeListing>,
87 1 : ): StorageTreeRow[] {
88 1 : const rows: StorageTreeRow[] = [];
89 1 : const visit = (root: StorageTreeRoot, path: string, depth: number) => {
90 1 : const key = storageTreeNodeKey(root, path);
91 1 : const listing = listings.get(key);
92 1 : const statusKey = `${key}\0status`;
93 1 : if (listing == null || listing.status === 'loading') {
94 1 : rows.push({ kind: 'status', key: statusKey, depth, text: 'Loading…', error: false, load: listing == null ? { root, path } : null });
95 1 : return;
96 1 : }
97 1 : if (listing.status === 'error') {
98 1 : rows.push({ kind: 'status', key: statusKey, depth, text: listing.message, error: true, load: null });
99 1 : return;
100 1 : }
101 1 : if (listing.entries.length === 0) {
102 1 : rows.push({ kind: 'status', key: statusKey, depth, text: 'Empty', error: false, load: null });
103 1 : return;
104 1 : }
105 1 : for (const entry of listing.entries) {
106 1 : const childPath = path === '' ? entry.name : `${path}/${entry.name}`;
107 1 : const childKey = storageTreeNodeKey(root, childPath);
108 1 : if (entry.kind === StorageTreeEntryKind.Directory) {
109 1 : const isExpanded = expanded.has(childKey);
110 1 : rows.push({ kind: 'directory', key: childKey, depth, root, path: childPath, name: entry.name, expanded: isExpanded });
111 1 : if (isExpanded) {
112 1 : visit(root, childPath, depth + 1);
113 : }
114 1 : } else {
115 1 : rows.push({ kind: 'file', key: childKey, depth, root, path: childPath, name: entry.name });
116 : }
117 1 : }
118 1 : };
119 1 : for (const root of roots) {
120 1 : const key = storageTreeNodeKey(root, '');
121 1 : const isExpanded = expanded.has(key);
122 1 : rows.push({ kind: 'root', key, depth: 0, root, expanded: isExpanded });
123 1 : if (isExpanded) {
124 1 : visit(root, '', 1);
125 : }
126 1 : }
127 1 : return rows;
128 1 : }
129 :
130 : /// Subscribe to the stat of a file in the stat cache
131 1 : function useFileStat(cache: StorageTreeStatCache, root: StorageTreeRoot, path: string, key: string): StorageTreeFileStat | null {
132 1 : const [stat, setStat] = React.useState<StorageTreeFileStat | null>(() => cache.get(key) ?? null);
133 1 : React.useEffect(() => {
134 1 : setStat(cache.get(key) ?? null);
135 1 : return cache.subscribe(key, () => root.stat(splitPath(path)), setStat);
136 1 : }, [cache, root, path, key]);
137 1 : return stat;
138 1 : }
139 :
140 : /// The size and modification cells of a file, filled once its stat arrives
141 1 : function FileStatCells(props: { cache: StorageTreeStatCache; root: StorageTreeRoot; path: string; nodeKey: string }) {
142 1 : const stat = useFileStat(props.cache, props.root, props.path, props.nodeKey);
143 1 : const modified = stat?.modifiedMs != null ? new Date(stat.modifiedMs) : null;
144 1 : return (
145 1 : <>
146 1 : <div className={styles.cell_size}>
147 1 : {stat == null ? null : stat.sizeBytes != null ? formatBytes(stat.sizeBytes) : <span className={styles.note}>{stat.note}</span>}
148 1 : </div>
149 1 : <div className={styles.cell_modified} title={modified?.toISOString()}>
150 1 : {modified != null ? formatRelativeTime(modified) : null}
151 1 : </div>
152 1 : </>
153 : );
154 1 : }
155 :
156 : interface StorageTreeRowProps {
157 : /// The flattened rows
158 : rows: StorageTreeRow[];
159 : /// The stats of the files
160 : statCache: StorageTreeStatCache;
161 : /// Expand or collapse a directory
162 : toggle: (key: string) => void;
163 : }
164 :
165 1 : function StorageTreeRowComponent(props: RowComponentProps<StorageTreeRowProps>) {
166 1 : const row = props.rows[props.index];
167 1 : if (!row) {
168 0 : return <div style={props.style} />;
169 : }
170 1 : switch (row.kind) {
171 1 : case 'root':
172 1 : case 'directory':
173 1 : return (
174 1 : <div className={rowContainerClass(props.index)} style={props.style}>
175 1 : <TreeEntry
176 1 : depth={row.depth}
177 1 : expanded={row.expanded}
178 1 : icon={row.kind === 'root' ? <FileDirectoryFillIcon size={14} /> : <FileDirectoryIcon size={14} />}
179 1 : label={row.kind === 'root' ? (
180 1 : <span className={styles.root_label}>
181 1 : <span className={styles.root_name} title={row.root.label}>{row.root.label}</span>
182 1 : <span className={styles.root_path} title={row.root.displayPath}>{row.root.displayPath}</span>
183 1 : </span>
184 1 : ) : <TreeEntryName title={row.path}>{row.name}</TreeEntryName>}
185 1 : className={classNames(styles.columns, row.kind === 'root' && styles.root_entry)}
186 1 : onClick={() => props.toggle(row.key)}
187 : >
188 1 : <span className={styles.cell_size} />
189 1 : <span className={styles.cell_modified} />
190 1 : </TreeEntry>
191 1 : </div>
192 : );
193 1 : case 'file':
194 1 : return (
195 1 : <div className={rowContainerClass(props.index)} style={props.style}>
196 1 : <TreeEntry
197 1 : depth={row.depth}
198 1 : icon={<FileIcon size={14} />}
199 1 : label={<TreeEntryName title={row.path}>{row.name}</TreeEntryName>}
200 1 : className={styles.columns}
201 : >
202 1 : <FileStatCells cache={props.statCache} root={row.root} path={row.path} nodeKey={row.key} />
203 1 : </TreeEntry>
204 1 : </div>
205 : );
206 1 : case 'status':
207 1 : return (
208 1 : <div className={rowContainerClass(props.index)} style={props.style}>
209 1 : <TreeEntry
210 1 : depth={row.depth}
211 1 : label={<span className={classNames(styles.status, row.error && styles.status_error)} title={row.text}>{row.text}</span>}
212 1 : className={styles.columns}
213 1 : />
214 1 : </div>
215 : );
216 1 : }
217 1 : }
218 :
219 : /// The state of a cleanup of the OPFS root
220 : type CleanupState =
221 : | { status: 'scanning' }
222 : /// The candidates wait for the user to confirm or cancel the deletion
223 : | { status: 'review'; candidates: OPFSCleanupCandidate[] }
224 : | { status: 'deleting'; candidates: OPFSCleanupCandidate[] }
225 : | { status: 'done'; message: string; error: boolean };
226 :
227 : interface CleanupRowProps {
228 : /// The entries that would be deleted
229 : candidates: OPFSCleanupCandidate[];
230 : }
231 :
232 1 : function CleanupRowComponent(props: RowComponentProps<CleanupRowProps>) {
233 1 : const candidate = props.candidates[props.index];
234 1 : if (!candidate) {
235 0 : return <div style={props.style} />;
236 : }
237 1 : const path = `${candidate.path.join('/')}${candidate.directory ? '/' : ''}`;
238 1 : return (
239 1 : <div className={rowContainerClass(props.index)} style={props.style}>
240 1 : <div className={classNames(styles.entry, styles.cleanup_columns)}>
241 1 : <span className={styles.cleanup_cell_name}>
242 1 : <span className={styles.icon} aria-hidden="true">{candidate.directory ? <FileDirectoryIcon size={14} /> : <FileIcon size={14} />}</span>
243 1 : <span className={styles.name} title={path}>{path}</span>
244 1 : </span>
245 1 : <span className={styles.reason} title={candidate.reason}>{candidate.reason}</span>
246 1 : </div>
247 1 : </div>
248 : );
249 1 : }
250 :
251 : /// Summarize the outcome of a cleanup
252 1 : function describeCleanupResult(result: OPFSCleanupResult): string {
253 1 : const removed = `Deleted ${result.removed} ${result.removed === 1 ? 'entry' : 'entries'}.`;
254 1 : if (result.skipped.length === 0) {
255 1 : return removed;
256 : }
257 0 : const kept = result.skipped.map(entry => `${entry.path} (${entry.reason})`).join(', ');
258 0 : return `${removed} Kept ${result.skipped.length}: ${kept}`;
259 1 : }
260 :
261 : /// A read-only browser of the folders the storage backend knows about: the OPFS root and, on desktop,
262 : /// the native session folders. Directories are listed when expanded, file stats are fetched for the
263 : /// visible rows only.
264 1 : export function StorageBrowserView(props: { onClose: () => void }) {
265 1 : const storageReader = useStorageReader();
266 1 : const storageWriter = useStorageWriter();
267 1 : const logger = useLogger();
268 :
269 1 : const [roots, setRoots] = React.useState<StorageTreeRoot[]>(() => listStorageTreeRoots(storageReader));
270 1 : const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set([`${OPFS_TREE_ROOT_KEY}\0`]));
271 1 : const [listings, setListings] = React.useState<Map<string, StorageTreeListing>>(() => new Map());
272 1 : const [statCache, setStatCache] = React.useState(() => new StorageTreeStatCache());
273 :
274 : // Listings that arrive after a refresh are dropped
275 1 : const generation = React.useRef(0);
276 :
277 1 : const rows = React.useMemo(() => flattenStorageTree(roots, expanded, listings), [roots, expanded, listings]);
278 :
279 : // Request the listings of expanded directories that are visible but not loaded yet
280 1 : React.useEffect(() => {
281 1 : const pending = rows.flatMap(row => row.kind === 'status' && row.load != null ? [row.load] : []);
282 1 : if (pending.length === 0) {
283 1 : return;
284 : }
285 1 : const current = generation.current;
286 1 : setListings(prev => {
287 1 : const next = new Map(prev);
288 1 : for (const { root, path } of pending) {
289 1 : next.set(storageTreeNodeKey(root, path), { status: 'loading' });
290 : }
291 1 : return next;
292 1 : });
293 1 : for (const { root, path } of pending) {
294 1 : const key = storageTreeNodeKey(root, path);
295 1 : root.list(splitPath(path)).then(
296 1 : (entries): StorageTreeListing => ({ status: 'ready', entries }),
297 1 : (error): StorageTreeListing => ({ status: 'error', message: (error as Error)?.message ?? String(error) }),
298 1 : ).then(listing => {
299 1 : if (current === generation.current) {
300 1 : setListings(prev => new Map(prev).set(key, listing));
301 : }
302 1 : });
303 1 : }
304 1 : }, [rows]);
305 :
306 1 : const toggle = React.useCallback((key: string) => {
307 1 : setExpanded(prev => {
308 1 : const next = new Set(prev);
309 1 : if (!next.delete(key)) {
310 1 : next.add(key);
311 : }
312 1 : return next;
313 1 : });
314 1 : }, []);
315 :
316 : // Drop all listings and stats, the visible expanded directories are listed again
317 1 : const refresh = React.useCallback(() => {
318 1 : ++generation.current;
319 1 : setRoots(listStorageTreeRoots(storageReader));
320 1 : setListings(new Map());
321 1 : setStatCache(new StorageTreeStatCache());
322 1 : }, [storageReader]);
323 :
324 : // Find the OPFS entries the current version doesn't use, the deletion waits for the user to confirm
325 1 : const [cleanup, setCleanup] = React.useState<CleanupState | null>(null);
326 1 : const startCleanup = React.useCallback(async () => {
327 1 : setCleanup({ status: 'scanning' });
328 1 : try {
329 : // Pending writes may register new sessions and connections
330 1 : await storageWriter.flush();
331 1 : const candidates = await findOPFSCleanupCandidates(await getOPFSRoot());
332 1 : setCleanup(candidates.length === 0
333 1 : ? { status: 'done', message: 'Nothing to clean up.', error: false }
334 1 : : { status: 'review', candidates });
335 1 : } catch (error) {
336 1 : logger.error('Failed to scan the OPFS for cleanup', { error: stringifyError(error) }, LOG_CTX);
337 1 : setCleanup({ status: 'done', message: `Scan failed: ${(error as Error)?.message ?? String(error)}`, error: true });
338 1 : }
339 1 : }, [storageWriter, logger]);
340 1 : const confirmCleanup = React.useCallback(async (candidates: OPFSCleanupCandidate[]) => {
341 1 : setCleanup({ status: 'deleting', candidates });
342 1 : try {
343 1 : const result = await removeOPFSCleanupCandidates(await getOPFSRoot(), candidates);
344 1 : logger.info('Cleaned up the OPFS', {
345 1 : removed: result.removed.toString(),
346 1 : skipped: result.skipped.length.toString(),
347 1 : }, LOG_CTX);
348 1 : setCleanup({ status: 'done', message: describeCleanupResult(result), error: result.skipped.length > 0 });
349 1 : } catch (error) {
350 0 : logger.error('Failed to clean up the OPFS', { error: stringifyError(error) }, LOG_CTX);
351 0 : setCleanup({ status: 'done', message: `Cleanup failed: ${(error as Error)?.message ?? String(error)}`, error: true });
352 0 : }
353 1 : refresh();
354 1 : }, [logger, refresh]);
355 1 : const reviewing = cleanup?.status === 'review' || cleanup?.status === 'deleting' ? cleanup.candidates : null;
356 1 : const cleanupBusy = cleanup?.status === 'scanning' || cleanup?.status === 'deleting';
357 :
358 1 : const sessionFolders = roots.length - 1;
359 :
360 : // Container size for the virtual list
361 1 : const containerRef = React.useRef<HTMLDivElement>(null);
362 1 : const containerSize = observeSize(containerRef);
363 1 : const containerWidth = containerSize?.width ?? 200;
364 1 : const containerHeight = containerSize?.height ?? 100;
365 :
366 1 : const [listApi, setListApi] = useListCallbackRef(null);
367 1 : const getScrollElement = React.useCallback(() => listApi?.element ?? null, [listApi]);
368 1 : const rowProps = React.useMemo<StorageTreeRowProps>(() => ({ rows, statCache, toggle }), [rows, statCache, toggle]);
369 1 : const cleanupRowProps = React.useMemo<CleanupRowProps>(() => ({ candidates: reviewing ?? [] }), [reviewing]);
370 :
371 1 : return (
372 1 : <div className={styles.overlay}>
373 1 : <div className={styles.header_container}>
374 1 : <div className={styles.header_left_container}>
375 1 : <div className={styles.title}>Storage</div>
376 1 : {sessionFolders > 0 && (
377 0 : <div className={styles.summary}>
378 0 : {sessionFolders} {sessionFolders === 1 ? 'session folder' : 'session folders'}
379 0 : </div>
380 : )}
381 0 : </div>
382 1 : <div className={styles.header_right_container}>
383 1 : <IconButton
384 1 : variant={ButtonVariant.Invisible}
385 1 : aria-label="Check the filesystem"
386 1 : title="Check the filesystem for entries this version of DashQL doesn't use"
387 1 : disabled={cleanupBusy || reviewing != null}
388 1 : onClick={() => void startCleanup()}
389 : >
390 1 : <BroomIcon />
391 1 : </IconButton>
392 1 : <IconButton
393 1 : variant={ButtonVariant.Invisible}
394 1 : aria-label="Refresh storage listing"
395 1 : disabled={reviewing != null}
396 1 : onClick={refresh}
397 : >
398 1 : <SyncIcon />
399 1 : </IconButton>
400 1 : <IconButton
401 1 : variant={ButtonVariant.Invisible}
402 1 : aria-label="close-overlay"
403 1 : onClick={props.onClose}
404 : >
405 1 : <XIcon />
406 1 : </IconButton>
407 1 : </div>
408 1 : </div>
409 1 : <div className={styles.cleanup_bar_row}>
410 1 : {cleanup?.status === 'scanning' && (
411 1 : <div className={styles.cleanup_bar}>
412 1 : <div className={styles.cleanup_message}>Checking the filesystem…</div>
413 1 : </div>
414 : )}
415 1 : {reviewing != null && (
416 1 : <div className={styles.cleanup_bar}>
417 1 : <div className={styles.cleanup_message}>
418 1 : Delete {reviewing.length} {reviewing.length === 1 ? 'entry' : 'entries'} that this version of DashQL doesn't use?
419 1 : </div>
420 1 : <Button
421 1 : size={ButtonSize.Small}
422 1 : disabled={cleanup?.status === 'deleting'}
423 1 : onClick={() => setCleanup(null)}
424 1 : >
425 : No
426 1 : </Button>
427 1 : <Button
428 1 : size={ButtonSize.Small}
429 1 : variant={ButtonVariant.Danger}
430 1 : disabled={cleanup?.status === 'deleting'}
431 1 : onClick={() => void confirmCleanup(reviewing)}
432 : >
433 1 : {cleanup?.status === 'deleting' ? 'Deleting…' : 'Yes, delete'}
434 1 : </Button>
435 1 : </div>
436 : )}
437 1 : {cleanup?.status === 'done' && (
438 1 : <div className={styles.cleanup_bar}>
439 1 : <div className={`${styles.cleanup_message} ${cleanup.error ? styles.cleanup_message_warning : ''}`} title={cleanup.message}>
440 1 : {cleanup.message}
441 1 : </div>
442 1 : <IconButton
443 1 : variant={ButtonVariant.Invisible}
444 1 : size={ButtonSize.Small}
445 1 : aria-label="Dismiss cleanup message"
446 1 : onClick={() => setCleanup(null)}
447 : >
448 1 : <XIcon />
449 1 : </IconButton>
450 1 : </div>
451 : )}
452 1 : </div>
453 1 : <div className={styles.column_header_row}>
454 1 : {reviewing != null ? (
455 1 : <div className={classNames(styles.header_columns, styles.cleanup_columns)}>
456 1 : <div>Entry</div>
457 1 : <div>Reason</div>
458 1 : </div>
459 : ) : (
460 1 : <div className={classNames(styles.header_columns, styles.columns)}>
461 1 : <div>Name</div>
462 1 : <div className={styles.cell_size}>Size</div>
463 1 : <div className={styles.cell_modified}>Modified</div>
464 1 : </div>
465 : )}
466 1 : </div>
467 1 : <div className={styles.list_container} ref={containerRef}>
468 1 : {reviewing != null ? (
469 1 : <List
470 1 : listRef={setListApi}
471 1 : style={{ width: containerWidth, height: containerHeight }}
472 1 : rowCount={reviewing.length}
473 1 : rowHeight={rowHeight}
474 1 : rowComponent={CleanupRowComponent}
475 1 : rowProps={cleanupRowProps}
476 1 : />
477 : ) : (
478 1 : <List
479 1 : listRef={setListApi}
480 1 : style={{ width: containerWidth, height: containerHeight }}
481 1 : rowCount={rows.length}
482 1 : rowHeight={rowHeight}
483 1 : rowComponent={StorageTreeRowComponent}
484 1 : rowProps={rowProps}
485 1 : />
486 : )}
487 1 : <VerticalScrollShadows
488 1 : getScrollElement={getScrollElement}
489 1 : refreshKey={reviewing != null ? -reviewing.length : rows.length}
490 1 : />
491 1 : </div>
492 1 : </div>
493 : );
494 1 : }
|