LCOV - code coverage report
Current view: top level - dashql-app/src/shell - filesystem_dialog.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 86.8 % 152 132
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
       4              : import { useFocusTrap } from '../ui/foundations/focus.js';
       5              : import { Overlay, OverlaySize } from '../ui/foundations/overlay.js';
       6              : import { FileDirectoryIcon, FileIcon, PlusIcon, UnlinkIcon, XIcon } from '../ui/foundations/symbol_icon.js';
       7              : import type { DashQLShellCommand } from './api.js';
       8              : import type { ExternalDirectoryMode } from '../platform/database/embedded_database.js';
       9              : import { CopyToClipboardButton } from '../ui/foundations/clipboard.js';
      10              : import {
      11              :     createShellFilesCommand,
      12              :     selectPlatformDirectory,
      13              :     selectPlatformFiles,
      14              :     type ShellFileListing,
      15              :     type ShellFileRegistry,
      16              : } from './shell_files.js';
      17              : import { useMountDialog } from './filesystem_mount_dialog.js';
      18              : import * as styles from './filesystem_dialog.module.css';
      19              : 
      20              : export interface FilesystemDialogActions {
      21              :     list(): Promise<readonly ShellFileListing[]>;
      22              :     unlink(path: string): Promise<void>;
      23              :     mount(signal?: AbortSignal): Promise<string>;
      24              : }
      25              : 
      26              : export interface FilesystemDialogController {
      27              :     request(signal?: AbortSignal): Promise<void>;
      28              : }
      29              : 
      30              : export interface FilesystemDialogHookResult {
      31              :     controller: FilesystemDialogController;
      32              :     dialog: React.ReactElement | null;
      33              : }
      34              : 
      35              : interface PendingRequest {
      36              :     resolve: () => void;
      37              :     signal?: AbortSignal;
      38              :     onAbort?: () => void;
      39              : }
      40              : 
      41            1 : export function useFilesystemDialog(actions: FilesystemDialogActions): FilesystemDialogHookResult {
      42            1 :     const [open, setOpen] = React.useState(false);
      43            1 :     const pendingRef = React.useRef<PendingRequest | null>(null);
      44              : 
      45            1 :     const resolvePending = React.useCallback(() => {
      46            1 :         const pending = pendingRef.current;
      47            1 :         pendingRef.current = null;
      48            1 :         pending?.signal?.removeEventListener('abort', pending.onAbort!);
      49            1 :         pending?.resolve();
      50            1 :     }, []);
      51            1 :     const dismiss = React.useCallback(() => {
      52            1 :         setOpen(false);
      53            1 :         resolvePending();
      54            1 :     }, [resolvePending]);
      55              : 
      56            1 :     const request = React.useCallback((signal?: AbortSignal) => {
      57            1 :         if (pendingRef.current != null) return Promise.reject(new Error('A filesystem request is already pending.'));
      58            1 :         if (signal?.aborted) return Promise.resolve();
      59            1 :         setOpen(true);
      60            1 :         return new Promise<void>(resolve => {
      61            1 :             const pending: PendingRequest = { resolve, signal };
      62            1 :             pending.onAbort = dismiss;
      63            1 :             pendingRef.current = pending;
      64            1 :             signal?.addEventListener('abort', pending.onAbort, { once: true });
      65            1 :         });
      66            1 :     }, [dismiss]);
      67              : 
      68            1 :     React.useEffect(() => () => dismiss(), [dismiss]);
      69            1 :     const mount = React.useCallback(async () => {
      70            1 :         setOpen(false);
      71            1 :         const path = await actions.mount();
      72            1 :         if (path !== 'Mount cancelled') {
      73            1 :             setOpen(true);
      74              :         } else {
      75            0 :             resolvePending();
      76              :         }
      77            1 :         return path;
      78            1 :     }, [actions, resolvePending]);
      79            1 :     const controller = React.useMemo<FilesystemDialogController>(() => ({ request }), [request]);
      80            1 :     return {
      81            1 :         controller,
      82            1 :         dialog: open ? <FilesystemDialog actions={{ ...actions, mount }} onClose={dismiss} /> : null,
      83            1 :     };
      84            1 : }
      85              : 
      86              : export interface ShellFilesHookResult {
      87              :     /// The .fs command, opens the dialogs on the mounts of the registry
      88              :     command: DashQLShellCommand;
      89              :     dialogs: React.ReactElement;
      90              : }
      91              : 
      92              : /// The filesystem and mount dialogs of the .fs command.
      93              : /// The registry is read when a dialog needs it, it may be created after the command.
      94            1 : export function useShellFiles(registryRef: React.RefObject<ShellFileRegistry | null>): ShellFilesHookResult {
      95            1 :     const { controller: mountDialog, dialog: mountDialogOverlay } = useMountDialog({
      96            1 :         selectFiles: async signal => {
      97            1 :             const files = await selectPlatformFiles(signal);
      98            1 :             if (files.length === 0) return null;
      99            1 :             if (files.length !== 1) throw new Error('select exactly one file to choose a mount path');
     100            1 :             return { kind: 'file', name: files[0].path, file: files[0] };
     101            1 :         },
     102            1 :         selectDirectory: async mode => {
     103            0 :             const handle = await selectPlatformDirectory(mode);
     104            0 :             return { kind: 'directory', name: handle.name, handle, mode };
     105            0 :         },
     106            1 :         mount: async (selection, path, signal) => {
     107            1 :             const registry = registryRef.current;
     108            1 :             if (registry == null) throw new Error('Filesystem mounting is not ready');
     109            1 :             return selection.kind === 'file'
     110            1 :                 ? await registry.mountFile(selection.file, path, { signal })
     111            0 :                 : await registry.mountDirectory(selection.handle, path, selection.mode);
     112            1 :         },
     113            1 :     });
     114            1 :     const { controller: filesystemDialog, dialog: filesystemDialogOverlay } = useFilesystemDialog({
     115            1 :         list: async () => registryRef.current?.listFiles() ?? [],
     116            1 :         unlink: async path => {
     117            0 :             const registry = registryRef.current;
     118            0 :             if (registry == null || !await registry.drop(path)) throw new Error(`filesystem entry not registered: ${path}`);
     119            0 :         },
     120            1 :         mount: mountDialog.request,
     121            1 :     });
     122            1 :     const command = React.useMemo(
     123            1 :         () => createShellFilesCommand(mountDialog.request, filesystemDialog.request),
     124            1 :         [mountDialog, filesystemDialog],
     125              :     );
     126            1 :     return {
     127            1 :         command,
     128            1 :         dialogs: <>{mountDialogOverlay}{filesystemDialogOverlay}</>,
     129            1 :     };
     130            1 : }
     131              : 
     132            1 : function FilesystemDialog(props: { actions: FilesystemDialogActions; onClose: () => void }) {
     133            1 :     const headingId = React.useId();
     134            1 :     const dialogRef = React.useRef<HTMLElement>(null);
     135            1 :     const [entries, setEntries] = React.useState<readonly ShellFileListing[]>([]);
     136            1 :     const [error, setError] = React.useState<string | null>(null);
     137            1 :     const reload = React.useCallback(async () => {
     138            1 :         try {
     139            1 :             setEntries(await props.actions.list());
     140            1 :             setError(null);
     141            1 :         } catch (cause) {
     142            0 :             setError(cause instanceof Error ? cause.message : String(cause));
     143            0 :         }
     144            1 :     }, [props.actions]);
     145            1 :     React.useEffect(() => { void reload(); }, [reload]);
     146            1 :     useFocusTrap({
     147            1 :         containerRef: dialogRef as React.RefObject<HTMLElement>,
     148            1 :         initialFocusRef: dialogRef as React.RefObject<HTMLElement>,
     149            1 :         restoreFocusOnCleanUp: true,
     150            1 :     });
     151              : 
     152            1 :     const unlink = async (path: string) => {
     153            0 :         try {
     154            0 :             await props.actions.unlink(path);
     155            0 :             await reload();
     156            0 :         } catch (cause) {
     157            0 :             setError(cause instanceof Error ? cause.message : String(cause));
     158            0 :         }
     159            0 :     };
     160              : 
     161            1 :     return (
     162            1 :         <Overlay centered width={OverlaySize.L} onEscape={props.onClose} onClickOutside={props.onClose}>
     163            1 :             <section ref={dialogRef} className={styles.dialog} role="dialog" aria-modal="true" aria-labelledby={headingId} tabIndex={-1}>
     164            1 :                 <header className={styles.header}>
     165            1 :                     <h2 id={headingId}>Filesystem</h2>
     166            1 :                     <div className={styles.header_actions}>
     167            1 :                         <IconButton variant={ButtonVariant.Invisible} aria-label="Mount filesystem entry" onClick={() => { void props.actions.mount().catch(() => { }); }}>
     168            1 :                             <PlusIcon />
     169            1 :                         </IconButton>
     170            1 :                         <IconButton variant={ButtonVariant.Invisible} aria-label="Close filesystem" onClick={props.onClose}>
     171            1 :                             <XIcon />
     172            1 :                         </IconButton>
     173            1 :                     </div>
     174            1 :                 </header>
     175            1 :                 {error != null && <p className={styles.error} role="alert">{error}</p>}
     176            1 :                 {entries.length === 0 ? <p className={styles.empty}>No mounted entries</p> : <div className={styles.entries}>
     177            1 :                     {entries.map(entry => <FilesystemEntry key={entry.path} entry={entry} onUnlink={unlink} />)}
     178            1 :                 </div>}
     179            1 :             </section>
     180            1 :         </Overlay>
     181              :     );
     182            1 : }
     183              : 
     184            1 : function FilesystemEntry(props: { entry: ShellFileListing; onUnlink: (path: string) => Promise<void> }) {
     185            1 :     const isDirectory = props.entry.directoryMode != null;
     186            1 :     return <div className={styles.entry}>
     187            1 :         {isDirectory ? <FileDirectoryIcon size={16} aria-hidden="true" /> : <FileIcon size={16} aria-hidden="true" />}
     188            1 :         <span className={styles.path}>{props.entry.path}</span>
     189            1 :         <div className={styles.entry_actions}>
     190            1 :             <span className={styles.mode}>{isDirectory ? displayMode(props.entry.directoryMode!) : 'READ'}</span>
     191            1 :             <CopyToClipboardButton
     192            1 :                 variant={ButtonVariant.Invisible}
     193            1 :                 size={ButtonSize.Small}
     194            1 :                 value={props.entry.path}
     195            1 :                 aria-label={`Copy ${props.entry.path}`}
     196            1 :                 aria-labelledby=""
     197            1 :                 logContext="shell_filesystem"
     198            1 :             />
     199            1 :             <IconButton
     200            1 :                 className={styles.unlink}
     201            1 :                 variant={ButtonVariant.Invisible}
     202            1 :                 size={ButtonSize.Small}
     203            1 :                 aria-label={`Unlink ${props.entry.path}`}
     204            1 :                 onClick={() => { void props.onUnlink(props.entry.path); }}
     205              :             >
     206            1 :                 <UnlinkIcon size={16} />
     207            1 :             </IconButton>
     208            1 :         </div>
     209            1 :     </div>;
     210            1 : }
     211              : 
     212            0 : function displayMode(mode: ExternalDirectoryMode): string {
     213            0 :     return mode === 'read' ? 'READ' : 'READ/WRITE';
     214            0 : }
        

Generated by: LCOV version 2.0-1