From 11c7d3d9dc16d93c0a09f76fe886491b77c3b22e Mon Sep 17 00:00:00 2001 From: Daniel Kats Date: Tue, 22 Sep 2026 08:25:43 +0200 Subject: [PATCH 1/9] Run ElemCo.jl and xtb in the launch directory when started from a terminal Every run used a mkdtemp work dir under the OS temp dir that was deleted when the run finished, so exported files (orbitals.molden from an Export step, xtbopt.xyz, ...) vanished silently. When jlmol is started from a terminal (stdin is a TTY), main registers process.cwd() as a work-dir token that is never removed, and the ElemCo and xtb runners use it: calculation.jl, coord.xyz and everything the calculation writes stay there. Launches without a terminal keep the temp dir. The output panel prints which directory is used. xtb removes a stale xtbopt.xyz before a run, since success is detected from that file. New bridge calls: launchDir(), removeFile(). The bridge probe checks the removal and that removeWorkDir on the launch dir is a no-op. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++++ README.md | 3 +++ js/elemco.js | 28 ++++++++++++++++++++++------ js/xtb.js | 17 +++++++++++++---- main.js | 41 ++++++++++++++++++++++++++++++++++++++--- preload.js | 11 ++++++++--- 6 files changed, 88 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae0483..6243521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- ElemCo.jl and xtb calculations started from a terminal now run in the directory jlmol was started from, so exported files (`orbitals.molden` from an Export step, `xtbopt.xyz`, `xtbopt.log`, ...) and the generated `calculation.jl` / `coord.xyz` stay there. Previously every run used a temporary directory under the OS temp dir that was deleted when the run finished, so exports silently vanished. Launches without a terminal (desktop launcher, Finder, Start menu) keep the temporary directory. The output panel now prints which directory is used and whether it is deleted. + ## [1.5.5] - 2026-09-07 ### Fixed diff --git a/README.md b/README.md index dd3a64a..8f07673 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ Run [xtb](https://github.com/grimme-lab/xtb) g-xTB calculations on the current s - Set charge, number of unpaired electrons (`--uhf`), and extra xtb flags - Configurable xtb command (supports WSL) in Settings → xtb - Browser mode shows a message to install jlmol locally; a clear message is shown if xtb is not accessible +- Working directory: when jlmol is started from a terminal, xtb runs in the directory it was started from, so `coord.xyz`, `xtbopt.xyz`, `xtbopt.log` and the other xtb output files stay there. Started from a desktop launcher, xtb runs in a temporary directory that is deleted after the run. The output panel prints which one is used. - Requires the g-xTB binary from [grimme-lab/g-xtb](https://github.com/grimme-lab/g-xtb). At the moment that is all you need — download it, extract it, and make sure the `xtb` binary in its `bin` folder is on your `PATH` (e.g. a symbolic link in `~/bin`) or set its full path in Settings → xtb. No separate xtb installation or parameter-file download is required. ## ElemCo.jl Integration @@ -340,6 +341,8 @@ jlmol now includes an integrated calculation runner that can execute ElemCo.jl c 3. **Run Calculation**: Click "Run Calculation" to execute the calculation 4. **View Results**: Monitor progress and view calculation output in real-time +When jlmol is started from a terminal, the calculation runs in the directory it was started from: `calculation.jl` and every file the calculation writes (e.g. `orbitals.molden` from an Export step) stay there. Started from a desktop launcher, it runs in a temporary directory that is deleted after the run. The output panel prints which one is used. + #### WSL (Windows Subsystem for Linux) Support For Windows users running Julia under WSL, jlmol provides seamless integration: diff --git a/js/elemco.js b/js/elemco.js index 4bfb349..fbd6e48 100644 --- a/js/elemco.js +++ b/js/elemco.js @@ -1234,12 +1234,16 @@ async function runJuliaInElectron(juliaCode) { const prefs = getPreferences(); const juliaCommand = prefs.juliaCommand || 'julia'; + // Started from a terminal: run in that directory, so @export_molden and + // friends write next to the user and nothing is deleted afterwards. + // Otherwise a temp work dir, removed after the run. let workDir = null; + let ownsWorkDir = false; function cleanupTempFile() { - if (workDir) { + if (workDir && ownsWorkDir) { native.removeWorkDir(workDir); // main also reaps on quit - workDir = null; } + workDir = null; } try { @@ -1273,8 +1277,14 @@ async function runJuliaInElectron(juliaCode) { const commandParts = parseCommand(juliaCommand.trim()); const isWSL = commandParts[0].toLowerCase() === 'wsl'; - // Julia code goes into a scoped work directory - workDir = await native.mkWorkDir('jlmol_julia_'); + // Julia code goes into the work directory + const launch = await native.launchDir(); + if (launch) { + workDir = launch.token; + } else { + workDir = await native.mkWorkDir('jlmol_julia_'); + ownsWorkDir = true; + } const workDirPath = await native.workDirPath(workDir); await native.writeFile(workDir, 'calculation.jl', juliaCode); const sep = native.platform === 'win32' ? '\\' : '/'; @@ -1291,7 +1301,10 @@ async function runJuliaInElectron(juliaCode) { } } - outputTextarea.value = `=== JLMol Julia Calculation ===\nTimestamp: ${new Date().toISOString()}\nJulia command: ${juliaCommand}\nTemporary file: ${tempFile}\n${isWSL ? `WSL path: ${filePathForCommand}\n` : ''}Full command: ${juliaCommand} "${filePathForCommand}"\n\n--- Starting calculation ---\n`; + const workDirNote = ownsWorkDir + ? `Working directory (temporary, deleted after the run): ${workDirPath}` + : `Working directory (started from here; exported files stay here): ${workDirPath}`; + outputTextarea.value = `=== JLMol Julia Calculation ===\nTimestamp: ${new Date().toISOString()}\nJulia command: ${juliaCommand}\n${workDirNote}\nInput file: ${tempFile}\n${isWSL ? `WSL path: ${filePathForCommand}\n` : ''}Full command: ${juliaCommand} "${filePathForCommand}"\n\n--- Starting calculation ---\n`; setStatusText('Starting Julia process...'); // Prepare command and arguments for version check @@ -1375,8 +1388,11 @@ async function runJuliaInElectron(juliaCode) { close: (code) => { updateOutput(); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + const cleanupNote = ownsWorkDir + ? '--- Cleanup: temporary work directory deleted ---' + : `--- Files written by the calculation are in ${workDirPath} ---`; cleanupTempFile(); - outputTextarea.value += `\n--- Cleanup: Temporary file deleted ---\n`; + outputTextarea.value += `\n${cleanupNote}\n`; if (code === 0) { outputTextarea.value += `\n=== CALCULATION COMPLETED SUCCESSFULLY ===\nElapsed time: ${elapsed} seconds\nExit code: ${code}`; setStatusText(`Julia calculation completed successfully (${elapsed}s)`); diff --git a/js/xtb.js b/js/xtb.js index 391c4aa..598b1d5 100644 --- a/js/xtb.js +++ b/js/xtb.js @@ -144,9 +144,18 @@ async function runXtbInElectron(xyzData, mode) { const baseCommand = isWSL ? 'wsl' : commandParts[0]; const cmdPrefixArgs = commandParts.slice(1); // e.g. ['xtb'] for "wsl xtb", or [] for "xtb" - // Dedicated working directory (xtb writes several output files into CWD) - const workDir = await native.mkWorkDir('jlmol_xtb_'); + // Working directory (xtb writes several output files into CWD). Started + // from a terminal: that directory, so the files stay where the user is. + // Otherwise a dedicated temp dir, removed after the run. + const launch = await native.launchDir(); + const workDir = launch ? launch.token : await native.mkWorkDir('jlmol_xtb_'); + const ownsWorkDir = !launch; const workDirPath = await native.workDirPath(workDir); + if (!ownsWorkDir) { + // Success is detected from the produced xtbopt.xyz; a stale one from + // an earlier run in the same directory must not pass for a new one. + await native.removeFile(workDir, 'xtbopt.xyz'); + } await native.writeFile(workDir, 'coord.xyz', xyzData); // Optionally freeze the non-selected atoms during optimization by writing @@ -182,7 +191,7 @@ async function runXtbInElectron(xyzData, mode) { function cleanup() { if (cleanedUp) return; cleanedUp = true; - native.removeWorkDir(workDir); // main also reaps all work dirs on quit + if (ownsWorkDir) native.removeWorkDir(workDir); // main also reaps all work dirs on quit } // Build arguments. The geometry file is passed as a relative name; cwd is workDir. @@ -191,7 +200,7 @@ async function runXtbInElectron(xyzData, mode) { if (extraFlags) calcArgs.push(...parseXtbCommand(extraFlags)); const fullCmd = `${xtbCommand} coord.xyz${inputFileArgs.length ? ' --input xtb.inp' : ''} --gxtb${mode === 'opt' ? ' --opt' : ''} --chrg ${charge} --uhf ${uhf}${extraFlags ? ' ' + extraFlags : ''}`; - outputTextarea.value = `=== jlmol xtb (g-xTB) Calculation ===\nTimestamp: ${new Date().toISOString()}\nMode: ${mode === 'opt' ? 'Geometry optimization' : 'Single-point energy'}\nxtb command: ${xtbCommand}\nWorking directory: ${workDirPath}\nFull command: ${fullCmd}\n${freezeSummary ? '\n' + freezeSummary : ''}\n--- Checking xtb availability ---\n`; + outputTextarea.value = `=== jlmol xtb (g-xTB) Calculation ===\nTimestamp: ${new Date().toISOString()}\nMode: ${mode === 'opt' ? 'Geometry optimization' : 'Single-point energy'}\nxtb command: ${xtbCommand}\nWorking directory${ownsWorkDir ? ' (temporary, deleted after the run)' : ' (started from here; output files stay here)'}: ${workDirPath}\nFull command: ${fullCmd}\n${freezeSummary ? '\n' + freezeSummary : ''}\n--- Checking xtb availability ---\n`; setStatusText('Checking xtb...'); // Availability check first diff --git a/main.js b/main.js index ae40215..94d3331 100644 --- a/main.js +++ b/main.js @@ -154,6 +154,23 @@ const { spawn: spawnNative } = require('child_process'); const workDirs = new Map(); // token -> absolute path under os.tmpdir() const procs = new Map(); // procId -> ChildProcess +// The directory jlmol was started from, when started from a terminal. In that +// case ElemCo.jl and xtb runs use it as their working directory, so exported +// files (orbitals.molden, xtbopt.xyz, ...) land where the user is instead of +// in a temp dir that is deleted after the run. A desktop launcher gives no +// TTY (and usually cwd = $HOME or /), so those launches keep the temp dirs. +// The launch dir is registered as a work-dir token like the temp dirs (same +// path confinement for reads/writes) but is never removed. +const LAUNCH_DIR_TOKEN = 'launch-dir'; +function startedFromTerminal() { + // process.stdin may throw for exotic handles (seen on Windows GUI starts + // without a console); that is a "no terminal" answer, not a crash. + try { return Boolean(process.stdin.isTTY); } catch (_) { return false; } +} +const launchDir = startedFromTerminal() ? process.cwd() : null; +if (launchDir) workDirs.set(LAUNCH_DIR_TOKEN, launchDir); +log('Launch directory: ' + (launchDir || '(not started from a terminal)')); + function resolveInWorkDir(dirToken, name) { const dir = workDirs.get(dirToken); if (!dir) throw new Error('unknown work directory'); @@ -184,6 +201,8 @@ ipcMain.handle('jlmol-workdir-path', (_e, token) => { if (!dir) throw new Error('unknown work directory'); return dir; }); +ipcMain.handle('jlmol-launch-dir', () => + launchDir ? { token: LAUNCH_DIR_TOKEN, path: launchDir } : null); ipcMain.handle('jlmol-write-file', (_e, token, name, content) => { fsNative.writeFileSync(resolveInWorkDir(token, name), String(content)); }); @@ -192,7 +211,11 @@ ipcMain.handle('jlmol-read-file', (_e, token, name) => { if (!fsNative.existsSync(target)) return null; return fsNative.readFileSync(target, 'utf8'); }); +ipcMain.handle('jlmol-rm-file', (_e, token, name) => { + fsNative.rmSync(resolveInWorkDir(token, name), { force: true }); +}); ipcMain.handle('jlmol-rm-workdir', (_e, token) => { + if (token === LAUNCH_DIR_TOKEN) return; const dir = workDirs.get(token); if (!dir) return; workDirs.delete(token); @@ -240,7 +263,8 @@ ipcMain.handle('jlmol-kill', (_e, procId) => { app.on('will-quit', () => { for (const child of procs.values()) { try { child.kill(); } catch (_) {} } - for (const dir of workDirs.values()) { + for (const [token, dir] of workDirs) { + if (token === LAUNCH_DIR_TOKEN) continue; try { fsNative.rmSync(dir, { recursive: true, force: true }); } catch (_) {} } }); @@ -255,7 +279,18 @@ function runBridgeProbe(win) { const back = await n.readFile(dir, 'x.txt'); let escape = 'not caught'; try { await n.readFile(dir, '../escape.txt'); } catch (e) { escape = 'caught'; } + await n.removeFile(dir, 'x.txt'); + const gone = (await n.readFile(dir, 'x.txt')) === null ? 'rm ok' : 'rm bad'; await n.removeWorkDir(dir); + // With a launch dir (terminal start) removeWorkDir must be a no-op + // on it; without one the bridge must answer null, not throw. + const launch = await n.launchDir(); + let launchCheck = 'launch none'; + if (launch) { + await n.removeWorkDir(launch.token); + launchCheck = (await n.workDirPath(launch.token)) === launch.path + ? 'launch kept' : 'launch lost'; + } const spawnResult = await new Promise((res) => { let text = ''; n.spawn('echo', ['bridge-echo'], {}, { @@ -266,9 +301,9 @@ function runBridgeProbe(win) { }); }); return [back === 'roundtrip' ? 'file ok' : 'file bad', - 'escape ' + escape, spawnResult].join(' | '); + 'escape ' + escape, gone, launchCheck, spawnResult].join(' | '); })()`).then((result) => { - const ok = result === 'file ok | escape caught | spawn ok'; + const ok = /^file ok \| escape caught \| rm ok \| launch (none|kept) \| spawn ok$/.test(result); console.log(ok ? 'BRIDGE OK' : `BRIDGE FAIL: ${result}`); app.exit(ok ? 0 : 1); }).catch((err) => { diff --git a/preload.js b/preload.js index 62eca23..916b732 100644 --- a/preload.js +++ b/preload.js @@ -5,8 +5,9 @@ // narrow, auditable surface: // - platform info (static strings), // - openExternal (http/https only, validated in the main process), -// - work directories: created by main under the OS temp dir, addressed by -// opaque tokens; file reads/writes are confined to those directories, +// - work directories: created by main under the OS temp dir (or, when the +// app was started from a terminal, the directory it was started from), +// addressed by opaque tokens; file reads/writes are confined to them, // - process runs: spawn with streamed stdout/stderr and kill, for the // user-configured xtb and Julia commands. // Running user-configured commands is the app's purpose, so spawn is not @@ -47,11 +48,15 @@ contextBridge.exposeInMainWorld('jlmolNative', { ipcRenderer.invoke('jlmol-write-file', dirToken, name, content), readFile: (dirToken, name) => ipcRenderer.invoke('jlmol-read-file', dirToken, name), + removeFile: (dirToken, name) => + ipcRenderer.invoke('jlmol-rm-file', dirToken, name), removeWorkDir: (dirToken) => ipcRenderer.invoke('jlmol-rm-workdir', dirToken), workDirPath: (dirToken) => ipcRenderer.invoke('jlmol-workdir-path', dirToken), - + // {token, path} of the directory jlmol was started from (terminal starts + // only), or null. Calculations run there so their output files persist. + launchDir: () => ipcRenderer.invoke('jlmol-launch-dir'), // --- processes -------------------------------------------------------- // handlers: { data(kind, text), close(code), error(message) } spawn: async (command, args, options, handlers) => { From 9fcdf3732d851d074d07f4e5c979ea0a4c05fbcc Mon Sep 17 00:00:00 2001 From: Daniel Kats Date: Tue, 22 Sep 2026 08:25:53 +0200 Subject: [PATCH 2/9] Image and XYZ export: save dialog, defaulting to the launch directory Browser-style downloads (image export, XYZ export) landed silently in the download folder. They now go through Electron's save dialog via setSaveDialogOptions on the download item. The default is the launch directory when started from a terminal, the download folder otherwise. Main reports the outcome to the renderer (jlmol-download-done) and the status line shows the saved path or that the export was cancelled. In --bridge-probe mode the file is written to the default path without a dialog (a native dialog cannot be driven headless); the probe checks the default location and the done message under a pty. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + README.md | 2 +- js/image-export.js | 10 ++++++++++ main.js | 43 ++++++++++++++++++++++++++++++++++++++++++- preload.js | 5 +++++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6243521..90e8d1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - ElemCo.jl and xtb calculations started from a terminal now run in the directory jlmol was started from, so exported files (`orbitals.molden` from an Export step, `xtbopt.xyz`, `xtbopt.log`, ...) and the generated `calculation.jl` / `coord.xyz` stay there. Previously every run used a temporary directory under the OS temp dir that was deleted when the run finished, so exports silently vanished. Launches without a terminal (desktop launcher, Finder, Start menu) keep the temporary directory. The output panel now prints which directory is used and whether it is deleted. +- Image export and XYZ export in the desktop app now open a save dialog instead of dropping the file silently into the download folder. The dialog defaults to the launch directory when started from a terminal and to the download folder otherwise; the status line shows where the file was saved, or that the export was cancelled. ## [1.5.5] - 2026-09-07 diff --git a/README.md b/README.md index 8f07673..5098ad7 100644 --- a/README.md +++ b/README.md @@ -341,7 +341,7 @@ jlmol now includes an integrated calculation runner that can execute ElemCo.jl c 3. **Run Calculation**: Click "Run Calculation" to execute the calculation 4. **View Results**: Monitor progress and view calculation output in real-time -When jlmol is started from a terminal, the calculation runs in the directory it was started from: `calculation.jl` and every file the calculation writes (e.g. `orbitals.molden` from an Export step) stay there. Started from a desktop launcher, it runs in a temporary directory that is deleted after the run. The output panel prints which one is used. +When jlmol is started from a terminal, the calculation runs in the directory it was started from: `calculation.jl` and every file the calculation writes (e.g. `orbitals.molden` from an Export step) stay there. Started from a desktop launcher, it runs in a temporary directory that is deleted after the run. The output panel prints which one is used. Image and XYZ exports open a save dialog; its default folder is the launch directory from a terminal and the download folder otherwise, and the status line shows where the file was saved. #### WSL (Windows Subsystem for Linux) Support diff --git a/js/image-export.js b/js/image-export.js index 2559bfb..98eafc2 100644 --- a/js/image-export.js +++ b/js/image-export.js @@ -149,3 +149,13 @@ function downloadImage(imageData, filename, format, transparent) { const transparencyNote = (format === 'png' && transparent) ? ' with transparent background' : ''; setStatusText(`${format.toUpperCase()} image exported successfully${transparencyNote}`); } + +// Desktop app: exports go through a save dialog and main reports the +// outcome; show the real path (or the cancel) instead of the guess above. +if (window.jlmolNative && window.jlmolNative.onDownloadDone) { + window.jlmolNative.onDownloadDone((state, savedPath) => { + if (state === 'completed') setStatusText(`Saved ${savedPath}`); + else if (state === 'cancelled') setStatusText('Export cancelled'); + else setStatusText(`Export failed (${state})`); + }); +} diff --git a/main.js b/main.js index 94d3331..ca4ec22 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, shell, ipcMain, protocol } = require('electron') +const { app, BrowserWindow, shell, ipcMain, protocol, session } = require('electron') // --- smoke mode ------------------------------------------------------------ // `electron . --smoke` boots the app, waits for the JSmol applet to reach @@ -290,6 +290,22 @@ function runBridgeProbe(win) { await n.removeWorkDir(launch.token); launchCheck = (await n.workDirPath(launch.token)) === launch.path ? 'launch kept' : 'launch lost'; + // A browser download must default to the launch dir, and the + // renderer must be told the final path. + const saved = new Promise((res) => + n.onDownloadDone((state, p) => res(state === 'completed' ? p : null))); + const a = document.createElement('a'); + a.href = 'data:text/plain,probe-download'; + a.download = 'jlmol_probe_download.txt'; + document.body.appendChild(a); a.click(); a.remove(); + const savedPath = await Promise.race([saved, + new Promise((res) => setTimeout(() => res(null), 5000))]); + const body = await n.readFile(launch.token, 'jlmol_probe_download.txt'); + await n.removeFile(launch.token, 'jlmol_probe_download.txt'); + if (body !== 'probe-download' || !savedPath + || !savedPath.endsWith('jlmol_probe_download.txt')) { + launchCheck = 'launch download bad ' + savedPath + ' ' + body; + } } const spawnResult = await new Promise((res) => { let text = ''; @@ -554,7 +570,32 @@ function createWindow() { }); } +// Browser-style downloads (image export, XYZ export) ask where to save, +// defaulting to the launch directory when started from a terminal and to the +// download folder otherwise. In --bridge-probe mode the file is written to the +// default without a dialog (a native dialog cannot be driven headless), so the +// probe still checks the redirection and the saved-path message. +function downloadDefaultPath(filename) { + return path.join(launchDir || app.getPath('downloads'), filename); +} + +function setupDownloads() { + session.defaultSession.on('will-download', (_event, item, wc) => { + const defaultPath = downloadDefaultPath(item.getFilename()); + if (BRIDGE_PROBE) item.setSavePath(defaultPath); + else item.setSaveDialogOptions({ defaultPath }); + item.once('done', (_e, state) => { + const target = item.getSavePath(); + log(`Download ${state}: ${target}`); + if (wc && !wc.isDestroyed()) { + wc.send('jlmol-download-done', state, target); + } + }); + }); +} + app.whenReady().then(() => { + setupDownloads(); // app:// serves the bundle directory, path-normalized and confined to it. // Served from fs STREAMS -- both constraints learned on 2026-08-22 the // hard way: (a) no file:// URLs anywhere (net.fetch of a hostful UNC file diff --git a/preload.js b/preload.js index 916b732..6987c12 100644 --- a/preload.js +++ b/preload.js @@ -57,6 +57,11 @@ contextBridge.exposeInMainWorld('jlmolNative', { // {token, path} of the directory jlmol was started from (terminal starts // only), or null. Calculations run there so their output files persist. launchDir: () => ipcRenderer.invoke('jlmol-launch-dir'), + // Fired when a browser download (image/XYZ export) ends: state is + // 'completed', 'cancelled' or 'interrupted'; path is where it was saved. + onDownloadDone: (cb) => + ipcRenderer.on('jlmol-download-done', (_event, state, savedPath) => cb(state, savedPath)), + // --- processes -------------------------------------------------------- // handlers: { data(kind, text), close(code), error(message) } spawn: async (command, args, options, handlers) => { From 83b1aba0aafea8db72dd45ea5ee1940fe4db9024 Mon Sep 17 00:00:00 2001 From: Daniel Kats Date: Tue, 22 Sep 2026 08:31:11 +0200 Subject: [PATCH 3/9] Launch dir: honour npm's INIT_CWD and accept any terminal stream `npm start` chdirs to the package root before running the script, so process.cwd() was ~/projects/jlmol whatever directory the command was typed in. npm exports the original directory as INIT_CWD; use it when set. Terminal detection now accepts a TTY on stdin, stdout or stderr, so a redirected stdin alone no longer disables the launch dir. Co-Authored-By: Claude Fable 5.1 --- main.js | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/main.js b/main.js index ca4ec22..d7b14e7 100644 --- a/main.js +++ b/main.js @@ -163,11 +163,26 @@ const procs = new Map(); // procId -> ChildProcess // path confinement for reads/writes) but is never removed. const LAUNCH_DIR_TOKEN = 'launch-dir'; function startedFromTerminal() { - // process.stdin may throw for exotic handles (seen on Windows GUI starts - // without a console); that is a "no terminal" answer, not a crash. - try { return Boolean(process.stdin.isTTY); } catch (_) { return false; } + // Any of the three standard streams on a terminal counts, so + // `jlmol < /dev/null` or a redirected stdout still qualifies. The + // process.std* getters may throw for exotic handles (seen on Windows GUI + // starts without a console); that is a "no terminal" answer, not a crash. + for (const name of ['stdin', 'stdout', 'stderr']) { + try { if (process[name].isTTY) return true; } catch (_) { /* no terminal */ } + } + return false; +} +function findLaunchDir() { + if (!startedFromTerminal()) return null; + // `npm start` (and npm run/exec) chdir to the package root before running + // the script; the directory the user typed the command in is INIT_CWD. + const init = process.env.INIT_CWD; + if (init && path.isAbsolute(init)) { + try { if (fs.statSync(init).isDirectory()) return init; } catch (_) { /* fall through */ } + } + return process.cwd(); } -const launchDir = startedFromTerminal() ? process.cwd() : null; +const launchDir = findLaunchDir(); if (launchDir) workDirs.set(LAUNCH_DIR_TOKEN, launchDir); log('Launch directory: ' + (launchDir || '(not started from a terminal)')); From 634feda6681beeedf8326391a4bbb0dc51086f0a Mon Sep 17 00:00:00 2001 From: Daniel Kats Date: Tue, 22 Sep 2026 08:38:03 +0200 Subject: [PATCH 4/9] Pass the launch directory explicitly: --workdir from the npm start scripts Under WSL `npm start` may run the Windows Electron binary, which gets neither a TTY (WSL interop hands it pipes) nor npm's INIT_CWD (not in WSLENV), so the terminal heuristic never fires and exports defaulted to Downloads. The start scripts now pass --workdir="$INIT_CWD" (the directory the command was typed in) and --wsl-distro="$WSL_DISTRO_NAME". launchdir.js resolves it: on win32 a POSIX path is mapped to C:\... for /mnt/c/... or to \\wsl.localhost\\... (then \\wsl$\...), the first that exists wins; an invalid value is logged and ignored. Without --workdir the terminal heuristic (INIT_CWD, else cwd) remains. The module is pure and carries a self-test (`node launchdir.js`). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- README.md | 2 ++ launchdir.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++ main.js | 39 +++++++++++++-------------- package.json | 7 ++--- 5 files changed, 99 insertions(+), 25 deletions(-) create mode 100644 launchdir.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e8d1e..254e3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- ElemCo.jl and xtb calculations started from a terminal now run in the directory jlmol was started from, so exported files (`orbitals.molden` from an Export step, `xtbopt.xyz`, `xtbopt.log`, ...) and the generated `calculation.jl` / `coord.xyz` stay there. Previously every run used a temporary directory under the OS temp dir that was deleted when the run finished, so exports silently vanished. Launches without a terminal (desktop launcher, Finder, Start menu) keep the temporary directory. The output panel now prints which directory is used and whether it is deleted. +- ElemCo.jl and xtb calculations started from a terminal now run in the directory jlmol was started from, so exported files (`orbitals.molden` from an Export step, `xtbopt.xyz`, `xtbopt.log`, ...) and the generated `calculation.jl` / `coord.xyz` stay there. Previously every run used a temporary directory under the OS temp dir that was deleted when the run finished, so exports silently vanished. Launches without a terminal (desktop launcher, Finder, Start menu) keep the temporary directory. The output panel now prints which directory is used and whether it is deleted. The directory comes from `--workdir=PATH` (the `npm start` scripts pass the directory the command was typed in, also from WSL running the Windows binary) or, when started from a terminal, the current directory. - Image export and XYZ export in the desktop app now open a save dialog instead of dropping the file silently into the download folder. The dialog defaults to the launch directory when started from a terminal and to the download folder otherwise; the status line shows where the file was saved, or that the export was cancelled. ## [1.5.5] - 2026-09-07 diff --git a/README.md b/README.md index 5098ad7..b62e093 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,8 @@ npm install npm start ``` +`npm start` passes the directory you typed it in to the app as `--workdir` (npm itself changes to the package root), so calculations run there and the export dialog defaults to it. Under WSL this also works when `electron` resolves to the Windows binary: the script passes `$WSL_DISTRO_NAME` and the path is mapped to `\\wsl.localhost\\...` (or `C:\...` for `/mnt/c/...`). For any other start, pass `--workdir=/abs/path` explicitly, or start the app from a terminal, in which case the current directory is used. + The terminal output is kept quiet by default. A full log is always written to `jsmol.log` in the app's user-data directory. To echo the detailed log to the terminal, run with verbose logging: ```bash diff --git a/launchdir.js b/launchdir.js new file mode 100644 index 0000000..660cd8f --- /dev/null +++ b/launchdir.js @@ -0,0 +1,74 @@ +// Where jlmol was started from, for a command-line start. Pure: no electron, +// so it can be unit-tested with plain node (see the self-test at the bottom). +// +// Sources, in order: +// 1. --workdir=PATH on the command line. The npm start scripts pass +// $INIT_CWD (the directory `npm start` was typed in; npm itself chdirs +// to the package root) plus --wsl-distro=$WSL_DISTRO_NAME. Under WSL +// the Windows Electron binary may be running, so a POSIX path is mapped +// to its Windows spelling (/mnt/c/x -> C:\x, else \\wsl.localhost\...). +// 2. A terminal on stdin/stdout/stderr: INIT_CWD if npm set it, else cwd. +// 3. Nothing: null, the caller keeps the temp-dir behaviour. +'use strict'; +const path = require('path'); + +function argValue(argv, name) { + const prefix = `--${name}=`; + const hit = argv.find((a) => a.startsWith(prefix)); + return hit ? hit.slice(prefix.length) : null; +} + +// Candidate spellings of a POSIX path for a win32 process started from WSL. +function windowsCandidates(posixPath, distro) { + const out = []; + const m = /^\/mnt\/([a-zA-Z])(\/.*)?$/.exec(posixPath); + if (m) out.push(`${m[1].toUpperCase()}:${(m[2] || '/').replace(/\//g, '\\')}`); + if (distro) { + const rest = posixPath.replace(/\//g, '\\'); + out.push(`\\\\wsl.localhost\\${distro}${rest}`, `\\\\wsl$\\${distro}${rest}`); + } + return out; +} + +function findLaunchDir({ argv, env, platform, isTTY, isDir, cwd }) { + const P = platform === 'win32' ? path.win32 : path.posix; + const explicit = argValue(argv, 'workdir'); + if (explicit) { + const candidates = [explicit]; + if (platform === 'win32' && explicit.startsWith('/')) { + candidates.push(...windowsCandidates(explicit, argValue(argv, 'wsl-distro') || env.WSL_DISTRO_NAME)); + } + for (const c of candidates) { + if (P.isAbsolute(c) && isDir(c)) return { dir: c, source: '--workdir' }; + } + return { dir: null, source: `--workdir rejected (${explicit})` }; + } + if (!isTTY()) return { dir: null, source: 'no terminal' }; + const init = env.INIT_CWD; + if (init && P.isAbsolute(init) && isDir(init)) return { dir: init, source: 'terminal, INIT_CWD' }; + return { dir: cwd(), source: 'terminal, cwd' }; +} + +module.exports = { findLaunchDir, windowsCandidates }; + +if (require.main === module) { + // node launchdir.js -- runs the self-test + const assert = require('assert'); + const dirs = new Set(['/tmp/here', '/home/u/proj', 'C:\\Users\\u\\x', '\\\\wsl.localhost\\Ubuntu\\home\\u\\x']); + const base = { env: {}, platform: 'linux', isTTY: () => false, isDir: (d) => dirs.has(d), cwd: () => '/home/u/proj' }; + const run = (o) => findLaunchDir({ ...base, ...o }).dir; + assert.strictEqual(run({ argv: [] }), null); + assert.strictEqual(run({ argv: [], isTTY: () => true }), '/home/u/proj'); + assert.strictEqual(run({ argv: [], isTTY: () => true, env: { INIT_CWD: '/tmp/here' } }), '/tmp/here'); + assert.strictEqual(run({ argv: [], isTTY: () => true, env: { INIT_CWD: '/nope' } }), '/home/u/proj'); + assert.strictEqual(run({ argv: ['--workdir=/tmp/here'] }), '/tmp/here'); + assert.strictEqual(run({ argv: ['--workdir=/nope'], isTTY: () => true }), null); + assert.strictEqual(run({ argv: ['--workdir=$INIT_CWD'] }), null); // cmd.exe left it unexpanded + assert.strictEqual(run({ argv: ['--workdir='], isTTY: () => true }), '/home/u/proj'); // empty = not given + assert.strictEqual(run({ argv: ['--workdir=/mnt/c/Users/u/x'], platform: 'win32' }), 'C:\\Users\\u\\x'); + assert.strictEqual(run({ argv: ['--workdir=/home/u/x', '--wsl-distro=Ubuntu'], platform: 'win32' }), + '\\\\wsl.localhost\\Ubuntu\\home\\u\\x'); + assert.strictEqual(run({ argv: ['--workdir=/home/u/x'], platform: 'win32' }), null); // no distro known + assert.deepStrictEqual(windowsCandidates('/mnt/d', 'Deb'), ['D:\\', '\\\\wsl.localhost\\Deb\\mnt\\d', '\\\\wsl$\\Deb\\mnt\\d']); + console.log('launchdir self-test ok'); +} diff --git a/main.js b/main.js index d7b14e7..4eed85a 100644 --- a/main.js +++ b/main.js @@ -154,17 +154,18 @@ const { spawn: spawnNative } = require('child_process'); const workDirs = new Map(); // token -> absolute path under os.tmpdir() const procs = new Map(); // procId -> ChildProcess -// The directory jlmol was started from, when started from a terminal. In that -// case ElemCo.jl and xtb runs use it as their working directory, so exported -// files (orbitals.molden, xtbopt.xyz, ...) land where the user is instead of -// in a temp dir that is deleted after the run. A desktop launcher gives no -// TTY (and usually cwd = $HOME or /), so those launches keep the temp dirs. -// The launch dir is registered as a work-dir token like the temp dirs (same -// path confinement for reads/writes) but is never removed. +// The directory jlmol was started from, for command-line starts (see +// launchdir.js for the sources: --workdir from the npm start scripts, or a +// terminal on a standard stream). ElemCo.jl and xtb runs use it as their +// working directory, so exported files (orbitals.molden, xtbopt.xyz, ...) +// land where the user is instead of in a temp dir that is deleted after the +// run, and the export save dialog defaults to it. Desktop-launcher starts +// have none and keep the temp dirs. The launch dir is registered as a +// work-dir token like the temp dirs (same path confinement for reads and +// writes) but is never removed. const LAUNCH_DIR_TOKEN = 'launch-dir'; function startedFromTerminal() { - // Any of the three standard streams on a terminal counts, so - // `jlmol < /dev/null` or a redirected stdout still qualifies. The + // Any of the three standard streams on a terminal counts. The // process.std* getters may throw for exotic handles (seen on Windows GUI // starts without a console); that is a "no terminal" answer, not a crash. for (const name of ['stdin', 'stdout', 'stderr']) { @@ -172,19 +173,15 @@ function startedFromTerminal() { } return false; } -function findLaunchDir() { - if (!startedFromTerminal()) return null; - // `npm start` (and npm run/exec) chdir to the package root before running - // the script; the directory the user typed the command in is INIT_CWD. - const init = process.env.INIT_CWD; - if (init && path.isAbsolute(init)) { - try { if (fs.statSync(init).isDirectory()) return init; } catch (_) { /* fall through */ } - } - return process.cwd(); -} -const launchDir = findLaunchDir(); +const launchDirInfo = require('./launchdir').findLaunchDir({ + argv: process.argv, env: process.env, platform: process.platform, + isTTY: startedFromTerminal, + isDir: (d) => { try { return fs.statSync(d).isDirectory(); } catch (_) { return false; } }, + cwd: () => process.cwd(), +}); +const launchDir = launchDirInfo.dir; if (launchDir) workDirs.set(LAUNCH_DIR_TOKEN, launchDir); -log('Launch directory: ' + (launchDir || '(not started from a terminal)')); +log(`Launch directory: ${launchDir || '(none)'} [${launchDirInfo.source}]`); function resolveInWorkDir(dirToken, name) { const dir = workDirs.get(dirToken); diff --git a/package.json b/package.json index 016b02d..0debca4 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "description": "jlmol - A Molecular Viewer Desktop App", "main": "main.js", "scripts": { - "start": "electron .", - "start-verbose": "electron . --verbose", - "start:win": "node_modules/electron/dist/electron.exe .", + "start": "electron . --workdir=\"$INIT_CWD\" --wsl-distro=\"$WSL_DISTRO_NAME\"", + "start-verbose": "electron . --verbose --workdir=\"$INIT_CWD\" --wsl-distro=\"$WSL_DISTRO_NAME\"", + "start:win": "node_modules/electron/dist/electron.exe . --workdir=\"%INIT_CWD%\"", "start-safe": "electron . --disable-hardware-acceleration", "start-debug": "electron . --enable-logging=file --log-file=jlmol-debug.log --log-level=0", "start-safe-debug": "electron . --disable-hardware-acceleration --enable-logging --log-level=0", @@ -31,6 +31,7 @@ ], "files": [ "main.js", + "launchdir.js", "index.html", "renderer.js", "preload.js", From 3b66c6422e4905c9a3b43863994aa71360c3d29c Mon Sep 17 00:00:00 2001 From: Daniel Kats Date: Tue, 22 Sep 2026 08:47:50 +0200 Subject: [PATCH 5/9] Log the export dialog's default path Co-Authored-By: Claude Fable 5.1 --- main.js | 1 + 1 file changed, 1 insertion(+) diff --git a/main.js b/main.js index 4eed85a..33457d5 100644 --- a/main.js +++ b/main.js @@ -594,6 +594,7 @@ function downloadDefaultPath(filename) { function setupDownloads() { session.defaultSession.on('will-download', (_event, item, wc) => { const defaultPath = downloadDefaultPath(item.getFilename()); + log(`Download dialog default: ${defaultPath}`); if (BRIDGE_PROBE) item.setSavePath(defaultPath); else item.setSaveDialogOptions({ defaultPath }); item.once('done', (_e, state) => { From 033f2f168b630fe8134acc82d403a0699a4f0467 Mon Sep 17 00:00:00 2001 From: Daniel Kats Date: Tue, 22 Sep 2026 08:52:53 +0200 Subject: [PATCH 6/9] Never take a slash-rooted --workdir raw on win32; map UNC launch dirs for WSL Julia Daniel's log from WSL: "Launch directory: /home/kats/projects/jlmol [--workdir]" and the dialog got "\home\kats\projects\jlmol\molecule.png". On win32 Node calls a slash-rooted path absolute, and stat() resolved it against the current drive, the \\wsl.localhost share, so it even existed. The save dialog then fell back to the home directory. A POSIX --workdir on win32 now only yields its mapped spellings (C:\ for /mnt/c, \\wsl.localhost\\..., \\wsl$\...). Self-test covers the trap. The Julia runner's WSL path translation now also maps \\wsl.localhost\\rest to /rest, so calculation.jl in a launch dir on the distro's own tree reaches `wsl julia` as a valid path. Co-Authored-By: Claude Fable 5.1 --- js/elemco.js | 7 ++++++- launchdir.js | 17 +++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/js/elemco.js b/js/elemco.js index fbd6e48..b7b3062 100644 --- a/js/elemco.js +++ b/js/elemco.js @@ -1293,7 +1293,12 @@ async function runJuliaInElectron(juliaCode) { // For WSL, convert Windows path to WSL path let filePathForCommand = tempFile; if (isWSL) { - if (/^[A-Za-z]:/.test(tempFile)) { + const unc = /^\\\\(?:wsl\.localhost|wsl\$)\\[^\\]+(\\.*)$/.exec(tempFile); + if (unc) { + // \\wsl.localhost\\home\... is the distro's own tree + // (the launch dir when started from WSL): drop the share. + filePathForCommand = unc[1].replace(/\\/g, '/'); + } else if (/^[A-Za-z]:/.test(tempFile)) { const driveLetter = tempFile.charAt(0).toLowerCase(); filePathForCommand = tempFile.replace(/^[A-Za-z]:/, `/mnt/${driveLetter}`).replace(/\\/g, '/'); } else { diff --git a/launchdir.js b/launchdir.js index 660cd8f..664f073 100644 --- a/launchdir.js +++ b/launchdir.js @@ -34,10 +34,14 @@ function findLaunchDir({ argv, env, platform, isTTY, isDir, cwd }) { const P = platform === 'win32' ? path.win32 : path.posix; const explicit = argValue(argv, 'workdir'); if (explicit) { - const candidates = [explicit]; - if (platform === 'win32' && explicit.startsWith('/')) { - candidates.push(...windowsCandidates(explicit, argValue(argv, 'wsl-distro') || env.WSL_DISTRO_NAME)); - } + // On win32 a slash-rooted path is never usable as given: Node calls + // it absolute and stat() resolves it against the current drive (a UNC + // share when started from WSL, so it even exists), but the save + // dialog and child processes get "\home\..." and fall back to $HOME. + // Only the mapped spellings are tried. + const candidates = platform === 'win32' && explicit.startsWith('/') + ? windowsCandidates(explicit, argValue(argv, 'wsl-distro') || env.WSL_DISTRO_NAME) + : [explicit]; for (const c of candidates) { if (P.isAbsolute(c) && isDir(c)) return { dir: c, source: '--workdir' }; } @@ -69,6 +73,11 @@ if (require.main === module) { assert.strictEqual(run({ argv: ['--workdir=/home/u/x', '--wsl-distro=Ubuntu'], platform: 'win32' }), '\\\\wsl.localhost\\Ubuntu\\home\\u\\x'); assert.strictEqual(run({ argv: ['--workdir=/home/u/x'], platform: 'win32' }), null); // no distro known + // Exists relative to the current drive (stat says yes) but must not be taken raw on win32. + assert.strictEqual(run({ argv: ['--workdir=/home/u/proj'], platform: 'win32' }), null); + assert.strictEqual(run({ argv: ['--workdir=/home/u/proj', '--wsl-distro=Ubuntu'], platform: 'win32', + isDir: (d) => dirs.has(d) || d === '\\\\wsl.localhost\\Ubuntu\\home\\u\\proj' }), + '\\\\wsl.localhost\\Ubuntu\\home\\u\\proj'); assert.deepStrictEqual(windowsCandidates('/mnt/d', 'Deb'), ['D:\\', '\\\\wsl.localhost\\Deb\\mnt\\d', '\\\\wsl$\\Deb\\mnt\\d']); console.log('launchdir self-test ok'); } From aefc11eace3e038713e43317a52c7afd0776e886 Mon Sep 17 00:00:00 2001 From: Daniel Kats Date: Tue, 22 Sep 2026 08:56:37 +0200 Subject: [PATCH 7/9] Choose File: native open dialog starting in the launch directory The browser file picker behind cannot be pointed at a directory. In the desktop app the input's click now opens dialog.showOpenDialog with defaultPath = launch dir (when there is one; otherwise the OS picks its usual folder), main reads the file and hands {name, content} to the renderer. handleFileSelect's loading body is extracted into loadStructureContent(name, content) and shared with the browser build, which keeps the input. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + README.md | 2 +- js/search.js | 128 +++++++++++++++++++++++++++++---------------------- main.js | 23 ++++++++- preload.js | 4 ++ 5 files changed, 102 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 254e3cd..ec001a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ElemCo.jl and xtb calculations started from a terminal now run in the directory jlmol was started from, so exported files (`orbitals.molden` from an Export step, `xtbopt.xyz`, `xtbopt.log`, ...) and the generated `calculation.jl` / `coord.xyz` stay there. Previously every run used a temporary directory under the OS temp dir that was deleted when the run finished, so exports silently vanished. Launches without a terminal (desktop launcher, Finder, Start menu) keep the temporary directory. The output panel now prints which directory is used and whether it is deleted. The directory comes from `--workdir=PATH` (the `npm start` scripts pass the directory the command was typed in, also from WSL running the Windows binary) or, when started from a terminal, the current directory. - Image export and XYZ export in the desktop app now open a save dialog instead of dropping the file silently into the download folder. The dialog defaults to the launch directory when started from a terminal and to the download folder otherwise; the status line shows where the file was saved, or that the export was cancelled. +- "Choose File" in the desktop app uses a native open dialog that starts in the launch directory when there is one (the browser file picker cannot be pointed at a directory). Without a launch directory the OS chooses the folder as before. ## [1.5.5] - 2026-09-07 diff --git a/README.md b/README.md index b62e093..b1763a9 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,7 @@ jlmol now includes an integrated calculation runner that can execute ElemCo.jl c 3. **Run Calculation**: Click "Run Calculation" to execute the calculation 4. **View Results**: Monitor progress and view calculation output in real-time -When jlmol is started from a terminal, the calculation runs in the directory it was started from: `calculation.jl` and every file the calculation writes (e.g. `orbitals.molden` from an Export step) stay there. Started from a desktop launcher, it runs in a temporary directory that is deleted after the run. The output panel prints which one is used. Image and XYZ exports open a save dialog; its default folder is the launch directory from a terminal and the download folder otherwise, and the status line shows where the file was saved. +When jlmol is started from a terminal, the calculation runs in the directory it was started from: `calculation.jl` and every file the calculation writes (e.g. `orbitals.molden` from an Export step) stay there. Started from a desktop launcher, it runs in a temporary directory that is deleted after the run. The output panel prints which one is used. Image and XYZ exports open a save dialog; its default folder is the launch directory from a terminal and the download folder otherwise, and the status line shows where the file was saved. "Choose File" opens in the launch directory as well. #### WSL (Windows Subsystem for Linux) Support diff --git a/js/search.js b/js/search.js index e315a94..9e4b408 100644 --- a/js/search.js +++ b/js/search.js @@ -1,66 +1,86 @@ +// Desktop app: "Choose File" goes through a native open dialog that starts +// in the launch directory, matching the export dialog. The browser's own +// file picker cannot be pointed at a directory, so the input's click is +// replaced; the input stays for the browser build and for its label. +document.addEventListener('DOMContentLoaded', () => { + const input = document.getElementById('structureFile'); + const native = window.jlmolNative; + if (!input || !native || !native.openStructureFile) return; + input.addEventListener('click', (evt) => { + evt.preventDefault(); + native.openStructureFile().then((file) => { + if (file) loadStructureContent(file.name, file.content); + }).catch((err) => setStatusText('Error opening file: ' + err.message)); + }); +}); + function handleFileSelect(evt) { var files = evt.target.files; if (files.length > 0) { var file = files[0]; var reader = new FileReader(); reader.onload = function(e) { - try { - // Save current display mode - const currentDisplayMode = displayMode || 'default'; - - const fileContent = e.target.result; - const fileName = file.name.toLowerCase(); + loadStructureContent(file.name, e.target.result); + }; + reader.readAsText(file); + } +} + +// Load a structure file's text into the viewer; the name picks the loader. +function loadStructureContent(name, fileContent) { + try { + // Save current display mode + const currentDisplayMode = displayMode || 'default'; + + const fileName = name.toLowerCase(); + + // Check if it's an XYZ file and use custom loader + if (fileName.endsWith('.xyz')) { + // Don't automatically set shouldUseNumberedAtoms to true + // Instead, let loadXYZWithNumberedAtoms detect if numbered atoms are present + loadXYZWithNumberedAtoms(fileContent); + } else { + // Clear numbered atom names for non-XYZ files + clearOriginalAtomNames(); + originalXYZContent = null; + shouldUseNumberedAtoms = false; // Non-XYZ files should not use numbered atoms + + // Clear database metadata since this is not a database load + window.databaseMetadata = null; + + // Use normal loading for other file types + Jmol.script(jmolApplet0, 'load inline "' + fileContent + '" filter "NOSORT"'); + document.getElementById('status').innerHTML = 'File loaded successfully'; + + // Refresh atom names to remove any numbered indices for non-XYZ files + setTimeout(() => { + refreshAtomNames(); + console.log('handleFileSelect: Atom names refreshed for non-XYZ file'); - // Check if it's an XYZ file and use custom loader - if (fileName.endsWith('.xyz')) { - // Don't automatically set shouldUseNumberedAtoms to true - // Instead, let loadXYZWithNumberedAtoms detect if numbered atoms are present - loadXYZWithNumberedAtoms(fileContent); - } else { - // Clear numbered atom names for non-XYZ files - clearOriginalAtomNames(); - originalXYZContent = null; - shouldUseNumberedAtoms = false; // Non-XYZ files should not use numbered atoms - - // Clear database metadata since this is not a database load - window.databaseMetadata = null; - - // Use normal loading for other file types - Jmol.script(jmolApplet0, 'load inline "' + fileContent + '" filter "NOSORT"'); - document.getElementById('status').innerHTML = 'File loaded successfully'; - - // Refresh atom names to remove any numbered indices for non-XYZ files - setTimeout(() => { - refreshAtomNames(); - console.log('handleFileSelect: Atom names refreshed for non-XYZ file'); - - // Update MOL file data for JSME integration after file loading - try { - lastMolFile = Jmol.evaluateVar(jmolApplet0, 'write("mol")').trim(); - debugLog('File', 'Updated lastMolFile after loading non-XYZ file'); - } catch (molError) { - console.error('Error updating MOL file data in handleFileSelect:', molError); - } - }, 100); + // Update MOL file data for JSME integration after file loading + try { + lastMolFile = Jmol.evaluateVar(jmolApplet0, 'write("mol")').trim(); + debugLog('File', 'Updated lastMolFile after loading non-XYZ file'); + } catch (molError) { + console.error('Error updating MOL file data in handleFileSelect:', molError); } - - // Restore display mode after loading with a delay - setTimeout(() => { - console.log('handleFileSelect: Restoring display mode:', currentDisplayMode); - setDisplayMode(currentDisplayMode); - - // Apply all JSmol preferences after loading - if (typeof applyJSmolPreferences === 'function') { - applyJSmolPreferences(); - console.log('handleFileSelect: Applied JSmol preferences'); - } - }, 300); - - } catch (err) { - setStatusText('Error loading file: ' + err.message); + }, 100); + } + + // Restore display mode after loading with a delay + setTimeout(() => { + console.log('handleFileSelect: Restoring display mode:', currentDisplayMode); + setDisplayMode(currentDisplayMode); + + // Apply all JSmol preferences after loading + if (typeof applyJSmolPreferences === 'function') { + applyJSmolPreferences(); + console.log('handleFileSelect: Applied JSmol preferences'); } - }; - reader.readAsText(file); + }, 300); + + } catch (err) { + setStatusText('Error loading file: ' + err.message); } } diff --git a/main.js b/main.js index 33457d5..fc3bc8e 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, shell, ipcMain, protocol, session } = require('electron') +const { app, BrowserWindow, shell, ipcMain, protocol, session, dialog } = require('electron') // --- smoke mode ------------------------------------------------------------ // `electron . --smoke` boots the app, waits for the JSmol applet to reach @@ -215,6 +215,27 @@ ipcMain.handle('jlmol-workdir-path', (_e, token) => { }); ipcMain.handle('jlmol-launch-dir', () => launchDir ? { token: LAUNCH_DIR_TOKEN, path: launchDir } : null); + +// "Choose File" in the desktop app: a native open dialog that starts in the +// launch directory (like the export dialog); without one the OS picks its +// usual folder. The chosen file is read here, so the renderer never sees a +// path, only {name, content}. Returns null when cancelled. +ipcMain.handle('jlmol-open-structure', async (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + const options = { + properties: ['openFile'], + filters: [ + { name: 'Structure files', extensions: ['xyz', 'pdb', 'mol', 'cif', 'molden'] }, + { name: 'All files', extensions: ['*'] }, + ], + }; + if (launchDir) options.defaultPath = launchDir; + const { canceled, filePaths } = await dialog.showOpenDialog(win, options); + if (canceled || filePaths.length === 0) return null; + const file = filePaths[0]; + log(`Open dialog: ${file}`); + return { name: path.basename(file), content: fsNative.readFileSync(file, 'utf8') }; +}); ipcMain.handle('jlmol-write-file', (_e, token, name, content) => { fsNative.writeFileSync(resolveInWorkDir(token, name), String(content)); }); diff --git a/preload.js b/preload.js index 6987c12..09c397d 100644 --- a/preload.js +++ b/preload.js @@ -62,6 +62,10 @@ contextBridge.exposeInMainWorld('jlmolNative', { onDownloadDone: (cb) => ipcRenderer.on('jlmol-download-done', (_event, state, savedPath) => cb(state, savedPath)), + // Native open dialog for "Choose File" (starts in the launch dir when + // there is one). Resolves to {name, content} or null when cancelled. + openStructureFile: () => ipcRenderer.invoke('jlmol-open-structure'), + // --- processes -------------------------------------------------------- // handlers: { data(kind, text), close(code), error(message) } spawn: async (command, args, options, handlers) => { From c848099e6578bce910ddb00aea0d581802b3d3f3 Mon Sep 17 00:00:00 2001 From: Daniel Kats Date: Tue, 22 Sep 2026 09:05:40 +0200 Subject: [PATCH 8/9] =?UTF-8?q?Desktop=20app:=20"Open=20File=E2=80=A6"=20b?= =?UTF-8?q?utton=20instead=20of=20the=20file=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file input's "No file chosen" label never learns about files opened through the native dialog. The desktop app now hides the input and shows a button plus a label with the loaded file's name; the browser build keeps the input. The #structureFile id rule (display: block) beat the hidden attribute, hence the explicit [hidden] rule. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- README.md | 2 +- css/styles.css | 20 ++++++++++++++++++++ index.html | 4 ++++ js/search.js | 21 ++++++++++++++------- 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec001a6..1416d04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ElemCo.jl and xtb calculations started from a terminal now run in the directory jlmol was started from, so exported files (`orbitals.molden` from an Export step, `xtbopt.xyz`, `xtbopt.log`, ...) and the generated `calculation.jl` / `coord.xyz` stay there. Previously every run used a temporary directory under the OS temp dir that was deleted when the run finished, so exports silently vanished. Launches without a terminal (desktop launcher, Finder, Start menu) keep the temporary directory. The output panel now prints which directory is used and whether it is deleted. The directory comes from `--workdir=PATH` (the `npm start` scripts pass the directory the command was typed in, also from WSL running the Windows binary) or, when started from a terminal, the current directory. - Image export and XYZ export in the desktop app now open a save dialog instead of dropping the file silently into the download folder. The dialog defaults to the launch directory when started from a terminal and to the download folder otherwise; the status line shows where the file was saved, or that the export was cancelled. -- "Choose File" in the desktop app uses a native open dialog that starts in the launch directory when there is one (the browser file picker cannot be pointed at a directory). Without a launch directory the OS chooses the folder as before. +- "Choose File" in the desktop app is replaced by an "Open File…" button with a native open dialog that starts in the launch directory when there is one (the browser file picker cannot be pointed at a directory, and its "No file chosen" label would stay stale). The loaded file's name is shown next to the button. Without a launch directory the OS chooses the folder as before. The browser build keeps the file input. ## [1.5.5] - 2026-09-07 diff --git a/README.md b/README.md index b1763a9..50a9382 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,7 @@ jlmol now includes an integrated calculation runner that can execute ElemCo.jl c 3. **Run Calculation**: Click "Run Calculation" to execute the calculation 4. **View Results**: Monitor progress and view calculation output in real-time -When jlmol is started from a terminal, the calculation runs in the directory it was started from: `calculation.jl` and every file the calculation writes (e.g. `orbitals.molden` from an Export step) stay there. Started from a desktop launcher, it runs in a temporary directory that is deleted after the run. The output panel prints which one is used. Image and XYZ exports open a save dialog; its default folder is the launch directory from a terminal and the download folder otherwise, and the status line shows where the file was saved. "Choose File" opens in the launch directory as well. +When jlmol is started from a terminal, the calculation runs in the directory it was started from: `calculation.jl` and every file the calculation writes (e.g. `orbitals.molden` from an Export step) stay there. Started from a desktop launcher, it runs in a temporary directory that is deleted after the run. The output panel prints which one is used. Image and XYZ exports open a save dialog; its default folder is the launch directory from a terminal and the download folder otherwise, and the status line shows where the file was saved. "Open File…" opens in the launch directory as well. #### WSL (Windows Subsystem for Linux) Support diff --git a/css/styles.css b/css/styles.css index 20d6ac1..87ca95a 100644 --- a/css/styles.css +++ b/css/styles.css @@ -461,6 +461,26 @@ margin-bottom: 8px; /* Add space below the file input */ display: block; /* Ensure it's on its own line */ } + #structureFile[hidden] { + display: none; /* the id rule above would beat the hidden attribute */ + } + /* Desktop app: replaces the file input (see js/search.js) */ + #structureFileNative { + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; + } + #structureFileNative[hidden] { + display: none; + } + #structureFileName { + font-size: 12px; + color: #666; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .sample-buttons { margin-top: 4px; /* Add space above the sample buttons */ } diff --git a/index.html b/index.html index 3b250c2..f4b87cd 100644 --- a/index.html +++ b/index.html @@ -62,6 +62,10 @@
jlmol vloading...

