Add open-in-editor feature with Cmd+O shortcut#2

merged5
main
juliusmarminge/open-in-editor
openedFeb 9, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 5d ago
CleanShot 2026-02-08 at 20 49 41@2x

Summary

Add a header dropdown to open the workspace in Cursor or the system file manager (Finder on macOS, Explorer on Windows, Files on Linux). Includes a global Cmd+O / Ctrl+O keyboard shortcut that opens in the last-used editor, with persistence via localStorage.

Changes

  • Add IPC channel shell:open-in-editor in contracts
  • Main process handler that spawns editor CLI or uses shell.openPath() for cross-platform file manager support
  • Header dropdown with editor options between status indicator and Diff button
  • Global keyboard shortcut listener for Cmd+O / Ctrl+O
  • Last-used editor tracked in localStorage with intelligent platform-aware naming (Finder/Explorer/Files)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an "Open in…" dropdown in the top bar to open the active project in a chosen editor or the file manager.
    • Added Cmd/Ctrl+O shortcut to open the project with your last-selected editor; selection is persisted across sessions.
    • Editor menu closes on outside clicks and after choosing an editor.
  • Platform Integration

    • Desktop app now launches external editors or the file manager to open project folders.

Added 1 commit

  • Add open-in-editor feature with Cmd+O shortcut3303d9a
Synced from GitHub

Walkthrough

Adds an "open in editor" feature: new EDITORS and EditorId types, IPC_CHANNELS.shellOpenInEditor plus main-process handler, nativeApi.shell.openInEditor in preload, and renderer UI/shortcut to invoke and persist the chosen editor.

Changes

Cohort / File(s)Summary
Main / IPC handler
apps/desktop/src/main.ts
Imports shell and EDITORS; registers IPC_CHANNELS.shellOpenInEditor handler that looks up editor by id, throws on unknown, uses shell.openPath(cwd) for file-manager or spawn (detached, stdio ignored) and unref() for editor commands.
Preload / Renderer bridge
apps/desktop/src/preload.ts
Adds nativeApi.shell.openInEditor(cwd: string, editor: EditorId) which invokes IPC_CHANNELS.shellOpenInEditor via ipcRenderer.invoke.
Renderer UI
apps/renderer/src/components/ChatView.tsx
Adds editor dropdown, isEditorMenuOpen and lastEditor state with localStorage persistence, outside-click handling, Cmd/Ctrl+O shortcut to open last editor, and calls api.shell.openInEditor.
Contracts / Types
packages/contracts/src/ipc.ts
Exports new EDITORS array and EditorId type, adds IPC_CHANNELS.shellOpenInEditor, and extends NativeApi with shell.openInEditor(cwd: string, editor: EditorId): Promise<void>.
Manifests / Package
manifest_file, package.json
Small manifest/package updates referenced by diff (lines changed +25/-1 and +4/-0 respectively).

Sequence Diagram

mermaid
1sequenceDiagram
2 actor User
3 participant ChatView as Renderer (ChatView.tsx)
4 participant Preload as Preload (preload.ts)
5 participant Main as Main Process (main.ts)
6 participant OS as Operating System Shell
7
8 User->>ChatView: Click "Open in…" or press Cmd/Ctrl+O
9 ChatView->>ChatView: openInEditor(editorId) and persist lastEditor
10 ChatView->>Preload: invoke 'shell:open-in-editor' (cwd, editorId)
11 Preload->>Main: Forward IPC invoke
12 Main->>Main: Lookup editor in EDITORS
13 alt editor == 'file-manager'
14 Main->>OS: shell.openPath(cwd)
15 else editor has command
16 Main->>OS: spawn(editorCommand, [cwd], {detached, stdio:'ignore'}) and unref()
17 end
18 OS-->>Main: process/path opened
19 Main-->>Preload: resolve Promise<void>
20 Preload-->>ChatView: invoke resolves
21 ChatView->>ChatView: close editor menu

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title accurately summarizes the main feature added: an open-in-editor capability triggered by a Cmd+O keyboard shortcut, which is the primary change across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch juliusmarminge/open-in-editor

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
apps/renderer/src/components/ChatView.tsx (3)

36-40: navigator.platform is deprecated but acceptable here.

While navigator.platform is technically deprecated in favor of navigator.userAgentData, it remains well-supported in Electron's renderer process. This is a reasonable choice for platform detection in this context.


203-220: Consider extracting click-outside logic to a reusable hook.

This pattern is duplicated with the model menu handler (lines 184-201). A useOnClickOutside(ref, callback) hook would reduce duplication.

