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