Load Structure

+
diff --git a/js/search.js b/js/search.js index 9e4b408..36fe626 100644 --- a/js/search.js +++ b/js/search.js @@ -1,15 +1,22 @@ -// Desktop app: "Choose File" goes through a native open dialog that starts +// Desktop app: "Open File" goes through a native open dialog that starts // in the launch directory, matching the export dialog. The browser's own -// file picker cannot be pointed at a directory, so the input's click is -// replaced; the input stays for the browser build and for its label. +// file picker cannot be pointed at a directory, and its "No file chosen" +// label never learns about files opened this way, so the input is swapped +// for a button plus a name label; the browser build keeps the input. document.addEventListener('DOMContentLoaded', () => { const input = document.getElementById('structureFile'); + const box = document.getElementById('structureFileNative'); const native = window.jlmolNative; - if (!input || !native || !native.openStructureFile) return; - input.addEventListener('click', (evt) => { - evt.preventDefault(); + if (!input || !box || !native || !native.openStructureFile) return; + input.hidden = true; + box.hidden = false; + document.getElementById('structureFileButton').addEventListener('click', () => { native.openStructureFile().then((file) => { - if (file) loadStructureContent(file.name, file.content); + if (!file) return; + const label = document.getElementById('structureFileName'); + label.textContent = file.name; + label.title = file.name; + loadStructureContent(file.name, file.content); }).catch((err) => setStatusText('Error opening file: ' + err.message)); }); }); From 27009286af6bbfa843c22cd33b35bb6a0baeb318 Mon Sep 17 00:00:00 2001 From: Daniel Kats Date: Tue, 22 Sep 2026 10:09:43 +0200 Subject: [PATCH 9/9] Check Molden files before loading; offer a fix for wrong atomic numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tblite (the g-xTB binary's molden writer) puts the valence electron count in the atomic-number column of [Atoms]: N 5, C 4, O 6. JSmol trusts that column over the label and draws boron, beryllium and carbon with garbled bonds. Daniel's caffeine file loads cleanly once the column is corrected. js/molden-check.js compares the column with the element label and builds a corrected text; it also reports, without fixing, an MO coefficient count that does not match the basis size (missing [5D]). loadStructureContent runs the check for .molden files and shows a dialog: "Fix and load", "Fix, load and save…" (a -fixed.molden copy through the save dialog) or "Load as is". Command-line files go through loadStructureContent too, so they get the same check. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 + css/styles.css | 15 +++- index.html | 1 + js/molden-check.js | 186 +++++++++++++++++++++++++++++++++++++++++++++ js/search.js | 19 +++++ main.js | 5 ++ 6 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 js/molden-check.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1416d04..bda6e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Molden files are checked before loading. The g-xTB binary's molden writer (tblite) puts the valence electron count in the atomic-number column of `[Atoms]`, and JSmol trusts that column over the element label, so such files showed up as boron/beryllium/carbon with garbled bonds. A dialog now reports the problem and offers "Fix and load", "Fix, load and save…" (writes a `-fixed.molden` copy through the save dialog) or "Load as is". The check also reports, without fixing, an MO coefficient count that does not match the basis size (the usual sign of a missing `[5D]` flag). Files given on the command line go through the same check. + ### Changed - ElemCo.jl and xtb calculations started from a terminal now run in the directory jlmol was started from, so exported files (`orbitals.molden` from an Export step, `xtbopt.xyz`, `xtbopt.log`, ...) and the generated `calculation.jl` / `coord.xyz` stay there. Previously every run used a temporary directory under the OS temp dir that was deleted when the run finished, so exports silently vanished. Launches without a terminal (desktop launcher, Finder, Start menu) keep the temporary directory. The output panel now prints which directory is used and whether it is deleted. The directory comes from `--workdir=PATH` (the `npm start` scripts pass the directory the command was typed in, also from WSL running the Windows binary) or, when started from a terminal, the current directory. diff --git a/css/styles.css b/css/styles.css index 87ca95a..3b7318d 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1362,7 +1362,7 @@ } /* ---- Update-available dialog (js/updater.js) ---- */ - #updateOverlay { + #updateOverlay, #moldenCheckOverlay { position: fixed; top: 0; left: 0; @@ -1375,7 +1375,7 @@ z-index: 100000; } - #updateDialog { + #updateDialog, #moldenCheckDialog { background: white; border: 1px solid #ccc; border-radius: 8px; @@ -1443,6 +1443,17 @@ text-decoration: underline; } + .molden-check-issues { + margin: 8px 0 0; + padding-left: 20px; + font-size: 13px; + line-height: 1.4; + color: #333; + } + .molden-check-issues li { + margin-bottom: 6px; + } + .update-dialog-footer { padding: 12px 18px; border-top: 1px solid #eee; diff --git a/index.html b/index.html index f4b87cd..b9e117e 100644 --- a/index.html +++ b/index.html @@ -37,6 +37,7 @@ + diff --git a/js/molden-check.js b/js/molden-check.js new file mode 100644 index 0000000..bb11eb7 --- /dev/null +++ b/js/molden-check.js @@ -0,0 +1,186 @@ +// Consistency check for Molden files before they go to JSmol. +// +// Known producer bug: tblite (the g-xTB binary's molden writer) puts the +// valence electron count in the atomic-number column of [Atoms] (N 5, C 4, +// O 6). JSmol trusts that column over the element label, so the molecule +// shows up as boron/beryllium/carbon with garbled bonds. The check compares +// the column with the label and offers a fixed copy. A second, report-only +// check compares the MO coefficient count with the basis size, which is +// what goes wrong when [5D]/[7F] flags are missing. + +const MOLDEN_ELEMENTS = ['X', 'H', 'He', 'Li', 'Be', 'B', 'C', 'N', 'O', 'F', 'Ne', 'Na', 'Mg', 'Al', + 'Si', 'P', 'S', 'Cl', 'Ar', 'K', 'Ca', 'Sc', 'Ti', 'V', 'Cr', 'Mn', 'Fe', 'Co', 'Ni', 'Cu', + 'Zn', 'Ga', 'Ge', 'As', 'Se', 'Br', 'Kr', 'Rb', 'Sr', 'Y', 'Zr', 'Nb', 'Mo', 'Tc', 'Ru', + 'Rh', 'Pd', 'Ag', 'Cd', 'In', 'Sn', 'Sb', 'Te', 'I', 'Xe', 'Cs', 'Ba', 'La', 'Ce', 'Pr', + 'Nd', 'Pm', 'Sm', 'Eu', 'Gd', 'Tb', 'Dy', 'Ho', 'Er', 'Tm', 'Yb', 'Lu', 'Hf', 'Ta', 'W', + 'Re', 'Os', 'Ir', 'Pt', 'Au', 'Hg', 'Tl', 'Pb', 'Bi', 'Po', 'At', 'Rn', 'Fr', 'Ra', 'Ac', + 'Th', 'Pa', 'U', 'Np', 'Pu', 'Am', 'Cm', 'Bk', 'Cf', 'Es', 'Fm', 'Md', 'No', 'Lr', 'Rf', + 'Db', 'Sg', 'Bh', 'Hs', 'Mt', 'Ds', 'Rg', 'Cn', 'Nh', 'Fl', 'Mc', 'Lv', 'Ts', 'Og']; + +function moldenElementNumber(label) { + // Labels may carry an index or charge (C1, N_2, O-): symbol letters only. + const m = /^([A-Za-z]{1,2})/.exec(label); + if (!m) return 0; + const sym = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase(); + let z = MOLDEN_ELEMENTS.indexOf(sym); + if (z < 0 && sym.length === 2) z = MOLDEN_ELEMENTS.indexOf(sym.charAt(0)); + return z < 0 ? 0 : z; +} + +// Returns { issues: [{ message, fixable }], fixedText } -- fixedText is null +// when nothing fixable was found. +function checkMoldenFile(text) { + const lines = text.split(/\r?\n/); + const issues = []; + let section = ''; + let wrongZ = 0; + let firstWrong = ''; + const fixed = lines.slice(); + let cart = 0, sph = 0; // basis size counted both ways + let coeffCounts = new Set(); // coefficients per MO + let inMO = false, count = 0, mos = 0; + const flags = { '5d': false, '7f': false, '9g': false }; + const shellSize = { s: [1, 1], p: [3, 3], sp: [4, 4], d: [6, 5], f: [10, 7], g: [15, 9] }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const t = line.trim(); + if (t.startsWith('[')) { + if (inMO && count > 0) { coeffCounts.add(count); mos++; } + inMO = false; + const tag = t.slice(1, t.indexOf(']')).toLowerCase(); + section = tag; + if (tag in flags) flags[tag] = true; + if (tag === '5d7f') { flags['5d'] = true; flags['7f'] = true; } + if (tag === '5d10f') { flags['5d'] = true; } + if (tag === 'mo') inMO = true; + continue; + } + if (section === 'atoms') { + const tok = t.split(/\s+/); + if (tok.length >= 6) { + const z = moldenElementNumber(tok[0]); + const col = parseInt(tok[2], 10); + if (z > 0 && col !== z) { + wrongZ++; + if (!firstWrong) firstWrong = `${tok[0]} listed as ${tok[2]} instead of ${z}`; + tok[2] = String(z); + fixed[i] = tok.join(' '); + } + } + } else if (section === 'gto') { + const m = /^(sp|[spdfg])\s+\d+/i.exec(t); + if (m) { + const sz = shellSize[m[1].toLowerCase()]; + cart += sz[0]; sph += sz[1]; + } + } else if (section === 'mo') { + if (/^(Sym|Ene|Spin|Occup)\s*=/i.test(t)) { + if (/^Sym\s*=/i.test(t) && count > 0) { coeffCounts.add(count); mos++; count = 0; } + if (/^Occup\s*=/i.test(t)) count = 0; + } else if (/^\d+\s+[-+0-9.EeDd]+$/.test(t)) { + count++; + } + } + } + if (inMO && count > 0) { coeffCounts.add(count); mos++; } + + if (wrongZ > 0) { + issues.push({ + message: `${wrongZ} atom line(s) in [Atoms] carry an atomic number that contradicts the element label (${firstWrong}). JSmol uses the number, so elements, colours and bonds come out wrong.`, + fixable: true, + }); + } + if (mos > 0 && cart > 0) { + const useSph = flags['5d']; + const expected = useSph ? sph : cart; + const counts = [...coeffCounts]; + if (counts.length !== 1 || counts[0] !== expected) { + const alt = useSph ? `${cart} for Cartesian` : `${sph} for spherical`; + issues.push({ + message: `MO coefficient count (${counts.join(', ')}) does not match the basis size (${expected} for ${useSph ? 'spherical' : 'Cartesian'} d/f functions; ${alt}). Orbitals will be wrong. A missing [5D] flag is the usual cause.`, + fixable: false, + }); + } + } + return { issues, fixedText: wrongZ > 0 ? fixed.join('\n') : null }; +} + +// Modal: shows the issues; resolves with 'fix', 'fix-save' or 'asis'. +function showMoldenCheckDialog(fileName, result) { + return new Promise((resolve) => { + const existing = document.getElementById('moldenCheckOverlay'); + if (existing) existing.remove(); + const overlay = document.createElement('div'); + overlay.id = 'moldenCheckOverlay'; + const card = document.createElement('div'); + card.id = 'moldenCheckDialog'; + + const header = document.createElement('div'); + header.className = 'update-dialog-header'; + const h3 = document.createElement('h3'); + h3.textContent = 'Molden file check'; + header.appendChild(h3); + + const body = document.createElement('div'); + body.className = 'update-dialog-body'; + const intro = document.createElement('div'); + intro.className = 'update-summary'; + intro.textContent = `${fileName} has problems:`; + body.appendChild(intro); + const list = document.createElement('ul'); + list.className = 'molden-check-issues'; + for (const issue of result.issues) { + const li = document.createElement('li'); + li.textContent = issue.message + (issue.fixable ? '' : ' (not fixable here)'); + list.appendChild(li); + } + body.appendChild(list); + + const footer = document.createElement('div'); + footer.className = 'update-dialog-footer'; + const choose = (choice) => { overlay.remove(); resolve(choice); }; + const asIs = document.createElement('button'); + asIs.className = 'update-skip-button'; + asIs.textContent = 'Load as is'; + asIs.addEventListener('click', () => choose('asis')); + footer.appendChild(asIs); + if (result.fixedText !== null) { + const fixSave = document.createElement('button'); + fixSave.className = 'update-later-button'; + fixSave.textContent = 'Fix, load and save…'; + fixSave.title = 'Load the corrected file and save a copy'; + fixSave.addEventListener('click', () => choose('fix-save')); + footer.appendChild(fixSave); + const fix = document.createElement('button'); + fix.className = 'update-download-button'; + fix.textContent = 'Fix and load'; + fix.addEventListener('click', () => choose('fix')); + footer.appendChild(fix); + } + + card.appendChild(header); + card.appendChild(body); + card.appendChild(footer); + overlay.appendChild(card); + document.body.appendChild(overlay); + }); +} + +// Offer the corrected text as a download (save dialog in the desktop app). +function saveFixedMolden(fileName, text) { + const base = fileName.replace(/\.molden$/i, ''); + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${base}-fixed.molden`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { checkMoldenFile, moldenElementNumber }; +} diff --git a/js/search.js b/js/search.js index 36fe626..75f48fe 100644 --- a/js/search.js +++ b/js/search.js @@ -34,7 +34,26 @@ function handleFileSelect(evt) { } // Load a structure file's text into the viewer; the name picks the loader. +// Molden files are checked first (see js/molden-check.js); when something is +// wrong the user chooses between the corrected text and the original. function loadStructureContent(name, fileContent) { + if (!/\.molden$/i.test(name) || typeof checkMoldenFile !== 'function') { + loadStructureNow(name, fileContent); + return; + } + const result = checkMoldenFile(fileContent); + if (result.issues.length === 0) { + loadStructureNow(name, fileContent); + return; + } + showMoldenCheckDialog(name, result).then((choice) => { + const text = choice === 'asis' ? fileContent : result.fixedText; + loadStructureNow(name, text); + if (choice === 'fix-save') saveFixedMolden(name, text); + }); +} + +function loadStructureNow(name, fileContent) { try { // Save current display mode const currentDisplayMode = displayMode || 'default'; diff --git a/main.js b/main.js index fc3bc8e..40d5287 100644 --- a/main.js +++ b/main.js @@ -571,6 +571,11 @@ function createWindow() { console.log('JSmol ready, loading molecule...'); try { const content = \`${fileContent.replace(/\\/g, '\\\\').replace(/\$/g, '\\$').replace(/\`/g, '\\\`')}\`; + // Same path as Open File (molden check, XYZ loader, refresh). + if (typeof loadStructureContent === 'function') { + loadStructureContent(${JSON.stringify(path.basename(fileArg))}, content); + return; + } Jmol.script(jmolApplet0, 'set echo top left; echo "Loading molecule...";'); setTimeout(() => { Jmol.script(jmolApplet0, 'load inline "' + content + '" filter "NOSORT";');