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:
+111
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user