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