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