Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/editor-launch.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,15 @@ function resolveLaunch(editorPath, sitePath, { platform } = {}) {
return { command: editorPath, args: [sitePath] };
}

// Every `reason` this module can answer `editor:open` with, refusals and
// failures alike. Exported as the complete list on purpose: the renderer's
// open-failure.cjs owes each of these a sentence, and its tests check that
// against this object rather than a copy that could go stale.
const REFUSAL_REASONS = {
UNREGISTERED_SITE: 'unregistered-site',
UNLAUNCHABLE_EDITOR: 'unlaunchable-editor',
UNKNOWN_EDITOR: 'unknown-editor'
UNKNOWN_EDITOR: 'unknown-editor',
SPAWN_FAILED: 'spawn-failed'
};

// The `editor:open` handler's body.
Expand Down Expand Up @@ -327,7 +332,7 @@ async function openSiteInEditor(sitePath, editorPath, {
} 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 { ok: false, reason: REFUSAL_REASONS.SPAWN_FAILED, error: e?.message ?? String(e) };
}

return awaitLaunch(child, { platform });
Expand Down Expand Up @@ -365,14 +370,14 @@ function awaitLaunch(child, { platform } = {}) {
};

child.on('error', (e) => {
settle({ ok: false, reason: 'spawn-failed', error: e?.message ?? String(e) });
settle({ ok: false, reason: REFUSAL_REASONS.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})` });
: { ok: false, reason: REFUSAL_REASONS.SPAWN_FAILED, error: `the editor could not be opened (exit code ${code})` });
});
return;
}
Expand Down
59 changes: 19 additions & 40 deletions src/renderer/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { computeSetupStepState } from './setup-steps.cjs';
import { shouldShowTerminalHints, computeTerminalBusy } from './terminal-hints.cjs';
import { planDevServerStart, formatElapsed } from './dev-server-command.cjs';
import { pathBasename } from './path-basename.cjs';
import { noticeForOpenResult } from './open-failure.cjs';
import { trunkAgeInfo, planUpdateSteps, updateStepStatuses, SKIP_INSTALL_MESSAGE, planApplySteps, APPLY_STATE_TO_STEP } from './update-plan.cjs';
import { pickLatest } from '../latest-patch.cjs';
import { parsePrRef } from '../patch-sources.cjs';
Expand Down Expand Up @@ -1207,37 +1208,15 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
// entry is ever drawn disabled: an application this app cannot find is not one
// it refuses to use, and the copy button above is the floor under all of it.
const { detected: detectedEditors, loading: detectingEditors, loadDetected } = editor;
const [editorNotice, setEditorNotice] = useState('');
// `{ message, offerPicker }` from open-failure.cjs, or null for nothing to
// say. Both what it reads and whether "Choose application…" is a way out of
// it are decided there, per reason — the two callers below deciding that
// separately is what #180 was.
const [editorNotice, setEditorNotice] = useState(null);

const fileManagerLabel = FILE_MANAGER_LABELS[window.api?.platform] || 'Show in file manager';
const fileManagerName = FILE_MANAGER_NAMES[window.api?.platform] || 'File manager';

// `picked` says which of the two failures 'unlaunchable-editor' is: an
// application detection offered that has since moved, or one the contributor
// just pointed at that is not an application at all. Main cannot tell them
// apart — the guard is the same — but the caller knows which it asked for, and
// the two need different next steps.
const describeOpenFailure = useCallback((result, { picked = false } = {}) => {
if (result?.reason === 'unlaunchable-editor') {
return picked
? 'That is not an application this app can open a folder in.'
: 'That application is no longer where it was. Choose another.';
}
if (result?.reason === 'unknown-editor') {
return 'That application is no longer where it was. Choose another.';
}
if (result?.reason === 'spawn-failed') {
return `The application 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 application.';
}, []);

