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

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import * as ActionList from '../ui/foundations/action_list.js';
       4              : import { Button, ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
       5              : import { useFocusTrap } from '../ui/foundations/focus.js';
       6              : import { Overlay, OverlaySize } from '../ui/foundations/overlay.js';
       7              : import { FileDirectoryIcon, FileIcon, XIcon } from '../ui/foundations/symbol_icon.js';
       8              : import { TextInput } from '../ui/foundations/text_input.js';
       9              : import type { PlatformFile } from '../platform/file/file.js';
      10              : import * as styles from './filesystem_mount_dialog.module.css';
      11              : 
      12              : export interface MountDialogController {
      13              :     request(signal?: AbortSignal): Promise<string>;
      14              : }
      15              : 
      16              : export interface MountDialogHookResult {
      17              :     controller: MountDialogController;
      18              :     dialog: React.ReactElement | null;
      19              : }
      20              : 
      21              : export interface MountDialogActions {
      22              :     selectFiles(signal?: AbortSignal): Promise<MountSelection | null>;
      23              :     selectDirectory(mode: 'read' | 'readwrite', signal?: AbortSignal): Promise<MountSelection | null>;
      24              :     mount(selection: MountSelection, path: string | undefined, signal?: AbortSignal): Promise<string>;
      25              : }
      26              : 
      27              : export type MountSelection = {
      28              :     readonly name: string;
      29              :     readonly kind: 'file';
      30              :     readonly file: PlatformFile;
      31              : } | {
      32              :     readonly name: string;
      33              :     readonly kind: 'directory';
      34              :     readonly handle: FileSystemDirectoryHandle;
      35              :     readonly mode: 'read' | 'readwrite';
      36              : };
      37              : 
      38              : interface PendingRequest {
      39              :     resolve: (path: string) => void;
      40              :     reject: (error: unknown) => void;
      41              :     signal?: AbortSignal;
      42              :     onAbort?: () => void;
      43              : }
      44              : 
      45            1 : export function useMountDialog(actions: MountDialogActions): MountDialogHookResult {
      46            1 :     const [open, setOpen] = React.useState(false);
      47            1 :     const pendingRef = React.useRef<PendingRequest | null>(null);
      48              : 
      49            1 :     const finish = React.useCallback((result?: { path?: string; error?: unknown }) => {
      50            1 :         const pending = pendingRef.current;
      51            1 :         pendingRef.current = null;
      52            1 :         pending?.signal?.removeEventListener('abort', pending.onAbort!);
      53            1 :         setOpen(false);
      54            1 :         setSelection(null);
      55            1 :         if (result?.error != null) pending?.reject(result.error);
      56            1 :         else pending?.resolve(result?.path ?? 'Mount cancelled');
      57            1 :     }, []);
      58              : 
      59            1 :     const request = React.useCallback((signal?: AbortSignal) => {
      60            1 :         if (pendingRef.current != null) return Promise.reject(new Error('A mount request is already pending.'));
      61            1 :         if (signal?.aborted) return Promise.resolve('Mount cancelled');
      62            1 :         setSelection(null);
      63            1 :         setOpen(true);
      64            1 :         return new Promise<string>((resolve, reject) => {
      65            1 :             const pending: PendingRequest = { resolve, reject, signal };
      66            1 :             pending.onAbort = () => finish();
      67            1 :             pendingRef.current = pending;
      68            1 :             signal?.addEventListener('abort', pending.onAbort, { once: true });
      69            1 :         });
      70            1 :     }, [finish]);
      71              : 
      72            1 :     React.useEffect(() => () => finish(), [finish]);
      73              : 
      74            1 :     const [selection, setSelection] = React.useState<MountSelection | null>(null);
      75            1 :     const select = React.useCallback(async (action: (signal?: AbortSignal) => Promise<MountSelection | null>) => {
      76            1 :         const signal = pendingRef.current?.signal;
      77            1 :         try {
      78            1 :             const next = await action(signal);
      79            1 :             if (next != null) setSelection(next);
      80            1 :         } catch (error) {
      81            0 :             finish({ error });
      82            0 :         }
      83            1 :     }, [finish]);
      84              : 
      85            1 :     const mount = React.useCallback(async (path: string) => {
      86            1 :         if (selection == null) return;
      87            1 :         try {
      88            1 :             finish({ path: await actions.mount(selection, path || undefined, pendingRef.current?.signal) });
      89            1 :         } catch (error) {
      90            0 :             finish({ error });
      91            0 :         }
      92            1 :     }, [actions, finish, selection]);
      93              : 
      94            1 :     const controller = React.useMemo<MountDialogController>(() => ({ request }), [request]);
      95            1 :     return {
      96            1 :         controller,
      97            1 :         dialog: open ? <MountDialog actions={actions} selection={selection} onCancel={finish} onMount={mount} onSelect={select} /> : null,
      98            1 :     };
      99            1 : }
     100              : 
     101              : interface MountDialogProps {
     102              :     actions: MountDialogActions;
     103              :     selection: MountSelection | null;
     104              :     onCancel: () => void;
     105              :     onMount: (path: string) => void;
     106              :     onSelect: (action: (signal?: AbortSignal) => Promise<MountSelection | null>) => void;
     107              : }
     108              : 
     109            1 : function MountDialog(props: MountDialogProps) {
     110            1 :     const headingId = React.useId();
     111            1 :     const dialogRef = React.useRef<HTMLElement>(null);
     112            1 :     const pathInputRef = React.useRef<HTMLInputElement>(null);
     113            1 :     const [path, setPath] = React.useState('');
     114            1 :     React.useEffect(() => {
     115            1 :         setPath(props.selection == null ? '' : `/mnt/external/${props.selection.name}`);
     116            1 :     }, [props.selection]);
     117            1 :     useFocusTrap({
     118            1 :         containerRef: dialogRef as React.RefObject<HTMLElement>,
     119            1 :         initialFocusRef: props.selection == null
     120            1 :             ? dialogRef as React.RefObject<HTMLElement>
     121            1 :             : pathInputRef as React.RefObject<HTMLElement>,
     122            1 :         restoreFocusOnCleanUp: true,
     123            1 :     });
     124              : 
     125            1 :     return (
     126            1 :         <Overlay
     127            1 :             centered
     128            1 :             width={OverlaySize.L}
     129            1 :             preventFocusOnOpen
     130            1 :             onEscape={props.onCancel}
     131            1 :             onClickOutside={props.onCancel}
     132              :         >
     133            1 :             <section ref={dialogRef} className={styles.dialog} role="dialog" aria-modal="true" aria-labelledby={headingId} tabIndex={-1}>
     134            1 :                 <header className={styles.header}>
     135            1 :                     <h2 id={headingId}>{props.selection == null ? 'Filesystem Mount' : 'Mount Path'}</h2>
     136            1 :                     <IconButton
     137            1 :                         variant={ButtonVariant.Invisible}
     138            1 :                         aria-label="Close filesystem mount"
     139            1 :                         onClick={props.onCancel}
     140              :                     >
     141            1 :                         <XIcon />
     142            1 :                     </IconButton>
     143            1 :                 </header>
     144            1 :                 {props.selection == null ? <ActionList.List aria-label="Mount type" leading>
     145            1 :                     <ActionList.ListItem onClick={() => props.onSelect(props.actions.selectFiles)}>
     146            1 :                         <ActionList.Leading><FileIcon size={16} /></ActionList.Leading>
     147            1 :                         <ActionList.ItemText>File</ActionList.ItemText>
     148            1 :                         <ActionList.Trailing><span className={styles.access_mode}>READ</span></ActionList.Trailing>
     149            1 :                     </ActionList.ListItem>
     150            1 :                     <ActionList.ListItem onClick={() => props.onSelect(signal => props.actions.selectDirectory('read', signal))}>
     151            1 :                         <ActionList.Leading><FileDirectoryIcon size={16} /></ActionList.Leading>
     152            1 :                         <ActionList.ItemText>Folder</ActionList.ItemText>
     153            1 :                         <ActionList.Trailing><span className={styles.access_mode}>READ</span></ActionList.Trailing>
     154            1 :                     </ActionList.ListItem>
     155            1 :                     <ActionList.ListItem onClick={() => props.onSelect(signal => props.actions.selectDirectory('readwrite', signal))}>
     156            1 :                         <ActionList.Leading><FileDirectoryIcon size={16} /></ActionList.Leading>
     157            1 :                         <ActionList.ItemText>Folder</ActionList.ItemText>
     158            1 :                         <ActionList.Trailing><span className={styles.access_mode}>READ/WRITE</span></ActionList.Trailing>
     159            1 :                     </ActionList.ListItem>
     160            1 :                 </ActionList.List> : <form className={styles.path_form} onSubmit={event => {
     161            1 :                     event.preventDefault();
     162            1 :                     props.onMount(path);
     163            1 :                 }}>
     164            1 :                     <TextInput
     165            1 :                         ref={pathInputRef}
     166            1 :                         id="mount-path"
     167            1 :                         aria-label="Mount path"
     168            1 :                         block
     169            1 :                         className={styles.mount_path}
     170            1 :                         value={path}
     171            1 :                         onChange={event => setPath(event.target.value)}
     172            1 :                     />
     173            1 :                     <div className={styles.actions}>
     174            1 :                         <Button variant={ButtonVariant.Invisible} onClick={props.onCancel}>Cancel</Button>
     175            1 :                         <Button variant={ButtonVariant.Primary} type="submit">Mount</Button>
     176            1 :                     </div>
     177            1 :                 </form>}
     178            1 :             </section>
     179            1 :         </Overlay>
     180              :     );
     181            1 : }
        

Generated by: LCOV version 2.0-1