Line data Source code
1 : import * as React from 'react';
2 :
3 : import { filterInvalidSessionCatalogs, renameBundleCatalogs, type SessionBundle } from '../../storage/session_bundle.js';
4 : import type { PreparedNativeSession } from '../../storage/composite_storage_backend.js';
5 : import type { CompositeStorageBackend } from '../../storage/composite_storage_backend.js';
6 : import {
7 : findSessionImportConflict,
8 : type SessionImportConflict,
9 : prepareNativeSessionImport,
10 : registerNativeSession,
11 : replaceSessionWithNativeFolder,
12 : replaceSessionWithPortableBundle,
13 : writePortableSessionFresh,
14 : } from '../../storage/session_import_transaction.js';
15 : import { displayPath } from '../../storage/session_locator.js';
16 : import { useStorage } from '../../storage/storage_provider.js';
17 : import { restoreSingleSession } from './app_state_loader.js';
18 : import { mergeRestoredSessionIntoConnections, mergeRestoredSessionIntoScripts } from './app_state_loader.js';
19 : import { connectionEntryFromState, findSessionQueries, useConnectionReferences, useConnectionRegistrySetter, useConnectionRegistryStore } from '../../connections/connection_registry.js';
20 : import type { ConnectionState } from '../../connections/connection_state.js';
21 : import { isHyperWasmConnection } from '../../connections/connection_label.js';
22 : import { useSessionScriptsRegistrySetter } from '../scripts/session_scripts_registry.js';
23 : import { useDashQLCoreSetup } from '../../runtime/core_provider.js';
24 : import { useLogger } from '../../platform/logger/logger_provider.js';
25 : import { useCancelQuery } from '../../connections/query_executor.js';
26 : import type { SessionImportCardDependencies } from './session_import_card.js';
27 : import { SessionImportConflictDialog } from './session_import_conflict_dialog.js';
28 : import { SessionConnectionImportDialog } from './session_connection_import_dialog.js';
29 : import { StorageBackendType, type ConnectionEntry } from '../../storage/storage_backend.js';
30 : import {
31 : applyConnectionImportPlan,
32 : connectionImportQuestions,
33 : connectionRefsChanged,
34 : planConnectionImport,
35 : type ConnectionImportChoice,
36 : type ConnectionImportPlanItem,
37 : } from './session_connection_import.js';
38 : import { AppHost, getAppHost } from '../../platform/native_globals.js';
39 :
40 : type ImportPresentation =
41 : | { mode: 'centered' }
42 : | {
43 : mode: 'anchored';
44 : anchorRef: React.RefObject<Element | null>;
45 : returnFocusRef: React.RefObject<HTMLElement | null>;
46 : };
47 :
48 : export interface PortableSessionImportOptions {
49 : presentation: ImportPresentation;
50 : reloadAfterSuccess?: boolean;
51 : }
52 :
53 : interface SessionImportContextValue {
54 : findPortableBundleConflict(bundle: SessionBundle): Promise<{ displayLocation: string; isNative: boolean } | null>;
55 : importPortableBundleWithChoice(bundle: SessionBundle, choice: ConflictChoice): Promise<string | null>;
56 : importPortableBundle(bundle: SessionBundle, options: PortableSessionImportOptions): Promise<string | null>;
57 : importNativeFolder(dir: string, presentation: ImportPresentation): Promise<string | null>;
58 : }
59 :
60 : type ConflictChoice = 'replace' | 'create-new';
61 :
62 : interface ConflictDialogState {
63 : bundle: SessionBundle;
64 : existingDisplayLocation: string;
65 : presentation: ImportPresentation;
66 : busy: boolean;
67 : /// Run the import with the choice, null if the user cancelled it while choosing connections
68 : run(choice: ConflictChoice): Promise<string | null>;
69 : resolve(value: string | null): void;
70 : }
71 :
72 : /// The question whether imported connections should use existing connections
73 : interface ConnectionDialogState {
74 : /// The name of the imported session
75 : sessionName: string;
76 : /// The connections that may consolidate
77 : questions: ConnectionImportPlanItem[];
78 : /// Resolve with the choices, null if the import was cancelled
79 : resolve(choices: Map<string, ConnectionImportChoice> | null): void;
80 : }
81 :
82 1 : const SESSION_IMPORT_CTX = React.createContext<SessionImportContextValue | null>(null);
83 :
84 1 : export function useSessionImport(): SessionImportContextValue {
85 1 : const value = React.useContext(SESSION_IMPORT_CTX);
86 1 : if (value == null) throw new Error('useSessionImport must be used within SessionImportProvider');
87 1 : return value;
88 1 : }
89 :
90 : /// What the provider needs from the page, tests replace it
91 : export interface SessionImportDependencies {
92 : /// Reload the app, an import that changes registered sessions runs the restoration again
93 : reload: () => void;
94 : /// The dependencies of the card of the conflict dialog
95 : card?: SessionImportCardDependencies;
96 : }
97 :
98 1 : const DEFAULT_DEPENDENCIES: SessionImportDependencies = {
99 1 : reload: () => globalThis.location.reload(),
100 1 : };
101 :
102 1 : export function SessionImportProvider(props: React.PropsWithChildren<{ dependencies?: SessionImportDependencies }>) {
103 1 : const { reload, card } = props.dependencies ?? DEFAULT_DEPENDENCIES;
104 1 : const logger = useLogger();
105 1 : const setupCore = useDashQLCoreSetup();
106 1 : const [reader, writer] = useStorage();
107 : // Read the registry when importing instead of re-creating the import context for every query event
108 1 : const connectionStore = useConnectionRegistryStore();
109 1 : const references = useConnectionReferences();
110 1 : const setConnections = useConnectionRegistrySetter();
111 1 : const setScripts = useSessionScriptsRegistrySetter();
112 1 : const cancelQuery = useCancelQuery();
113 1 : const [dialog, setDialog] = React.useState<ConflictDialogState | null>(null);
114 1 : const [connectionDialog, setConnectionDialog] = React.useState<ConnectionDialogState | null>(null);
115 :
116 1 : const backend = reader.sessionFolders;
117 :
118 1 : const validatePortableBundle = React.useCallback(async (bundle: SessionBundle): Promise<SessionBundle> => {
119 1 : const core = await setupCore('session_import_validation');
120 1 : const validation = filterInvalidSessionCatalogs(core, bundle);
121 1 : if (validation.invalidFiles.length > 0) {
122 1 : logger.warn('ignored malformed session catalog files', {
123 1 : sessionId: bundle.session.sessionId,
124 1 : files: validation.invalidFiles.join(', '),
125 1 : }, 'session_import');
126 : }
127 1 : return validation.bundle;
128 1 : }, [logger, setupCore]);
129 :
130 : /// Map the connections of an imported session to the connections of the app.
131 : /// Connections that may consolidate with existing ones are decided by the user, null if the
132 : /// user cancelled the import.
133 1 : const resolveConnections = React.useCallback(async (bundle: SessionBundle): Promise<SessionBundle | null> => {
134 1 : if (backend == null) return bundle;
135 : // The live connections may be ahead of the manifest while writes are debounced
136 1 : const live = connectionStore.current;
137 1 : const entries: ConnectionEntry[] = ((await backend.loadManifest()).connections ?? [])
138 1 : .map(entry => ({ ...entry, params: live.connections.get(entry.id)?.params ?? entry.params }));
139 : // The embedded database always takes the embedded databases of the imported session
140 1 : for (const [connectionId, connection] of live.connections) {
141 1 : if (!entries.some(entry => entry.id === connectionId)
142 0 : && (references.sessionsOf(connectionId).length > 0 || isHyperWasmConnection(connection))) {
143 0 : entries.push(connectionEntryFromState({ ...connection, paramsDraft: undefined }));
144 : }
145 : }
146 1 : const plan = planConnectionImport(bundle.session, entries);
147 1 : const questions = connectionImportQuestions(plan);
148 1 : let choices = new Map<string, ConnectionImportChoice>();
149 1 : if (questions.length > 0) {
150 1 : const sessionName = bundle.session.name?.trim() || bundle.session.metadata.originalFileName || 'Unnamed session';
151 1 : const chosen = await new Promise<Map<string, ConnectionImportChoice> | null>(resolve => {
152 1 : setConnectionDialog({ sessionName, questions, resolve });
153 1 : });
154 1 : setConnectionDialog(null);
155 1 : if (chosen == null) return null;
156 1 : choices = chosen;
157 1 : }
158 1 : const { session, connectionIds } = applyConnectionImportPlan(bundle.session, plan, choices, entries);
159 1 : logger.info('resolved the connections of an imported session', {
160 1 : sessionId: bundle.session.sessionId,
161 1 : connections: plan.map(item => item.decision.kind).join(','),
162 1 : }, 'session_import');
163 1 : return { ...bundle, session, catalogs: renameBundleCatalogs(bundle.catalogs, connectionIds) };
164 1 : }, [backend, connectionStore, logger, references]);
165 :
166 1 : const stopSessionWork = React.useCallback(async (sessionId: string) => {
167 1 : const databases = references.connectionsOf(sessionId)
168 1 : .map(connectionId => connectionStore.current.connections.get(connectionId))
169 1 : .filter((database): database is ConnectionState => database != null);
170 : // Connections may be shared, only the queries of the session are cancelled
171 1 : await Promise.all(databases.flatMap(database => findSessionQueries(database, sessionId, false).map(queryId =>
172 1 : cancelQuery(database.connectionId, queryId)
173 1 : )));
174 1 : writer.pause();
175 1 : try {
176 1 : await writer.flush();
177 1 : } catch (error) {
178 1 : writer.resume();
179 1 : throw error;
180 1 : }
181 1 : }, [cancelQuery, connectionStore, references, writer]);
182 :
183 1 : const restoreFreshPortable = React.useCallback(async (
184 1 : bundle: SessionBundle,
185 1 : targetSessionId: string,
186 1 : suffixNameWithCopy: boolean,
187 1 : ) => {
188 1 : if (backend == null) throw new Error('Session imports require composite storage');
189 1 : const importedBundle = suffixNameWithCopy
190 1 : ? withFreshIdentity(bundle, targetSessionId)
191 1 : : bundle;
192 1 : await writePortableSessionFresh(backend, importedBundle, targetSessionId);
193 : // The same bundle may have been imported and deleted before in this tab
194 1 : writer.reviveSession(targetSessionId);
195 1 : try {
196 1 : const core = await setupCore('session_import');
197 1 : const restored = await restoreSingleSession(
198 1 : core,
199 1 : backend,
200 1 : logger,
201 1 : targetSessionId,
202 1 : connectionStore.current,
203 1 : );
204 1 : setConnections(registry => mergeRestoredSessionIntoConnections(registry, restored));
205 1 : setScripts(registry => mergeRestoredSessionIntoScripts(registry, restored));
206 1 : return targetSessionId;
207 1 : } catch (error) {
208 1 : try {
209 1 : await backend.deletePortableSession(targetSessionId);
210 1 : } catch {
211 : // Preserve the restoration error.
212 0 : }
213 1 : throw error;
214 1 : }
215 1 : }, [backend, connectionStore, logger, setConnections, setScripts, setupCore, writer]);
216 :
217 1 : const requestConflictChoice = React.useCallback((
218 1 : bundle: SessionBundle,
219 1 : existingDisplayLocation: string,
220 1 : presentation: ImportPresentation,
221 1 : run: (choice: ConflictChoice) => Promise<string | null>,
222 1 : ): Promise<string | null> => {
223 1 : return new Promise<string | null>(resolve => {
224 1 : setDialog(current => {
225 1 : if (current != null) {
226 1 : resolve(null);
227 1 : logger.warn('ignored session import while another decision is pending', {}, 'session_import');
228 1 : return current;
229 1 : }
230 1 : return { bundle, existingDisplayLocation, presentation, busy: false, run, resolve };
231 1 : });
232 1 : });
233 1 : }, [logger]);
234 :
235 1 : const importPortableBundle = React.useCallback(async (
236 1 : bundle: SessionBundle,
237 1 : options: PortableSessionImportOptions,
238 1 : ): Promise<string | null> => {
239 1 : if (backend == null) throw new Error('Session imports require composite storage');
240 1 : const validated = await validatePortableBundle(bundle);
241 : // Whether the session is already registered is asked before the connections, resolving them
242 : // keeps the session id
243 1 : const conflict = await findSessionImportConflict(backend, validated.session.sessionId);
244 1 : if (conflict == null) {
245 1 : const resolved = await resolveConnections(validated);
246 1 : if (resolved == null) return null;
247 1 : bundle = resolved;
248 1 : const sessionId = bundle.session.sessionId;
249 1 : if (options.reloadAfterSuccess) {
250 1 : await reloadThen(() => writePortableSessionFresh(backend, bundle, sessionId), writer, reload);
251 : } else {
252 1 : await restoreFreshPortable(bundle, sessionId, false);
253 : }
254 1 : return sessionId;
255 1 : }
256 :
257 1 : return await requestConflictChoice(
258 1 : validated,
259 1 : displayPath(conflict.sessionId, conflict.location),
260 1 : options.presentation,
261 1 : async choice => {
262 1 : const bundle = await resolveConnections(validated);
263 1 : if (bundle == null) return null;
264 1 : if (choice === 'create-new') {
265 1 : const sessionId = crypto.randomUUID();
266 1 : const importedBundle = withFreshIdentity(bundle, sessionId);
267 1 : if (options.reloadAfterSuccess) {
268 1 : await reloadThen(() => writePortableSessionFresh(backend, importedBundle, sessionId), writer, reload);
269 : } else {
270 1 : await restoreFreshPortable(bundle, sessionId, true);
271 : }
272 1 : return sessionId;
273 1 : }
274 1 : await stopSessionWork(conflict.sessionId);
275 1 : try {
276 1 : const sessionId = await replaceSessionWithPortableBundle(backend, bundle, conflict);
277 1 : reload();
278 1 : return sessionId;
279 1 : } catch (error) {
280 1 : writer.resume();
281 1 : throw error;
282 1 : }
283 1 : },
284 1 : );
285 1 : }, [backend, reload, requestConflictChoice, resolveConnections, restoreFreshPortable, stopSessionWork, validatePortableBundle, writer]);
286 :
287 1 : const findPortableBundleConflict = React.useCallback(async (bundle: SessionBundle): Promise<{ displayLocation: string; isNative: boolean } | null> => {
288 1 : if (backend == null) throw new Error('Session imports require composite storage');
289 1 : const conflict = await findSessionImportConflict(backend, bundle.session.sessionId);
290 1 : return conflict == null ? null : {
291 1 : displayLocation: displayPath(conflict.sessionId, conflict.location),
292 1 : isNative: conflict.location.type === StorageBackendType.Native,
293 1 : };
294 1 : }, [backend]);
295 :
296 1 : const importPortableBundleWithChoice = React.useCallback(async (
297 1 : bundle: SessionBundle,
298 1 : choice: ConflictChoice,
299 1 : ): Promise<string | null> => {
300 1 : if (backend == null) throw new Error('Session imports require composite storage');
301 1 : bundle = await validatePortableBundle(bundle);
302 1 : const resolved = await resolveConnections(bundle);
303 1 : if (resolved == null) return null;
304 1 : bundle = resolved;
305 1 : const conflict = await findSessionImportConflict(backend, bundle.session.sessionId);
306 1 : if (conflict == null) return await restoreFreshPortable(bundle, bundle.session.sessionId, false);
307 1 : if (choice === 'create-new') {
308 1 : const sessionId = crypto.randomUUID();
309 1 : return await restoreFreshPortable(bundle, sessionId, true);
310 1 : }
311 1 : await stopSessionWork(conflict.sessionId);
312 1 : try {
313 1 : const sessionId = await replaceSessionWithPortableBundle(backend, bundle, conflict);
314 1 : reload();
315 1 : return sessionId;
316 1 : } catch (error) {
317 1 : writer.resume();
318 1 : throw error;
319 1 : }
320 1 : }, [backend, reload, resolveConnections, restoreFreshPortable, stopSessionWork, validatePortableBundle, writer]);
321 :
322 1 : const importNativeFolder = React.useCallback(async (
323 1 : dir: string,
324 1 : presentation: ImportPresentation,
325 1 : ): Promise<string | null> => {
326 1 : if (backend == null) throw new Error('Native session imports require composite storage');
327 1 : const original = await prepareNativeSessionImport(backend, dir);
328 : // The folder is registered untouched, references to consolidated connections are written after
329 1 : const resolve = async () => {
330 1 : const resolved = await resolveConnections(original.bundle);
331 1 : if (resolved == null) return null;
332 1 : const prepared: PreparedNativeSession = { ...original, bundle: resolved };
333 1 : return { prepared, refsChanged: connectionRefsChanged(original.bundle.session, resolved.session) };
334 1 : };
335 1 : const conflict = await findSessionImportConflict(backend, original.bundle.session.sessionId);
336 1 : if (conflict == null) {
337 1 : const resolution = await resolve();
338 1 : if (resolution == null) return null;
339 1 : const { prepared, refsChanged } = resolution;
340 1 : await reloadThen(async () => {
341 1 : const sessionId = await registerNativeSession(backend, prepared);
342 1 : if (refsChanged) await writeNativeConnectionRefs(backend, sessionId, prepared.bundle);
343 1 : return sessionId;
344 1 : }, writer, reload);
345 1 : return prepared.bundle.session.sessionId;
346 1 : }
347 1 : return await requestConflictChoice(
348 1 : original.bundle,
349 1 : displayPath(conflict.sessionId, conflict.location),
350 1 : presentation,
351 1 : async choice => {
352 1 : const resolution = await resolve();
353 1 : if (resolution == null) return null;
354 1 : return await runNativeConflict(choice, resolution.prepared, resolution.refsChanged, conflict, backend, writer, stopSessionWork, reload);
355 1 : },
356 1 : );
357 1 : }, [backend, reload, requestConflictChoice, resolveConnections, stopSessionWork, writer]);
358 :
359 1 : const handleChoice = React.useCallback(async (choice: ConflictChoice) => {
360 1 : const current = dialog;
361 1 : if (current == null || current.busy) return;
362 1 : setDialog({ ...current, busy: true });
363 1 : try {
364 1 : const result = await current.run(choice);
365 1 : setDialog(null);
366 1 : current.resolve(result);
367 1 : } catch (error) {
368 1 : logger.error('session import failed', { error: String(error) }, 'session_import');
369 1 : setDialog({ ...current, busy: false });
370 1 : }
371 1 : }, [dialog, logger]);
372 :
373 1 : const cancelDialog = React.useCallback(() => {
374 1 : if (dialog == null || dialog.busy) return;
375 1 : setDialog(null);
376 1 : dialog.resolve(null);
377 1 : }, [dialog]);
378 :
379 1 : const value = React.useMemo<SessionImportContextValue>(() => ({
380 1 : findPortableBundleConflict,
381 1 : importPortableBundleWithChoice,
382 1 : importPortableBundle,
383 1 : importNativeFolder,
384 1 : }), [findPortableBundleConflict, importNativeFolder, importPortableBundle, importPortableBundleWithChoice]);
385 :
386 1 : return (
387 1 : <SESSION_IMPORT_CTX.Provider value={value}>
388 1 : <div
389 1 : aria-hidden={dialog?.presentation.mode === 'centered' || undefined}
390 1 : style={{ display: dialog?.presentation.mode === 'centered' ? 'none' : 'contents' }}
391 : >
392 1 : {props.children}
393 1 : </div>
394 : {/* The connections are asked after the conflict choice, the conflict dialog waits behind them */}
395 1 : {connectionDialog == null && dialog?.presentation.mode === 'centered' && renderConflictDialog(dialog, handleChoice, cancelDialog, card)}
396 1 : {connectionDialog == null && dialog?.presentation.mode === 'anchored' && renderConflictDialog(dialog, handleChoice, cancelDialog, card)}
397 1 : {connectionDialog != null && (
398 1 : <SessionConnectionImportDialog
399 1 : sessionName={connectionDialog.sessionName}
400 1 : questions={connectionDialog.questions}
401 1 : busy={false}
402 1 : onConfirm={choices => connectionDialog.resolve(choices)}
403 1 : onCancel={() => connectionDialog.resolve(null)}
404 1 : />
405 : )}
406 1 : </SESSION_IMPORT_CTX.Provider>
407 : );
408 1 : }
409 :
410 : /// Give an imported session a new identity.
411 : /// The connections keep their ids, the connections of the copy were resolved by the import.
412 1 : function withFreshIdentity(bundle: SessionBundle, sessionId: string): SessionBundle {
413 1 : const session = { ...bundle.session, sessionId };
414 1 : const name = session.name?.trim();
415 1 : if (name) session.name = `${name} (copy)`;
416 1 : return { ...bundle, session };
417 1 : }
418 :
419 1 : async function reloadThen<T>(operation: () => Promise<T>, writer: ReturnType<typeof useStorage>[1], reload: () => void): Promise<T> {
420 1 : writer.pause();
421 1 : try {
422 1 : await writer.flush();
423 1 : const result = await operation();
424 1 : reload();
425 1 : return result;
426 1 : } catch (error) {
427 1 : writer.resume();
428 1 : throw error;
429 1 : }
430 1 : }
431 :
432 : /// Write the resolved connection references into a registered native folder
433 1 : async function writeNativeConnectionRefs(backend: CompositeStorageBackend, sessionId: string, bundle: SessionBundle): Promise<void> {
434 1 : const existing = await backend.loadSession(sessionId);
435 1 : await backend.saveSessionManifest(sessionId, {
436 1 : ...existing,
437 1 : connections: bundle.session.connections,
438 1 : scripts: bundle.session.scripts,
439 1 : });
440 1 : }
441 :
442 1 : async function runNativeConflict(
443 1 : choice: ConflictChoice,
444 1 : prepared: PreparedNativeSession,
445 1 : refsChanged: boolean,
446 1 : conflict: SessionImportConflict,
447 1 : backend: CompositeStorageBackend,
448 1 : writer: ReturnType<typeof useStorage>[1],
449 1 : stopSessionWork: (sessionId: string) => Promise<void>,
450 1 : reload: () => void,
451 1 : ): Promise<string> {
452 1 : if (choice === 'create-new') {
453 1 : const sessionId = crypto.randomUUID();
454 1 : const bundle = withFreshIdentity(prepared.bundle, sessionId);
455 1 : return await reloadThen(() => writePortableSessionFresh(backend, bundle, sessionId), writer, reload);
456 1 : }
457 1 : await stopSessionWork(conflict.sessionId);
458 1 : try {
459 1 : const sessionId = await replaceSessionWithNativeFolder(backend, prepared, conflict);
460 1 : if (refsChanged) await writeNativeConnectionRefs(backend, sessionId, prepared.bundle);
461 1 : reload();
462 1 : return sessionId;
463 1 : } catch (error) {
464 0 : writer.resume();
465 0 : throw error;
466 0 : }
467 1 : }
468 :
469 1 : function renderConflictDialog(
470 1 : dialog: ConflictDialogState,
471 1 : handleChoice: (choice: ConflictChoice) => Promise<void>,
472 1 : onCancel: () => void,
473 1 : dependencies: SessionImportCardDependencies | undefined,
474 1 : ): React.ReactElement {
475 1 : const common = {
476 1 : sessionName: dialog.bundle.session.name?.trim()
477 0 : || dialog.bundle.session.metadata.originalFileName
478 0 : || 'Unnamed session',
479 1 : sessionUuid: dialog.bundle.session.sessionId,
480 1 : folderCount: 0,
481 1 : scriptCount: dialog.bundle.scripts.length,
482 1 : existingDisplayLocation: dialog.existingDisplayLocation,
483 1 : existingIsNative: getAppHost() === AppHost.ELECTRON && dialog.existingDisplayLocation.startsWith('fs://'),
484 1 : busy: dialog.busy,
485 1 : onReplace: () => void handleChoice('replace'),
486 1 : onCreateNew: () => void handleChoice('create-new'),
487 1 : onCancel,
488 1 : dependencies,
489 1 : };
490 1 : return dialog.presentation.mode === 'centered'
491 1 : ? <SessionImportConflictDialog mode="centered" {...common} />
492 1 : : <SessionImportConflictDialog
493 1 : mode="anchored"
494 1 : anchorRef={dialog.presentation.anchorRef}
495 1 : returnFocusRef={dialog.presentation.returnFocusRef}
496 1 : {...common}
497 1 : />;
498 1 : }
|