diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae0483..bda6e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ 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. +- 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 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 ### Fixed diff --git a/README.md b/README.md index dd3a64a..50a9382 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 @@ -291,6 +293,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 +343,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. 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 For Windows users running Julia under WSL, jlmol provides seamless integration: diff --git a/css/styles.css b/css/styles.css index 20d6ac1..3b7318d 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 */ } @@ -1342,7 +1362,7 @@ } /* ---- Update-available dialog (js/updater.js) ---- */ - #updateOverlay { + #updateOverlay, #moldenCheckOverlay { position: fixed; top: 0; left: 0; @@ -1355,7 +1375,7 @@ z-index: 100000; } - #updateDialog { + #updateDialog, #moldenCheckDialog { background: white; border: 1px solid #ccc; border-radius: 8px; @@ -1423,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 3b250c2..b9e117e 100644 --- a/index.html +++ b/index.html @@ -37,6 +37,7 @@ + @@ -62,6 +63,10 @@
jlmol vloading...

Load Structure

+
diff --git a/js/elemco.js b/js/elemco.js index 4bfb349..b7b3062 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' ? '\\' : '/'; @@ -1283,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 { @@ -1291,7 +1306,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 +1393,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/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/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 e315a94..75f48fe 100644 --- a/js/search.js +++ b/js/search.js @@ -1,66 +1,112 @@ +// 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, 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 || !box || !native || !native.openStructureFile) return; + input.hidden = true; + box.hidden = false; + document.getElementById('structureFileButton').addEventListener('click', () => { + native.openStructureFile().then((file) => { + 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)); + }); +}); + 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. +// 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'; + + 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/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/launchdir.js b/launchdir.js new file mode 100644 index 0000000..664f073 --- /dev/null +++ b/launchdir.js @@ -0,0 +1,83 @@ +// 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) { + // 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' }; + } + 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 + // 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'); +} diff --git a/main.js b/main.js index ae40215..40d5287 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, dialog } = require('electron') // --- smoke mode ------------------------------------------------------------ // `electron . --smoke` boots the app, waits for the JSmol applet to reach @@ -154,6 +154,35 @@ 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, 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. 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; +} +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 || '(none)'} [${launchDirInfo.source}]`); + function resolveInWorkDir(dirToken, name) { const dir = workDirs.get(dirToken); if (!dir) throw new Error('unknown work directory'); @@ -184,6 +213,29 @@ 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); + +// "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)); }); @@ -192,7 +244,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 +296,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 +312,34 @@ 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'; + // 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 = ''; n.spawn('echo', ['bridge-echo'], {}, { @@ -266,9 +350,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) => { @@ -487,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";'); @@ -519,7 +608,33 @@ 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()); + log(`Download dialog default: ${defaultPath}`); + 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/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", diff --git a/preload.js b/preload.js index 62eca23..09c397d 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,10 +48,23 @@ 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'), + // 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)), + + // 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) }