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:
Generated
+105
-6
@@ -511,6 +511,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
@@ -919,6 +925,12 @@ dependencies = [
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -981,7 +993,7 @@ dependencies = [
|
||||
"rustc_version",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1099,6 +1111,17 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
@@ -1475,6 +1498,7 @@ dependencies = [
|
||||
"commit_ai",
|
||||
"keyring",
|
||||
"log",
|
||||
"portable-pty",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2160,6 +2184,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
version = "0.9.0"
|
||||
@@ -2378,6 +2408,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.1.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -2386,7 +2428,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
@@ -2399,7 +2441,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -3055,6 +3097,27 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
"downcast-rs",
|
||||
"filedescriptor",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.28.0",
|
||||
"serial2",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
"winapi",
|
||||
"winreg 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -3163,7 +3226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
@@ -3203,7 +3266,7 @@ version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
@@ -3901,6 +3964,17 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial2"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -3954,6 +4028,22 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -4027,7 +4117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"core-graphics 0.24.0",
|
||||
"foreign-types 0.5.0",
|
||||
"js-sys",
|
||||
@@ -5954,6 +6044,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
|
||||
@@ -25,6 +25,7 @@ log = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
||||
shlex = "2"
|
||||
portable-pty = "0.9"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user