Open a site's folder from one menu instead of a remembered editor - #209
Merged
Conversation
Replaces the remembered-editor flow from #158 with a single "Open directory in" menu under the site's path: the file manager, every editor detection found, and "Other application…". The application is now an argument to the action rather than a setting configured first, so there is nothing remembered, nothing to change later, and no first-run picker. Deliberately not in it: the "(not installed)" rows from the sketch. Detection is a fixed table of absolute install locations that misses JetBrains' versioned directories and most Linux packaging, so a greyed-out "VS Code (not installed)" would be a confident lie on the machines it misses — and #158's rule is that nothing here ends at a disabled button. The window names the executable now, where the path used to come out of the store, so `editor:open` re-establishes the set it is allowed to launch: `matchDetectedEditor` in editor-launch.js answers with the path detection vouches for, and that is what gets spawned. Checking one string and launching another would not be a check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 9, 2026
juanmaguitar
added a commit
that referenced
this pull request
Aug 10, 2026
## Why On Windows, choosing a detected editor from **Open directory in** appears to do nothing. No editor window, and no error notice either — the app reports success. The editor is in fact running: Task Manager shows Visual Studio Code under *Background processes*, holding ~400 MB, with no window anywhere. (Reported against the buttons #158 shipped; #209 has since replaced them with the menu, and the bug is in the spawn underneath both.) That is the failure shape this project treats as an architectural bug rather than a cosmetic one. A Contributor Day newcomer clicks a button, nothing happens, and there is nothing on screen to diagnose. ## What changes The root cause is `windowsHide: true` on the editor's spawn — the same option the app passes to its console children, where it stops Windows allocating a visible console for every npm and grunt subprocess. For a GUI application the flag means something else. Alongside `CREATE_NO_WINDOW` it sets `STARTF_USESHOWWINDOW` with `SW_HIDE` in the new process's STARTUPINFO, and an application that honors `nCmdShow` when creating its first window starts invisible. Electron apps do, so VS Code and Cursor both. The spawn itself succeeds, the child emits `spawn`, `awaitLaunch` returns ok, and the renderer correctly has nothing to report. So the editor spawn drops the flag. Nothing else does: the runners keep it through `hide-child-windows.js`, which this does not touch. That patch is applied only inside the four runner child processes and never in the main process, so nothing re-adds the flag to this call site. The comment at the call site was also wrong in a way worth correcting while here — it claimed the options match what main.js uses everywhere else, but `detached` is unconditional here and conditional in the runners, for a real reason: the runners are killed as a process group, and this child is released rather than ever signalled. ## How to test this **Platform: Windows.** The bug does not exist on macOS or Linux — `windowsHide` is a no-op there, and macOS goes through `/usr/bin/open` rather than spawning the editor directly. Buildkite builds a signed artifact for this branch; check it matches the head commit, since rebasing onto current trunk invalidated the earlier ones. **Starting state:** a Windows machine with Visual Studio Code installed by the user installer, so it lands at `%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe`. Any site in the app will do; it does not need to be initialized. 1. Open the app, select a site, and open the **Open directory in** menu under the site path. It lists **Show in Explorer**, then **Visual Studio Code** — if the editor is not listed, detection did not find it, which is a different problem from this one. 2. Choose **Visual Studio Code**. It opens **with a visible window**, showing the site folder. 3. Repeat through **Other application…** and pick `Code.exe` by hand. It opens with a window too — that path reaches the same spawn, and it is the one a contributor with an editor detection misses will take. 4. Close VS Code, then close the app entirely, and repeat step 2 from a fresh launch. The window appears again — the editor is spawned detached, so this also confirms it still outlives the app rather than being killed with it. **What must not have happened:** - **No console window flashes** at any point during steps 2 and 3. That is what `windowsHide` was doing correctly for the runners, and the concern with removing it. Then run **Install npm dependencies** on a site and watch: still no console flashes there either, since that path is unaffected. - **No orphaned editor process.** After step 4, closing VS Code's window should leave nothing behind in Task Manager under "Visual Studio Code" — the symptom of this bug was precisely a windowless process sitting there, so the old failure is easy to recognise. To watch the bug fail to reproduce, do step 2 on a build from `trunk` first: the menu item does nothing, and Task Manager shows Visual Studio Code as a background process with no window. The regression test is `the editor is not asked to start hidden on Windows` in `test/editor-launch.test.cjs`. It drives the win32 branch from any machine by injecting `platform`, the house pattern from `win-spawn-patch.test.cjs`. I checked it fails on the old code — `windowsHide: true` was present in the recorded spawn options — and passes on the new. The existing launch-options assertion was updated in the same direction rather than left pinning the old behaviour. ## Risks and limitations Self-review came back **0 [fix here] · 2 [follow-up]**, both filed rather than deferred silently (#202, #203). I could not test this by hand myself — I have no Windows machine. The reproduction and the confirmation of the running-but-windowless process came from a maintainer's VM; the fix itself has only been verified by the suite and by reading the Win32 process-creation semantics. A reviewer on Windows driving the steps above is what would actually close that gap. The one behaviour change beyond the fix: a contributor who points the picker at a *console-mode* editor now gets its console window, where before it was hidden. That is the wanted behaviour — a terminal editor with a hidden terminal is the same bug — but it is a change, not a no-op. ## Related Fixes #181. Follow-ups filed: #202, #203. --- <details> <summary>Design decisions and alternatives considered</summary> **Why not keep the flag and add `shell: false` + a `start` wrapper, or go through `shell.openPath`?** Both would work around the flag rather than remove the thing that was wrong. `shell.openPath` in particular would hand the folder to whatever the OS has registered for a directory, which is Explorer — that is the *other* button. **Why not make it conditional — hide for console editors, show for GUI ones?** There is no reliable way to ask a `.exe` which subsystem it targets without reading its PE header, and the app has no business doing that. The simpler rule is correct: this call site launches the contributor's editor, and an editor's window is the point of the click. **Why the flag stays for the runners.** They exist to collect output that is streamed to the renderer; their console windows are pure noise, and `hide-child-windows.js` documents why the patch has to reach grandchildren. Nothing about that reasoning applies to the editor. </details> <details> <summary>Review outcome (required — see AGENTS.md)</summary> **0 [fix here] · 2 [follow-up]** — both follow-ups filed as issues rather than fixed here. The judgement pass ran in a subagent with fresh context, per `.claude/skills/self-review/SKILL.md`. What it verified rather than assumed: - Nothing re-applies `windowsHide` to this spawn. `hideChildWindows()` is called only in the four runner child processes; the main process never requires the module. `win-spawn-patch.js` self-applies only under `WPTK_SPAWN_PATCH=1`, never touches `windowsHide`, and its `resolveSpawnTarget` returns `null` for a `.exe` anyway. - No console-flash regression at this call site: `resolveLaunch` produces `/usr/bin/open` on macOS and the editor `.exe` elsewhere, not the `cmd.exe`/`grunt.cmd` grandchildren the patch exists for. - No kill-tree interaction: the child is `unref`'d and never registered with `killChildTree`, so `detached` here is about outliving the app, not signalling a group. - The regression test genuinely fails on the old code. **Follow-up 1 — #202** (cross-platform, 🔵): the Windows rule in `.github/instructions/code-review.instructions.md` states `windowsHide: true` flatly, without the GUI/console distinction that caused this. As written it will give the same wrong advice to the next GUI spawn someone adds. **Follow-up 2 — #203** (tests/architecture, 🔵): `patchChildProcess` deliberately overrides an explicit `windowsHide: false`, so a future call to `hideChildWindows()` in the main process would silently reinstate this bug, and no test would catch it — `test/editor-launch.test.cjs` injects its own `spawn`, and `test/runner-wiring.test.cjs` only asserts the runners *do* call the patch. One style note from the review was fixed before opening: the comment claiming parity with main.js's other spawn options, described under **What changes**. </details> <details> <summary>Screenshots or recording</summary> Nothing on screen changed inside the app — the menu, its items and the notice area are untouched. What changes is off-app: whether VS Code's own window appears after the choice. The before state is visible in #181: Task Manager filtered to "code", showing Visual Studio Code under *Background processes (1)* with *Apps (0)* — the editor running with no window. </details> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
juanmaguitar
added a commit
that referenced
this pull request
Aug 10, 2026
## Why Revealing a site's folder in the file manager failed with `Could not open the folder: unknown error`. The app knew exactly what had gone wrong — it had refused on purpose — and said the one thing that was not true. It happens most visibly while a site is still being created: the clone takes minutes, the site is not in the registry until it finishes, and every reveal in that window is refused. That is #180, and the wrong message is the half a contributor actually meets. ## What changes The root cause is not the sentence, it is that there were two places deciding it. `showInFileManager` built its own string out of `result.error` alone, and a refusal is precisely the case with no `error` — it carries a `reason` instead. #209 had already written the vocabulary for the editor menu; the reveal path never asked it. So the decision moves, not just the words. `noticeForOpenResult` in the new `src/renderer/open-failure.cjs` answers all three questions the two callers were answering separately — is there a notice at all, what does it say, and is "Choose application…" a way out of it — and the window is left with an assignment. Three things fall out of moving the decision rather than only the sentence: - **`open-failed` gets a branch.** It is the file manager's own refusal and it carries the OS's message, which the hand-rolled string did surface. Reusing the editor's vocabulary without adding it would have been a regression dressed as a cleanup. - **The picker is offered per reason instead of always.** "Choose application…" answers "that editor did not work". It is not an answer to a refused *folder* — `openSiteInEditor` checks the folder before it looks at the editor, so picking another one returns the identical sentence — and there is no second file manager to choose. - **The generic fallback stops naming an application**, since both callers share it now and only one of them is opening one. This is PR 1 of 3 on #180 and it deliberately does not make the folder open; the refusal is still there. It ships first because it stands on its own: any `openPath` failure, guard or not, currently ends in "unknown error". ## How to test this **Platform: any.** Nothing here is platform-specific — the same refusal and the same notice on macOS, Windows and Linux. **Starting state:** the app open with at least one site that has finished setting up. 1. Open the **Open directory in** menu under the site path and choose the file manager entry (**Show in Finder** / **Show in Explorer**). The folder opens, and no notice appears. This is the path that must not have regressed. 2. Quit the app. Move or rename the site's folder on disk, then reopen the app and repeat step 1. A notice appears explaining that the folder could not be opened, quoting the reason the OS gave — **not** the words "unknown error". 3. With the notice showing, confirm there is **no "Choose application…" button** beside it. Picking an application is not a way out of a folder that will not open. 4. Now choose an *editor* from the same menu. Its failures still offer **Choose application…**, because there picking a different one is a real next step. **What must not have happened:** - **No blank notice.** The notice's state changed from a string to `{ message, offerPicker }`; a missed read would render an empty warning box rather than throw. Any notice you can produce must have visible text in it. - **The dialog's Cancel is still silent.** Choose **Other application…** and press Escape or Cancel. No notice appears — closing a dialog is an answer, not a failure, and the early return that guaranteed that moved into the new module. - **A failed editor is still re-detected.** After an editor fails, reopening the menu re-runs detection rather than offering the dead entry as if nothing happened. To watch the old behaviour, do step 2 on a build from `trunk`: the notice reads `Could not open the folder: unknown error`. ## Risks and limitations Self-review came back **1 [fix here] · 2 [follow-up]** on the first pass and **1 [fix here] · 1 [follow-up]** on the re-review; everything at `[fix here]` is fixed, and the detail is in the collapsed block below. The first pass caught something worth stating plainly: my original test claimed to reproduce #180 and did not, because the sentence was never the broken part. That is what drove the restructure. The honest limitation is that **nothing fails if the wire is cut.** These tests cover the module; reverting the call site in `index.jsx` to the hand-rolled string would leave the suite green. `test/ipc-wiring.test.cjs` exists for exactly that class of gap on the main-process side, but there is no renderer equivalent, and `setup-steps.cjs`, `terminal-hints.cjs` and `update-plan.cjs` all have the same unproven wire. This PR inherits the hole rather than digging it; it is filed as a follow-up rather than solved here. I could not test the OS-failure branch by hand on Windows — step 2 above was driven on macOS. The message it quotes comes from `shell.openPath`, which is Electron's, not ours. ## Related Part of #180. Follow-ups: #202, #203 are unrelated to this one. --- <details> <summary>Design decisions and alternatives considered</summary> **Why not just add the `reason` branch to `showInFileManager`'s string?** That is the two-line version, and it leaves two places deciding the same thing — which is the actual defect. The next reason added to either guard module would have to be remembered in both. **Why a `.cjs` module rather than a hook or a helper inside the component?** It is the house pattern for renderer logic worth testing: `setup-steps.cjs`, `path-basename.cjs`, `update-plan.cjs`. The renderer bundle imports it, `node --test` requires it directly, and neither needs a DOM. **Why derive `offerPicker` from the reason rather than from the caller?** The first draft passed `offerPicker: true` from the editor path and `false` from the reveal path, which reads as correct and is not: `unregistered-site` and `unavailable` reach the *editor* caller too, and the picker is a dead end for both. The reason knows; the caller only thinks it does. </details> <details> <summary>Review outcome (required — see AGENTS.md)</summary> Two passes, both dispatched to a subagent with fresh context per `.claude/skills/self-review/SKILL.md`. **First pass — 1 [fix here] · 2 [follow-up].** - *[fix here] Tests 🟡* — the "#180 reproduction" test did not fail on the old code. `describeOpenFailure` already handled `unregistered-site` on trunk; the bug was at the call site. Reverting only the call site would have left the whole new suite green with #180 back. **Fixed** by moving the mapping — not just the sentence — into `noticeForOpenResult`, which is the restructure this PR is now built around. - *[follow-up] Architecture 🔵* — `offerPicker` was unconditional on the editor path. **Fixed**, not deferred: derived from the reason via `PICKING_HELPS`. - *[follow-up] Architecture 🔵* — the generic fallback said "in an application", wrong for the file-manager caller. **Fixed**, not deferred. **Re-review — 1 [fix here] · 1 [follow-up].** All three above confirmed resolved by reading. - *[fix here] Tests 🔵* — the picker-membership and per-reason lists were hand-maintained, so a seventh reason would arrive with the generic sentence and no failure. **Fixed**: both tests now read the reason set out of `editor-launch.js` and `site-registry.js` (`REFUSAL_REASONS` plus scanned literals) and fail if a reason is undescribed or unjudged. I verified the guard bites by adding a fake reason to `site-registry.js` — two tests fail — then reverting it. The scan also asserts it matched something, so it cannot go vacuously green. - *[follow-up] Tests 🔵* — the untested wire, described under Risks above. </details> <details> <summary>Screenshots or recording</summary> The only visible change is the text inside the existing notice, and the absence of the **Choose application…** button beside a file-manager failure. The notice box, its position and its styling are untouched. Before: `Could not open the folder: unknown error`, with a **Choose application…** button that returns the same sentence. </details> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Opening a site's folder shipped in #158 as a remembered choice: an Open in editor button, a
Show in Finder button, a Change editor button, and a modal picker on first use — four
controls and a modal for one intention. This explores the alternative shape sketched in the issue
thread: one menu that says what it does, listing the applications the folder can be opened in.
The application is what the contributor is actually choosing, and it changes — Finder to look at the
files, an editor to change them. Making it a setting configured once puts the wrong thing in front
of them and then makes them go back to change it.
What changes
One control, on the row under the path it acts on.
Open directory in ⌄opens a menu of thefile manager, every editor detection found, and Other application…. Nothing is remembered: the
application is an argument to
editor:open, not a stored preference. That deleteseditor:get,editor:choose, thepreferences.editorstore entry, the first-run picker modal, and thestale-remembered-editor failure path — three of the four old buttons go with them.
Deliberately not in it: the "(not installed)" rows from the sketch. Detection in
src/editor-launch.jsis a fixed table of absolute install locations, and its own header calls it"a convenience, not the contract" — it misses JetBrains' versioned install directories and most
Linux packaging. A greyed-out VS Code (not installed) would be a confident lie to anyone whose
install the table does not probe, and #158's standing rule is that nothing here ends at a disabled
button. The menu shows what was found; Other application… is always there, never only as a
fallback.
The security shape changed, and that is the part to review hardest. The renderer now names an
executable, where the path used to come out of the main-side store. So
editor:openre-establisheswhat it is allowed to launch:
matchDetectedEditorre-runs detection and answers with thedetected path, which is what gets spawned — checking one string and launching the caller's string
would not be a check (same reason
external-url.jshands the OS its parsed URL). A path detectiondid not return is refused as
unknown-editorbeforeopenSiteInEditoris reached.nullstillmeans the native file dialog, whose result is validated exactly as it was under
editor:choose.The path chip and its copy button are untouched — still the floor under all of it.
How to test this
Platforms: any, but the case-insensitivity of the new check is what differs across them and is
covered by tests that run both branches from one machine (
test/editor-launch.test.cjs).Starting state: the app open on any site, with at least one of VS Code / Cursor / PhpStorm /
Sublime Text / Zed installed in its normal location, and ideally one editor installed somewhere
unusual (a JetBrains Toolbox PhpStorm, or any
.appoutside/Applications).old Open in editor / Show in Finder / Change editor buttons are gone, and the path
chip and its copy button are unchanged.
machine by name, then Other application…. No row is greyed out, and no row claims something
is "not installed".
exactly as it did — nothing was remembered, no "Change editor" appeared, the chosen one is not
marked.
error appears — dismissing a dialog is an answer, not a failure.
not list. It opens, and it is still not remembered.
.txt, orany document). A yellow notice appears under the path saying it is not an application this app
can open a folder in, with a Choose application… button that reopens the dialog.
Open in editor is not — that lives in the new menu now.
What must not have happened:
on first use. If any of those appear, a code path from Open a site's code without leaving the app #158 survived.
forward. A row that opens nothing and says nothing is the one outcome this feature may not
produce.
preferences.editorany more. An existing user's stored editor is left inert insettings.json— it must not be read, and their sites and contributor details must be untouched.Not testable by hand here: the Windows and Linux branches of the launchable-path and
case-sensitivity checks. Those are exercised by injection in
test/editor-launch.test.cjsratherthan skipped, and I verified both new checks fail the suite when the logic is removed.
Risks and limitations
That is the trade this shape makes, and it is the main thing to decide before merging.
collapsed block below.
app is running appears next time. That is ~10–20
stats per open, all async, off the mainthread's critical path.
Dropdown+MenuGroup/MenuItemfrom@wordpress/components, the same primitives the sidebar'sfeedback dropdown and the ⋯ menu already use.
Related
Follow-up to #158. Explores the alternative UI proposed for it.
Design decisions and alternatives considered
Greyed-out "not installed" rows — rejected. The sketch showed all five known editors always
listed, disabled when absent. Two problems: it turns the detection table from a shortcut into a
claim about the machine, and the table is knowingly incomplete (its own comment says the picker
covers versioned JetBrains installs "exactly as well"). Someone with PhpStorm installed via the
JetBrains installer would read PHPStorm (not installed) and believe the app. A third option —
dimmed but clickable, opening the dialog prefilled — was also dropped: it makes a row mean two
different things depending on a state the contributor cannot see, and Other application… already
covers it honestly.
Dropping the memory entirely — chosen over "remember, last used on top". Keeping the memory
would have kept
editor:get,editor:chooseand the stored preference alive to save one click.Since the menu has to exist anyway, the remembered copy is a second source of truth for the same
answer, plus a failure path (the remembered editor moved) that only exists because something was
remembered. The one click is the price.
Where the menu goes. First on the metadata line beside the created date, then beside the copy
button, and finally on its own row under the path — the folder is what it acts on, so it reads
directly beneath the folder, above everything that acts on the site itself.
matchDetectedEditorreturns the candidate, not a boolean. A boolean would let a caller checkone string and spawn another; returning the path detection vouches for makes that mistake
unwritable. It lives in
editor-launch.jsrather than the handler so the case-sensitivity split hasan injected
platformand both branches are testable from one machine — the house pattern.Review outcome (required — see AGENTS.md)
3 [fix here] · 1 [follow-up] — all 3 fixed, the follow-up deferred.
Fixed:
process.platforminline, so neither branch of thecase-sensitivity comparison could be exercised from one machine, and no test passed a
case-differing path at all: the suite would have stayed green with
normalizedeleted. Moved thecheck into
editor-launch.jsasmatchDetectedEditorwith an injectedplatform, and addedthree tests covering the macOS, Windows and Linux branches. Verified both new tests fail when the
logic is removed.
normalize(candidate.path)but then passedtarget, therenderer's own string, to
openSiteInEditor. On a case-sensitive volume/applications/Cursor.appwould pass the comparison while being a different filesystem entry. Now the detected path is what
is spawned.
knownEditorName's normalisation while leavingthat function without a production caller. Both now live in
editor-launch.js; the duplication isgone.
Deferred:
sitePathis checked against the registry, so a site forgotten in another window would show afile browser and only then refuse. The ordering predates this PR (
editor:choosehad the samedialog with no site in hand at all), and fixing it belongs with the registry-staleness handling
rather than here.
Verified and explicitly not findings:
preferences.editorneeds no migration (nothing reads it oneither side of the upgrade, and the surviving writer spreads the existing object, so contributor
provenance is preserved); the second detection sweep per open is not a user-visible cost.
Style notes from the same pass, all applied: comments in
settings-store.js,main.jsandindex.jsxthat still described the remembered editor, theeditorChoicevariable name for a hookthat no longer holds a choice, and
'unknown-editor'as a bare literal rather than aREFUSAL_REASONSentry.Implementation notes
src/editor-launch.js— addsmatchDetectedEditor(editorPath, { platform, env, exists })andREFUSAL_REASONS.UNKNOWN_EDITOR. Detection, launchability and the spawn are unchanged; nothinghere consults
PATH, at either end, for the reason the file's header gives.src/main.js—editor:getandeditor:chooseare gone;editor:listdropschosen;editor:opentakes(sitePath, editorPath | null).src/preload.js—getEditorandchooseEditorremoved;openInEditortakes the secondargument.
src/renderer/index.jsx—useEditorChoicebecomesuseDetectedEditors(detected + loading, nochosen, no remember); the picker modal and the three-button row are deleted; the inline notice
stays and is now the only place a failure speaks, so it carries Choose application… with it.
test/editor-launch.test.cjsgains the threematchDetectedEditorcases;test/ipc-wiring.test.cjsswaps theeditor:get/editor:choosewiring tests for the refusal andfile-dialog paths. 419 pass, lint clean.
Screenshots or recording
The menu, open, on a site with four applications found. Everything the folder can be opened in,
in one list, with
Other application…always at the end — no row disabled, nothing claiming to knowwhat is not installed:
Closed, the resting state: one link directly under the path it acts on, where three buttons and
a "Change editor" used to be:
Both captured from the running app on macOS through
webContents.capturePage(), with the menuopened by a real click rather than a mock.