feat(terminal): add embedded PTY-backed terminal dock with per-tab sessions

Adds a built-in terminal dock that runs a real pseudo-terminal per repository tab.

- Backend: new src-tauri/src/terminal.rs implements TerminalState and Tauri commands
  terminal_open, terminal_write, terminal_resize and terminal_close using the
  portable-pty crate. Terminal output is emitted via "terminal:data" and exits
  via "terminal:exit". Includes unit tests for UTF-8 chunk handling. Cargo.toml
  updated to depend on portable-pty and main.rs registers the state/commands.
- Frontend: App.svelte/UI and app.css updated to show a resizable terminal dock,
  toggleable with Ctrl+` and persisted open/height state. TerminalPanel.svelte is
  lazy-loaded per repo tab (frontend provides session ids like repo:<path>).
  package.json adds @xterm/xterm and @xterm/addon-fit for the frontend terminal
  surface.
- Docs: CHANGELOG.md notes the new embedded terminal feature.

Behavior: each repository tab has its own PTY-backed shell started in the repo
cwd; the user's shell (SHELL/COMSPEC) is used so prompts, aliases and interactive
behavior work as in a normal terminal.
This commit is contained in:
2026-09-22 21:11:48 +02:00
parent 8bac03e3fc
commit 6ef5d3b677
13 changed files with 832 additions and 85 deletions
+2
View File
@@ -249,6 +249,7 @@
commands: [
{ command: "Ctrl + /", description: "Diese Hilfe öffnen" },
{ command: "Ctrl + 1 … 4", description: "Zwischen Dashboard, Repositories, Pull Requests und Issues & Boards wechseln" },
{ command: "Ctrl + ^", description: "Terminal im Repository ein- und ausblenden" },
{ command: "Ctrl + A", description: "Alle Dateien in der aktiven Statusliste („Ungestaged“ oder „Gestaged“) auswählen" },
{ command: "Escape", description: "Aktuelles Overlay oder Dialogfenster schließen – in der Statusliste die aktuelle Auswahl aufheben" },
{ command: "Tab / Shift + Tab", description: "Zwischen Bedienelementen wechseln" },
@@ -461,6 +462,7 @@
commands: [
{ command: "Ctrl + /", description: "Open this help center" },
{ command: "Ctrl + 1 … 4", description: "Switch between Dashboard, Repositories, Pull Requests and Issues & Boards" },
{ command: "Ctrl + `", description: "Show or hide the terminal inside the repository view" },
{ command: "Ctrl + A", description: "Select every file in the focused status list (Unstaged or Staged)" },
{ command: "Escape", description: "Close the current overlay or dialog – in the status list, clear the current selection" },
{ command: "Tab / Shift + Tab", description: "Move between controls" },
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import { onDestroy, onMount, tick } from "svelte";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { closeTerminal, openTerminal, resizeTerminal, writeTerminal } from "../git";
import { t } from "../i18n.svelte";
interface Props {
/** Stable per repository tab, so every tab keeps its own shell. */
sessionId: string;
repoPath: string;
/** Only the active tab's terminal is visible; the others stay alive. */
active: boolean;
}
let { sessionId, repoPath, active = false }: Props = $props();
let host = $state<HTMLDivElement | null>(null);
let terminal: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let observer: ResizeObserver | null = null;
let unlisteners: UnlistenFn[] = [];
let started = false;
let exited = $state(false);
let error = $state("");
/** xterm needs concrete colors, so the theme tokens are resolved once here. */
function readTheme() {
const styles = getComputedStyle(document.documentElement);
const read = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback;
const foreground = read("--color-ink", "#e6e9f0");
return {
background: read("--color-surface-solid", "#12141a"),
foreground,
cursor: read("--color-primary", "#4f8cff"),
cursorAccent: read("--color-surface-solid", "#12141a"),
selectionBackground: read("--color-selection", "rgba(79, 140, 255, 0.32)"),
red: read("--code-delete-strong", "#e86060"),
green: read("--code-add-strong", "#4eca76"),
brightRed: read("--code-delete-text", "#e86060"),
brightGreen: read("--code-add-text", "#5dd88a"),
blue: read("--color-primary", "#4f8cff"),
brightBlue: read("--color-accent", "#7aa2ff"),
};
}
function fit() {
if (!fitAddon || !terminal || !active) return;
try {
fitAddon.fit();
void resizeTerminal(sessionId, terminal.cols, terminal.rows).catch(() => {});
} catch {
// A hidden or zero-sized host throws; the next visible fit corrects it.
}
}
async function start() {
if (started || !host) return;
started = true;
terminal = new Terminal({
fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--font-mono").trim() || "monospace",
fontSize: 12.5,
lineHeight: 1.25,
cursorBlink: true,
scrollback: 5000,
allowProposedApi: true,
theme: readTheme(),
});
fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(host);
await tick();
try { fitAddon.fit(); } catch { /* see fit() */ }
terminal.onData((data) => {
if (exited) return;
void writeTerminal(sessionId, data).catch((cause) => { error = String(cause); });
});
terminal.onResize(({ cols, rows }) => {
if (exited) return;
void resizeTerminal(sessionId, cols, rows).catch(() => {});
});
unlisteners.push(await listen<{ id: string; data: string }>("terminal:data", (event) => {
if (event.payload.id !== sessionId) return;
terminal?.write(event.payload.data);
}));
unlisteners.push(await listen<{ id: string; message: string }>("terminal:exit", (event) => {
if (event.payload.id !== sessionId) return;
exited = true;
if (event.payload.message) error = event.payload.message;
}));
try {
await openTerminal(sessionId, repoPath, terminal.cols, terminal.rows);
terminal.focus();
} catch (cause) {
error = String(cause);
exited = true;
}
}
onMount(() => {
void start();
if (host) {
observer = new ResizeObserver(() => fit());
observer.observe(host);
}
return () => {};
});
onDestroy(() => {
observer?.disconnect();
for (const unlisten of unlisteners) unlisten();
unlisteners = [];
terminal?.dispose();
terminal = null;
void closeTerminal(sessionId).catch(() => {});
});
// Becoming visible again needs a fresh measurement: xterm cannot size itself
// while its host is display:none.
$effect(() => {
if (!active) return;
void tick().then(() => {
fit();
terminal?.focus();
});
});
</script>
<div class="terminal-surface" class:hidden={!active} aria-hidden={!active}>
<div bind:this={host} class="terminal-host"></div>
{#if error}
<p class="terminal-error" role="status">{error}</p>
{:else if exited}
<p class="terminal-error" role="status">{t("terminal.exited")}</p>
{/if}
</div>
+19
View File
@@ -777,3 +777,22 @@ export function getIntegrationIssueLabels(provider: GitIntegrationProvider, base
export function setIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, labels: import("./types").IntegrationLabel[], expected: string[] | null = null): Promise<import("./types").IntegrationLabel[]> {
return invoke("set_integration_issue_labels", { provider, baseUrl, username, token, repository, number, labels, expected });
}
// ── Embedded terminal ────────────────────────────────────────────────────────
// Session ids are owned by the frontend: one per repository tab.
export function openTerminal(id: string, cwd: string, cols: number, rows: number): Promise<void> {
return invoke<void>("terminal_open", { id, cwd, cols, rows });
}
export function writeTerminal(id: string, data: string): Promise<void> {
return invoke<void>("terminal_write", { id, data });
}
export function resizeTerminal(id: string, cols: number, rows: number): Promise<void> {
return invoke<void>("terminal_resize", { id, cols, rows });
}
export function closeTerminal(id: string): Promise<void> {
return invoke<void>("terminal_close", { id });
}
+9
View File
@@ -185,6 +185,15 @@ export const messages = {
"stashes.drop": { en: "Drop", de: "Löschen" },
// ── Status panel ───────────────────────────────────────────────────────────
// ── Embedded terminal ──────────────────────────────────────────────────────
"terminal.title": { en: "Terminal", de: "Terminal" },
"terminal.show": { en: "Show terminal", de: "Terminal anzeigen" },
"terminal.hide": { en: "Hide terminal", de: "Terminal ausblenden" },
"terminal.close": { en: "Close terminal", de: "Terminal schließen" },
"terminal.resize": { en: "Resize terminal", de: "Terminalhöhe ändern" },
"terminal.exited": { en: "The shell has ended. Close and reopen the terminal to start a new one.", de: "Die Shell wurde beendet. Terminal schließen und erneut öffnen startet eine neue." },
"terminal.hint": { en: "Runs in the repository directory", de: "Läuft im Repository-Verzeichnis" },
"status.panelLabel": { en: "Working tree status", de: "Status des Arbeitsverzeichnisses" },
"status.eyebrow": { en: "Workspace", de: "Arbeitsbereich" },
"status.title": { en: "Changes", de: "Änderungen" },