diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4f84aa55b7..868219fe68 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3,9 +3,10 @@ fixPath(); import { spawn } from "node:child_process"; import path from "node:path"; -import { BrowserWindow, app, dialog, ipcMain, session } from "electron"; +import { BrowserWindow, app, dialog, ipcMain, session, shell } from "electron"; import { + EDITORS, IPC_CHANNELS, type TerminalCommandInput, type TerminalCommandResult, @@ -125,6 +126,29 @@ function registerIpcHandlers(): void { }), ); + // Shell handlers + ipcMain.handle( + IPC_CHANNELS.shellOpenInEditor, + async (_event, cwd: string, editor: string) => { + if (!cwd) throw new Error("cwd is required"); + const editorDef = EDITORS.find((e) => e.id === editor); + if (!editorDef) throw new Error(`Unknown editor: ${editor}`); + if (!editorDef.command) { + const error = await shell.openPath(cwd); + if (error) throw new Error(error); + return; + } + const child = spawn(editorDef.command, [cwd], { + detached: true, + stdio: "ignore", + }); + child.on("error", () => { + /* ignore spawn failures for detached editors */ + }); + child.unref(); + }, + ); + // Agent handlers ipcMain.handle( IPC_CHANNELS.agentSpawn, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b72be5e13d..8fd7370d2b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -52,6 +52,10 @@ const nativeApi: NativeApi = { ipcRenderer.removeListener(IPC_CHANNELS.providerEvent, listener); }, }, + shell: { + openInEditor: (cwd: string, editor: string) => + ipcRenderer.invoke(IPC_CHANNELS.shellOpenInEditor, cwd, editor), + }, }; contextBridge.exposeInMainWorld("nativeApi", nativeApi); diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index f86f3b42a7..9bccb80fd4 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -8,6 +8,7 @@ import { useState, } from "react"; +import { EDITORS, type EditorId } from "@acme/contracts"; import { DEFAULT_MODEL, DEFAULT_REASONING, @@ -32,6 +33,18 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { return `${formatTimestamp(createdAt)} • ${duration}`; } +const FILE_MANAGER_LABEL = navigator.platform.includes("Mac") + ? "Finder" + : navigator.platform.includes("Win") + ? "Explorer" + : "Files"; + +function editorLabel(editor: (typeof EDITORS)[number]): string { + return editor.command ? editor.label : FILE_MANAGER_LABEL; +} + +const LAST_EDITOR_KEY = "codething:last-editor"; + function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50"; if (tone === "tool") return "text-[#8a8a8a]"; @@ -46,12 +59,20 @@ export default function ChatView() { const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); + const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); + const [lastEditor, setLastEditor] = useState(() => { + const stored = localStorage.getItem(LAST_EDITOR_KEY); + return EDITORS.some((e) => e.id === stored) + ? (stored as EditorId) + : EDITORS[0].id; + }); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [nowTick, setNowTick] = useState(() => Date.now()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const modelMenuRef = useRef(null); + const editorMenuRef = useRef(null); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeProject = state.projects.find( @@ -179,6 +200,47 @@ export default function ChatView() { }; }, [isModelMenuOpen]); + useEffect(() => { + if (!isEditorMenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if (!editorMenuRef.current) return; + if ( + event.target instanceof Node && + !editorMenuRef.current.contains(event.target) + ) { + setIsEditorMenuOpen(false); + } + }; + + window.addEventListener("mousedown", handleClickOutside); + return () => { + window.removeEventListener("mousedown", handleClickOutside); + }; + }, [isEditorMenuOpen]); + + // Cmd+O / Ctrl+O to open in last-used editor + useEffect(() => { + const handler = (e: globalThis.KeyboardEvent) => { + if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + if (api && activeProject) { + e.preventDefault(); + void api.shell.openInEditor(activeProject.cwd, lastEditor); + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [api, activeProject, lastEditor]); + + const openInEditor = (editorId: EditorId) => { + if (!api || !activeProject) return; + void api.shell.openInEditor(activeProject.cwd, editorId); + setLastEditor(editorId); + localStorage.setItem(LAST_EDITOR_KEY, editorId); + setIsEditorMenuOpen(false); + }; + const ensureSession = async (): Promise => { if (!api || !activeThread || !activeProject) return null; if (activeThread.session && activeThread.session.status !== "closed") { @@ -313,6 +375,39 @@ export default function ChatView() {
+ {/* Open in editor */} + {activeProject && ( +
+ + {isEditorMenuOpen && ( +
+ {EDITORS.map((editor) => ( + + ))} +
+ )} +
+ )} {/* Diff toggle */}