// `editorPath` is one of the detected applications; null asks the main process
// for the file dialog instead.
//
Expand All @@ -1254,29 +1233,27 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
console.error('Could not open the site directory:', err);
result = { ok: false, reason: 'unavailable', error: String(err?.message ?? err) };
}
// Closing the dialog is an answer, not a failure — saying something about it
// would be the app arguing with a decision the contributor just made.
if (result?.ok || result?.reason === 'cancelled') {
setEditorNotice('');
return;
}
setEditorNotice(describeOpenFailure(result, { picked: editorPath === null }));
const notice = noticeForOpenResult(result, { picked: editorPath === null });
setEditorNotice(notice);
// An application that was detected and then failed is one detection should be
// asked about again, so the next menu does not offer it as if nothing had
// happened.
if (editorPath !== null) await loadDetected();
}, [describeOpenFailure, loadDetected, sitePath]);
if (notice && editorPath !== null) await loadDetected();
}, [loadDetected, sitePath]);

// Through the same function as `openIn` above, deliberately: this used to
// build its own sentence out of `error` alone, so a refusal — which carries a
// `reason` and no `error` — came out as the words "unknown error" (#180).
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) };
result = { ok: false, reason: 'unavailable', error: String(err?.message ?? err) };
}
setEditorNotice(result?.ok ? '' : `Could not open the folder: ${result?.error || 'unknown error'}`);
setEditorNotice(noticeForOpenResult(result));
}, [sitePath]);

const appendNpm = useCallback((s)=>setNpmLogs((v)=>v+s),[]);
Expand Down Expand Up @@ -2993,8 +2970,10 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
contributor to find the menu again. */}
{editorNotice ? (
<div role="alert" style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginTop: 8, padding: '8px 12px', background: '#fcf9e8', border: '1px solid #dba617', borderRadius: 6, fontSize: 12, color: '#6e5406' }}>
<span style={{ flex: '1 1 240px' }}>{editorNotice}</span>
<Button variant="tertiary" isSmall onClick={() => void openIn(null)}>Choose application…</Button>
<span style={{ flex: '1 1 240px' }}>{editorNotice.message}</span>
{editorNotice.offerPicker ? (
<Button variant="tertiary" isSmall onClick={() => void openIn(null)}>Choose application…</Button>
) : null}
</div>
) : null}
</div>
Expand Down
108 changes: 108 additions & 0 deletions src/renderer/open-failure.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// What the window says when a folder will not open.
//
// There were two of these. The editor menu grew a real one in #209 — a branch
// per reason, each saying what the contributor can do next — while revealing
// the folder in the file manager kept the string it shipped with: the `error`
// field, or the words "unknown error" when there was none.
//
// A refusal is precisely the case with no `error` field: main declined on
// purpose and returned a `reason` instead. So the failure the app understood
// best was the one it described as unknown, which is #180 as the contributor
// meets it — a button that does nothing, explained by a sentence that explains
// nothing.
//
// Pure and dependency-free for the same reason as setup-steps.cjs: the renderer
// bundle imports it, `node --test` requires it directly, and neither needs a DOM.
'use strict';

// An OS-supplied message is quoted rather than replaced — it is the only part of
// these failures the app did not write, and usually the only part that says
// which of a dozen things went wrong.
//
// "unknown error" as the fallback is #209's wording, kept deliberately. Here it
// is honest: the attempt failed and nothing came back to say why. What #180 was
// about is the opposite case — a refusal, where the app knows exactly why and
// has a `reason` — and that never reaches this function's fallback.
function quote(error) {
const text = typeof error === 'string' ? error.trim() : '';
return text || 'unknown error';
}