♻️ Example hook extraction
typescript
1function useOnClickOutside(
2 ref: React.RefObject<HTMLElement>,
3 handler: () => void,
4 enabled: boolean,
5) {
6 useEffect(() => {
7 if (!enabled) return;
8 const handleClickOutside = (event: MouseEvent) => {
9 if (
10 ref.current &&
11 event.target instanceof Node &&
12 !ref.current.contains(event.target)
13 ) {
14 handler();
15 }
16 };
17 window.addEventListener("mousedown", handleClickOutside);
18 return () => window.removeEventListener("mousedown", handleClickOutside);
19 }, [ref, handler, enabled]);
20}

Then use:

typescript
1useOnClickOutside(editorMenuRef, () => setIsEditorMenuOpen(false), isEditorMenuOpen);
2useOnClickOutside(modelMenuRef, () => setIsModelMenuOpen(false), isModelMenuOpen);

378-410: Add aria attributes for accessibility.

The dropdown trigger is missing aria-expanded and aria-haspopup attributes that improve screen reader experience.

♿ Suggested accessibility improvement
diff
1 <button
2 type="button"
3 className="rounded-md px-2 py-1 text-[10px] text-[`#a0a0a0`]/40 transition-colors duration-150 hover:text-[`#a0a0a0`]/60"
4 onClick={() => setIsEditorMenuOpen((v) => !v)}
5+ aria-expanded={isEditorMenuOpen}
6+ aria-haspopup="menu"
7 >
8 Open in&hellip;
9 </button>

Comment @coderabbitai help to get the list of available commands and usage tips.

Synced from GitHub

Add Cmd+O/Ctrl+O open-in-editor action in chat view and handle IPC_CHANNELS.shellOpenInEditor in main process

Implement window.nativeApi.shell.openInEditor and a main-process IPC_CHANNELS.shellOpenInEditor handler that opens the active project's cwd in a selected editor from EDITORS, plus add an 'Open in…' menu and Cmd+O/Ctrl+O shortcut in the chat view.

📍Where to Start

Start with the IPC handler in registerIpcHandlers in main.ts, then review the preload API in preload.ts, and the UI wiring in ChatView.tsx.


📊 Macroscope summarized ff34d2c. 98 files reviewed, 10 issues evaluated, 8 issues filtered, 0 comments posted. View details

apps/desktop/src/main.tslines 152–152Outdated · 3303d9aOpen thread
……@@ -125,6 +132,27 @@ function registerIpcHandlers(): void {
149+ detached: true,
150+ stdio: "ignore",
151+ });
152+ child.unref();
MmacroscopeappBoton lines 152–152Feb 9, 2026

src/main.ts:152 The spawned child process lacks an error event listener. If the command isn't found (ENOENT), the unhandled error will crash the Electron main process. Consider adding child.on('error', () => {}) or wrapping in try-catch.

✅ Addressed in 84b71e0a016acc71cf12e375a2b151ee20bc11b3

CcoderabbitaiBotreviewed · 4 inline threadsFeb 9, 2026
Synced from GitHub

Actionable comments posted: 4

🤖 Fix all issues with AI agents
1In `@apps/desktop/src/main.ts`:
2- Around line 143-145: The CI formatter flagged the EDITOR_COMMANDS block — run
3the project formatter (e.g., run the formatter or execute `bun run lint`) to
4reformat the object literal; specifically ensure the EDITOR_COMMANDS constant
5and its entry for "cursor" follow the project's formatting rules (spacing,
6trailing commas, and indentation) so the object shape Record<string, { command:
7string; args: (cwd: string) => string[] }> and the cursor entry { command:
8"cursor", args: (p) => [p] } are formatted exactly as the linter expects.
9- Around line 135-153: The IPC handler for ipcMain.handle should validate cwd is
10non-empty, await shell.openPath(cwd) and check its returned string for an error
11(log/throw when non-empty) instead of assuming success, and protect the spawn
12path by wrapping the spawn call in try/catch and attaching an 'error' listener
13to the returned child (e.g., child.on('error', ...) to log/throw when the CLI
14cannot be spawned); reference EDITOR_COMMANDS and entry (from editor) to locate
15the spawn logic, and ensure you handle and surface errors for both
16shell.openPath and spawn so the handler doesn't crash or fail silently.
17
18In `@apps/renderer/src/components/ChatView.tsx`:
19- Around line 66-69: Validate the value read from localStorage under
20LAST_EDITOR_KEY against the known EDITORS list before using it as the initial
21state for lastEditor; replace the current initializer (() =>
22localStorage.getItem(LAST_EDITOR_KEY) ?? EDITORS[0].id) with logic that reads
23localStorage, checks EDITORS.some(e => e.id === stored), and falls back to
24EDITORS[0].id when the stored id is missing or not found; also ensure any places
25that write to lastEditor (setLastEditor) persist only validated ids to
26localStorage.
27- Around line 183-191: The keydown handler currently calls e.preventDefault()
28unconditionally for Cmd/Ctrl+O even when the action won't run; update the
29handler in the useEffect (function handler used for the KeyboardEvent) to only
30call e.preventDefault() when api and activeProject are truthy and you are about
31to call api.shell.openInEditor(activeProject.cwd, lastEditor), i.e. move or
32guard preventDefault() behind the same condition that checks api and
33activeProject so the key event is not swallowed when no activeProject exists.
apps/renderer/src/components/ChatView.tsxlines 183–191Outdated · 3303d9aOpen thread
……@@ -144,6 +161,47 @@ export default function ChatView() {
188+ if (api && activeProject) {
189+ void api.shell.openInEditor(activeProject.cwd, lastEditor);
190+ }
191+ }
CcoderabbitaiBoton lines 183–191Feb 9, 2026

