From 600501ededc8dd63f0efe6fdd81ad2cc5c5f2854 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 25 Mar 2026 15:14:39 +1300 Subject: [PATCH 1/3] New version. --- package.json | 2 +- src/renderer/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7b84bdc9..db1e6b8b 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "url": "git+https://github.com/opencor/webapp.git" }, "type": "module", - "version": "0.20260325.1", + "version": "0.20260325.2", "engines": { "bun": ">=1.2.0" }, diff --git a/src/renderer/package.json b/src/renderer/package.json index 42796669..4223fff9 100644 --- a/src/renderer/package.json +++ b/src/renderer/package.json @@ -42,7 +42,7 @@ }, "./style.css": "./dist/opencor.css" }, - "version": "0.20260325.1", + "version": "0.20260325.2", "scripts": { "build": "vite build && bun scripts/generate.version.js", "build:lib": "vite build --config vite.lib.config.ts && bunx --bun vue-tsc --project tsconfig.lib.types.json", From ff3bcdfe77fbfe9d9568e989212f1207354587eb Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 25 Mar 2026 18:40:37 +1300 Subject: [PATCH 2/3] Some minor cleaning up. --- src/renderer/src/common/common.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/common/common.ts b/src/renderer/src/common/common.ts index fb67cb2a..baec8504 100644 --- a/src/renderer/src/common/common.ts +++ b/src/renderer/src/common/common.ts @@ -212,7 +212,9 @@ export const fileName = (filePath: string): string => { // A method to sleep for a number of milliseconds. export const sleep = (ms: number): Promise => { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); }; // Some constants related to simulation data values. From a38f1fb3275d2495b45e0b34ad831e44914e50bb Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 25 Mar 2026 18:40:28 +1300 Subject: [PATCH 3/3] Make the opening of files deterministic. --- src/renderer/src/common/common.ts | 8 + .../src/components/ContentsComponent.vue | 48 +++- src/renderer/src/components/OpenCOR.vue | 268 +++++++++++------- 3 files changed, 206 insertions(+), 118 deletions(-) diff --git a/src/renderer/src/common/common.ts b/src/renderer/src/common/common.ts index baec8504..859a5f47 100644 --- a/src/renderer/src/common/common.ts +++ b/src/renderer/src/common/common.ts @@ -217,6 +217,14 @@ export const sleep = (ms: number): Promise => { }); }; +// A method to wait for the next animation frame. + +export const waitForNextAnimationFrame = (): Promise => { + return new Promise((resolve: FrameRequestCallback) => { + requestAnimationFrame(resolve); + }); +}; + // Some constants related to simulation data values. export const EMPTY_FLOAT64_ARRAY = Object.freeze(new Float64Array(0)); diff --git a/src/renderer/src/components/ContentsComponent.vue b/src/renderer/src/components/ContentsComponent.vue index fe1f6216..bba6138e 100644 --- a/src/renderer/src/components/ContentsComponent.vue +++ b/src/renderer/src/components/ContentsComponent.vue @@ -113,34 +113,52 @@ const hasFiles = (): boolean => { return fileTabs.value.length > 0; }; -const selectFile = (filePath: string): void => { +const waitForTabsUpdate = async (): Promise => { + // Wait a bit to ensure that the file tabs are properly updated. + // Note: although a bit of a hack, this is needed when opening multiple files. Indeed, without this, the file tab may + // not be fully initialised when opening the next file. This means that when we select the file tab, it may try + // to finish initialising itself and, when it comes to our simulation experiment view, this means resizing the + // plots. + + await vue.nextTick(); + + for (let i = 0; i < 3; ++i) { + await common.waitForNextAnimationFrame(); + } +}; + +const selectFile = async (filePath: string, wait: boolean = false): Promise => { activeFile.value = filePath; + + if (wait) { + await waitForTabsUpdate(); + } }; -const selectNextFile = (): void => { +const selectNextFile = async (): Promise => { const fileTabIndex = fileTabs.value.findIndex((fileTab) => fileTab.file.path() === activeFile.value); const fileTabName = fileTabs.value[(fileTabIndex + 1) % fileTabs.value.length]?.file.path() || ''; if (fileTabName !== '') { - selectFile(fileTabName); + await selectFile(fileTabName); } }; -const selectPreviousFile = (): void => { +const selectPreviousFile = async (): Promise => { const fileTabIndex = fileTabs.value.findIndex((fileTab) => fileTab.file.path() === activeFile.value); const fileTabName = fileTabs.value[(fileTabIndex - 1 + fileTabs.value.length) % fileTabs.value.length]?.file.path() || ''; if (fileTabName !== '') { - selectFile(fileTabName); + await selectFile(fileTabName); } }; -const openFile = (file: locApi.File): void => { +const openFile = async (file: locApi.File, wait: boolean = false): Promise => { const filePath = file.path(); const prevActiveFile = activeFile.value; - selectFile(filePath); + await selectFile(filePath); fileTabs.value.splice(fileTabs.value.findIndex((fileTab) => fileTab.file.path() === prevActiveFile) + 1, 0, { file: file, @@ -148,9 +166,13 @@ const openFile = (file: locApi.File): void => { }); electronApi?.fileOpened(filePath); + + if (wait) { + await waitForTabsUpdate(); + } }; -const closeFile = (filePath: string): void => { +const closeFile = async (filePath: string): Promise => { locApi.fileManager.unmanage(filePath); const fileTabIndex = fileTabs.value.findIndex((fileTab) => fileTab.file.path() === filePath); @@ -161,20 +183,20 @@ const closeFile = (filePath: string): void => { const nextFileTab = fileTabs.value[Math.min(fileTabIndex, fileTabs.value.length - 1)]; if (nextFileTab) { - selectFile(nextFileTab.file.path()); + await selectFile(nextFileTab.file.path()); } } electronApi?.fileClosed(filePath); }; -const closeCurrentFile = (): void => { - closeFile(activeFile.value); +const closeCurrentFile = async (): Promise => { + await closeFile(activeFile.value); }; -const closeAllFiles = (): void => { +const closeAllFiles = async (): Promise => { while (fileTabs.value.length) { - closeCurrentFile(); + await closeCurrentFile(); } }; diff --git a/src/renderer/src/components/OpenCOR.vue b/src/renderer/src/components/OpenCOR.vue index 20e6d030..796d7a3d 100644 --- a/src/renderer/src/components/OpenCOR.vue +++ b/src/renderer/src/components/OpenCOR.vue @@ -226,6 +226,7 @@ const octokit = vue.ref(null); */ const initialisingOpencorMessageVisible = vue.ref(true); const loadingModelMessageVisible = vue.ref(false); +const activeRemoteModelLoadsCount = vue.ref(0); // Keep track of which instance of OpenCOR is currently active. @@ -482,9 +483,7 @@ const handleAction = (action: string): void => { (isAction(actionName, 'openFile') && filePaths.length === 1) || (isAction(actionName, 'openFiles') && filePaths.length > 1) ) { - for (const filePath of filePaths) { - openFile(filePath); - } + openFiles(filePaths); } else { addToast({ severity: 'error', @@ -622,134 +621,197 @@ const onUpdateAvailable = () => { let globalOmexDataUrlCounter = 0; -const openFile = (fileFilePathOrFileContents: string | Uint8Array | File): void => { +interface IFileInfo { + alreadyOpen: boolean; + file: locApi.File | null; + filePath: string; +} + +const processFile = async (fileFilePathOrFileContents: string | Uint8Array | File): Promise => { // Check whether we were passed a ZIP-CellML data URL. let cellmlDataUrlFileName: string = ''; let omexDataUrlCounter: number = 0; - locCommon.zipCellmlDataUrl(fileFilePathOrFileContents).then((zipCellmlDataUriInfo: locCommon.IDataUriInfo) => { - if (zipCellmlDataUriInfo.res) { - if (zipCellmlDataUriInfo.error) { + const zipCellmlDataUriInfo = await locCommon.zipCellmlDataUrl(fileFilePathOrFileContents); + + if (zipCellmlDataUriInfo.res) { + if (zipCellmlDataUriInfo.error) { + addToast({ + severity: 'error', + summary: 'Opening a file', + detail: zipCellmlDataUriInfo.error, + life: TOAST_LIFE + }); + + return null; + } + + cellmlDataUrlFileName = zipCellmlDataUriInfo.fileName as string; + fileFilePathOrFileContents = zipCellmlDataUriInfo.data as Uint8Array; + } else { + // Check whether we were passed a COMBINE archive data URL. + + const combineArchiveDataUriInfo = locCommon.combineArchiveDataUrl(fileFilePathOrFileContents); + + if (combineArchiveDataUriInfo.res) { + if (combineArchiveDataUriInfo.error) { addToast({ severity: 'error', summary: 'Opening a file', - detail: zipCellmlDataUriInfo.error, + detail: combineArchiveDataUriInfo.error, life: TOAST_LIFE }); - return; + return null; } - cellmlDataUrlFileName = zipCellmlDataUriInfo.fileName as string; - fileFilePathOrFileContents = zipCellmlDataUriInfo.data as Uint8Array; - } else { - // Check whether we were passed a COMBINE archive data URL. + omexDataUrlCounter = ++globalOmexDataUrlCounter; + fileFilePathOrFileContents = combineArchiveDataUriInfo.data as Uint8Array; + } + } - const combineArchiveDataUriInfo = locCommon.combineArchiveDataUrl(fileFilePathOrFileContents); + // Check whether the file is already open and if so then select it. - if (combineArchiveDataUriInfo.res) { - if (combineArchiveDataUriInfo.error) { - addToast({ - severity: 'error', - summary: 'Opening a file', - detail: combineArchiveDataUriInfo.error, - life: TOAST_LIFE - }); + const filePath = locCommon.filePath(fileFilePathOrFileContents, cellmlDataUrlFileName, omexDataUrlCounter); - return; - } + if (contentsRef.value?.hasFile(filePath) ?? false) { + return { + alreadyOpen: true, + file: null, + filePath + }; + } + + // Retrieve a locApi.File object for the given file or file path. + + const isRemoteFilePath = locCommon.isRemoteFilePath(filePath); + + if (isRemoteFilePath) { + ++activeRemoteModelLoadsCount.value; + + loadingModelMessageVisible.value = true; + } - omexDataUrlCounter = ++globalOmexDataUrlCounter; - fileFilePathOrFileContents = combineArchiveDataUriInfo.data as Uint8Array; + try { + const file = await locCommon.file(fileFilePathOrFileContents, cellmlDataUrlFileName, omexDataUrlCounter); + const fileType = file.type(); + + if ( + fileType === locApi.EFileType.IRRETRIEVABLE_FILE || + fileType === locApi.EFileType.UNKNOWN_FILE || + fileType === locApi.EFileType.SEDML_FILE || + (props.omex && fileType !== locApi.EFileType.COMBINE_ARCHIVE) + ) { + if (props.omex) { + vue.nextTick(() => { + issues.value.push({ + type: locApi.EIssueType.ERROR, + description: + fileType === locApi.EFileType.IRRETRIEVABLE_FILE + ? 'The file could not be retrieved.' + : 'Only COMBINE archives are supported.' + }); + }); + } else { + addToast({ + severity: 'error', + summary: 'Opening a file', + detail: + filePath + + '\n\n' + + (fileType === locApi.EFileType.IRRETRIEVABLE_FILE + ? 'The file could not be retrieved.' + : fileType === locApi.EFileType.SEDML_FILE + ? 'SED-ML files are not currently supported.' + : 'Only CellML files and COMBINE archives are supported.'), + life: TOAST_LIFE + }); } + + electronApi?.fileIssue(filePath); + + return null; + } + + return { + alreadyOpen: false, + file, + filePath + }; + } catch (error: unknown) { + if (props.omex) { + vue.nextTick(() => { + issues.value.push({ + type: locApi.EIssueType.ERROR, + description: common.formatMessage(common.formatError(error)) + }); + }); + } else { + addToast({ + severity: 'error', + summary: 'Opening a file', + detail: `${filePath}\n\n${common.formatMessage(common.formatError(error))}`, + life: TOAST_LIFE + }); } - // Check whether the file is already open and if so then select it. + electronApi?.fileIssue(filePath); - const filePath = locCommon.filePath(fileFilePathOrFileContents, cellmlDataUrlFileName, omexDataUrlCounter); + return null; + } finally { + if (isRemoteFilePath) { + --activeRemoteModelLoadsCount.value; - if (contentsRef.value?.hasFile(filePath) ?? false) { - contentsRef.value?.selectFile(filePath); + loadingModelMessageVisible.value = activeRemoteModelLoadsCount.value > 0; + } + } +}; +const openFile = (fileFilePathOrFileContents: string | Uint8Array | File): void => { + processFile(fileFilePathOrFileContents).then(async (fileInfo) => { + if (!fileInfo) { return; } - // Retrieve a locApi.File object for the given file or file path and add it to the contents. - - const isRemoteFilePath = locCommon.isRemoteFilePath(filePath); + if (fileInfo.alreadyOpen) { + await contentsRef.value?.selectFile(fileInfo.filePath); - if (isRemoteFilePath) { - loadingModelMessageVisible.value = true; + return; } - locCommon - .file(fileFilePathOrFileContents, cellmlDataUrlFileName, omexDataUrlCounter) - .then((file) => { - const fileType = file.type(); - - if ( - fileType === locApi.EFileType.IRRETRIEVABLE_FILE || - fileType === locApi.EFileType.UNKNOWN_FILE || - fileType === locApi.EFileType.SEDML_FILE || - (props.omex && fileType !== locApi.EFileType.COMBINE_ARCHIVE) - ) { - if (props.omex) { - vue.nextTick(() => { - issues.value.push({ - type: locApi.EIssueType.ERROR, - description: - fileType === locApi.EFileType.IRRETRIEVABLE_FILE - ? 'The file could not be retrieved.' - : 'Only COMBINE archives are supported.' - }); - }); - } else { - addToast({ - severity: 'error', - summary: 'Opening a file', - detail: - filePath + - '\n\n' + - (fileType === locApi.EFileType.IRRETRIEVABLE_FILE - ? 'The file could not be retrieved.' - : fileType === locApi.EFileType.SEDML_FILE - ? 'SED-ML files are not currently supported.' - : 'Only CellML files and COMBINE archives are supported.'), - life: TOAST_LIFE - }); - } + if (fileInfo.file) { + await contentsRef.value?.openFile(fileInfo.file); + } + }); +}; - electronApi?.fileIssue(filePath); - } else { - contentsRef.value?.openFile(file); - } - }) - .catch((error: unknown) => { - if (props.omex) { - vue.nextTick(() => { - issues.value.push({ - type: locApi.EIssueType.ERROR, - description: common.formatMessage(common.formatError(error)) - }); - }); - } else { - addToast({ - severity: 'error', - summary: 'Opening a file', - detail: `${filePath}\n\n${common.formatMessage(common.formatError(error))}`, - life: TOAST_LIFE - }); - } +const openFiles = (filesFilePathsOrFileContents: (string | Uint8Array | File)[]): void => { + // Start processing all files in parallel but open their tabs in the original order. - electronApi?.fileIssue(filePath); - }) - .finally(() => { - if (isRemoteFilePath) { - loadingModelMessageVisible.value = false; - } - }); + const filePromises = filesFilePathsOrFileContents.map((fileFilePathOrFileContents) => { + return processFile(fileFilePathOrFileContents); }); + + filePromises.reduce(async (previousFilePromises, currentFilePromise) => { + await previousFilePromises; + + const currentFileInfo = await currentFilePromise; + + if (!currentFileInfo) { + return; + } + + if (currentFileInfo.alreadyOpen) { + await contentsRef.value?.selectFile(currentFileInfo.filePath, true); + + return; + } + + if (currentFileInfo.file) { + await contentsRef.value?.openFile(currentFileInfo.file, true); + } + }, Promise.resolve()); }; // Open file(s) dialog. @@ -760,9 +822,7 @@ const onChange = (event: Event): void => { const input = event.target as HTMLInputElement; if (input.files) { - for (const file of input.files) { - openFile(file); - } + openFiles(Array.from(input.files)); } // Reset the input. @@ -793,9 +853,7 @@ const onDrop = (event: DragEvent): void => { const files = event.dataTransfer?.files; if (files) { - for (const file of Array.from(files)) { - openFile(file); - } + openFiles(Array.from(files)); } };