diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 29bc383a2c89a735b791cdf71f6338e5821075d2..4b32d7bdb5513088caa82beb4349bbd6c8e8dd3e 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, @@ -126,6 +127,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 498403942587f3f045db02b383db910c4aa13983..68b9311f59d3631e0db2d9557856684764949436 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -54,6 +54,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 96483ea70e142f6caea464a2259ce6579a034dd5..d73aa4d6bf21777b1788129d812db9086bcc4108 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -9,6 +9,7 @@ import { useState, } from "react"; +import { EDITORS, type EditorId } from "@acme/contracts"; import { DEFAULT_MODEL, DEFAULT_REASONING, @@ -33,6 +34,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]"; @@ -111,6 +124,13 @@ 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 [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); @@ -121,6 +141,7 @@ export default function ChatView() { 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( @@ -291,6 +312,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") { @@ -458,6 +520,39 @@ export default function ChatView() {
+ {/* Open in editor */} + {activeProject && ( +
+ + {isEditorMenuOpen && ( +
+ {EDITORS.map((editor) => ( + + ))} +
+ )} +
+ )} {/* Diff toggle */}