diff --git a/src/editor-launch.js b/src/editor-launch.js new file mode 100644 index 0000000..4683f74 --- /dev/null +++ b/src/editor-launch.js @@ -0,0 +1,365 @@ +// Opening a site's folder in the contributor's editor. +// +// This existed once and was removed (#24 → #26) for a reason that decides the +// shape of everything below: detection ran `which`/`where`, and **a packaged +// Electron app does not inherit the shell's PATH**. A correctly installed VS +// Code reported as missing in the shipped build while working fine in +// `npm start`, which is the worst possible failure — it only appears in the +// artifact contributors actually download. +// +// So nothing here consults PATH, at either end: +// +// - Detection is a filesystem existence check against absolute, per-platform +// install locations. No `which`, no `where`, no spawning anything to find out +// whether something is installed. +// - Launching refuses a relative command. `spawn('code', …)` without a shell +// would resolve through PATH — the same environment that is not there — so an +// editor path that is not absolute is not one this module will run. +// +// The table below is a convenience, not the contract. It exists so the common +// case needs no configuration; the contributor pointing at their own +// application is the case that always works, and the caller must always offer +// it. An editor this table misses must never surface as "unavailable" with +// nothing to do about it. +// +// The guard is the same shape as external-url.js and site-registry.js: a pure +// check, a safe log formatter, and a wrapper whose effects (`exists`, +// `statPath`, `spawn`) are injected, so both branches are testable with no +// Electron process and no editor installed. + +const path = require('path'); +const { describeRefused } = require('./safe-log'); +const { isRegisteredSite } = require('./site-registry'); + +// Path semantics follow the platform being asked about, not the platform the +// test happens to run on: `path.isAbsolute('C:\\x')` is false under POSIX, and a +// Windows check that only holds on Windows is a check that never runs in CI. +function pathApi(platform) { + return platform === 'win32' ? path.win32 : path.posix; +} + +// Windows environment variables are case-insensitive to the OS, and the casing +// in documentation ('ProgramFiles', 'LOCALAPPDATA') is not always the casing in +// the environment. Reading them case-insensitively keeps the table from +// depending on which one a machine happens to use. +function envValue(env, name) { + if (!env) return undefined; + if (env[name] !== undefined) return env[name]; + const wanted = name.toLowerCase(); + const match = Object.keys(env).find((key) => key.toLowerCase() === wanted); + return match === undefined ? undefined : env[match]; +} + +// Every entry is `{ id, name, paths }` where `paths` are absolute locations to +// probe, most likely first. A location that cannot be built — the environment +// variable it needs is not set — is dropped rather than guessed at. +// +// Deliberately not here: anything installed under a version-numbered directory +// (JetBrains' own installer writes `PhpStorm 2024.3\bin\…`). Finding those means +// listing directories and pattern-matching versions, which is a second thing to +// be wrong about; the picker covers them exactly as well. +function editorCandidates({ platform, env = {} } = {}) { + const p = pathApi(platform); + + if (platform === 'darwin') { + const home = envValue(env, 'HOME'); + const roots = ['/Applications', home ? p.join(home, 'Applications') : null].filter(Boolean); + const bundles = [ + { id: 'vscode', name: 'Visual Studio Code', bundle: 'Visual Studio Code.app' }, + { id: 'cursor', name: 'Cursor', bundle: 'Cursor.app' }, + { id: 'phpstorm', name: 'PhpStorm', bundle: 'PhpStorm.app' }, + { id: 'sublime', name: 'Sublime Text', bundle: 'Sublime Text.app' }, + { id: 'zed', name: 'Zed', bundle: 'Zed.app' } + ]; + return bundles.map(({ id, name, bundle }) => ({ + id, + name, + paths: roots.map((root) => p.join(root, bundle)) + })); + } + + if (platform === 'win32') { + const localAppData = envValue(env, 'LOCALAPPDATA'); + const programFiles = envValue(env, 'ProgramFiles'); + const programFilesX86 = envValue(env, 'ProgramFiles(x86)'); + const under = (root, ...rest) => (root ? p.join(root, ...rest) : null); + + return [ + { + id: 'vscode', + name: 'Visual Studio Code', + paths: [ + under(localAppData, 'Programs', 'Microsoft VS Code', 'Code.exe'), + under(programFiles, 'Microsoft VS Code', 'Code.exe'), + under(programFilesX86, 'Microsoft VS Code', 'Code.exe') + ] + }, + { + id: 'cursor', + name: 'Cursor', + paths: [under(localAppData, 'Programs', 'cursor', 'Cursor.exe')] + }, + { + id: 'phpstorm', + name: 'PhpStorm', + // JetBrains Toolbox's stable launcher location. The standalone + // installer's versioned directory is the picker's job. + paths: [under(localAppData, 'Programs', 'PhpStorm', 'bin', 'phpstorm64.exe')] + }, + { + id: 'sublime', + name: 'Sublime Text', + paths: [under(programFiles, 'Sublime Text', 'sublime_text.exe')] + }, + { + id: 'zed', + name: 'Zed', + paths: [under(localAppData, 'Programs', 'Zed', 'Zed.exe')] + } + ].map((entry) => ({ ...entry, paths: entry.paths.filter(Boolean) })); + } + + // Linux packaging is too varied for a table to be authoritative — these are + // the locations the common packages use, and the picker is the real answer. + const home = envValue(env, 'HOME'); + const inHome = (...rest) => (home ? p.join(home, ...rest) : null); + return [ + { + id: 'vscode', + name: 'Visual Studio Code', + paths: ['/usr/share/code/code', '/usr/bin/code', '/snap/bin/code', '/opt/visual-studio-code/code'] + }, + { id: 'cursor', name: 'Cursor', paths: ['/usr/bin/cursor', '/snap/bin/cursor', '/opt/Cursor/cursor'] }, + { id: 'phpstorm', name: 'PhpStorm', paths: ['/snap/bin/phpstorm', '/opt/phpstorm/bin/phpstorm.sh'] }, + { id: 'sublime', name: 'Sublime Text', paths: ['/usr/bin/subl', '/snap/bin/sublime-text'] }, + { id: 'zed', name: 'Zed', paths: ['/usr/bin/zed', '/snap/bin/zed', inHome('.local', 'bin', 'zed')].filter(Boolean) } + ]; +} + +// The editors this machine has, in table order, each reduced to the first +// location that exists. `exists` is injected — it is the only thing detection +// does, and the only thing a test has to stand in for. +// +// It is awaited rather than called for a return value, because the caller is +// Electron's main process: a dozen or so probes of locations that mostly do not +// exist is exactly the kind of work that reads as free on the author's machine +// and stalls the whole window behind a Windows antivirus filter driver. Nothing +// here may be synchronous filesystem access. +// +// Candidates are probed together rather than one after another: they are +// independent questions, and the answer arrives in one round rather than a dozen. +async function detectEditors({ platform, env = {}, exists } = {}) { + if (typeof exists !== 'function') return []; + + const probe = async (candidate) => { + try { + return (await exists(candidate)) === true; + } catch { + // An unreadable location is a location we do not have, not a crash on + // the way to drawing a button. + return false; + } + }; + + const found = await Promise.all(editorCandidates({ platform, env }).map(async ({ id, name, paths }) => { + const present = await Promise.all(paths.map(probe)); + const index = present.indexOf(true); + return index === -1 ? null : { id, name, path: paths[index] }; + })); + + return found.filter(Boolean); +} + +// The name the table has for an application at this path, or null for one it +// does not know. +// +// Without this the stored name is the basename, which on macOS reads well by +// accident ('Sublime Text.app' → 'Sublime Text') and on Windows does not: +// 'Code.exe' → 'Code', 'phpstorm64.exe' → 'phpstorm64'. The button promises to +// name the contributor's editor, so a known one is named the way the picker +// named it, and only a manually chosen unknown application falls back to its +// filename. +// +// The comparison is case-insensitive on Windows and macOS because their default +// filesystems are: the same application reached through a differently-cased path +// is the same application. +function knownEditorName(editorPath, { platform, env = {} } = {}) { + if (typeof editorPath !== 'string' || editorPath === '') return null; + + const insensitive = platform === 'win32' || platform === 'darwin'; + const normalize = (p) => (insensitive ? p.toLowerCase() : p); + const wanted = normalize(editorPath); + + const match = editorCandidates({ platform, env }) + .find(({ paths }) => paths.some((candidate) => normalize(candidate) === wanted)); + + return match ? match.name : null; +} + +// Whether a path is something this app will hand to the OS as an application. +// +// Absolute, because a relative command would be resolved through PATH by spawn, +// and of the shape the platform uses for an application: a `.app` bundle +// (a directory) on macOS, an `.exe` on Windows, and elsewhere a file the OS will +// actually execute. That last one is not the same as "a regular file": the Linux +// picker cannot filter by extension, so a document passes every other check and +// then fails with EACCES at the spawn, after being remembered as the +// contributor's editor. +// +// The same check covers a detected path and one the contributor picked — the +// picker is a dialog, and a dialog's result is still input. +// +// `statPath` resolves to `{ isDirectory, isFile, isExecutable }`, or null when +// there is nothing there; it is injected and awaited for the same reasons +// `exists` is. +async function isLaunchableEditorPath(editorPath, { platform, statPath } = {}) { + if (typeof editorPath !== 'string' || editorPath === '') return false; + if (typeof statPath !== 'function') return false; + if (!pathApi(platform).isAbsolute(editorPath)) return false; + + let stats; + try { + stats = await statPath(editorPath); + } catch { + return false; + } + if (!stats) return false; + + if (platform === 'darwin') { + return stats.isDirectory === true && editorPath.toLowerCase().endsWith('.app'); + } + if (platform === 'win32') { + return stats.isFile === true && editorPath.toLowerCase().endsWith('.exe'); + } + return stats.isFile === true && stats.isExecutable === true; +} + +// What to run, as a command and an argument vector — never a string to be +// re-parsed by a shell, and never a concatenation. +// +// macOS goes through `/usr/bin/open -a`, a fixed absolute path, because a `.app` +// bundle is a directory rather than something executable. Everywhere else the +// executable takes the folder as its argument, which is what every editor in the +// table above supports. +function resolveLaunch(editorPath, sitePath, { platform } = {}) { + if (platform === 'darwin') { + return { command: '/usr/bin/open', args: ['-a', editorPath, sitePath] }; + } + return { command: editorPath, args: [sitePath] }; +} + +const REFUSAL_REASONS = { + UNREGISTERED_SITE: 'unregistered-site', + UNLAUNCHABLE_EDITOR: 'unlaunchable-editor' +}; + +// The `editor:open` handler's body. +// +// Two gates, both of which have to pass before anything is spawned. The folder +// must be one the app has on record — `isRegisteredSite` from site-registry.js, +// the same boundary `sites:delete` uses — so "open this site" cannot become +// "open this arbitrary directory". And the application must be absolute and of +// the platform's shape, so an editor path that has been tampered with, or an +// editor that has since been uninstalled, is refused rather than resolved +// through an environment that is not there. +// +// The spawn options are the ones main.js holds everywhere else it starts a +// child: no shell, hidden on Windows, detached with no stdio so the editor +// outlives the app and cannot block on a pipe nobody reads. +async function openSiteInEditor(sitePath, editorPath, { + sites, + platform, + statPath, + spawn, + onRefused +} = {}) { + if (!isRegisteredSite(sitePath, sites)) { + if (typeof onRefused === 'function') { + onRefused(REFUSAL_REASONS.UNREGISTERED_SITE, describeRefused(sitePath)); + } + return { ok: false, reason: REFUSAL_REASONS.UNREGISTERED_SITE }; + } + + if (!await isLaunchableEditorPath(editorPath, { platform, statPath })) { + if (typeof onRefused === 'function') { + onRefused(REFUSAL_REASONS.UNLAUNCHABLE_EDITOR, describeRefused(editorPath)); + } + return { ok: false, reason: REFUSAL_REASONS.UNLAUNCHABLE_EDITOR }; + } + + const { command, args } = resolveLaunch(editorPath, sitePath, { platform }); + + let child; + try { + child = spawn(command, args, { + detached: true, + stdio: 'ignore', + shell: false, + windowsHide: true + }); + } catch (e) { + // A synchronous throw is the argument-shape failure only. The one that + // actually happens — the target cannot be executed — arrives as an event. + return { ok: false, reason: 'spawn-failed', error: e?.message ?? String(e) }; + } + + return awaitLaunch(child, { platform }); +} + +// Whether the launch worked, answered from the child's own events rather than +// from `spawn` having returned. +// +// `spawn` returns a ChildProcess before the OS has been asked to execute +// anything, so a target that cannot be run — EACCES on a file that is not +// executable, EPERM from a Windows policy, or a path deleted between the check +// and the launch — fails afterwards, on the 'error' event. Returning `{ ok: true }` +// at that point is the "the button did nothing" failure this project treats as +// an architectural bug: the contributor cannot debug it, and with no listener the +// emit becomes an uncaught exception that only reaches the log file. +// +// The two platforms need different evidence: +// +// - macOS spawns `/usr/bin/open`, which is not the editor. It exits as soon as +// it has asked Launch Services to open the bundle, so its exit code is the +// answer and waiting for it costs milliseconds. +// - Everywhere else the child *is* the editor and stays alive, so the answer is +// 'spawn' — Node's "the OS accepted this" event — and waiting for exit would +// mean waiting for the contributor to close their editor. +// +// The handle is released either way, so the editor outlives the app. +function awaitLaunch(child, { platform } = {}) { + return new Promise((resolve) => { + let settled = false; + const settle = (result) => { + if (settled) return; + settled = true; + if (typeof child.unref === 'function') child.unref(); + resolve(result); + }; + + child.on('error', (e) => { + settle({ ok: false, reason: 'spawn-failed', error: e?.message ?? String(e) }); + }); + + if (platform === 'darwin') { + child.on('close', (code) => { + settle(code === 0 + ? { ok: true } + : { ok: false, reason: 'spawn-failed', error: `the editor could not be opened (exit code ${code})` }); + }); + return; + } + + child.on('spawn', () => settle({ ok: true })); + }); +} + +module.exports = { + REFUSAL_REASONS, + editorCandidates, + detectEditors, + knownEditorName, + isLaunchableEditorPath, + resolveLaunch, + openSiteInEditor +}; diff --git a/src/external-url.js b/src/external-url.js index 5b59a28..22a6dc1 100644 --- a/src/external-url.js +++ b/src/external-url.js @@ -17,6 +17,8 @@ // Widen ALLOWED_URL_SCHEMES only for a scheme the app actually needs, and only // after asking what the OS does with it. +const { describeRefused } = require('./safe-log'); + const ALLOWED_URL_SCHEMES = ['http:', 'https:']; // Returns the address to open, or null if it is not one this app opens. @@ -53,34 +55,11 @@ function isAllowedExternalUrl(url) { return normalizeExternalUrl(url) !== null; } -// Line breaks, and everything else that would let a refused address end a log -// line and start another one. -const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/g; - // A refused address is attacker-influenced by hypothesis, and it is about to be -// written into the file contributors attach to bug reports. Two things follow. -// -// It has to stay on one line: a newline in the address would otherwise let it -// write a second entry in the app's own timestamp-and-scope format, and a log -// that can be made to describe events that never happened is worse than no log. -// The control characters are escaped rather than dropped so the line still says -// what the caller actually sent. -// -// And it has to be bounded, so a very long address cannot flood the file. -// Truncation comes after escaping, since escaping is what decides the final -// length. +// written into the file contributors attach to bug reports, so it has to stay on +// one line and it has to be bounded. safe-log.js is where both live, and why. function describeRefusedUrl(url) { - if (typeof url !== 'string') return `<${url === null ? 'null' : typeof url}>`; - - const oneLine = url.replace(CONTROL_CHARACTERS, (c) => { - const code = c.codePointAt(0); - return code <= 0xff - ? `\\x${code.toString(16).padStart(2, '0')}` - : `\\u${code.toString(16).padStart(4, '0')}`; - }); - - if (oneLine.length <= 120) return oneLine; - return `${oneLine.slice(0, 120)}…`; + return describeRefused(url); } // The `url:open` handler's body, kept out of main.js so both sides of the guard diff --git a/src/main.js b/src/main.js index 1045ede..2d6f050 100644 --- a/src/main.js +++ b/src/main.js @@ -34,9 +34,11 @@ const { parsePatchFiles, planApply } = require('./patch-plan.cjs'); const { fetchLinkedPrs, fetchPrDiff } = require('./github-prs'); const { openAndScrape, fetchAttachment } = require('./trac-view'); const { openExternalUrl, ALLOWED_URL_SCHEMES } = require('./external-url'); -const { deleteRegisteredSite } = require('./site-registry'); +const { deleteRegisteredSite, revealRegisteredSite } = require('./site-registry'); const { getStore } = require('./settings-store'); const { parseTicketRef } = require('./renderer/trac-ticket.cjs'); +const { describeRefused } = require('./safe-log'); +const { detectEditors, knownEditorName, isLaunchableEditorPath, openSiteInEditor } = require('./editor-launch'); const WORDPRESS_GIT_URL = 'https://github.com/WordPress/wordpress-develop.git'; @@ -957,6 +959,137 @@ ipcMain.handle('url:open', async (_e, url) => openExternalUrl(url, { onRefused: (description) => logEvent('url', `refused to open ${description} — only ${ALLOWED_URL_SCHEMES.join(', ')} are allowed`) })); +// --- opening a site's code ----------------------------------------------- +// +// See editor-launch.js for why none of this consults PATH. What is here is the +// wiring: the store holds one app-wide editor choice, and every path — detected, +// picked, or remembered from a previous run — goes through the same check before +// anything is spawned. + +// Asynchronous on purpose: this runs on the process that draws the window, and +// probing a dozen locations that mostly do not exist is exactly what a slow +// volume or an antivirus filter driver turns into a frozen UI. +// +// Executability is asked of the OS with `access(X_OK)` rather than read off the +// mode bits, so the answer is about the user this app is running as, ACLs and +// mount options included. On Windows every file answers X_OK, which is why the +// `.exe` check there is the one that matters. +async function statPath(targetPath) { + let stats; + try { + stats = await fs.promises.stat(targetPath); + } catch { + return null; + } + + let isExecutable = false; + try { + await fs.promises.access(targetPath, fs.constants.X_OK); + isExecutable = true; + } catch {} + + return { isDirectory: stats.isDirectory(), isFile: stats.isFile(), isExecutable }; +} + +const editorLaunchDeps = () => ({ platform: process.platform, statPath }); + +async function getChosenEditor() { + const s = await getStore(); + const chosen = (s.get('preferences') || {}).editor; + return chosen && typeof chosen.path === 'string' ? chosen : null; +} + +// The remembered choice, and nothing else. This is what the window asks for on +// load — just enough to name the button "Open in Cursor" — so it touches no +// filesystem at all. Whether that editor is still installed is answered by +// trying to open it, which is a question the contributor has just asked anyway. +ipcMain.handle('editor:get', async () => getChosenEditor()); + +// The editors on this machine. Detection stats a dozen or so absolute locations, +// so it runs when the contributor opens the picker rather than on every load — +// `editor:get` is the cheap one. +ipcMain.handle('editor:list', async () => ({ + detected: await detectEditors({ + platform: process.platform, + env: process.env, + exists: async (p) => (await statPath(p)) !== null + }), + chosen: await getChosenEditor() +})); + +// Remembers an editor. With a path it is the one the contributor picked from the +// detected list; without one it opens the file dialog, which is the answer for +// every editor the detection table does not know about — the reason no editor is +// ever shown as unavailable with nothing to do about it. +// +// The dialog's result is validated exactly like a detected path. A dialog is +// still input. +ipcMain.handle('editor:choose', async (_e, editorPath) => { + let target = typeof editorPath === 'string' ? editorPath : null; + + if (!target) { + const filtersByPlatform = { + darwin: [{ name: 'Applications', extensions: ['app'] }], + win32: [{ name: 'Programs', extensions: ['exe'] }] + }; + // Everywhere else an application is just a file, so the dialog does not + // narrow what can be picked. + const filters = filtersByPlatform[process.platform] || []; + const result = await dialog.showOpenDialog({ + title: 'Choose the editor to open sites in', + properties: ['openFile'], + defaultPath: process.platform === 'darwin' ? '/Applications' : undefined, + filters + }); + if (result.canceled || result.filePaths.length === 0) return { ok: false, reason: 'cancelled' }; + target = result.filePaths[0]; + } + + if (!await isLaunchableEditorPath(target, editorLaunchDeps())) { + logEvent('editor', `refused to remember ${describeRefused(target)} — not an application this app can launch`); + return { ok: false, reason: 'unlaunchable-editor' }; + } + + const s = await getStore(); + const editor = { + path: target, + // The table's name for a known application; a filename only for one the + // contributor pointed at that the table has never heard of. + name: knownEditorName(target, { platform: process.platform, env: process.env }) + || path.basename(target, path.extname(target)) + }; + s.set('preferences', { ...(s.get('preferences') || {}), editor }); + return { ok: true, editor }; +}); + +// Only a path the app has on record is opened, and only in an application that +// is still where it was — see editor-launch.js. A refusal is logged rather than +// dropped so a caller that trips the guard shows up in the log file instead of +// just doing nothing. +ipcMain.handle('editor:open', async (_e, sitePath) => { + const chosen = await getChosenEditor(); + if (!chosen) return { ok: false, reason: 'no-editor' }; + + const s = await getStore(); + return openSiteInEditor(sitePath, chosen.path, { + ...editorLaunchDeps(), + sites: s.get('sites'), + spawn, + onRefused: (reason, description) => logEvent('editor', `refused to open ${description} — ${reason}`) + }); +}); + +// The fallback that needs no configuration at all — see site-registry.js for why +// it is behind the same boundary as `sites:delete`. +ipcMain.handle('dir:show', async (_e, sitePath) => { + const s = await getStore(); + return revealRegisteredSite(sitePath, { + sites: s.get('sites'), + reveal: (target) => shell.openPath(target), + onRefused: (description) => logEvent('sites', `refused to reveal ${description} — not a registered site`) + }); +}); + const ENGINE_RETRY_NOTICE = '\n⚠ This site requires a newer Node.js than this app bundles.\n Retrying with engine checks relaxed…\n\n'; // Spawns an npm runner, and if it fails specifically because a dependency diff --git a/src/preload.js b/src/preload.js index 669e783..52504fb 100644 --- a/src/preload.js +++ b/src/preload.js @@ -1,6 +1,10 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('api', { + // Only so the window can name things the way the platform does — "Show in + // Finder" against "Show in Explorer". Nothing branches on it in the main + // process, where `process.platform` is read directly. + platform: process.platform, getSites: () => ipcRenderer.invoke('sites:get'), getSitesWithMeta: () => ipcRenderer.invoke('sites:getAll'), addSite: (dir) => ipcRenderer.invoke('sites:add', dir), @@ -42,6 +46,17 @@ contextBridge.exposeInMainWorld('api', { npmKill: (params) => ipcRenderer.invoke('npm:kill', params) , openExternal: (url) => ipcRenderer.invoke('url:open', url) +, + getEditor: () => ipcRenderer.invoke('editor:get') +, + listEditors: () => ipcRenderer.invoke('editor:list') +, + // With a path, remembers that editor; without one, opens the file dialog. + chooseEditor: (editorPath) => ipcRenderer.invoke('editor:choose', editorPath) +, + openInEditor: (sitePath) => ipcRenderer.invoke('editor:open', sitePath) +, + showSiteInFileManager: (sitePath) => ipcRenderer.invoke('dir:show', sitePath) , markSiteInitialized: (sitePath) => ipcRenderer.invoke('sites:mark-initialized', sitePath) , diff --git a/src/renderer/index.jsx b/src/renderer/index.jsx index a63305a..b48260e 100644 --- a/src/renderer/index.jsx +++ b/src/renderer/index.jsx @@ -39,6 +39,9 @@ const UPDATE_STEP_MARKS = { complete: { symbol: '✓', color: '#0f5132' }, current: { symbol: '›', color: '#0b5d95' } }; +// The file manager has a name on the two platforms that have one; everywhere +// else it is whatever the desktop provides, so it is called what it is. +const FILE_MANAGER_LABELS = { darwin: 'Show in Finder', win32: 'Show in Explorer' }; const TERMINAL_INSTALL_ALIASES = ['npm install', 'npm i', 'install']; const RENAME_INPUT_ID = 'rename-site-name-input'; const CREATE_SITE_NAME_INPUT_ID = 'create-site-name-input'; @@ -60,6 +63,65 @@ function formatEmailDate(email) { } const FEEDBACK_FORM_URL = 'https://docs.google.com/forms/d/e/1FAIpQLScnMxicyDxZO2OoaS5ela8FArYWjCyLfC3hxRBBRSF7XLPzKg/viewform'; +// The editor is one app-wide choice, so it is held once for the window rather +// than once per site. Every site is mounted at all times (the inactive ones are +// hidden), so per-row state here would mean N copies of the same answer: N loads +// on startup, and a choice made in one row leaving every other row's button +// still saying "Open in editor". +// +// Detection is deliberately not part of the load: `editor:get` is a store read, +// and the filesystem probe behind `editor:list` waits until the picker is +// actually opened. +function useEditorChoice() { + const [chosen, setChosen] = useState(null); + const [detected, setDetected] = useState([]); + + useEffect(() => { + let cancelled = false; + window.api.getEditor() + .then((editor) => { if (!cancelled) setChosen(editor || null); }) + // The window carries on with no remembered editor, which is a state it + // handles — but "the store could not be read" and "nothing chosen yet" + // must not be the same event to whoever reads the log afterwards. + // eslint-disable-next-line no-console -- reaches the log file: logging.js initializes electron-log with spyRendererConsole, so this is how the renderer records a diagnostic. + .catch((err) => console.error('Could not read the remembered editor:', err)); + return () => { cancelled = true; }; + }, []); + + const loadDetected = useCallback(async () => { + try { + const result = await window.api.listEditors(); + setDetected(result?.detected || []); + setChosen(result?.chosen || null); + } catch (err) { + // eslint-disable-next-line no-console -- see the note on the first console.error above. + console.error('Could not list the editors on this machine:', err); + setDetected([]); + } + }, []); + + // `editorPath` is one of the detected editors; without one the main process + // opens the file dialog, which is what covers every editor detection misses. + // + // A rejected invoke is reported as a refusal rather than raised: the caller + // draws a notice from it, and an unhandled rejection here would be a button + // that silently did nothing. + const remember = useCallback(async (editorPath) => { + let result; + try { + result = await window.api.chooseEditor(editorPath); + } catch (err) { + // eslint-disable-next-line no-console -- see the note on the first console.error above. + console.error('Could not remember that editor:', err); + return { ok: false, reason: 'unavailable', error: String(err?.message ?? err) }; + } + if (result?.ok) setChosen(result.editor); + return result; + }, []); + + return { chosen, detected, loadDetected, remember }; +} + function useSites() { const [sites, setSites] = useState([]); const [siteMeta, setSiteMeta] = useState({}); @@ -74,6 +136,8 @@ function useSites() { function App() { const { sites, siteMeta, refresh, setSiteMeta, setSites } = useSites(); + // One choice for the window, shared by every site row. + const editorChoice = useEditorChoice(); const [downloadPhase, setDownloadPhase] = useState(''); // Directories whose clone is still running. An array rather than a single // path because the main process may settle on a different (deduplicated) @@ -677,6 +741,7 @@ function App() { onForget={onForget} onDelete={onDelete} onRename={onRename} + editor={editorChoice} isPending={pendingSites.includes(s)} setupLogs={setupLogsBySite[s] || ''} isActive={activeSite === s} @@ -759,7 +824,7 @@ function App() { ); } -function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSiteMetaPatch, onForget, onDelete, onRename, isPending = false, setupLogs = '', isActive = false }) { +function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSiteMetaPatch, onForget, onDelete, onRename, editor, isPending = false, setupLogs = '', isActive = false }) { // Kept in a ref so loadStatus's dependency list stays [sitePath] — a // recreated callback prop must not retrigger the status-loading effect. const metaPatchRef = useRef(onSiteMetaPatch); @@ -926,6 +991,96 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit } }, [sitePath]); + // --- opening the code --------------------------------------------------- + // + // The editor itself is chosen once for the whole window (see useEditorChoice); + // what is per-site here is only the UI around it. Every path out of this ends + // somewhere the contributor can act: a launch that fails opens the picker, the + // picker always offers "Choose application…", and the copy button above is the + // floor under all of it. + const { chosen: chosenEditor, detected: detectedEditors, loadDetected, remember } = editor; + const [editorPickerOpen, setEditorPickerOpen] = useState(false); + const [editorNotice, setEditorNotice] = useState(''); + + const fileManagerLabel = FILE_MANAGER_LABELS[window.api?.platform] || 'Show in file manager'; + + const describeOpenFailure = useCallback((result) => { + if (result?.reason === 'unlaunchable-editor') { + return 'That editor is no longer where it was. Choose it again.'; + } + if (result?.reason === 'spawn-failed') { + return `The editor would not start: ${result.error || 'unknown error'}`; + } + if (result?.reason === 'unregistered-site') { + return 'This app has no record of that folder, so it will not open it.'; + } + if (result?.reason === 'unavailable') { + return `Could not reach the app's main process: ${result.error || 'unknown error'}`; + } + return 'Could not open the folder in an editor.'; + }, []); + + // The invoke itself can reject — a handler that throws, a window being torn + // down — and a rejection here would leave the notice unset and the picker + // unopened: the button would appear to do nothing, which is the one outcome + // this feature is not allowed to produce. + const askToOpen = useCallback(async () => { + try { + return await window.api.openInEditor(sitePath); + } catch (err) { + // eslint-disable-next-line no-console -- see the note on the first console.error above. + console.error('Could not open the site in an editor:', err); + return { ok: false, reason: 'unavailable', error: String(err?.message ?? err) }; + } + }, [sitePath]); + + const openInEditor = useCallback(async () => { + const result = await askToOpen(); + if (result?.ok) { + setEditorNotice(''); + return; + } + // Nothing chosen yet is not an error, it is the first use. Anything else is + // worth saying out loud — but both end at the same place: the picker. + setEditorNotice(result?.reason === 'no-editor' ? '' : describeOpenFailure(result)); + await loadDetected(); + setEditorPickerOpen(true); + }, [askToOpen, describeOpenFailure, loadDetected]); + + const rememberEditor = useCallback(async (editorPath) => { + const result = await remember(editorPath); + if (!result?.ok) { + if (result?.reason === 'unlaunchable-editor') { + setEditorNotice('That is not an application this app can open a folder in.'); + } else if (result?.reason === 'unavailable') { + setEditorNotice(describeOpenFailure(result)); + } + // 'cancelled' is the contributor closing the dialog, which is an answer, + // not a failure. The picker stays open either way: it is where the next + // attempt starts from. + return; + } + const opened = await askToOpen(); + setEditorNotice(opened?.ok ? '' : describeOpenFailure(opened)); + // Only a launch that actually worked closes the picker. Closing it on the + // choice alone would leave a contributor whose editor failed looking at a + // notice with no picker, one step further from a working editor than before + // they clicked. + if (opened?.ok) setEditorPickerOpen(false); + }, [askToOpen, describeOpenFailure, remember]); + + const showInFileManager = useCallback(async () => { + let result; + try { + result = await window.api.showSiteInFileManager(sitePath); + } catch (err) { + // eslint-disable-next-line no-console -- see the note on the first console.error above. + console.error('Could not reveal the site folder:', err); + result = { ok: false, error: String(err?.message ?? err) }; + } + setEditorNotice(result?.ok ? '' : `Could not open the folder: ${result?.error || 'unknown error'}`); + }, [sitePath]); + const appendNpm = useCallback((s)=>setNpmLogs((v)=>v+s),[]); const appendRuntime = useCallback((s)=>setRuntimeLogs((v)=>v + String(s ?? '')),[]); const sortEmails = useCallback((list)=>[...list].sort((a,b)=>new Date(b.sentAt||b.date||0)-new Date(a.sentAt||a.date||0)),[]); @@ -2124,6 +2279,25 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit isSmall /> +
+ {detectedEditors.length + ? 'Choose the editor to open this site in. This app will remember it.' + : 'This app could not find an editor in the usual place. Point at yours and it will remember it.'} +
+ {detectedEditors.map((candidate) => ( + + ))} + {/* Always offered, never only as a fallback: the detection list is a + shortcut, and an editor missing from it is not an editor this app + refuses to use. */} + +