LCOV - code coverage report
Current view: top level - dashql-app/src/storage - storage_tree.ts Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 121 121
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import { StorageBackendType } from './storage_backend.js';
       2              : import { type SessionLocation, displayPath } from './session_locator.js';
       3              : import { join, readDir, stat } from '../platform/electron_fs.js';
       4              : import { isDesktopHost } from '../platform/native_globals.js';
       5              : import { getOPFSRoot } from '../platform/opfs_root.js';
       6              : 
       7              : /// The kind of a directory entry
       8            1 : export enum StorageTreeEntryKind {
       9            1 :     File = 0,
      10            1 :     Directory = 1,
      11            1 : }
      12              : 
      13              : /// One entry of a listed directory. A listing carries names only, the metadata of files is fetched
      14              : /// separately for the rows that are visible.
      15              : export interface StorageTreeEntry {
      16              :     /// The name of the entry in its directory
      17              :     name: string;
      18              :     /// Is the entry a file or a directory?
      19              :     kind: StorageTreeEntryKind;
      20              : }
      21              : 
      22              : /// The metadata of a file
      23              : export interface StorageTreeFileStat {
      24              :     /// The size in bytes, null if the file could not be read
      25              :     sizeBytes: number | null;
      26              :     /// The last modification time in epoch ms, null if unknown
      27              :     modifiedMs: number | null;
      28              :     /// A short note shown instead of the size, e.g. for files that are held open by HyperDB
      29              :     note: string | null;
      30              : }
      31              : 
      32              : /// A folder the storage browser can list, e.g. the OPFS root or a native session folder
      33              : export interface StorageTreeRoot {
      34              :     /// The unique key of the root
      35              :     key: string;
      36              :     /// The label of the root
      37              :     label: string;
      38              :     /// The path of the root for display, e.g. `opfs://` or `fs:///home/me/session`
      39              :     displayPath: string;
      40              :     /// List the entries of a directory, given as path segments relative to the root
      41              :     list(path: readonly string[]): Promise<StorageTreeEntry[]>;
      42              :     /// Read the metadata of a file, given as path segments relative to the root
      43              :     stat(path: readonly string[]): Promise<StorageTreeFileStat>;
      44              : }
      45              : 
      46              : /// The key of the OPFS root
      47            1 : export const OPFS_TREE_ROOT_KEY = 'opfs';
      48              : 
      49              : /// Directories first, then a natural sort by name (e.g. "page-2" < "page-10")
      50            1 : export function sortStorageTreeEntries(entries: StorageTreeEntry[]): StorageTreeEntry[] {
      51            1 :     return entries.sort((a, b) => (b.kind - a.kind) || a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
      52            1 : }
      53              : 
      54              : /// The Origin Private File System as a storage tree root
      55            1 : export function opfsTreeRoot(getRoot: () => Promise<FileSystemDirectoryHandle> = getOPFSRoot): StorageTreeRoot {
      56            1 :     const directory = async (path: readonly string[]): Promise<FileSystemDirectoryHandle> => {
      57            1 :         let dir = await getRoot();
      58            1 :         for (const name of path) {
      59            1 :             dir = await dir.getDirectoryHandle(name, { create: false });
      60              :         }
      61            1 :         return dir;
      62            1 :     };
      63            1 :     return {
      64            1 :         key: OPFS_TREE_ROOT_KEY,
      65            1 :         label: 'Origin Private File System',
      66            1 :         displayPath: 'opfs://',
      67            1 :         async list(path) {
      68            1 :             const dir = await directory(path);
      69            1 :             const entries: StorageTreeEntry[] = [];
      70            1 :             for await (const [name, handle] of dir.entries()) {
      71            1 :                 entries.push({ name, kind: handle.kind === 'directory' ? StorageTreeEntryKind.Directory : StorageTreeEntryKind.File });
      72              :             }
      73            1 :             return sortStorageTreeEntries(entries);
      74            1 :         },
      75            1 :         async stat(path) {
      76            1 :             const dir = await directory(path.slice(0, -1));
      77            1 :             const handle = await dir.getFileHandle(path[path.length - 1], { create: false });
      78            1 :             try {
      79            1 :                 const file = await handle.getFile();
      80            1 :                 return { sizeBytes: file.size, modifiedMs: file.lastModified, note: null };
      81            1 :             } catch (error) {
      82              :                 // Files with an open sync access handle, e.g. the databases of a live HyperDB instance,
      83              :                 // may refuse to be read.
      84            1 :                 const name = (error as Error)?.name;
      85            1 :                 const inUse = name === 'NoModificationAllowedError' || name === 'InvalidStateError';
      86            1 :                 return { sizeBytes: null, modifiedMs: null, note: inUse ? 'in use' : 'unavailable' };
      87            1 :             }
      88            1 :         },
      89            1 :     };
      90            1 : }
      91              : 
      92              : /// A native folder as a storage tree root. Symlinks are listed as files and never followed.
      93            1 : export function nativeTreeRoot(dir: string, label: string, shownPath: string): StorageTreeRoot {
      94            1 :     return {
      95            1 :         key: `native:${dir}`,
      96            1 :         label,
      97            1 :         displayPath: shownPath,
      98            1 :         async list(path) {
      99            1 :             const entries = await readDir(await join(dir, ...path));
     100            1 :             return sortStorageTreeEntries(entries.map(entry => ({
     101            1 :                 name: entry.name,
     102            1 :                 kind: entry.isDirectory && !entry.isSymlink ? StorageTreeEntryKind.Directory : StorageTreeEntryKind.File,
     103            1 :             })));
     104            1 :         },
     105            1 :         async stat(path) {
     106            1 :             const value = await stat(await join(dir, ...path));
     107            1 :             return { sizeBytes: value.size, modifiedMs: value.mtime?.getTime() ?? null, note: null };
     108            1 :         },
     109            1 :     };
     110            1 : }
     111              : 
     112              : /// The session routing the storage browser reads its native roots from
     113              : export interface StorageTreeSessions {
     114              :     /// The user-facing session order, as session UUIDs
     115              :     getSessionOrder(): string[];
     116              :     /// The physical location of a session's files
     117              :     getSessionLocation(sessionId: string): SessionLocation;
     118              : }
     119              : 
     120              : /// The roots of the storage browser: the OPFS root, followed by the native session folders on desktop
     121            1 : export function listStorageTreeRoots(sessions: StorageTreeSessions, desktop: boolean = isDesktopHost()): StorageTreeRoot[] {
     122            1 :     const roots = [opfsTreeRoot()];
     123            1 :     if (!desktop) {
     124            1 :         return roots;
     125              :     }
     126            1 :     const seen = new Set<string>();
     127            1 :     for (const sessionId of sessions.getSessionOrder()) {
     128            1 :         const location = sessions.getSessionLocation(sessionId);
     129            1 :         if (location.type !== StorageBackendType.Native || !location.nativePath || seen.has(location.nativePath)) {
     130            1 :             continue;
     131              :         }
     132            1 :         seen.add(location.nativePath);
     133            1 :         roots.push(nativeTreeRoot(location.nativePath, sessionId, displayPath(sessionId, location)));
     134            1 :     }
     135            1 :     return roots;
     136            1 : }
     137              : 
     138              : /// A queued or running stat request
     139              : interface StatRequest {
     140              :     /// Fetch the stat
     141              :     fetch: () => Promise<StorageTreeFileStat>;
     142              :     /// The subscribers waiting for the stat
     143              :     listeners: Set<(stat: StorageTreeFileStat) => void>;
     144              : }
     145              : 
     146              : /// The file stats of the storage browser, fetched on demand for the visible rows.
     147              : ///
     148              : /// At most `concurrency` stats are fetched at once. A queued request whose subscribers all left
     149              : /// (e.g. the row was scrolled out of view) is skipped. Fetched stats stay cached, a refresh replaces
     150              : /// the whole cache.
     151            1 : export class StorageTreeStatCache {
     152              :     /// The fetched stats by node key
     153            1 :     private readonly stats = new Map<string, StorageTreeFileStat>();
     154              :     /// The queued and running requests by node key
     155            1 :     private readonly requests = new Map<string, StatRequest>();
     156              :     /// The node keys of the queued requests
     157            1 :     private readonly queue: string[] = [];
     158              :     /// The number of running requests
     159            1 :     private running = 0;
     160              : 
     161            1 :     constructor(private readonly concurrency: number = 4) {}
     162              : 
     163              :     /// The cached stat of a file
     164            1 :     get(key: string): StorageTreeFileStat | undefined {
     165            1 :         return this.stats.get(key);
     166            1 :     }
     167              : 
     168              :     /// Subscribe to the stat of a file, fetching it unless it is cached. Returns the unsubscribe function.
     169            1 :     subscribe(key: string, fetch: () => Promise<StorageTreeFileStat>, listener: (stat: StorageTreeFileStat) => void): () => void {
     170            1 :         if (this.stats.has(key)) {
     171            1 :             return () => {};
     172              :         }
     173            1 :         let request = this.requests.get(key);
     174            1 :         if (request == null) {
     175            1 :             request = { fetch, listeners: new Set() };
     176            1 :             this.requests.set(key, request);
     177            1 :             this.queue.push(key);
     178            1 :         }
     179            1 :         const listeners = request.listeners;
     180            1 :         listeners.add(listener);
     181            1 :         this.pump();
     182            1 :         return () => {
     183            1 :             listeners.delete(listener);
     184            1 :         };
     185            1 :     }
     186              : 
     187              :     /// Start queued requests up to the concurrency limit
     188            1 :     private pump(): void {
     189            1 :         while (this.running < this.concurrency && this.queue.length > 0) {
     190            1 :             const key = this.queue.shift()!;
     191            1 :             const request = this.requests.get(key)!;
     192            1 :             if (request.listeners.size === 0) {
     193            1 :                 this.requests.delete(key);
     194            1 :                 continue;
     195            1 :             }
     196            1 :             ++this.running;
     197            1 :             void request.fetch()
     198            1 :                 .catch((): StorageTreeFileStat => ({ sizeBytes: null, modifiedMs: null, note: 'unavailable' }))
     199            1 :                 .then(stat => {
     200            1 :                     this.stats.set(key, stat);
     201            1 :                     this.requests.delete(key);
     202            1 :                     for (const listener of request.listeners) {
     203            1 :                         listener(stat);
     204              :                     }
     205            1 :                 })
     206            1 :                 .finally(() => {
     207            1 :                     --this.running;
     208            1 :                     this.pump();
     209            1 :                 });
     210            1 :         }
     211            1 :     }
     212            1 : }
        

Generated by: LCOV version 2.0-1