⚠️ Potential issue | 🟡 Minor

Only prevent default when the action will run.
Right now Cmd/Ctrl+O is swallowed even when no active project exists. Gate preventDefault() behind the same condition as the action.

✅ Suggested tweak
diff
1 const handler = (e: globalThis.KeyboardEvent) => {
2 if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
3- e.preventDefault();
4- if (api && activeProject) {
5- void api.shell.openInEditor(activeProject.cwd, lastEditor);
6- }
7+ if (api && activeProject) {
8+ e.preventDefault();
9+ void api.shell.openInEditor(activeProject.cwd, lastEditor);
10+ }
11 }
12 };
📝 Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1 // Cmd+O / Ctrl+O to open in last-used editor
2 useEffect(() => {
3 const handler = (e: globalThis.KeyboardEvent) => {
4 if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
5 if (api && activeProject) {
6 e.preventDefault();
7 void api.shell.openInEditor(activeProject.cwd, lastEditor);
8 }
9 }
🤖 Prompt for AI Agents
1In `@apps/renderer/src/components/ChatView.tsx` around lines 183 - 191, The
2keydown handler currently calls e.preventDefault() unconditionally for
3Cmd/Ctrl+O even when the action won't run; update the handler in the useEffect
4(function handler used for the KeyboardEvent) to only call e.preventDefault()
5when api and activeProject are truthy and you are about to call
6api.shell.openInEditor(activeProject.cwd, lastEditor), i.e. move or guard
7preventDefault() behind the same condition that checks api and activeProject so
8the key event is not swallowed when no activeProject exists.

✅ Addressed in commit 84b71e0

apps/desktop/src/main.tslines 135–153Outdated · 3303d9aOpen thread
……@@ -125,6 +132,27 @@ function registerIpcHandlers(): void {
150+ stdio: "ignore",
151+ });
152+ child.unref();
153+ },
CcoderabbitaiBoton lines 135–153Feb 9, 2026

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

shell
1cat -n apps/desktop/src/main.ts | sed -n '135,153p'

Repository: pingdotgg/codething-mvp

Length of output: 848


🌐 Web query:

Electron shell.openPath() return value error handling

💡 Result:

shell.openPath(path) returns a Promise<string> that resolves (not rejects) to:

  • "" (empty string) on success
  • a non-empty error message string on failure (electronjs.org)

So error handling is typically:

ts
1import { shell } from "electron";
2
3async function openFile(p: string) {
4 try {
5 const err = await shell.openPath(p);
6 if (err) {
7 // open failed; Electron returns an error message string
8 console.error("openPath failed:", err);
9 // show UI / log / fallback here
10 }
11 } catch (e) {
12 // for unexpected exceptions (e.g., bad argument types, other runtime issues)
13 console.error("openPath threw:", e);
14 }
15}

Key point: check the resolved string—don’t rely only on catch. (electronjs.org)

Citations:


🌐 Web query:

Node.js child_process spawn error event handling

💡 Result:

In Node.js, child_process.spawn() can fail asynchronously, so you should always attach an 'error' handler to the returned ChildProcess:

  • 'error' event is emitted when the process could not be spawned (e.g., ENOENT, EACCES), could not be killed, or IPC send failed. The 'exit' event may or may not fire after an 'error', so don’t rely on 'exit' alone. [1]
  • 'close' event fires after the process ends and all stdio streams are closed; it will emit after 'exit', or after 'error' if the child failed to spawn. [2]

A common safe pattern is to funnel both 'error' and 'close' into a single “done once” handler:

