LCOV - code coverage report
Current view: top level - dashql-app/src/shell - shell_files.ts Coverage Total Hit
Test: DashQL 6e74948 Lines: 100.0 % 225 225
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : import type {
       2              :     EmbeddedExternalDirectoryDatabase,
       3              :     EmbeddedExternalFileDatabase,
       4              :     ExternalDirectoryMode,
       5              : } from '../platform/database/embedded_database.js';
       6              : import type { PlatformFile } from '../platform/file/file.js';
       7              : import { stringifyError, type LoggerLike } from '../platform/logger/logger.js';
       8              : import type { DashQLShellCommand, DashQLShellCommandContext } from './api.js';
       9              : 
      10            1 : const LOG_CTX = 'standalone_shell_files';
      11            1 : const FS_USAGE = 'usage: .fs';
      12              : 
      13              : interface ShellFileEntry {
      14              :     readonly path: string;
      15              :     size(): Promise<number | undefined>;
      16              :     drop(): Promise<void>;
      17              : }
      18              : 
      19              : export interface ShellFileListing {
      20              :     readonly path: string;
      21              :     readonly size?: number;
      22              :     readonly directoryMode?: ExternalDirectoryMode;
      23              : }
      24              : 
      25              : interface FileOperationOptions {
      26              :     readonly signal?: AbortSignal;
      27              :     readonly onProgress?: (message: string) => void;
      28              : }
      29              : 
      30              : /// The external file registration of a database, if it supports it
      31            1 : export function externalFileDatabase(database: unknown): EmbeddedExternalFileDatabase | undefined {
      32            1 :     if (database == null || typeof database !== 'object') return undefined;
      33            1 :     if (!('registerExternalFile' in database) || !('removeExternalFile' in database)) return undefined;
      34            1 :     return database as EmbeddedExternalFileDatabase;
      35            1 : }
      36              : 
      37              : /// The external directory registration of a database, if it supports it
      38            1 : export function externalDirectoryDatabase(database: unknown): EmbeddedExternalDirectoryDatabase | undefined {
      39            1 :     if (database == null || typeof database !== 'object') return undefined;
      40            1 :     if (!('registerExternalDirectory' in database) || !('removeExternalDirectory' in database)) return undefined;
      41            1 :     return database as EmbeddedExternalDirectoryDatabase;
      42            1 : }
      43              : 
      44            1 : export class ShellFileRegistry {
      45            1 :     private readonly files = new Map<string, ShellFileEntry>();
      46            1 :     private readonly directories = new Map<string, ExternalDirectoryMode>();
      47            1 :     private readonly mountedPaths = new Set<string>();
      48            1 :     private disposed = false;
      49              : 
      50            1 :     constructor(
      51            1 :         private readonly logger?: LoggerLike,
      52            1 :         private readonly externalFiles?: EmbeddedExternalFileDatabase,
      53            1 :         private readonly externalDirectories?: EmbeddedExternalDirectoryDatabase,
      54            1 :     ) {}
      55              : 
      56            1 :     async list(): Promise<readonly string[]> {
      57            1 :         return (await this.listFiles()).map(file => file.path);
      58            1 :     }
      59              : 
      60            1 :     async listFiles(): Promise<readonly ShellFileListing[]> {
      61            1 :         const files: ShellFileListing[] = [];
      62            1 :         for (const path of this.mountedPaths) {
      63            1 :             files.push({ path, size: await this.files.get(path)?.size() });
      64              :         }
      65            1 :         for (const [path, directoryMode] of this.directories) {
      66            1 :             files.push({ path, directoryMode });
      67              :         }
      68            1 :         return files.sort((a, b) => a.path.localeCompare(b.path));
      69            1 :     }
      70              : 
      71            1 :     async mountFile(file: PlatformFile, path?: string, options: FileOperationOptions = {}): Promise<string> {
      72            1 :         this.checkUsable();
      73            1 :         if (this.externalFiles == null) throw new Error('external browser files are not supported');
      74            1 :         if (file.blob == null) throw new Error(`file cannot be mounted directly: ${file.path}`);
      75            1 :         throwIfAborted(options.signal);
      76              : 
      77            1 :         const startedAt = Date.now();
      78            1 :         const name = fileName(file.path);
      79            1 :         this.logger?.info('Mounting shell file', logDetails(file.path, undefined, file.size, 'register_external_file', startedAt), LOG_CTX);
      80            1 :         let mountedPath: string | null = null;
      81            1 :         try {
      82            1 :             options.onProgress?.(`Mounting ${name}`);
      83            1 :             mountedPath = await this.externalFiles.registerExternalFile(name, file.blob, path, options.signal);
      84            1 :             if (this.disposed || options.signal?.aborted) {
      85            1 :                 await this.externalFiles.removeExternalFile(mountedPath);
      86            1 :                 throw options.signal?.reason ?? new DOMException('The operation was aborted', 'AbortError');
      87            1 :             }
      88            1 :             const entry: ShellFileEntry = {
      89            1 :                 path: mountedPath,
      90            1 :                 size: async () => file.size ?? file.blob!.size,
      91            1 :                 drop: () => this.externalFiles!.removeExternalFile(mountedPath!),
      92            1 :             };
      93            1 :             this.files.set(mountedPath, entry);
      94            1 :             this.mountedPaths.add(mountedPath);
      95            1 :             this.logger?.info('Mounted shell file', logDetails(file.path, mountedPath, file.size, 'complete', startedAt), LOG_CTX);
      96            1 :             return mountedPath;
      97            1 :         } catch (error) {
      98            1 :             this.logger?.error('Failed to mount shell file', {
      99            1 :                 ...logDetails(file.path, mountedPath ?? undefined, file.size, 'register_external_file', startedAt),
     100            1 :                 error: stringifyError(error),
     101            1 :                 error_name: error instanceof Error ? error.name : undefined,
     102            1 :             }, LOG_CTX);
     103            1 :             throw error;
     104            1 :         }
     105            1 :     }
     106              : 
     107            1 :     async mountDirectory(
     108            1 :         handle: FileSystemDirectoryHandle,
     109            1 :         path: string | undefined,
     110            1 :         mode: ExternalDirectoryMode,
     111            1 :     ): Promise<string> {
     112            1 :         this.checkUsable();
     113            1 :         if (this.externalDirectories == null) throw new Error('external browser directories are not supported');
     114            1 :         const startedAt = Date.now();
     115            1 :         this.logger?.info('Mounting shell directory', logDetails(handle.name, undefined, undefined, 'register_external_directory', startedAt), LOG_CTX);
     116            1 :         let mountedPath: string | null = null;
     117            1 :         try {
     118            1 :             mountedPath = await this.externalDirectories.registerExternalDirectory(handle, path, mode);
     119            1 :             if (this.disposed) {
     120            1 :                 await this.externalDirectories.removeExternalDirectory(mountedPath);
     121            1 :                 throw new Error('shell file registry is disposed');
     122            1 :             }
     123            1 :             this.directories.set(mountedPath, mode);
     124            1 :             this.logger?.info('Mounted shell directory', {
     125            1 :                 ...logDetails(handle.name, mountedPath, undefined, 'complete', startedAt),
     126            1 :                 mode,
     127            1 :             }, LOG_CTX);
     128            1 :             return mountedPath;
     129            1 :         } catch (error) {
     130            1 :             this.logger?.error('Failed to mount shell directory', {
     131            1 :                 ...logDetails(handle.name, mountedPath ?? undefined, undefined, 'register_external_directory', startedAt),
     132            1 :                 mode,
     133            1 :                 error: stringifyError(error),
     134            1 :                 error_name: error instanceof Error ? error.name : undefined,
     135            1 :             }, LOG_CTX);
     136            1 :             throw error;
     137            1 :         }
     138            1 :     }
     139              : 
     140            1 :     async drop(path: string): Promise<boolean> {
     141            1 :         if (this.directories.has(path)) {
     142            1 :             await this.externalDirectories!.removeExternalDirectory(path);
     143            1 :             this.directories.delete(path);
     144            1 :             return true;
     145            1 :         }
     146            1 :         const entry = this.files.get(path);
     147            1 :         if (entry != null) {
     148            1 :             await entry.drop();
     149            1 :             this.files.delete(path);
     150            1 :             this.mountedPaths.delete(path);
     151            1 :             return true;
     152            1 :         }
     153            1 :         return false;
     154            1 :     }
     155              : 
     156            1 :     async dispose(): Promise<void> {
     157            1 :         if (this.disposed) return;
     158            1 :         this.disposed = true;
     159            1 :         for (const path of Array.from(this.mountedPaths)) {
     160            1 :             const entry = this.files.get(path);
     161            1 :             if (entry == null) continue;
     162            1 :             try {
     163            1 :                 await entry.drop();
     164            1 :                 this.files.delete(path);
     165            1 :                 this.mountedPaths.delete(path);
     166            1 :             } catch (error) {
     167            1 :                 this.logger?.warn('Failed to unmount shell file during disposal', {
     168            1 :                     path,
     169            1 :                     error: stringifyError(error),
     170            1 :                 }, LOG_CTX);
     171            1 :             }
     172            1 :         }
     173            1 :         for (const path of Array.from(this.directories.keys())) {
     174            1 :             try {
     175            1 :                 await this.externalDirectories!.removeExternalDirectory(path);
     176            1 :                 this.directories.delete(path);
     177            1 :             } catch (error) {
     178            1 :                 this.logger?.warn('Failed to unmount shell directory during disposal', {
     179            1 :                     path,
     180            1 :                     error: stringifyError(error),
     181            1 :                 }, LOG_CTX);
     182            1 :             }
     183              :         }
     184            1 :     }
     185              : 
     186            1 :     private checkUsable(): void {
     187            1 :         if (this.disposed) throw new Error('shell file registry is disposed');
     188            1 :     }
     189            1 : }
     190              : 
     191            1 : export function createShellFilesCommand(
     192            1 :     requestMountDialog?: (signal?: AbortSignal) => Promise<string>,
     193            1 :     requestFilesystemDialog?: (signal?: AbortSignal) => Promise<void>,
     194            1 : ): DashQLShellCommand {
     195            1 :     return [
     196            1 :         'fs',
     197            1 :         'Manage filesystem mounts',
     198            1 :         async (args, context) => {
     199            1 :             if (args.length === 0 || args[0] === 'ls') {
     200            1 :                 if (args.length > 1) return FS_USAGE;
     201            1 :                 if (requestFilesystemDialog == null) return FS_USAGE;
     202            1 :                 await requestFilesystemDialog(context.signal);
     203            1 :                 return;
     204            1 :             }
     205            1 :             if (args[0] === 'mount' || args[0] === 'mount-dir' || args[0] === 'mount-dir-rw') {
     206            1 :                 if (args.length > 1 || requestMountDialog == null) return FS_USAGE;
     207            1 :                 const path = await requestMountDialog(context.signal);
     208            1 :                 return path === 'Mount cancelled' ? undefined : path;
     209            1 :             }
     210            1 :             return FS_USAGE;
     211            1 :         },
     212            1 :     ];
     213            1 : }
     214              : 
     215              : async function selectShellFiles(
     216              :     selectFiles: (signal?: AbortSignal) => Promise<readonly PlatformFile[]>,
     217              :     context: DashQLShellCommandContext,
     218              :     logger?: LoggerLike,
     219              : ): Promise<readonly PlatformFile[]> {
     220              :     logger?.info('Opening shell file picker', {}, LOG_CTX);
     221              :     try {
     222              :         const selected = await selectFiles(context.signal);
     223              :         logger?.info('Selected shell files', {
     224              :             file_count: selected.length.toString(),
     225              :             total_size_bytes: selected.every(file => file.size != null)
     226              :                 ? selected.reduce((total, file) => total + (file.size ?? 0), 0).toString()
     227              :                 : undefined,
     228              :         }, LOG_CTX);
     229              :         return selected;
     230              :     } catch (error) {
     231              :         logger?.error('Failed to select shell files', {
     232              :             error: stringifyError(error),
     233              :             error_name: error instanceof Error ? error.name : undefined,
     234              :         }, LOG_CTX);
     235              :         throw error;
     236              :     }
     237              : }
     238              : 
     239            1 : function logDetails(
     240            1 :     sourcePath: string,
     241            1 :     targetPath: string | undefined,
     242            1 :     sizeBytes: number | undefined,
     243            1 :     stage: string,
     244            1 :     startedAt: number,
     245            1 : ): Record<string, string | undefined> {
     246            1 :     return {
     247            1 :         source_path: sourcePath,
     248            1 :         target_path: targetPath,
     249            1 :         size_bytes: sizeBytes?.toString(),
     250            1 :         stage,
     251            1 :         elapsed_ms: (Date.now() - startedAt).toString(),
     252            1 :     };
     253            1 : }
     254              : 
     255            1 : export async function selectPlatformFiles(signal?: AbortSignal): Promise<readonly PlatformFile[]> {
     256            1 :     const { WebFile } = await import('../platform/file/web_file.js');
     257            1 :     return await new Promise((resolve, reject) => {
     258            1 :         const input = document.createElement('input');
     259            1 :         input.type = 'file';
     260            1 :         input.multiple = true;
     261            1 :         let settled = false;
     262            1 :         const finish = (files?: readonly PlatformFile[], error?: unknown) => {
     263            1 :             if (settled) return;
     264            1 :             settled = true;
     265            1 :             signal?.removeEventListener('abort', onAbort);
     266            1 :             input.remove();
     267            1 :             if (error != null) reject(error);
     268            1 :             else resolve(files ?? []);
     269            1 :         };
     270            1 :         const onAbort = () => finish(undefined, signal?.reason ?? new DOMException('The operation was aborted', 'AbortError'));
     271            1 :         input.addEventListener('change', () => {
     272            1 :             finish(Array.from(input.files ?? [], file => new WebFile(file, file.name)));
     273            1 :         }, { once: true });
     274            1 :         input.addEventListener('cancel', () => finish([]), { once: true });
     275            1 :         if (signal?.aborted) {
     276            1 :             onAbort();
     277            1 :             return;
     278            1 :         }
     279            1 :         signal?.addEventListener('abort', onAbort, { once: true });
     280            1 :         input.click();
     281            1 :     });
     282            1 : }
     283              : 
     284            1 : export async function selectPlatformDirectory(mode: ExternalDirectoryMode): Promise<FileSystemDirectoryHandle> {
     285            1 :     const picker = (window as Window & typeof globalThis & {
     286              :         showDirectoryPicker?: (options: { mode: ExternalDirectoryMode }) => Promise<FileSystemDirectoryHandle>;
     287            1 :     }).showDirectoryPicker;
     288            1 :     if (picker == null) {
     289            1 :         throw new Error('folder mounting requires the File System Access API');
     290              :     }
     291            1 :     return await picker({ mode });
     292            1 : }
     293              : 
     294            1 : function throwIfAborted(signal?: AbortSignal): void {
     295            1 :     if (signal?.aborted) throw signal.reason ?? new DOMException('The operation was aborted', 'AbortError');
     296            1 : }
     297              : 
     298            1 : function fileName(path: string): string {
     299            1 :     return path.split(/[\\/]/).pop() || 'file';
     300            1 : }
        

Generated by: LCOV version 2.0-1