LCOV - code coverage report
Current view: top level - dashql-app/src/app/internals - storage_browser_view.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 96.8 % 344 333
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1