diff --git a/Examples/standard_shell_script_examples.zip b/Examples/standard_shell_script_examples.zip deleted file mode 100644 index 4cf51dd..0000000 Binary files a/Examples/standard_shell_script_examples.zip and /dev/null differ diff --git a/README.md b/README.md index 50597c7..189c526 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,13 @@ MDX is treated as Markdown-like source. EXT does not execute imports, exports, J - Do not execute, source, lint, format, or run shell scripts. - Use viewport-bounded highlighting for large structured/source files where needed. +### Terminal + +- Open an integrated terminal directly in the workspace. +- Seamlessly matches EXT themes. +- Optionally syncs the terminal directory to the active file's parent folder. +- Toggle visibility quickly with `Ctrl`/`Cmd` + `` ` ``. + ### File actions - Create files and folders. @@ -128,7 +135,6 @@ EXT is not: - a hosted vault - a collaboration suite - a WYSIWYG editor -- a terminal - a Git client - an IDE - a publishing system @@ -137,11 +143,15 @@ It is a local workspace and editor for common project/doc files. ## Demo -![EXT Demo Part 1](./public/demo-example/demo_part1.gif) -![EXT Demo Part 2](./public/demo-example/demo_part2.gif) -![EXT Demo Part 3](./public/demo-example/demo_part3.gif) -![EXT Demo Part 4](./public/demo-example/demo_part4.gif) -![EXT Demo Part 5](./public/demo-example/demo_part5.gif) +![EXT Demo Part 1](./public/demo-example/ext_demo_1.gif) +![EXT Demo Part 2](./public/demo-example/ext_demo_2.gif) +![EXT Demo Part 3](./public/demo-example/ext_demo_3.gif) +![EXT Demo Part 4](./public/demo-example/ext_demo_4.gif) +![EXT Demo Part 5](./public/demo-example/ext_demo_5.gif) +![EXT Demo Part 6](./public/demo-example/ext_demo_6.gif) +![EXT Demo Part 7](./public/demo-example/ext_demo_7.gif) +![EXT Demo Part 8](./public/demo-example/ext_demo_8.gif) +![EXT Demo Part 9](./public/demo-example/ext_demo_9.gif) Demo media lives in the repository for README and development use. Production builds strip demo media and development examples from packaged apps. @@ -171,6 +181,7 @@ On macOS, use `Cmd` where the table says `Ctrl`/`Cmd`. | `Ctrl`/`Cmd` + `P` | Focus global file search / quick open | | `Ctrl`/`Cmd` + `F` | Open find and replace | | `Ctrl`/`Cmd` + `B` | Toggle the sidebar | +| `Ctrl`/`Cmd` + `` ` `` | Toggle the terminal panel | | `Ctrl`/`Cmd` + `1` | Focus the editor | | `Ctrl`/`Cmd` + `2` | Focus the preview / split area | | `Ctrl`/`Cmd` + `Tab` | Switch to the next tab | diff --git a/package-lock.json b/package-lock.json index e440aa9..ca6cbe5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,9 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2", "@vscode/markdown-it-katex": "^1.1.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "dompurify": "^3.4.8", "katex": "^0.17.0", @@ -2759,6 +2762,27 @@ "katex": "cli.js" } }, + "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/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "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/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", diff --git a/package.json b/package.json index 44ca7c8..a5c9da9 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,9 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2", "@vscode/markdown-it-katex": "^1.1.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "dompurify": "^3.4.8", "katex": "^0.17.0", diff --git a/public/demo-example/demo_part1.gif b/public/demo-example/demo_part1.gif deleted file mode 100644 index 5c75bce..0000000 Binary files a/public/demo-example/demo_part1.gif and /dev/null differ diff --git a/public/demo-example/demo_part2.gif b/public/demo-example/demo_part2.gif deleted file mode 100644 index e42210e..0000000 Binary files a/public/demo-example/demo_part2.gif and /dev/null differ diff --git a/public/demo-example/demo_part3.gif b/public/demo-example/demo_part3.gif deleted file mode 100644 index 93e3ab9..0000000 Binary files a/public/demo-example/demo_part3.gif and /dev/null differ diff --git a/public/demo-example/demo_part4.gif b/public/demo-example/demo_part4.gif deleted file mode 100644 index 6998f94..0000000 Binary files a/public/demo-example/demo_part4.gif and /dev/null differ diff --git a/public/demo-example/demo_part5.gif b/public/demo-example/ext_demo_1.gif similarity index 54% rename from public/demo-example/demo_part5.gif rename to public/demo-example/ext_demo_1.gif index 4fe6a4b..f89d29f 100644 Binary files a/public/demo-example/demo_part5.gif and b/public/demo-example/ext_demo_1.gif differ diff --git a/public/demo-example/ext_demo_2.gif b/public/demo-example/ext_demo_2.gif new file mode 100644 index 0000000..74f5fdf Binary files /dev/null and b/public/demo-example/ext_demo_2.gif differ diff --git a/public/demo-example/ext_demo_3.gif b/public/demo-example/ext_demo_3.gif new file mode 100644 index 0000000..307c9cf Binary files /dev/null and b/public/demo-example/ext_demo_3.gif differ diff --git a/public/demo-example/ext_demo_4.gif b/public/demo-example/ext_demo_4.gif new file mode 100644 index 0000000..07f44de Binary files /dev/null and b/public/demo-example/ext_demo_4.gif differ diff --git a/public/demo-example/ext_demo_5.gif b/public/demo-example/ext_demo_5.gif new file mode 100644 index 0000000..d0a1204 Binary files /dev/null and b/public/demo-example/ext_demo_5.gif differ diff --git a/public/demo-example/ext_demo_6.gif b/public/demo-example/ext_demo_6.gif new file mode 100644 index 0000000..e20b050 Binary files /dev/null and b/public/demo-example/ext_demo_6.gif differ diff --git a/public/demo-example/ext_demo_7.gif b/public/demo-example/ext_demo_7.gif new file mode 100644 index 0000000..1725c2c Binary files /dev/null and b/public/demo-example/ext_demo_7.gif differ diff --git a/public/demo-example/ext_demo_8.gif b/public/demo-example/ext_demo_8.gif new file mode 100644 index 0000000..1cd2488 Binary files /dev/null and b/public/demo-example/ext_demo_8.gif differ diff --git a/public/demo-example/ext_demo_9.gif b/public/demo-example/ext_demo_9.gif new file mode 100644 index 0000000..dd22516 Binary files /dev/null and b/public/demo-example/ext_demo_9.gif differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5cae86a..f85b438 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -799,6 +799,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" @@ -883,7 +889,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -972,6 +978,7 @@ name = "ext" version = "0.1.0" dependencies = [ "chrono", + "portable-pty", "serde", "serde_json", "tauri", @@ -1003,10 +1010,21 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "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 = "find-msvc-tools" version = "0.1.9" @@ -1778,6 +1796,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1921,6 +1948,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2029,6 +2062,15 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2116,6 +2158,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2530,6 +2586,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -2600,6 +2662,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3112,6 +3195,48 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3154,6 +3279,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" @@ -3742,6 +3883,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4107,7 +4257,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "windows-sys 0.61.2", ] @@ -5045,6 +5195,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 7a38ca9..0e98e7c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,4 +26,5 @@ chrono = "0.4.45" walkdir = "2.5.0" tauri-plugin-dialog = "2.7.1" tauri-plugin-drag = "2.1.1" +portable-pty = "0.8.1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2732e90..28a7b70 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,7 @@ +pub mod terminal; +use terminal::{spawn_terminal, write_terminal, resize_terminal, kill_terminal, TerminalState}; +use std::sync::{Arc, Mutex}; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fs; @@ -1417,6 +1421,9 @@ fn fetch_git_status(file_path: String) -> Option { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(TerminalState { + instance: Arc::new(Mutex::new(None)), + }) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ @@ -1443,7 +1450,11 @@ pub fn run() { force_restart, open_devtools, initialize_example_workspace, - fetch_git_status + fetch_git_status, + spawn_terminal, + write_terminal, + resize_terminal, + kill_terminal ]) .setup(|app| { let open_i = MenuItem::with_id(app, "open", "Open", true, None::<&str>)?; diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs new file mode 100644 index 0000000..3005710 --- /dev/null +++ b/src-tauri/src/terminal.rs @@ -0,0 +1,195 @@ +use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem, MasterPty, Child}; +use serde::Serialize; +use std::env; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter, State}; + +pub struct TerminalInstance { + pub master: Box, + pub child: Box, + pub writer: Box, +} + +pub struct TerminalState { + pub instance: Arc>>, +} + +#[derive(Clone, Serialize)] +pub struct TerminalSpawnResult { + pub success: bool, + pub shell: String, + pub error: Option, +} + +#[cfg(target_os = "windows")] +fn spawn_shell( + pty_system: &dyn PtySystem, + pty_size: PtySize, + cwd: &str, +) -> Result<(Box, Box, String), String> { + let pair = pty_system.openpty(pty_size).map_err(|e| e.to_string())?; + + let mut cmd = CommandBuilder::new("pwsh.exe"); + cmd.cwd(cwd); + if let Ok(child) = pair.slave.spawn_command(cmd) { + return Ok((pair.master, child, "pwsh".to_string())); + } + + let mut cmd2 = CommandBuilder::new("powershell.exe"); + cmd2.cwd(cwd); + let child2 = pair.slave.spawn_command(cmd2).map_err(|e| e.to_string())?; + Ok((pair.master, child2, "powershell".to_string())) +} + +#[cfg(not(target_os = "windows"))] +fn spawn_shell( + pty_system: &dyn PtySystem, + pty_size: PtySize, + cwd: &str, +) -> Result<(Box, Box, String), String> { + let shell = env::var("SHELL").unwrap_or_else(|_| { + #[cfg(target_os = "macos")] + { "/bin/zsh".to_string() } + #[cfg(not(target_os = "macos"))] + { "/bin/bash".to_string() } + }); + + let pair = pty_system.openpty(pty_size).map_err(|e| e.to_string())?; + let mut cmd = CommandBuilder::new(&shell); + cmd.cwd(cwd); + let child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?; + + let shell_name = Path::new(&shell) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + Ok((pair.master, child, shell_name)) +} + +#[tauri::command] +pub fn spawn_terminal( + cwd: String, + app: AppHandle, + state: State<'_, TerminalState>, +) -> Result { + let mut instance_lock = state.instance.lock().unwrap(); + + // Kill existing + if let Some(mut existing) = instance_lock.take() { + let _ = existing.child.kill(); + } + + let pty_system = NativePtySystem::default(); + let pty_size = PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }; + + match spawn_shell(&pty_system, pty_size, &cwd) { + Ok((master, child, shell_name)) => { + let master_clone = master.try_clone_reader().map_err(|e| e.to_string())?; + let writer = master.take_writer().map_err(|e| e.to_string())?; + + *instance_lock = Some(TerminalInstance { master, child, writer }); + + let (tx, rx) = std::sync::mpsc::channel::>(); + + // Reader thread + std::thread::spawn(move || { + let mut reader = std::io::BufReader::new(master_clone); + let mut buffer = [0u8; 8192]; + loop { + match std::io::Read::read(&mut reader, &mut buffer) { + Ok(0) => break, // EOF + Ok(n) => { + if tx.send(buffer[..n].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + // Emitter thread + let app_clone = app.clone(); + std::thread::spawn(move || { + let mut accum = Vec::new(); + let timeout = std::time::Duration::from_millis(15); + loop { + match rx.recv_timeout(timeout) { + Ok(chunk) => { + accum.extend_from_slice(&chunk); + // If accumulated data exceeds 64KB, flush immediately + if accum.len() >= 65536 { + let _ = app_clone.emit("terminal-output", accum.clone()); + accum.clear(); + } + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if !accum.is_empty() { + let _ = app_clone.emit("terminal-output", accum.clone()); + accum.clear(); + } + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + if !accum.is_empty() { + let _ = app_clone.emit("terminal-output", accum.clone()); + } + break; + } + } + } + }); + + Ok(TerminalSpawnResult { + success: true, + shell: shell_name, + error: None, + }) + } + Err(e) => Ok(TerminalSpawnResult { + success: false, + shell: "".to_string(), + error: Some(e), + }), + } +} + +#[tauri::command] +pub fn write_terminal(data: String, state: State<'_, TerminalState>) -> Result<(), String> { + if let Some(instance) = state.instance.lock().unwrap().as_mut() { + if let Err(e) = std::io::Write::write_all(&mut instance.writer, data.as_bytes()) { + return Err(e.to_string()); + } + } + Ok(()) +} + +#[tauri::command] +pub fn resize_terminal(rows: u16, cols: u16, state: State<'_, TerminalState>) -> Result<(), String> { + if let Some(instance) = state.instance.lock().unwrap().as_mut() { + let size = PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }; + let _ = instance.master.resize(size); + } + Ok(()) +} + +#[tauri::command] +pub fn kill_terminal(state: State<'_, TerminalState>) -> Result<(), String> { + let mut instance_lock = state.instance.lock().unwrap(); + if let Some(mut existing) = instance_lock.take() { + let _ = existing.child.kill(); + } + Ok(()) +} diff --git a/src/App.tsx b/src/App.tsx index 0bcfc3b..be92e95 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,7 @@ import { SettingsModal } from './components/settings/SettingsModal'; import { ContextMenu } from './components/context-menu/ContextMenu'; import { DndContext, pointerWithin } from '@dnd-kit/core'; import { normalizeLargeFileSettings } from './utils/largeFile'; - +import { EmbeddedTerminal } from './components/terminal/EmbeddedTerminal'; import { useAppLogic } from './hooks/useAppLogic'; import { useIdleState } from './hooks/useIdleState'; @@ -82,15 +82,58 @@ function App() { const activeFileExtension = activeTabMemo?.extension; const largeFileSettings = normalizeLargeFileSettings(appearance.largeFileMode); + const [isTerminalVisible, setIsTerminalVisible] = useState(false); + + useEffect(() => { + if (!appearance.enableTerminal) { + setIsTerminalVisible(false); + } else if (appearance.enableTerminal && !isTerminalVisible) { + setIsTerminalVisible(true); + } + }, [appearance.enableTerminal]); + + const activeWorkspace = workspaces.find(w => w.path === activeTabMemo?.workspacePath) || workspaces[0]; + const terminalCwd = useMemo(() => { + if (activeTabMemo && activeTabMemo.absolutePath) { + const path = activeTabMemo.absolutePath; + const lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + if (lastSlash > 0) { + return path.substring(0, lastSlash); + } + } + if (activeWorkspace) { + return activeWorkspace.path; + } + return ''; + }, [activeTabMemo, activeWorkspace]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey || e.key.toLowerCase() !== 'b') return; - e.preventDefault(); - setIsSidebarVisible((visible) => !visible); + // Toggle sidebar: Ctrl/Cmd + B + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'b') { + e.preventDefault(); + setIsSidebarVisible((visible) => !visible); + return; + } + + // Toggle terminal: Ctrl/Cmd + ` + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key === '`') { + e.preventDefault(); + e.stopPropagation(); // prevent xterm from also processing it if it was focused + // Automatically enable terminal setting if it was disabled + setAppearance(prev => { + if (!prev.enableTerminal) { + return { ...prev, enableTerminal: true }; + } + return prev; + }); + setIsTerminalVisible((visible) => !visible); + return; + } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); }, []); // ── Run Profile Automation ──────────────────────── @@ -260,6 +303,18 @@ function App() { showLargeFileDetails={largeFileSettings.showDetailsPanel} /> } + terminal={ + appearance.enableTerminal ? ( + setIsTerminalVisible(false)} + height={0} // will be overridden by AppShell cloneElement + isVisible={isTerminalVisible} + /> + ) : undefined + } /> {/* Modals & Context Menus */} diff --git a/src/components/editor/EditorPanel.css b/src/components/editor/EditorPanel.css index 46574da..a13a58f 100644 --- a/src/components/editor/EditorPanel.css +++ b/src/components/editor/EditorPanel.css @@ -58,6 +58,15 @@ -webkit-user-drag: element; } +.editor-tab:focus { + outline: none; +} + +.editor-tab:focus-visible { + outline: 2px solid var(--color-border-subtle); + outline-offset: -2px; +} + .editor-tab:hover { color: var(--color-text-secondary); background: var(--color-hover); diff --git a/src/components/editor/EditorPanel.tsx b/src/components/editor/EditorPanel.tsx index 54eeeee..30616e5 100644 --- a/src/components/editor/EditorPanel.tsx +++ b/src/components/editor/EditorPanel.tsx @@ -385,6 +385,30 @@ export const EditorPanel: React.FC = ({ e.currentTarget.scrollLeft += e.deltaY; } }} + onKeyDown={(e) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault(); // prevent native dnd-kit or browser scroll focus + const activeIndex = tabs.findIndex(t => t.id === activeTabId); + if (activeIndex === -1) return; + + let nextIndex = activeIndex; + if (e.key === 'ArrowLeft') { + nextIndex = activeIndex > 0 ? activeIndex - 1 : tabs.length - 1; + } else { + nextIndex = activeIndex < tabs.length - 1 ? activeIndex + 1 : 0; + } + + const nextTab = tabs[nextIndex]; + if (nextTab) { + onTabSelect(nextTab.id); + // Move focus so subsequent arrow presses work + requestAnimationFrame(() => { + const nextEl = document.getElementById(`editor-tab-${nextTab.id}`); + if (nextEl) nextEl.focus(); + }); + } + } + }} > `tab-${t.id}`)} strategy={horizontalListSortingStrategy}> {tabs.map((tab) => ( diff --git a/src/components/layout/AppShell.css b/src/components/layout/AppShell.css index d478c17..bb7b1d2 100644 --- a/src/components/layout/AppShell.css +++ b/src/components/layout/AppShell.css @@ -37,6 +37,36 @@ border-radius: var(--radius-pill); } +.resize-handle-vertical { + height: 4px; + cursor: row-resize; + background: transparent; + position: relative; + flex-shrink: 0; + z-index: 10; + transition: background var(--transition-fast); + width: 100%; +} + +.resize-handle-vertical::after { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + height: 1px; + width: 100%; + background: var(--color-border-subtle); + transition: all var(--transition-fast); +} + +.resize-handle-vertical:hover::after, +.resize-handle-vertical.dragging::after { + height: 3px; + background: var(--color-accent); + border-radius: var(--radius-pill); +} + /* ── Pane wrappers ──────────────────────────────── */ .pane-sidebar { @@ -53,4 +83,17 @@ flex: 1; min-width: 300px; overflow: hidden; + display: flex; + flex-direction: column; +} + +.pane-editor-main { + flex: 1; + overflow: hidden; + position: relative; +} + +.pane-terminal { + flex-shrink: 0; + overflow: hidden; } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 011bce1..fb23f8b 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -7,6 +7,7 @@ interface AppShellProps { sidebar: React.ReactNode; fileList: React.ReactNode; editor: React.ReactNode; + terminal?: React.ReactNode; isSidebarVisible?: boolean; } @@ -61,12 +62,64 @@ const ResizeHandle: React.FC = ({ onDrag }) => { ); }; +// ── ResizeHandleVertical Component ────────────────── + +interface ResizeHandleVerticalProps { + onDrag: (deltaY: number) => void; +} + +const ResizeHandleVertical: React.FC = ({ onDrag }) => { + const [dragging, setDragging] = useState(false); + const lastY = useRef(0); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setDragging(true); + lastY.current = e.clientY; + }, []); + + useEffect(() => { + if (!dragging) return; + + const handleMouseMove = (e: MouseEvent) => { + const delta = e.clientY - lastY.current; + lastY.current = e.clientY; + onDrag(delta); + }; + + const handleMouseUp = () => { + setDragging(false); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [dragging, onDrag]); + + return ( +
+ ); +}; + // ── AppShell Component ────────────────────────────── export const AppShell: React.FC = ({ sidebar, fileList, editor, + terminal, isSidebarVisible = true, }) => { const [sidebarWidth, setSidebarWidth] = useState(220); @@ -80,6 +133,13 @@ export const AppShell: React.FC = ({ setFileListWidth((w) => Math.max(200, Math.min(500, w + delta))); }, []); + const [terminalHeight, setTerminalHeight] = useState(250); + const handleTerminalResize = useCallback((deltaY: number) => { + setTerminalHeight((h) => Math.max(100, Math.min(800, h - deltaY))); + }, []); + + const isTerminalVisible = terminal && (!React.isValidElement(terminal) || (terminal.props as any).isVisible !== false); + return (
{isSidebarVisible && ( @@ -95,7 +155,27 @@ export const AppShell: React.FC = ({
- {editor} +
+ {editor} +
+ {terminal && ( + <> + {isTerminalVisible && ( + + )} +
+ {React.isValidElement(terminal) + ? React.cloneElement(terminal as React.ReactElement, { height: terminalHeight }) + : terminal} +
+ + )}
); diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 40cf975..8be4273 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -226,6 +226,38 @@ export const SettingsModal: React.FC = ({ +
+

Terminal

+

+ Cross-platform embedded terminal panel. +

+ +
+ + + +
+
+

Large Files

diff --git a/src/components/terminal/EmbeddedTerminal.css b/src/components/terminal/EmbeddedTerminal.css new file mode 100644 index 0000000..15b05d7 --- /dev/null +++ b/src/components/terminal/EmbeddedTerminal.css @@ -0,0 +1,99 @@ +.embedded-terminal-container { + display: flex; + flex-direction: column; + border-top: 1px solid var(--color-border); + overflow: hidden; + position: relative; + width: 100%; + backdrop-filter: blur(12px) saturate(150%); + -webkit-backdrop-filter: blur(12px) saturate(150%); + transition: background-color var(--transition-normal); +} + +.embedded-terminal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px; + border-bottom: 1px solid var(--color-border); + font-size: 0.8rem; + color: var(--color-text-muted); + user-select: none; + height: 32px; + flex-shrink: 0; +} + +.terminal-title span { + opacity: 0.7; + font-family: Consolas, "Courier New", monospace; + font-size: 0.75rem; + margin-left: 6px; +} + +.terminal-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.terminal-actions .icon-btn { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--color-text-muted); + cursor: pointer; + border-radius: 4px; + padding: 4px; + font-size: 1rem; + transition: all 0.2s ease; +} + +.terminal-actions .icon-btn.text-btn { + font-size: 0.75rem; + padding: 4px 8px; +} + +.terminal-actions .icon-btn:hover { + background-color: var(--color-hover); + color: var(--color-text-primary); +} + +.embedded-terminal-body { + flex: 1; + overflow: hidden; + padding: 8px; + /* Required for xterm FitAddon to correctly measure dimensions */ + position: relative; +} + +.embedded-terminal-body .xterm { + height: 100%; +} + +/* Smooth Cursor & Glow */ +.xterm-cursor { + box-shadow: 0 0 10px var(--color-accent); + transition: opacity 0.15s ease-in-out; +} +.xterm-cursor-layer { + animation: xtermBlink 1s step-end infinite; +} + +/* Custom xterm scrollbar styling */ +.xterm .xterm-viewport::-webkit-scrollbar { + width: 10px; + background: transparent; +} +.xterm .xterm-viewport::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 5px; + border: 2px solid transparent; + background-clip: padding-box; +} +.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); + border: 2px solid transparent; + background-clip: padding-box; +} diff --git a/src/components/terminal/EmbeddedTerminal.tsx b/src/components/terminal/EmbeddedTerminal.tsx new file mode 100644 index 0000000..6597f36 --- /dev/null +++ b/src/components/terminal/EmbeddedTerminal.tsx @@ -0,0 +1,277 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebglAddon } from '@xterm/addon-webgl'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { useTheme } from '../../theme/ThemeContext'; +import '@xterm/xterm/css/xterm.css'; +import './EmbeddedTerminal.css'; + +interface EmbeddedTerminalProps { + initialCwd: string; + currentCwd: string; + autoSyncCwd?: boolean; + isVisible?: boolean; + onClose: () => void; + height: number; +} + +interface TerminalSpawnResult { + success: boolean; + shell: string; + error?: string; +} + +export const EmbeddedTerminal: React.FC = ({ + initialCwd, + currentCwd, + autoSyncCwd = false, + isVisible = true, + onClose, + height, +}) => { + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const initialCwdRef = useRef(initialCwd); // Only capture first CWD to prevent unmounts + const [shellName, setShellName] = useState('Terminal'); + const [isSpawned, setIsSpawned] = useState(false); + const { currentTheme } = useTheme(); + + const getXtermTheme = useCallback(() => { + const t = currentTheme.tokens; + return { + background: t['--color-editor'] || t['--color-bg'] || '#000000', + foreground: t['--color-text-primary'] || '#ffffff', + cursor: t['--color-accent'] || '#ffffff', + cursorAccent: t['--color-bg'] || '#000000', + selectionBackground: t['--color-selection'] || 'rgba(255, 255, 255, 0.3)', + black: '#000000', + red: t['--color-error'] || '#cc0000', + green: t['--color-success'] || '#4e9a06', + yellow: t['--color-warning'] || '#c4a000', + blue: t['--color-info'] || '#3465a4', + magenta: t['--color-syntax-keyword'] || '#75507b', + cyan: t['--color-syntax-operator'] || '#06989a', + white: t['--color-text-primary'] || '#d3d7cf', + brightBlack: t['--color-text-muted'] || '#555753', + brightRed: t['--color-syntax-invalid'] || '#ef2929', + brightGreen: t['--color-syntax-string'] || '#8ae234', + brightYellow: t['--color-syntax-type'] || '#fce94f', + brightBlue: t['--color-syntax-function'] || '#729fcf', + brightMagenta: t['--color-syntax-constant'] || '#ad7fa8', + brightCyan: t['--color-syntax-operator'] || '#34e2e2', + brightWhite: '#ffffff', + }; + }, [currentTheme]); + + const spawnBackendTerminal = useCallback(async (cwdToSpawn: string) => { + try { + const result = await invoke('spawn_terminal', { cwd: cwdToSpawn }); + if (result.success) { + setShellName(result.shell); + setIsSpawned(true); + setSyncedCwd(cwdToSpawn); + lastSyncedCwdRef.current = cwdToSpawn; + } else { + xtermRef.current?.write(`\r\n\x1b[31mFailed to spawn terminal: ${result.error}\x1b[0m\r\n`); + } + } catch (err) { + xtermRef.current?.write(`\r\n\x1b[31mError spawning terminal: ${String(err)}\x1b[0m\r\n`); + } + }, []); + + useEffect(() => { + if (!terminalRef.current) return; + + const term = new Terminal({ + theme: getXtermTheme(), + fontFamily: '"JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace', + fontSize: 14, + lineHeight: 1.3, + fontWeight: 'normal', + cursorBlink: true, + cursorStyle: 'bar', + cursorWidth: 2, + allowProposedApi: true, + allowTransparency: true, + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(terminalRef.current); + fitAddon.fit(); + + try { + const webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + webglAddon.dispose(); + }); + term.loadAddon(webglAddon); + } catch (e) { + console.warn("WebGL addon failed to load, falling back to Canvas renderer", e); + } + + xtermRef.current = term; + fitAddonRef.current = fitAddon; + + const onDataDisposable = term.onData((data) => { + invoke('write_terminal', { data }).catch(console.error); + }); + + let unlistenOutput: (() => void) | undefined; + let resizeObserver: ResizeObserver | undefined; + + const setupTerminal = async () => { + unlistenOutput = await listen('terminal-output', (event) => { + const uint8 = new Uint8Array(event.payload); + term.write(uint8); + }); + + const handleResize = () => { + if (terminalRef.current?.clientWidth === 0) return; + requestAnimationFrame(() => { + try { + fitAddon.fit(); + if (term.cols && term.rows) { + invoke('resize_terminal', { rows: term.rows, cols: term.cols }).catch(console.error); + } + } catch (e) { + // Ignore fit errors if container is hidden + } + }); + }; + + await spawnBackendTerminal(initialCwdRef.current); + + resizeObserver = new ResizeObserver(() => { + handleResize(); + }); + // Wait a tick for layout to settle before observing + setTimeout(() => { + if (terminalRef.current && resizeObserver) { + resizeObserver.observe(terminalRef.current); + handleResize(); // Initial fit + } + }, 50); + }; + + setupTerminal(); + + return () => { + onDataDisposable.dispose(); + unlistenOutput?.(); + resizeObserver?.disconnect(); + term.dispose(); + invoke('kill_terminal').catch(console.error); + }; + }, [spawnBackendTerminal]); // Only runs once now since spawnBackendTerminal has no deps + + // Update theme dynamically + useEffect(() => { + if (xtermRef.current) { + xtermRef.current.options.theme = getXtermTheme(); + } + }, [getXtermTheme]); + + useEffect(() => { + // Attempt to refit when height or visibility prop changes + if (isVisible && fitAddonRef.current && xtermRef.current && terminalRef.current?.clientWidth !== 0) { + requestAnimationFrame(() => { + try { + fitAddonRef.current!.fit(); + const term = xtermRef.current!; + if (term.cols && term.rows) { + invoke('resize_terminal', { rows: term.rows, cols: term.cols }).catch(console.error); + } + } catch (e) { + // Ignore + } + }); + } + }, [height, isVisible]); + + const [syncedCwd, setSyncedCwd] = useState(initialCwdRef.current); + + const handleSyncCwd = useCallback(async (targetCwd: string) => { + if (!targetCwd) return; + const isWindows = shellName.toLowerCase().includes('pwsh') || shellName.toLowerCase().includes('powershell'); + + // Safety: escape single quotes properly depending on shell + let cmd = ''; + if (isWindows) { + const escaped = `'${targetCwd.replace(/'/g, "''")}'`; + cmd = `Set-Location -LiteralPath ${escaped}\r\n`; + } else { + const escaped = `'${targetCwd.replace(/'/g, "'\\''")}'`; + cmd = `cd ${escaped}\n`; + } + + await invoke('write_terminal', { data: cmd }); + setSyncedCwd(targetCwd); + }, [shellName]); + + const syncTimeoutRef = useRef | null>(null); + const lastSyncedCwdRef = useRef(initialCwdRef.current); + + // Auto-sync CWD when active file changes + useEffect(() => { + if (autoSyncCwd && currentCwd && currentCwd !== lastSyncedCwdRef.current && isSpawned) { + if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current); + syncTimeoutRef.current = setTimeout(() => { + if (currentCwd === lastSyncedCwdRef.current) return; + lastSyncedCwdRef.current = currentCwd; + handleSyncCwd(currentCwd); + }, 300); // Debounce to prevent shell spam + } + }, [currentCwd, isSpawned, handleSyncCwd, autoSyncCwd]); + + const handleManualSync = () => { + if (currentCwd) { + handleSyncCwd(currentCwd); + lastSyncedCwdRef.current = currentCwd; + } + }; + + const handleClear = () => { + xtermRef.current?.clear(); + }; + + const handleRestart = async () => { + setIsSpawned(false); + xtermRef.current?.clear(); + await spawnBackendTerminal(currentCwd || initialCwdRef.current); + }; + + return ( +

+
+
+ Terminal {isSpawned ? `(${shellName})` : ''} + {syncedCwd && - {syncedCwd}} + {currentCwd && syncedCwd !== currentCwd && ( + + ⚠️ Out of sync + + )} +
+
+ + + + +
+
+
+
+ ); +}; diff --git a/src/hooks/useAppLogic.ts b/src/hooks/useAppLogic.ts index 8050d62..ec5570c 100644 --- a/src/hooks/useAppLogic.ts +++ b/src/hooks/useAppLogic.ts @@ -65,6 +65,8 @@ export function useAppLogic() { ignoredDirs: defaultIgnoredDirs, enableProfiler: false, previewCentered: false, + enableTerminal: false, + terminalAutoSyncCwd: false, largeFileMode: DEFAULT_LARGE_FILE_SETTINGS, }), []); diff --git a/src/types.ts b/src/types.ts index 5ffe0e2..3535ef1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,8 @@ export interface AppearanceSettings { ignoredDirs: string[]; enableProfiler?: boolean; previewCentered?: boolean; + enableTerminal?: boolean; + terminalAutoSyncCwd?: boolean; largeFileMode: LargeFileSettings; } diff --git a/src/utils/fileTypes.ts b/src/utils/fileTypes.ts index feb4fdd..cc844cd 100644 --- a/src/utils/fileTypes.ts +++ b/src/utils/fileTypes.ts @@ -81,7 +81,16 @@ function normalizeExtension(pathOrExtension: string): string { } const dotIndex = fileName.lastIndexOf('.'); - return dotIndex >= 0 ? fileName.slice(dotIndex).toLowerCase() : ''; + if (dotIndex >= 0) { + return fileName.slice(dotIndex).toLowerCase(); + } + + // If there's no dot and it's just a word, assume it's a bare extension (e.g. "md", "json") + if (fileName && !fileName.includes(' ')) { + return `.${fileName.toLowerCase()}`; + } + + return ''; } export function getFileType(pathOrExtension: string): FileTypeId {