LCOV - code coverage report
Current view: top level - dashql-app/src/session/persistence - session_import_provider.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 97.8 % 360 352
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1