diff --git a/apps/desktop/src/main/editors.ts b/apps/desktop/src/main/editors.ts index 0f1ad7e..7830451 100644 --- a/apps/desktop/src/main/editors.ts +++ b/apps/desktop/src/main/editors.ts @@ -15,6 +15,14 @@ type EditorSpec = InstalledEditor & { type ScannedEditor = { editor: InstalledEditor; executable?: string }; +function fileArgs(targetPath: string, editorId: string, line: number | undefined, column?: number): string[] { + if (line && ["cursor", "vscode", "antigravity", "windsurf", "vscode-insiders", "vscodium"].includes(editorId)) { + return ["-g", `${targetPath}:${line}${column ? `:${column}` : ""}`]; + } + if (line && jetBrainsEditors.some(([id]) => id === editorId)) return ["--line", String(line), targetPath]; + return [targetPath]; +} + /** The platform's own file manager, named the way its own users would name it. */ export function fileManagerName(): string { if (process.platform === "darwin") return "Finder"; @@ -374,11 +382,11 @@ export async function listInstalledEditors(): Promise { * `open -a`, which also hands the app the target path the way double-clicking * a file in Finder would. Windows and Linux executables are launched directly. */ -function launchEditor(executable: string, target: string): ChildProcess { +function launchEditor(executable: string, target: string, args = [target]): ChildProcess { if (process.platform === "darwin" && executable.endsWith(".app")) { return spawn("open", ["-a", executable, target], { detached: true, stdio: "ignore" }); } - return spawn(executable, [target], { detached: true, stdio: "ignore", windowsHide: true }); + return spawn(executable, args, { detached: true, stdio: "ignore", windowsHide: true }); } export async function openProjectIn(projectDir: string, editorId: string): Promise { @@ -399,7 +407,7 @@ export async function openProjectIn(projectDir: string, editorId: string): Promi }); } -export async function openFileIn(projectDir: string, file: string, editorId: string): Promise { +export async function openFileIn(projectDir: string, file: string, editorId: string, line?: number, column?: number): Promise { const targetPath = resolve(projectDir, file); const relativePath = relative(projectDir, targetPath); if (isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${sep}`)) { @@ -413,7 +421,7 @@ export async function openFileIn(projectDir: string, file: string, editorId: str if (!target?.executable) throw new Error("That editor is no longer installed."); const executable = target.executable; await new Promise((resolveSpawn, reject) => { - const child = launchEditor(executable, targetPath); + const child = launchEditor(executable, targetPath, fileArgs(targetPath, target.editor.id, line, column)); child.once("spawn", resolveSpawn); child.once("error", reject); child.unref(); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index b44232e..f6c438b 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -24,9 +24,12 @@ import { closeTerminal, closeProjectTerminals, createTerminal, + listShellProfiles, listTerminals, liveTerminalProjects, + renameTerminal, resizeTerminal, + restartTerminal, stopAllTerminals, terminalSnapshot, writeTerminal, @@ -306,7 +309,7 @@ function toSessionState(data: RpcSessionState): RpcSessionState { const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); const openProjectInParamsSchema = z.object({ projectDir: z.string().min(1), editorId: z.string().min(1) }); -const openFileInParamsSchema = openProjectInParamsSchema.extend({ file: z.string().min(1) }); +const openFileInParamsSchema = openProjectInParamsSchema.extend({ file: z.string().min(1), line: z.number().int().positive().optional(), column: z.number().int().positive().optional() }); const saveImageParamsSchema = z.object({ data: z.string().min(1).max(64 * 1024 * 1024), mimeType: z.enum(["image/png", "image/jpeg", "image/gif", "image/webp"]), @@ -371,6 +374,11 @@ const tuiApplyParamsSchema = tuiCompleteParamsSchema.extend({ prefix: z.string().max(200), }); const terminalIdParamsSchema = projectDirParamsSchema.extend({ terminalId: z.string().min(1) }); +const terminalCreateParamsSchema = projectDirParamsSchema.extend({ + shellId: z.string().optional(), + name: z.string().optional(), +}); +const terminalRenameParamsSchema = terminalIdParamsSchema.extend({ name: z.string().min(1).max(64) }); const terminalResizeParamsSchema = terminalIdParamsSchema.extend({ cols: z.number().int().min(2).max(1000), rows: z.number().int().min(1).max(1000), @@ -826,8 +834,8 @@ const handlers: HandlerMap = { }, openFileIn: async (params) => { try { - const { projectDir, file, editorId } = openFileInParamsSchema.parse(params); - await openFileIn(projectDir, file, editorId); + const { projectDir, file, editorId, line, column } = openFileInParamsSchema.parse(params); + await openFileIn(projectDir, file, editorId, line, column); return { ok: true }; } catch (err) { return { ok: false, error: errorMessage(err) }; @@ -949,13 +957,15 @@ const handlers: HandlerMap = { }, terminalEnsure: (params) => { - const { projectDir } = projectDirParamsSchema.parse(params); + const { projectDir, shellId } = projectDirParamsSchema.extend({ shellId: z.string().optional() }).parse(params); const existing = listTerminals(projectDir); if (existing.length > 0) return { terminals: existing }; return { terminals: [ createTerminal( projectDir, + shellId, + undefined, (payload) => push("terminalData", payload), (payload) => push("terminalExit", payload), ), @@ -963,15 +973,34 @@ const handlers: HandlerMap = { }; }, terminalCreate: (params) => { - const { projectDir } = projectDirParamsSchema.parse(params); + const { projectDir, shellId, name } = terminalCreateParamsSchema.parse(params); return { terminal: createTerminal( projectDir, + shellId, + name, (payload) => push("terminalData", payload), (payload) => push("terminalExit", payload), ), }; }, + terminalListShells: () => ({ shells: listShellProfiles() }), + terminalRename: (params) => { + const { projectDir, terminalId, name } = terminalRenameParamsSchema.parse(params); + renameTerminal(projectDir, terminalId, name); + return { ok: true }; + }, + terminalRestart: (params) => { + const { projectDir, terminalId } = terminalIdParamsSchema.parse(params); + const terminal = restartTerminal( + projectDir, + terminalId, + (payload) => push("terminalData", payload), + (payload) => push("terminalExit", payload), + ); + push("terminalRestart", { projectDir, terminal }); + return { terminal }; + }, terminalSnapshot: (params) => { const { projectDir, terminalId } = terminalIdParamsSchema.parse(params); return terminalSnapshot(projectDir, terminalId); diff --git a/apps/desktop/src/main/terminal.ts b/apps/desktop/src/main/terminal.ts index d402d91..bb73fe2 100644 --- a/apps/desktop/src/main/terminal.ts +++ b/apps/desktop/src/main/terminal.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { delimiter, join } from "node:path"; import type { IPty } from "node-pty"; import { spawn } from "node-pty"; -import type { TerminalSession } from "../shared/rpc-schema.ts"; +import type { ShellProfile, TerminalSession } from "../shared/rpc-schema.ts"; const MAX_BUFFER_SIZE = 2 * 1024 * 1024; @@ -16,19 +16,63 @@ type ManagedTerminal = TerminalSession & { const terminals = new Map(); -function resolveShell(): string { - if (process.platform !== "win32") return process.env["SHELL"] || "/bin/bash"; +/** + * Shells NativePi knows how to look for. Detection is PATH-first with a few + * well-known install locations as a fallback, the same shape `editors.ts` + * uses for applications — adding a shell for another platform is one entry + * here, not a new branch. + */ +const SHELL_CANDIDATES: { id: string; name: string; executable: string; paths: string[]; args: string[] }[] = [ + { id: "pwsh", name: "PowerShell 7", executable: "pwsh.exe", paths: [], args: ["-NoLogo"] }, + { id: "powershell", name: "Windows PowerShell", executable: "powershell.exe", paths: [], args: ["-NoLogo"] }, + { id: "cmd", name: "Command Prompt", executable: "cmd.exe", paths: [], args: [] }, + { + id: "gitbash", + name: "Git Bash", + executable: "bash.exe", + paths: [ + join(process.env["ProgramFiles"] ?? "", "Git", "bin", "bash.exe"), + join(process.env["ProgramFiles(x86)"] ?? "", "Git", "bin", "bash.exe"), + ], + args: ["--login", "-i"], + }, + { id: "wsl", name: "WSL", executable: "wsl.exe", paths: [], args: [] }, +]; + +function findOnPath(executable: string): string | undefined { const path = process.env["PATH"] ?? ""; for (const dir of path.split(delimiter)) { if (!dir) continue; - const candidate = join(dir, "pwsh.exe"); + const candidate = join(dir, executable); if (existsSync(candidate)) return candidate; } - return "powershell.exe"; + return undefined; +} + +function resolveCandidate(candidate: { executable: string; paths: string[] }): string | undefined { + return findOnPath(candidate.executable) ?? candidate.paths.find(existsSync); +} + +export function listShellProfiles(): ShellProfile[] { + if (process.platform !== "win32") return [{ id: "default", name: process.env["SHELL"] || "Shell" }]; + return SHELL_CANDIDATES.filter((candidate) => resolveCandidate(candidate) !== undefined).map( + ({ id, name }) => ({ id, name }), + ); } -function shellArgs(): string[] { - return process.platform === "win32" ? ["-NoLogo"] : process.platform === "darwin" ? ["-l"] : []; +function resolveShell(shellId: string | undefined): { id: string; path: string; args: string[] } { + if (process.platform !== "win32") { + return { + id: "default", + path: process.env["SHELL"] || "/bin/bash", + args: process.platform === "darwin" ? ["-l"] : [], + }; + } + const candidate = SHELL_CANDIDATES.find((entry) => entry.id === shellId); + const resolved = candidate ? resolveCandidate(candidate) : undefined; + if (candidate && resolved) return { id: candidate.id, path: resolved, args: candidate.args }; + const path = findOnPath("pwsh.exe") ?? "powershell.exe"; + return { id: "pwsh", path, args: ["-NoLogo"] }; } function shellEnv(): Record { @@ -49,6 +93,8 @@ function publicSession(terminal: ManagedTerminal): TerminalSession { return { id: terminal.id, projectDir: terminal.projectDir, + name: terminal.name, + shellId: terminal.shellId, exited: terminal.exited, exitCode: terminal.exitCode, }; @@ -65,33 +111,39 @@ export function liveTerminalProjects(): string[] { return [...terminals.values()].filter((terminal) => !terminal.exited).map((terminal) => terminal.projectDir); } -export function createTerminal( - projectDir: string, - onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void, - onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void, -): TerminalSession { - const id = randomUUID(); - const pty = spawn(resolveShell(), shellArgs(), { +function nextTerminalName(projectDir: string): string { + const used = new Set( + [...terminals.values()].filter((terminal) => terminal.projectDir === projectDir).map((terminal) => terminal.name), + ); + let index = 1; + while (used.has(`Terminal ${index}`)) index += 1; + return `Terminal ${index}`; +} + +function spawnPty(projectDir: string, shellId: string | undefined): { pty: IPty; resolvedShellId: string } { + const shell = resolveShell(shellId); + const pty = spawn(shell.path, shell.args, { name: "xterm-256color", cols: 100, rows: 24, cwd: projectDir, env: shellEnv(), }); - const terminal: ManagedTerminal = { - id, - projectDir, - process: pty, - output: [], - outputSize: 0, - sequence: 0, - exited: false, - }; - terminals.set(id, terminal); + return { pty, resolvedShellId: shell.id }; +} - pty.onData((data) => { +function attachHandlers( + id: string, + projectDir: string, + onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void, + onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void, +): void { + const terminal = terminals.get(id); + if (!terminal) return; + const process = terminal.process; + process.onData((data) => { const current = terminals.get(id); - if (!current) return; + if (!current || current.process !== process) return; current.output.push(data); current.outputSize += data.length; while (current.outputSize > MAX_BUFFER_SIZE && current.output.length > 1) { @@ -101,14 +153,66 @@ export function createTerminal( onData({ projectDir, terminalId: id, data, sequence: current.sequence }); }); - pty.onExit(({ exitCode }) => { + process.onExit(({ exitCode }) => { const current = terminals.get(id); - if (!current) return; + if (!current || current.process !== process) return; current.exited = true; current.exitCode = exitCode; onExit({ projectDir, terminalId: id, exitCode }); }); +} +export function createTerminal( + projectDir: string, + shellId: string | undefined, + name: string | undefined, + onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void, + onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void, +): TerminalSession { + const id = randomUUID(); + const { pty, resolvedShellId } = spawnPty(projectDir, shellId); + const terminal: ManagedTerminal = { + id, + projectDir, + name: name?.trim() || nextTerminalName(projectDir), + shellId: resolvedShellId, + process: pty, + output: [], + outputSize: 0, + sequence: 0, + exited: false, + }; + terminals.set(id, terminal); + attachHandlers(id, projectDir, onData, onExit); + return publicSession(terminal); +} + +export function renameTerminal(projectDir: string, terminalId: string, name: string): void { + const terminal = terminals.get(terminalId); + if (!terminal || terminal.projectDir !== projectDir) return; + const trimmed = name.trim(); + if (trimmed) terminal.name = trimmed; +} + +/** Kills the running shell and starts a fresh one under the same id, name, and profile. */ +export function restartTerminal( + projectDir: string, + terminalId: string, + onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void, + onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void, +): TerminalSession { + const terminal = terminals.get(terminalId); + if (!terminal || terminal.projectDir !== projectDir) throw new Error("Terminal not found"); + if (!terminal.exited) terminal.process.kill(); + const { pty, resolvedShellId } = spawnPty(projectDir, terminal.shellId); + terminal.process = pty; + terminal.shellId = resolvedShellId; + terminal.output = []; + terminal.outputSize = 0; + terminal.sequence = 0; + terminal.exited = false; + terminal.exitCode = undefined; + attachHandlers(terminalId, projectDir, onData, onExit); return publicSession(terminal); } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index dd1e66c..682ae86 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -37,6 +37,7 @@ const allowedEvents: Record = { quitRequested: true, terminalData: true, terminalExit: true, + terminalRestart: true, tuiFrame: true, }; diff --git a/apps/desktop/src/renderer/components/TerminalDock.tsx b/apps/desktop/src/renderer/components/TerminalDock.tsx index 8239d29..9e8788e 100644 --- a/apps/desktop/src/renderer/components/TerminalDock.tsx +++ b/apps/desktop/src/renderer/components/TerminalDock.tsx @@ -1,13 +1,19 @@ import { useEffect, useRef, useState } from "react"; import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; +import { CaretDownIcon } from "@phosphor-icons/react/CaretDown"; +import { CheckIcon } from "@phosphor-icons/react/Check"; import { SplitHorizontalIcon } from "@phosphor-icons/react/SplitHorizontal"; import { TerminalWindowIcon } from "@phosphor-icons/react/TerminalWindow"; import { TrashIcon } from "@phosphor-icons/react/Trash"; import { XIcon } from "@phosphor-icons/react/X"; -import type { TerminalSession } from "../../shared/rpc-schema.ts"; +import { toast } from "sonner"; +import type { ShellProfile, TerminalSession } from "../../shared/rpc-schema.ts"; +import { findTerminalLinks } from "../lib/terminalLinks.ts"; import { useAppStore } from "../lib/store.ts"; import { Button } from "@/components/ui/button.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu.tsx"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable.tsx"; import { rpc } from "@/lib/rpc.ts"; import { @@ -18,6 +24,29 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu.tsx"; +function columnForTextOffset(line: { length: number; getCell(index: number): { getChars(): string; getWidth(): number } | undefined }, offset: number) { + let textOffset = 0; + for (let index = 0; index < line.length; index += 1) { + const cell = line.getCell(index); + const chars = cell?.getChars() ?? ""; + if (!chars) continue; + if (textOffset >= offset) return index + 1; + textOffset += chars.length; + if (textOffset >= offset) return index + cell!.getWidth(); + } + return line.length; +} + +function updateWorkingDirectory(cwdRef: { current: string }, controlRef: { current: string }, data: string) { + const control = `${controlRef.current}${data}`; + for (const match of control.matchAll(/\x1b]7;file:\/\/[^/]*([^\x07\x1b]*)(?:\x07|\x1b\\)/g)) { + try { + cwdRef.current = decodeURIComponent(match[1]).replace(/^\/([A-Za-z]:)/, "$1"); + } catch {} + } + controlRef.current = control.slice(-4096); +} + export default function TerminalDock({ projectDir, onMinimize, @@ -26,12 +55,15 @@ export default function TerminalDock({ onMinimize: () => void; }) { const [terminals, setTerminals] = useState([]); + const [shells, setShells] = useState([]); const [error, setError] = useState(null); const [closing, setClosing] = useState(false); + const preferredShellId = useAppStore((s) => s.preferences.preferredShellId); + const setPreference = useAppStore((s) => s.setPreference); useEffect(() => { let cancelled = false; - void rpc.request.terminalEnsure({ projectDir }).then( + void rpc.request.terminalEnsure({ projectDir, shellId: preferredShellId || undefined }).then( ({ terminals: next }) => { if (!cancelled) setTerminals(next); }, @@ -39,6 +71,12 @@ export default function TerminalDock({ if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); }, ); + void rpc.request.terminalListShells({}).then( + ({ shells: next }) => { + if (!cancelled) setShells(next); + }, + () => {}, + ); const offExit = rpc.events.on("terminalExit", ({ projectDir: exitedProject, terminalId, exitCode }) => { if (exitedProject !== projectDir) return; setTerminals((current) => @@ -47,21 +85,52 @@ export default function TerminalDock({ ), ); }); + const offRestart = rpc.events.on("terminalRestart", ({ projectDir: restartedProject, terminal }) => { + if (restartedProject !== projectDir) return; + setTerminals((current) => current.map((session) => (session.id === terminal.id ? terminal : session))); + }); return () => { cancelled = true; offExit(); + offRestart(); }; - }, [projectDir]); + }, [projectDir, preferredShellId]); - async function addSplit() { + async function addSplit(shellId?: string, name?: string) { try { - const { terminal } = await rpc.request.terminalCreate({ projectDir }); + const { terminal } = await rpc.request.terminalCreate({ projectDir, shellId, name }); setTerminals((current) => [...current, terminal]); } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } } + async function duplicateSplit(session: TerminalSession) { + await addSplit(session.shellId, `${session.name} copy`); + } + + async function restartSplit(terminalId: string) { + try { + const { terminal } = await rpc.request.terminalRestart({ projectDir, terminalId }); + setTerminals((current) => current.map((session) => (session.id === terminalId ? terminal : session))); + } catch (reason) { + toast.error(reason instanceof Error ? reason.message : String(reason)); + } + } + + async function renameSplit(terminalId: string, name: string) { + const trimmed = name.trim(); + if (!trimmed) return; + setTerminals((current) => + current.map((session) => (session.id === terminalId ? { ...session, name: trimmed } : session)), + ); + try { + await rpc.request.terminalRename({ projectDir, terminalId, name: trimmed }); + } catch (reason) { + toast.error(reason instanceof Error ? reason.message : String(reason)); + } + } + async function closeSplit(terminalId: string) { if (closing) return; setClosing(true); @@ -100,15 +169,12 @@ export default function TerminalDock({ }> - + void addSplit(preferredShellId || undefined)} closeAll={closeAll} closing={closing} /> {error ? (
- Could not start PowerShell: {error} + Could not start a shell: {error}
) : terminals.length === 0 ? (
- Starting PowerShell… + Starting a shell…
) : ( @@ -143,13 +209,13 @@ export default function TerminalDock({ projectDir={projectDir} defaultSize={`${100 / terminals.length}%`} showHandle={index > 0} - // A single terminal needs no chrome: the dock header already names it - // and can close it. Splits do, or an individual split cannot be closed. - showChrome={terminals.length > 1} closing={closing} onClose={() => void closeSplit(terminal.id)} - onSplit={addSplit} + onSplit={() => void addSplit(preferredShellId || undefined)} onCloseAll={closeAll} + onDuplicate={() => void duplicateSplit(terminal)} + onRestart={() => void restartSplit(terminal.id)} + onRename={(name) => void renameSplit(terminal.id, name)} /> ))} @@ -158,74 +224,201 @@ export default function TerminalDock({ ); } +function ShellSplitButton({ + shells, + preferredShellId, + onChoose, + onSplit, +}: { + shells: ShellProfile[]; + preferredShellId: string; + onChoose: (shellId: string) => void; + onSplit: (shellId?: string) => void; +}) { + if (shells.length === 0) { + return ( + + ); + } + const selected = shells.find((shell) => shell.id === preferredShellId); + return ( +
+ + + } + title="Choose a shell" + aria-label="Choose a shell" + > + + + + {shells.map((shell) => ( + onChoose(shell.id)} + className="text-sm" + > + {shell.name} + {shell.id === preferredShellId ? : null} + + ))} + + +
+ ); +} + function TerminalSplit({ session, projectDir, defaultSize, showHandle, - showChrome, closing, onClose, onSplit, onCloseAll, + onDuplicate, + onRestart, + onRename, }: { session: TerminalSession; projectDir: string; defaultSize: string; showHandle: boolean; - showChrome: boolean; closing: boolean; onClose: () => void; - onSplit: () => Promise; + onSplit: () => void; onCloseAll: () => Promise; + onDuplicate: () => void; + onRestart: () => void; + onRename: (name: string) => void; }) { + const [editingName, setEditingName] = useState(false); + return ( <> {showHandle ? : null}
- {showChrome ? ( - - }> + + }> + + {editingName ? ( + { + onRename(event.currentTarget.value); + setEditingName(false); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") setEditingName(false); + }} + /> + ) : ( + + )} - - - - ) : null} - + + setEditingName(true)} + /> + +
); } +function StatusDot({ session }: { session: TerminalSession }) { + if (!session.exited) { + return