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
+111 -1
View File
@@ -5,7 +5,7 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, SquareTerminal, Star, X } from "@lucide/svelte";
import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry";
import { setLanguage, t } from "./lib/i18n.svelte";
import type { ConfirmRequest } from "./lib/components/ConfirmDialog.svelte";
@@ -298,6 +298,10 @@
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
const TERMINAL_HEIGHT_KEY = "gitlite.terminalHeight.v1";
const TERMINAL_OPEN_KEY = "gitlite.terminalOpen.v1";
const TERMINAL_MIN_HEIGHT = 120;
const TERMINAL_MAX_HEIGHT = 720;
const SIDEBAR_VISIBILITY_KEY = "gitlite.sidebarVisibility.v1";
const SIDEBAR_PANEL_HEIGHTS_KEY = "gitlite.sidebarPanelHeights.v2";
const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1";
@@ -566,6 +570,11 @@
let resizeStartY = 0;
let resizeStartHeight = 0;
let leftSidebarWidth = loadLeftSidebarWidth();
let terminalHeight = loadTerminalHeight();
let terminalOpen = loadTerminalOpen();
let resizingTerminal = false;
let terminalResizeStartY = 0;
let terminalResizeStartHeight = 0;
let resizingLeftSidebar = false;
let leftSidebarResizeStartX = 0;
let leftSidebarResizeStartWidth = 0;
@@ -2041,6 +2050,60 @@
persistCommitPanelHeight(commitPanelHeight);
}
// ── Embedded terminal ──────────────────────────────────────────────────────
function clampTerminalHeight(value: number): number {
return Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, Math.round(value)));
}
function loadTerminalHeight(): number {
try {
const stored = Number(localStorage.getItem(TERMINAL_HEIGHT_KEY));
return Number.isFinite(stored) && stored > 0 ? clampTerminalHeight(stored) : 260;
} catch { return 260; }
}
function persistTerminalHeight(value: number) {
try { localStorage.setItem(TERMINAL_HEIGHT_KEY, String(value)); } catch { /* storage may be unavailable */ }
}
function loadTerminalOpen(): boolean {
try { return localStorage.getItem(TERMINAL_OPEN_KEY) === "1"; } catch { return false; }
}
function toggleTerminal() {
terminalOpen = !terminalOpen;
try { localStorage.setItem(TERMINAL_OPEN_KEY, terminalOpen ? "1" : "0"); } catch { /* storage may be unavailable */ }
}
function startTerminalResize(event: PointerEvent) {
event.preventDefault();
resizingTerminal = true;
terminalResizeStartY = event.clientY;
terminalResizeStartHeight = terminalHeight;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onTerminalResizeMove(event: PointerEvent) {
if (!resizingTerminal) return;
// The dock grows upwards, so a smaller clientY means a taller terminal.
terminalHeight = clampTerminalHeight(terminalResizeStartHeight + (terminalResizeStartY - event.clientY));
}
function endTerminalResize(event: PointerEvent) {
if (!resizingTerminal) return;
resizingTerminal = false;
persistTerminalHeight(terminalHeight);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onTerminalResizeKeydown(event: KeyboardEvent) {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
terminalHeight = clampTerminalHeight(terminalHeight + (event.key === "ArrowUp" ? 20 : -20));
persistTerminalHeight(terminalHeight);
}
function startLeftSidebarResize(event: PointerEvent) {
event.preventDefault();
resizingLeftSidebar = true;
@@ -6012,6 +6075,13 @@
openHelp();
return;
}
// Backquote by code, so the shortcut survives keyboard layouts where ` is
// a dead key.
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.code === "Backquote") {
event.preventDefault();
if (!event.repeat && workspaceActive) toggleTerminal();
return;
}
if (event.key === "Escape" && commandPaletteOpen) {
commandPaletteOpen = false;
return;
@@ -6671,6 +6741,41 @@
}}
/>
</aside>
{#if terminalOpen}
<section class="terminal-dock" style="--terminal-height: {terminalHeight}px" aria-label={t("terminal.title")}>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="terminal-resize-handle"
role="separator"
aria-orientation="horizontal"
aria-label={t("terminal.resize")}
aria-valuenow={terminalHeight}
aria-valuemin={TERMINAL_MIN_HEIGHT}
aria-valuemax={TERMINAL_MAX_HEIGHT}
tabindex="0"
onpointerdown={startTerminalResize}
onpointermove={onTerminalResizeMove}
onpointerup={endTerminalResize}
onpointercancel={endTerminalResize}
onkeydown={onTerminalResizeKeydown}
></div>
<header class="terminal-dock-head">
<span class="terminal-dock-title"><SquareTerminal size={13} aria-hidden="true" />{t("terminal.title")}</span>
<span class="terminal-dock-path" title={activeRepoPath}>{activeRepoPath}</span>
<button class="terminal-dock-close" type="button" onclick={toggleTerminal} title={t("terminal.hide")} aria-label={t("terminal.hide")}>
<X size={14} aria-hidden="true" />
</button>
</header>
<div class="terminal-dock-body">
{#await import("./lib/components/TerminalPanel.svelte") then module}
{#each repoTabs as tab (tab.path)}
<module.default sessionId={`repo:${tab.path}`} repoPath={tab.path} active={sameRepoPath(tab.path, activeRepoPath)} />
{/each}
{/await}
</div>
</section>
{/if}
</section>
{/if}
<footer class="workspace-statusbar" aria-label="Application status summary">
@@ -6690,6 +6795,11 @@
{/if}
<span class:active={autoRefreshEnabled} class="workspace-auto">Auto <i aria-hidden="true"></i></span>
{/if}
{#if workspaceActive}
<button class:active={terminalOpen} class="workspace-terminal-toggle" type="button" onclick={toggleTerminal} aria-pressed={terminalOpen} title={terminalOpen ? t("terminal.hide") : t("terminal.show")}>
<SquareTerminal size={12} aria-hidden="true" />{t("terminal.title")}
</button>
{/if}
{#if appVersion}<span class="app-version" title={`Gitty version ${appVersion}`}>Gitty v{appVersion}</span>{/if}
</footer>
</div>