-
Notifications
You must be signed in to change notification settings - Fork 0
feat: expand integrated terminal with named tabs, shell profiles, and links #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3d637e9
746e82e
d5a2725
ed7ab52
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, ManagedTerminal>(); | ||
|
|
||
| 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<string, string> { | ||
|
|
@@ -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; | ||
|
Comment on lines
+206
to
+208
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a running terminal is restarted, Useful? React with 👍 / 👎. |
||
| 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); | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user selects and persists Git Bash, WSL, or another profile, reopening the dock with no existing terminals still passes
undefinedas the shell ID, soterminalEnsurealways starts the PowerShell fallback. The saved preference currently affects only subsequently created splits, making it ineffective for the normal single-terminal workflow.Useful? React with 👍 / 👎.