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
+7
View File
@@ -5,6 +5,7 @@ mod external_tools;
mod git;
mod integrations;
mod telemetry;
mod terminal;
use badge::set_sync_badge;
use external_tools::{
@@ -46,6 +47,7 @@ use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::{Emitter, Manager};
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
use terminal::{TerminalState, terminal_close, terminal_open, terminal_resize, terminal_write};
struct StartupRepository(Mutex<Option<String>>);
@@ -325,6 +327,7 @@ async fn main() {
.manage(StartupRepository(Mutex::new(startup_repository)))
.manage(StartupClone(Mutex::new(startup_clone)))
.manage(SearchCancellationState::default())
.manage(TerminalState::default())
.plugin(tauri_plugin_dialog::init());
// Linux installs are expected to come from the system package manager (see the
@@ -335,6 +338,10 @@ async fn main() {
builder
.invoke_handler(tauri::generate_handler![
terminal_open,
terminal_write,
terminal_resize,
terminal_close,
open_repository,
init_repository,
clone_repository,
+276
View File
@@ -0,0 +1,276 @@
//! Embedded terminal: one PTY-backed shell session per repository tab.
//!
//! The frontend owns the session ids and talks to a real pseudo terminal, so
//! colors, interactive prompts, tab completion and Ctrl+C behave exactly like
//! they do in the system terminal. Everything the shell prints is forwarded as
//! UTF-8 chunks; everything the user types goes back through `terminal_write`.
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::Path;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State};
const DATA_EVENT: &str = "terminal:data";
const EXIT_EVENT: &str = "terminal:exit";
/// A read never returns more than this, so one busy command cannot starve the
/// event loop with a single enormous payload.
const READ_CHUNK: usize = 8 * 1024;
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TerminalChunk {
id: String,
data: String,
}
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TerminalExit {
id: String,
message: String,
}
struct Session {
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
child: Box<dyn Child + Send + Sync>,
}
#[derive(Default)]
pub struct TerminalState(Mutex<HashMap<String, Session>>);
fn lock_error(details: impl std::fmt::Display) -> String {
format!("Terminal state is unavailable: {details}")
}
/// The user's own shell, so their aliases, prompt and PATH are present. A PTY
/// makes the shell interactive on its own, so no login flag is needed — that
/// would only risk a profile changing the working directory.
fn shell_command() -> CommandBuilder {
#[cfg(windows)]
let mut command = {
let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string());
CommandBuilder::new(shell)
};
#[cfg(not(windows))]
let mut command = {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
CommandBuilder::new(shell)
};
// xterm.js speaks xterm-256color, and git should not page into a terminal
// the user cannot scroll back with a mouse wheel alone.
command.env("TERM", "xterm-256color");
command.env("COLORTERM", "truecolor");
command
}
/// Decodes as much of `buffer` as forms complete UTF-8 and returns it, leaving
/// a split multi-byte sequence in place for the next read.
fn take_utf8(buffer: &mut Vec<u8>) -> String {
match std::str::from_utf8(buffer) {
Ok(text) => {
let text = text.to_string();
buffer.clear();
text
}
Err(error) => {
let valid = error.valid_up_to();
let text = String::from_utf8_lossy(&buffer[..valid]).into_owned();
match error.error_len() {
// Genuinely invalid bytes: drop them, or the stream would stall.
Some(length) => buffer.drain(..valid + length),
// An incomplete tail: keep it for the next chunk.
None => buffer.drain(..valid),
};
text
}
}
}
#[tauri::command]
pub fn terminal_open(
app: AppHandle,
state: State<'_, TerminalState>,
id: String,
cwd: String,
cols: u16,
rows: u16,
) -> Result<(), String> {
let mut sessions = state.0.lock().map_err(lock_error)?;
if sessions.contains_key(&id) {
return Ok(());
}
let directory = Path::new(&cwd);
if !directory.is_dir() {
return Err(format!("Working directory does not exist: {cwd}"));
}
let size = PtySize {
rows: rows.max(1),
cols: cols.max(1),
pixel_width: 0,
pixel_height: 0,
};
let pair = native_pty_system()
.openpty(size)
.map_err(|error| format!("Could not open a pseudo terminal: {error}"))?;
let mut command = shell_command();
command.cwd(directory);
let child = pair
.slave
.spawn_command(command)
.map_err(|error| format!("Could not start the shell: {error}"))?;
// The slave side must go, otherwise the reader never sees end of file.
drop(pair.slave);
let mut reader = pair
.master
.try_clone_reader()
.map_err(|error| format!("Could not read from the terminal: {error}"))?;
let writer = pair
.master
.take_writer()
.map_err(|error| format!("Could not write to the terminal: {error}"))?;
let reader_app = app.clone();
let reader_id = id.clone();
std::thread::spawn(move || {
let mut pending: Vec<u8> = Vec::new();
let mut chunk = [0u8; READ_CHUNK];
let message = loop {
match reader.read(&mut chunk) {
Ok(0) => break String::new(),
Ok(count) => {
pending.extend_from_slice(&chunk[..count]);
let text = take_utf8(&mut pending);
if text.is_empty() {
continue;
}
if reader_app
.emit(
DATA_EVENT,
TerminalChunk {
id: reader_id.clone(),
data: text,
},
)
.is_err()
{
// The window is gone; nothing left to deliver output to.
break String::new();
}
}
Err(error) => break error.to_string(),
}
};
if let Some(state) = reader_app.try_state::<TerminalState>() {
if let Ok(mut sessions) = state.0.lock() {
if let Some(mut session) = sessions.remove(&reader_id) {
let _ = session.child.kill();
}
}
}
let _ = reader_app.emit(
EXIT_EVENT,
TerminalExit {
id: reader_id,
message,
},
);
});
sessions.insert(
id,
Session {
master: pair.master,
writer,
child,
},
);
Ok(())
}
#[tauri::command]
pub fn terminal_write(
state: State<'_, TerminalState>,
id: String,
data: String,
) -> Result<(), String> {
let mut sessions = state.0.lock().map_err(lock_error)?;
let session = sessions
.get_mut(&id)
.ok_or_else(|| "This terminal session is no longer running.".to_string())?;
session
.writer
.write_all(data.as_bytes())
.map_err(|error| format!("Could not send input to the terminal: {error}"))?;
session
.writer
.flush()
.map_err(|error| format!("Could not send input to the terminal: {error}"))
}
#[tauri::command]
pub fn terminal_resize(
state: State<'_, TerminalState>,
id: String,
cols: u16,
rows: u16,
) -> Result<(), String> {
let sessions = state.0.lock().map_err(lock_error)?;
// A closed session is not an error here: resize races with tab switching.
let Some(session) = sessions.get(&id) else {
return Ok(());
};
session
.master
.resize(PtySize {
rows: rows.max(1),
cols: cols.max(1),
pixel_width: 0,
pixel_height: 0,
})
.map_err(|error| format!("Could not resize the terminal: {error}"))
}
#[tauri::command]
pub fn terminal_close(state: State<'_, TerminalState>, id: String) -> Result<(), String> {
let mut sessions = state.0.lock().map_err(lock_error)?;
if let Some(mut session) = sessions.remove(&id) {
let _ = session.child.kill();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::take_utf8;
#[test]
fn take_utf8_keeps_a_split_sequence_for_the_next_chunk() {
// "ä" is two bytes; the second one arrives later.
let mut buffer = vec![b'a', 0xc3];
assert_eq!(take_utf8(&mut buffer), "a");
assert_eq!(buffer, vec![0xc3]);
buffer.push(0xa4);
assert_eq!(take_utf8(&mut buffer), "ä");
assert!(buffer.is_empty());
}
#[test]
fn take_utf8_drops_invalid_bytes_instead_of_stalling() {
let mut buffer = vec![b'a', 0xff, b'b'];
assert_eq!(take_utf8(&mut buffer), "a");
assert_eq!(take_utf8(&mut buffer), "b");
assert!(buffer.is_empty());
}
}