js
1import { spawn } from 'node:child_process';
2
3function spawnChecked(cmd, args, opts) {
4 const child = spawn(cmd, args, opts);
5
6 let settled = false;
7 const done = (err, result) => {
8 if (settled) return;
9 settled = true;
10 if (err) throw err; // or reject a Promise / call cb(err)
11 return result;
12 };
13
14 child.once('error', (err) => done(err)); // spawn/kill/IPC failure
15 child.once('close', (code, signal) => { // stdio fully closed
16 if (code === 0) return done(null, { code, signal });
17 done(Object.assign(new Error(`exit ${code ?? 'null'} (${signal ?? 'no-signal'})`), { code, signal }));
18 });
19
20 return child;
21}

Sources: Node.js ChildProcess event docs for 'error' and 'close'. [1] [2]

References [1] Node.js docs — ChildProcess event: 'error' (nodejs.org)
[2] Node.js v22 docs — ChildProcess event: 'close' (nodejs.org)

Citations:


Add error handling for shell.openPath() and spawn() to prevent crashes and silent failures.
shell.openPath() returns a Promise that resolves to an error string (not thrown), and spawn() emits an 'error' event asynchronously when the process cannot be spawned (e.g., missing CLI). Without handling these, the IPC handler silently fails or crashes. Also guard against empty cwd.

🛠️ Suggested fix
diff
1- async (_event, cwd: string, editor: string) => {
2- if (editor === "file-manager") {
3- await shell.openPath(cwd);
4- return;
5- }
6+ async (_event, cwd: string, editor: string) => {
7+ if (!cwd) {
8+ throw new Error("cwd is required");
9+ }
10+ if (editor === "file-manager") {
11+ const error = await shell.openPath(cwd);
12+ if (error) {
13+ throw new Error(error);
14+ }
15+ return;
16+ }
17 const EDITOR_COMMANDS: Record<string, { command: string; args: (cwd: string) => string[] }> = {
18 cursor: { command: "cursor", args: (p) => [p] },
19 };
20 const entry = EDITOR_COMMANDS[editor];
21 if (!entry) throw new Error(`Unknown editor: ${editor}`);
22- const child = spawn(entry.command, entry.args(cwd), {
23- detached: true,
24- stdio: "ignore",
25- });
26- child.unref();
27+ await new Promise<void>((resolve, reject) => {
28+ const child = spawn(entry.command, entry.args(cwd), {
29+ detached: true,
30+ stdio: "ignore",
31+ });
32+ child.once("error", reject);
33+ child.once("spawn", () => {
34+ child.unref();
35+ resolve();
36+ });
37+ });
38 },
39 );
📝 Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1 // Shell handlers
2 ipcMain.handle(
3 IPC_CHANNELS.shellOpenInEditor,
4 async (_event, cwd: string, editor: string) => {
5 if (!cwd) {
6 throw new Error("cwd is required");
7 }
8 if (editor === "file-manager") {
9 const error = await shell.openPath(cwd);
10 if (error) {
11 throw new Error(error);
12 }
13 return;
14 }
15 const EDITOR_COMMANDS: Record<string, { command: string; args: (cwd: string) => string[] }> = {
16 cursor: { command: "cursor", args: (p) => [p] },
17 };
18 const entry = EDITOR_COMMANDS[editor];
19 if (!entry) throw new Error(`Unknown editor: ${editor}`);
20 await new Promise<void>((resolve, reject) => {
21 const child = spawn(entry.command, entry.args(cwd), {
22 detached: true,
23 stdio: "ignore",
24 });
25 child.once("error", reject);
26 child.once("spawn", () => {
27 child.unref();
28 resolve();
29 });
30 });
31 },
32 );
🤖 Prompt for AI Agents
1In `@apps/desktop/src/main.ts` around lines 135 - 153, The IPC handler for
2ipcMain.handle should validate cwd is non-empty, await shell.openPath(cwd) and
3check its returned string for an error (log/throw when non-empty) instead of
4assuming success, and protect the spawn path by wrapping the spawn call in
5try/catch and attaching an 'error' listener to the returned child (e.g.,
6child.on('error', ...) to log/throw when the CLI cannot be spawned); reference
7EDITOR_COMMANDS and entry (from editor) to locate the spawn logic, and ensure
8you handle and surface errors for both shell.openPath and spawn so the handler
9doesn't crash or fail silently.
apps/desktop/src/main.tslines 143–145Outdated · 3303d9aOpen thread
……@@ -125,6 +132,27 @@ function registerIpcHandlers(): void {
142+ }
143+ const EDITOR_COMMANDS: Record<string, { command: string; args: (cwd: string) => string[] }> = {
144+ cursor: { command: "cursor", args: (p) => [p] },
145+ };
CcoderabbitaiBoton lines 143–145Feb 9, 2026

