diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e802d..1664d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`. selection again. - Issues can be assigned to people, and labels are managed directly from the Issue Center. +- An embedded terminal in the repository view, docked above the status bar and + adjustable in height. It runs a real pseudo terminal with the user's own + shell, so colors, interactive prompts, tab completion, and Ctrl+C work as + usual. Every repository tab keeps its own session; Ctrl + ` toggles the dock. ### Changed diff --git a/package-lock.json b/package-lock.json index d2f7d30..94df986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "dompurify": "^3.4.15", "marked": "^18.0.12", "simple-icons": "^16.24.1", @@ -582,9 +584,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -598,9 +597,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -614,9 +610,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -630,9 +623,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -646,9 +636,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -662,9 +649,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -678,9 +662,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -694,9 +675,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -710,9 +688,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -726,9 +701,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -742,9 +714,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -758,9 +727,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -774,9 +740,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1045,9 +1008,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1064,9 +1024,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1083,9 +1040,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1102,9 +1056,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1288,9 +1239,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1308,9 +1256,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1328,9 +1273,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1348,9 +1290,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1368,9 +1307,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1461,6 +1397,21 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -1846,9 +1797,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1869,9 +1817,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1892,9 +1837,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1915,9 +1857,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 8b9e892..54dcf45 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "dompurify": "^3.4.15", "marked": "^18.0.12", "simple-icons": "^16.24.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a809c04..e4dc71f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d5f864f..52d61f4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 = [] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4a578d9..e1a0409 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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>); @@ -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, diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs new file mode 100644 index 0000000..4b699d2 --- /dev/null +++ b/src-tauri/src/terminal.rs @@ -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, + writer: Box, + child: Box, +} + +#[derive(Default)] +pub struct TerminalState(Mutex>); + +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) -> 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 = 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::() { + 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()); + } +} diff --git a/src/App.svelte b/src/App.svelte index bfab03a..462afdb 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 @@ }} /> + {#if terminalOpen} +
+ + + +
+ + {activeRepoPath} + +
+
+ {#await import("./lib/components/TerminalPanel.svelte") then module} + {#each repoTabs as tab (tab.path)} + + {/each} + {/await} +
+
+ {/if} {/if}
@@ -6690,6 +6795,11 @@ {/if} Auto {/if} + {#if workspaceActive} + + {/if} {#if appVersion}Gitty v{appVersion}{/if}
diff --git a/src/app.css b/src/app.css index 86199b4..b0bb38c 100644 --- a/src/app.css +++ b/src/app.css @@ -6453,6 +6453,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s .workspace { grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 620px)); + grid-template-rows: minmax(0, 1fr) auto; flex: 1 1 0; padding: 0; background: var(--color-border-subtle); @@ -6460,6 +6461,11 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s } .left-sidebar { background: var(--color-surface); } .main-panel { border: 0; border-radius: 0; background: var(--color-surface-solid); } +.left-sidebar, +.left-sidebar-resize-handle, +.history-resize-handle, +.history-aside { grid-row: 1 / -1; } +.main-panel { grid-row: 1; } .history-aside { background: var(--color-border-subtle); row-gap: 0; } .panel { @@ -9301,3 +9307,134 @@ section > header.page-header.page-header { /* Tab strips keep their hidden scrollbars. */ .repo-tabs-scroll { scrollbar-width: none; } + +/* ── Embedded terminal ─────────────────────────────────────────────────────── */ +.terminal-dock { + /* Center column only: the gutters beside it belong to the resize handles, + which now run the full height. */ + grid-column: 3; + grid-row: 2; + display: grid; + grid-template-rows: 5px auto minmax(0, 1fr); + height: var(--terminal-height, 260px); + min-height: 0; + overflow: hidden; + border-top: 1px solid var(--color-border); + background: var(--color-surface-solid); +} + +.terminal-resize-handle { + cursor: ns-resize; + background: var(--color-border-subtle); + touch-action: none; +} +.terminal-resize-handle:hover, +.terminal-resize-handle:focus-visible { + outline: none; + background: color-mix(in srgb, var(--color-primary) 45%, var(--color-border)); +} + +.terminal-dock-head { + display: flex; + align-items: center; + gap: 10px; + min-height: 30px; + padding: 0 8px 0 12px; + border-bottom: 1px solid var(--color-border-subtle); + background: var(--color-surface-dim); +} +.terminal-dock-title { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-ink); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.terminal-dock-path { + flex: 1 1 auto; + min-width: 0; + color: var(--color-ink-muted); + font-size: 11px; + font-family: var(--font-mono); + direction: rtl; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.terminal-dock-close { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-ink-muted); + cursor: pointer; +} +.terminal-dock-close:hover { + background: color-mix(in srgb, var(--color-danger) 16%, transparent); + color: var(--color-danger); +} + +.terminal-dock-body { + position: relative; + min-height: 0; + overflow: hidden; +} + +.terminal-surface { + position: absolute; + inset: 0; + padding: 6px 4px 6px 10px; +} +.terminal-surface.hidden { display: none; } +.terminal-host { width: 100%; height: 100%; } + +/* xterm draws its own scrollbar; keep it in the app's visual language. */ +.terminal-surface .xterm-viewport { background: transparent !important; } +.terminal-surface .xterm-viewport::-webkit-scrollbar { width: 9px; } +.terminal-surface .xterm-viewport::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--color-ink-muted) 34%, transparent); +} + +.terminal-error { + position: absolute; + right: 12px; + bottom: 8px; + margin: 0; + padding: 3px 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--color-danger) 16%, var(--color-surface-solid)); + color: var(--color-danger); + font-size: 11px; +} + +.workspace-terminal-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border: 0; + border-radius: 999px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} +.workspace-terminal-toggle:hover { background: color-mix(in srgb, var(--color-primary) 16%, transparent); } +.workspace-terminal-toggle.active { color: var(--color-primary); } + +/* Stacked layout: one column, so nothing spans rows. */ +@media (max-width: 760px) { + .terminal-dock { grid-column: 1; } + .left-sidebar, + .left-sidebar-resize-handle, + .history-resize-handle, + .history-aside { grid-row: auto; } +} diff --git a/src/lib/components/HelpOverlay.svelte b/src/lib/components/HelpOverlay.svelte index 4b4b49e..61c605d 100644 --- a/src/lib/components/HelpOverlay.svelte +++ b/src/lib/components/HelpOverlay.svelte @@ -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" }, diff --git a/src/lib/components/TerminalPanel.svelte b/src/lib/components/TerminalPanel.svelte new file mode 100644 index 0000000..22cfe4e --- /dev/null +++ b/src/lib/components/TerminalPanel.svelte @@ -0,0 +1,142 @@ + + +
+
+ {#if error} +

{error}

+ {:else if exited} +

{t("terminal.exited")}

+ {/if} +
diff --git a/src/lib/git.ts b/src/lib/git.ts index 6a7a076..5933f3a 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -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 { 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 { + return invoke("terminal_open", { id, cwd, cols, rows }); +} + +export function writeTerminal(id: string, data: string): Promise { + return invoke("terminal_write", { id, data }); +} + +export function resizeTerminal(id: string, cols: number, rows: number): Promise { + return invoke("terminal_resize", { id, cols, rows }); +} + +export function closeTerminal(id: string): Promise { + return invoke("terminal_close", { id }); +} diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 157df28..e280d6d 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -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" },