/**
* The sentence for a failed attempt to open a site's folder.
*
* `picked` says which of the two situations 'unlaunchable-editor' is: an
* application detection offered that has since moved, or one the contributor
* just pointed at that is not an application at all. Main cannot tell them
* apart — the guard is the same — but the caller knows which it asked for, and
* the two need different next steps.
*
* @param {Object} result What the main process returned.
* @param {Object} [options]
* @param {boolean} [options.picked]
* @return {string}
*/
function describeOpenFailure(result, { picked = false } = {}) {
if (result?.reason === 'unlaunchable-editor') {
return picked
? 'That is not an application this app can open a folder in.'
: 'That application is no longer where it was. Choose another.';
}
if (result?.reason === 'unknown-editor') {
return 'That application is no longer where it was. Choose another.';
}
if (result?.reason === 'spawn-failed') {
return `The application would not start: ${quote(result.error)}`;
}
// The file manager's own refusal, from `shell.openPath` — a different verb
// from the editor's, and the one case here that carries the OS's message.
if (result?.reason === 'open-failed') {
return `The file manager would not open the folder: ${quote(result.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: ${quote(result.error)}`;
}
// Both callers share this now, so it says nothing about an application —
// "could not open it in an application" is not what happened when the file
// manager is what failed.
return 'Could not open the folder.';
}

// The reasons another application is a way out of. The notice's only affordance
// is "Choose application…", and beside the other reasons it is a dead end that
// looks like a fix: `openSiteInEditor` checks the folder before it looks at the
// editor (see editor-launch.js), so answering a refused *folder* by picking a
// different application returns the identical sentence.
const PICKING_HELPS = new Set(['unlaunchable-editor', 'unknown-editor', 'spawn-failed']);

/**
* The whole notice for an attempt to open a site's folder, or null when there
* is nothing to say.
*
* This, rather than `describeOpenFailure`, is what the window calls. The two
* callers used to decide separately whether there was a failure at all and what
* to render beside it, which is how one of them ended up printing its own
* "unknown error" for a refusal that had a perfectly good reason (#180). One
* function means one answer.
*
* A closed dialog is not a failure: saying something about it would be the app
* arguing with a decision the contributor just made.
*
* @param {Object} result What the main process returned.
* @param {Object} [options]
* @param {boolean} [options.picked]
* @return {?{message: string, offerPicker: boolean}}
*/
function noticeForOpenResult(result, { picked = false } = {}) {
if (result?.ok || result?.reason === 'cancelled') return null;

return {
message: describeOpenFailure(result, { picked }),
offerPicker: PICKING_HELPS.has(result?.reason)
};
}

module.exports = { describeOpenFailure, noticeForOpenResult };
14 changes: 12 additions & 2 deletions src/site-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@

const { describeRefused } = require('./safe-log');

// Every `reason` this module can answer `dir:show` with, same convention as
// REFUSAL_REASONS in editor-launch.js: the renderer's open-failure.cjs owes
// each of these a sentence, and its tests read this object rather than keeping
// a copy that could go stale.
const REVEAL_REASONS = {
UNREGISTERED_SITE: 'unregistered-site',
OPEN_FAILED: 'open-failed'
};

// True only for a path the app has on record. Exact string match, the same
// convention `sites:add`/`sites:delete` already use (`sites.includes(sitePath)`,
// `filter((p) => p !== sitePath)`): the registry stores the paths verbatim, so a
Expand Down Expand Up @@ -61,14 +70,15 @@ async function deleteRegisteredSite(sitePath, { sites, forget, remove, onRefused
async function revealRegisteredSite(sitePath, { sites, reveal, onRefused } = {}) {
if (!isRegisteredSite(sitePath, sites)) {
if (typeof onRefused === 'function') onRefused(describeRefusedSite(sitePath));
return { ok: false, reason: 'unregistered-site' };
return { ok: false, reason: REVEAL_REASONS.UNREGISTERED_SITE };
}

const error = await reveal(sitePath);
return error ? { ok: false, reason: 'open-failed', error } : { ok: true };
return error ? { ok: false, reason: REVEAL_REASONS.OPEN_FAILED, error } : { ok: true };
}

module.exports = {
REVEAL_REASONS,
isRegisteredSite,
describeRefusedSite,
revealRegisteredSite,
Expand Down
Loading
Loading