⚠️ Potential issue | 🟡 Minor

Formatter failure in CI — please run the formatter.
CI reports the formatter would reformat this section. Run bun run lint after applying changes.

✨ Example formatting adjustment
diff
1- const EDITOR_COMMANDS: Record<string, { command: string; args: (cwd: string) => string[] }> = {
2+ const EDITOR_COMMANDS: Record<
3+ string,
4+ { command: string; args: (cwd: string) => string[] }
5+ > = {
6 cursor: { command: "cursor", args: (p) => [p] },
7 };
📝 Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1 const EDITOR_COMMANDS: Record<
2 string,
3 { command: string; args: (cwd: string) => string[] }
4 > = {
5 cursor: { command: "cursor", args: (p) => [p] },
6 };
🤖 Prompt for AI Agents
1In `@apps/desktop/src/main.ts` around lines 143 - 145, The CI formatter flagged
2the EDITOR_COMMANDS block — run the project formatter (e.g., run the formatter
3or execute `bun run lint`) to reformat the object literal; specifically ensure
4the EDITOR_COMMANDS constant and its entry for "cursor" follow the project's
5formatting rules (spacing, trailing commas, and indentation) so the object shape
6Record<string, { command: string; args: (cwd: string) => string[] }> and the
7cursor entry { command: "cursor", args: (p) => [p] } are formatted exactly as
8the linter expects.

✅ Addressed in commit 6204eaf

apps/renderer/src/components/ChatView.tsxlines 66–69Outdated · 3303d9aOpen thread
……@@ -51,12 +63,17 @@ export default function ChatView() {
66+ const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);
67+ const [lastEditor, setLastEditor] = useState(
68+ () => localStorage.getItem(LAST_EDITOR_KEY) ?? EDITORS[0].id,
69+ );
CcoderabbitaiBoton lines 66–69Feb 9, 2026

⚠️ Potential issue | 🟡 Minor

Guard against stale lastEditor values in localStorage.
If localStorage contains an unknown editor id (older versions, manual edits), the IPC handler will throw. Prefer validating against EDITORS and falling back.

🧩 Suggested fix
diff
1- const [lastEditor, setLastEditor] = useState(
2- () => localStorage.getItem(LAST_EDITOR_KEY) ?? EDITORS[0].id,
3- );
4+ const [lastEditor, setLastEditor] = useState(() => {
5+ const stored = localStorage.getItem(LAST_EDITOR_KEY);
6+ return EDITORS.some((editor) => editor.id === stored)
7+ ? stored
8+ : EDITORS[0].id;
9+ });
📝 Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1 const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);
2 const [lastEditor, setLastEditor] = useState(() => {
3 const stored = localStorage.getItem(LAST_EDITOR_KEY);
4 return EDITORS.some((editor) => editor.id === stored)
5 ? stored
6 : EDITORS[0].id;
7 });
🤖 Prompt for AI Agents
1In `@apps/renderer/src/components/ChatView.tsx` around lines 66 - 69, Validate the
2value read from localStorage under LAST_EDITOR_KEY against the known EDITORS
3list before using it as the initial state for lastEditor; replace the current
4initializer (() => localStorage.getItem(LAST_EDITOR_KEY) ?? EDITORS[0].id) with
5logic that reads localStorage, checks EDITORS.some(e => e.id === stored), and
6falls back to EDITORS[0].id when the stored id is missing or not found; also
7ensure any places that write to lastEditor (setLastEditor) persist only
8validated ids to localStorage.
Synced from GitHub

All done. Here's what I fixed:

apps/desktop/src/main.ts - IPC handler hardening:

  • Added cwd validation (throw if empty)
  • Check shell.openPath() return value and throw on error (it resolves to an error string, not rejects)
  • Added child.on('error', ...) listener on the spawned process to prevent crashes if the editor CLI is missing

apps/renderer/src/components/ChatView.tsx - Review feedback:

  • Validate localStorage value against the known EDITORS list before using as initial state, falling back to EDITORS[0].id for stale/unknown values
  • Moved e.preventDefault() inside the api && activeProject guard so Cmd/Ctrl+O isn't swallowed when no active project exists

Lint passes cleanly across all three packages. Changes pushed to juliusmarminge/open-in-editor.

Open in Cursor Open in Web

Added 4 commits

  • Move editor definitions to contracts for strict IPC typing6204eaf
  • Fix biome formatting issuesc8f33b8
  • fix: add error handling and address review feedback84b71e0
  • Merge origin/main and resolve conflicts36314bb