diff --git a/src-tauri/crates/infra/scripts/default_init_fish.fish b/src-tauri/crates/infra/scripts/default_init_fish.fish new file mode 100644 index 00000000..0f0e38af --- /dev/null +++ b/src-tauri/crates/infra/scripts/default_init_fish.fish @@ -0,0 +1,10 @@ +# 2code common init (fish) +# The hook/wrapper files are written by 2code at session start via Rust. +# This script only exports env vars and updates PATH for the running shell. +set -gx _2CODE_HOME "$HOME/.2code" +set -gx _2CODE_BIN "$_2CODE_HOME/bin" +set -gx _2CODE_HOOKS "$_2CODE_HOME/hooks" +set -gx _2CODE_NOTIFY "$_2CODE_HOOKS/notify.sh" +set -gx _2CODE_SETTINGS "$_2CODE_HOOKS/claude-settings.json" + +fish_add_path -gP $_2CODE_BIN diff --git a/src-tauri/crates/infra/scripts/default_init_pwsh.ps1 b/src-tauri/crates/infra/scripts/default_init_pwsh.ps1 new file mode 100644 index 00000000..107a31e7 --- /dev/null +++ b/src-tauri/crates/infra/scripts/default_init_pwsh.ps1 @@ -0,0 +1,20 @@ +# 2code common init (PowerShell) +# The hook/wrapper files are written by 2code at session start via Rust. +# This script only exports env vars and updates PATH for the running shell. +$env:_2CODE_HOME = "$HOME\.2code" +$env:_2CODE_BIN = "$env:_2CODE_HOME\bin" +$env:_2CODE_HOOKS = "$env:_2CODE_HOME\hooks" +$env:_2CODE_NOTIFY = "$env:_2CODE_HOOKS\notify.sh" +$env:_2CODE_SETTINGS = "$env:_2CODE_HOOKS\claude-settings.json" + +$pathSep = [System.IO.Path]::PathSeparator +$alreadyOnPath = $false +foreach ($entry in ($env:PATH -split [regex]::Escape($pathSep))) { + if ([string]::Equals($entry, $env:_2CODE_BIN, [System.StringComparison]::OrdinalIgnoreCase)) { + $alreadyOnPath = $true + break + } +} +if (-not $alreadyOnPath) { + $env:PATH = "$env:_2CODE_BIN$pathSep$env:PATH" +} diff --git a/src-tauri/crates/infra/scripts/shellIntegration-bash.sh b/src-tauri/crates/infra/scripts/shellIntegration-bash.sh index c5729c39..3af22784 100644 --- a/src-tauri/crates/infra/scripts/shellIntegration-bash.sh +++ b/src-tauri/crates/infra/scripts/shellIntegration-bash.sh @@ -102,8 +102,11 @@ fi if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then export VSCODE_PYTHON_AUTOACTIVATE_GUARD=1 if [ -n "${VSCODE_PYTHON_BASH_ACTIVATE:-}" ] && [ "$TERM_PROGRAM" = "vscode" ]; then - # Prevent crashing by negating exit code - if ! builtin eval "$VSCODE_PYTHON_BASH_ACTIVATE"; then + # Capture the eval's real exit status (the previous `if ! ...; then $?` form + # captured the negated pipeline's status, which is always 0 on failure). + if builtin eval "$VSCODE_PYTHON_BASH_ACTIVATE"; then + : + else __vsc_activation_status=$? builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python bash activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" fi diff --git a/src-tauri/crates/infra/src/db.rs b/src-tauri/crates/infra/src/db.rs index eab1bc32..3e213acf 100644 --- a/src-tauri/crates/infra/src/db.rs +++ b/src-tauri/crates/infra/src/db.rs @@ -30,6 +30,32 @@ pub fn init_db(app_data_dir: &std::path::Path) -> Result { { tracing::warn!("Failed to set foreign_keys=ON: {e}"); } + // Performance: synchronous=NORMAL is the recommended companion for WAL mode. + // FULL (default) causes excessive fsync calls, especially slow on Windows NTFS. + if let Err(e) = + diesel::sql_query("PRAGMA synchronous=NORMAL;").execute(&mut conn) + { + tracing::warn!("Failed to set synchronous=NORMAL: {e}"); + } + // Allow SQLite to wait up to 5s when the database is locked by another + // connection, instead of failing immediately with SQLITE_BUSY. + if let Err(e) = + diesel::sql_query("PRAGMA busy_timeout=5000;").execute(&mut conn) + { + tracing::warn!("Failed to set busy_timeout=5000: {e}"); + } + // 64 MB page cache — the default 2 MB is too small for PTY output BLOB workloads. + if let Err(e) = + diesel::sql_query("PRAGMA cache_size=-65536;").execute(&mut conn) + { + tracing::warn!("Failed to set cache_size: {e}"); + } + // Keep temp tables in memory instead of writing to disk. + if let Err(e) = + diesel::sql_query("PRAGMA temp_store=MEMORY;").execute(&mut conn) + { + tracing::warn!("Failed to set temp_store=MEMORY: {e}"); + } conn.run_pending_migrations(MIGRATIONS) .map_err(|e| format!("Failed to run migrations: {e}"))?; diff --git a/src-tauri/crates/infra/src/lib.rs b/src-tauri/crates/infra/src/lib.rs index 3629248f..f2d8b1d5 100644 --- a/src-tauri/crates/infra/src/lib.rs +++ b/src-tauri/crates/infra/src/lib.rs @@ -5,6 +5,7 @@ pub mod git; pub mod logger; pub mod no_window; pub mod pty; +pub mod shell_detect; pub mod shell_init; pub mod slug; pub mod watcher; diff --git a/src-tauri/crates/infra/src/no_window.rs b/src-tauri/crates/infra/src/no_window.rs index 628130aa..0705df1e 100644 --- a/src-tauri/crates/infra/src/no_window.rs +++ b/src-tauri/crates/infra/src/no_window.rs @@ -4,6 +4,14 @@ use std::process::Command; /// /// On non-Windows platforms this is identical to `Command::new`. pub fn command_without_windows_console(program: &str) -> Command { + // Alias for shell_detect.rs + silent_command(program) +} + +/// Create a `Command` that won't open a console window on Windows. +/// +/// On non-Windows platforms this is identical to `Command::new`. +pub fn silent_command(program: &str) -> Command { #[cfg(target_os = "windows")] { windows_no_window_command(program) diff --git a/src-tauri/crates/infra/src/pty.rs b/src-tauri/crates/infra/src/pty.rs index c0bbb0e4..38e24200 100644 --- a/src-tauri/crates/infra/src/pty.rs +++ b/src-tauri/crates/infra/src/pty.rs @@ -76,7 +76,7 @@ pub fn create_session( // Common env vars for all shells cmd.env("TERM", "xterm-256color"); cmd.env("TERM_PROGRAM", "vscode"); // Makes VS Code's shell integration scripts work - cmd.env("VSCODE_INJECTION", "1"); // Tells scripts they were injected (not manually installed) + cmd.env("VSCODE_INJECTION", "1"); // Tells scripts they were injected (not manually installed) // Inject helper env vars for CLI sidecar communication if let Some(url) = options.helper_url { @@ -134,21 +134,46 @@ pub fn create_session( } /// Parse a shell command string into (program, args), handling paths with spaces. -/// e.g. `"C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe -NoLogo -NoProfile"` -/// → `("C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", ["-NoLogo", "-NoProfile"])` +/// +/// Accepts three shapes: +/// 1. Quoted program: `"C:\Program Files\PowerShell\7\pwsh.exe" -NoLogo` +/// → program is the quoted span (quotes stripped), args split by whitespace. +/// 2. Bare path with spaces ending in a known extension or matching an existing +/// file: `C:\Program Files\PowerShell\7\pwsh.exe -NoLogo` → joined back into +/// a single program via the extension/exists fallback below. +/// 3. Simple `program [args...]`. fn parse_shell_command(shell: &str) -> (String, Vec) { let shell = shell.trim(); + if shell.is_empty() { + return (String::new(), vec![]); + } + + // Shape 1: quoted program. Preserves spaces and escaping intent the user + // expressed explicitly — no heuristic guesswork needed. + let first_char = shell.as_bytes()[0]; + if first_char == b'"' || first_char == b'\'' { + let quote = first_char as char; + if let Some(end) = shell[1..].find(quote) { + let program = shell[1..=end].to_string(); + let rest = shell[end + 2..].trim(); + let args = rest.split_whitespace().map(|s| s.to_string()).collect(); + return (program, args); + } + } + let parts: Vec<&str> = shell.split_whitespace().collect(); if parts.is_empty() { return (shell.to_string(), vec![]); } let first = parts[0]; - let looks_like_path = - first.len() >= 2 && first.as_bytes()[1] == b':' // C: D: etc + let looks_like_path = first.len() >= 2 && first.as_bytes()[1] == b':' // C: D: etc || first.contains('/') || first.contains('\\'); if !looks_like_path || parts.len() == 1 { - return (first.to_string(), parts[1..].iter().map(|s| s.to_string()).collect()); + return ( + first.to_string(), + parts[1..].iter().map(|s| s.to_string()).collect(), + ); } // Reconstruct the path by joining tokens until we hit one ending with a @@ -167,11 +192,18 @@ fn parse_shell_command(shell: &str) -> (String, Vec) { break; } end_idx += 1; - if end_idx > 6 { break; } + if end_idx > 6 { + break; + } } if end_idx > parts.len() { - end_idx = parts.iter().position(|p| p.starts_with('-')).unwrap_or(parts.len()); - if end_idx == 0 { end_idx = 1; } + end_idx = parts + .iter() + .position(|p| p.starts_with('-')) + .unwrap_or(parts.len()); + if end_idx == 0 { + end_idx = 1; + } } let program = parts[..end_idx].join(" "); let args = parts[end_idx..].iter().map(|s| s.to_string()).collect(); @@ -179,22 +211,31 @@ fn parse_shell_command(shell: &str) -> (String, Vec) { } /// Build a CommandBuilder with the right executable and args for the given injection type. -fn build_injected_command(shell: &str, injection: &ShellInjection) -> CommandBuilder { +fn build_injected_command( + shell: &str, + injection: &ShellInjection, +) -> CommandBuilder { // Parse the shell command, handling paths with spaces like // "C:\Program Files\PowerShell\7-preview\pwsh.exe -NoLogo -NoProfile" let (program, existing_args) = parse_shell_command(shell); match injection { ShellInjection::Bash { init_file } => { - // bash --init-file /path/to/shellIntegration-bash.sh + // bash [user args] --init-file /path/to/shellIntegration-bash.sh let mut cmd = CommandBuilder::new(program); + for arg in &existing_args { + cmd.arg(arg.as_str()); + } cmd.arg("--init-file"); cmd.arg(init_file.to_string_lossy().as_ref()); cmd } ShellInjection::Zsh { .. } => { - // zsh -i (ZDOTDIR is set via env var, scripts are in the dir) + // zsh [user args] -i (ZDOTDIR is set via env var, scripts are in the dir) let mut cmd = CommandBuilder::new(program); + for arg in &existing_args { + cmd.arg(arg.as_str()); + } cmd.arg("-i"); cmd } @@ -205,10 +246,7 @@ fn build_injected_command(shell: &str, injection: &ShellInjection) -> CommandBui cmd.arg(arg.as_str()); } cmd.arg("--init-command"); - cmd.arg(format!( - "source \"{}\"", - init_script.to_string_lossy() - )); + cmd.arg(format!("source \"{}\"", init_script.to_string_lossy())); cmd } ShellInjection::Pwsh { init_script } => { @@ -223,10 +261,7 @@ fn build_injected_command(shell: &str, injection: &ShellInjection) -> CommandBui } cmd.arg("-noexit"); cmd.arg("-command"); - cmd.arg(format!( - ". \"{}\"", - init_script.to_string_lossy() - )); + cmd.arg(format!(". \"{}\"", init_script.to_string_lossy())); cmd } ShellInjection::None => { @@ -384,7 +419,8 @@ mod tests { #[test] fn parse_shell_with_args() { - let (prog, args) = parse_shell_command("powershell.exe -NoLogo -NoProfile"); + let (prog, args) = + parse_shell_command("powershell.exe -NoLogo -NoProfile"); assert_eq!(prog, "powershell.exe"); assert_eq!(args, vec!["-NoLogo".to_string(), "-NoProfile".to_string()]); } @@ -400,8 +436,32 @@ mod tests { #[test] fn parse_shell_git_bash() { - let (prog, args) = parse_shell_command(r"C:\Program Files\Git\bin\bash.exe"); + let (prog, args) = + parse_shell_command(r"C:\Program Files\Git\bin\bash.exe"); assert_eq!(prog, r"C:\Program Files\Git\bin\bash.exe"); assert!(args.is_empty()); } + + #[test] + fn parse_shell_quoted_path() { + let (prog, args) = parse_shell_command( + r#""C:\Program Files\PowerShell\7\pwsh.exe" -NoLogo -NoProfile"#, + ); + assert_eq!(prog, r"C:\Program Files\PowerShell\7\pwsh.exe"); + assert_eq!(args, vec!["-NoLogo".to_string(), "-NoProfile".to_string()]); + } + + #[test] + fn parse_shell_quoted_path_no_args() { + let (prog, args) = parse_shell_command(r#""/usr/local/bin/my shell""#); + assert_eq!(prog, "/usr/local/bin/my shell"); + assert!(args.is_empty()); + } + + #[test] + fn parse_shell_empty() { + let (prog, args) = parse_shell_command(""); + assert!(prog.is_empty()); + assert!(args.is_empty()); + } } diff --git a/src-tauri/crates/infra/src/shell_detect.rs b/src-tauri/crates/infra/src/shell_detect.rs new file mode 100644 index 00000000..cc267394 --- /dev/null +++ b/src-tauri/crates/infra/src/shell_detect.rs @@ -0,0 +1,349 @@ +use std::collections::HashSet; +use std::path::Path; + +use serde::Serialize; + +#[cfg(windows)] +use crate::no_window::silent_command; + +#[derive(Debug, Serialize, Clone)] +pub struct AvailableShell { + pub label: String, + pub command: String, + pub is_default: bool, + pub supports_integration: bool, +} + +fn push_shell( + shells: &mut Vec, + seen: &mut HashSet, + command: impl Into, + default_command: &str, + integration: bool, + label: Option<&str>, +) { + let command = command.into(); + if command.trim().is_empty() || !seen.insert(command.clone()) { + return; + } + let label = label.unwrap_or(&command).to_string(); + shells.push(AvailableShell { + label, + is_default: command == default_command, + supports_integration: integration, + command, + }); +} + +// --------------------------------------------------------------------------- +// Unix shell detection +// --------------------------------------------------------------------------- + +#[cfg(unix)] +fn command_exists(command: &str) -> bool { + Path::new(command).is_file() +} + +#[cfg(unix)] +fn push_existing_shell( + shells: &mut Vec, + seen: &mut HashSet, + command: &str, + default_command: &str, +) { + let command = command.trim(); + if command.is_empty() || command.starts_with('#') || seen.contains(command) + { + return; + } + if command_exists(command) { + push_shell(shells, seen, command, default_command, true, None); + } +} + +#[cfg(target_os = "linux")] +fn default_shell_command() -> String { + std::env::var("SHELL") + .ok() + .filter(|shell| command_exists(shell)) + .unwrap_or_else(|| "/bin/bash".to_string()) +} + +#[cfg(target_os = "macos")] +fn default_shell_command() -> String { + std::env::var("SHELL") + .ok() + .filter(|shell| command_exists(shell)) + .unwrap_or_else(|| "/bin/zsh".to_string()) +} + +#[cfg(unix)] +fn load_unix_shells(default_command: &str) -> Vec { + let mut shells = Vec::new(); + let mut seen = HashSet::new(); + + push_shell( + &mut shells, + &mut seen, + default_command, + default_command, + true, + None, + ); + + if let Ok(contents) = std::fs::read_to_string("/etc/shells") { + for line in contents.lines() { + push_existing_shell(&mut shells, &mut seen, line, default_command); + } + } + + for command in [ + "/bin/bash", + "/usr/bin/bash", + "/bin/zsh", + "/usr/bin/zsh", + "/bin/fish", + "/usr/bin/fish", + "/bin/sh", + "/usr/bin/sh", + ] { + push_existing_shell(&mut shells, &mut seen, command, default_command); + } + + shells +} + +// --------------------------------------------------------------------------- +// Windows shell detection +// --------------------------------------------------------------------------- + +#[cfg(windows)] +fn default_shell_command() -> String { + if let Some(pwsh_path) = find_pwsh_path() { + format!("{} -NoLogo -NoProfile", pwsh_path) + } else { + "powershell.exe -NoLogo -NoProfile".to_string() + } +} + +/// Find the pwsh (PowerShell 7+) executable path. +/// Checks well-known install locations first (fast, no subprocess), then +/// falls back to `where pwsh` on PATH. +#[cfg(windows)] +pub fn find_pwsh_path() -> Option { + // Well-known install locations — check these first to avoid subprocess + let candidates = [ + r"C:\Program Files\PowerShell\7\pwsh.exe", + r"C:\Program Files\PowerShell\7-preview\pwsh.exe", + r"C:\Program Files (x86)\PowerShell\7\pwsh.exe", + ]; + for path in &candidates { + if Path::new(path).exists() { + return Some(path.to_string()); + } + } + + // Fallback: check PATH via `where` + if let Ok(output) = silent_command("where").arg("pwsh").output() { + if output.status.success() { + let first = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if !first.is_empty() { + return Some(first); + } + } + } + + None +} + +/// Run `where ` and return the first match, or None. +#[cfg(windows)] +fn find_on_path(exe: &str) -> Option { + let output = silent_command("where").arg(exe).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Detect installed WSL distributions by running `wsl -l -q`. +/// Returns a list of distro names (e.g. ["Ubuntu", "Debian"]). +/// `wsl -l -q` outputs UTF-16LE on Windows, so we decode accordingly. +#[cfg(windows)] +fn detect_wsl_distros() -> Vec { + let output = match silent_command("wsl").args(["-l", "-q"]).output() { + Ok(o) if o.status.success() => o, + _ => return Vec::new(), + }; + + // wsl -l -q outputs UTF-16LE (little-endian with BOM) + let stdout = &output.stdout; + if stdout.len() < 2 { + return Vec::new(); + } + + // Try UTF-16LE decoding (skip BOM if present) + let words: Vec = + if stdout.len() >= 2 && stdout[0] == 0xFF && stdout[1] == 0xFE { + // Has BOM — skip first 2 bytes + stdout[2..] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect() + } else { + stdout + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect() + }; + + let text = String::from_utf16_lossy(&words); + text.lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect() +} + +#[cfg(windows)] +fn load_windows_shells(default_command: &str) -> Vec { + let mut shells = Vec::new(); + let mut seen = HashSet::new(); + + // 1. pwsh (PowerShell 7+) — preferred default + if let Some(pwsh_path) = find_pwsh_path() { + push_shell( + &mut shells, + &mut seen, + format!("{} -NoLogo -NoProfile", pwsh_path), + default_command, + true, + Some("PowerShell 7"), + ); + } + + // 2. powershell.exe (Windows PowerShell 5.x) — always present on Windows + push_shell( + &mut shells, + &mut seen, + "powershell.exe -NoLogo -NoProfile", + default_command, + true, + Some("Windows PowerShell"), + ); + + // 3. cmd.exe — no shell integration + push_shell( + &mut shells, + &mut seen, + "cmd.exe", + default_command, + false, + Some("Command Prompt"), + ); + + // 4. Git Bash — check well-known paths, then PATH + let git_bash_candidates = [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files (x86)\Git\bin\bash.exe", + ]; + for path in &git_bash_candidates { + if Path::new(path).exists() { + push_shell( + &mut shells, + &mut seen, + *path, + default_command, + true, + Some("Git Bash"), + ); + } + } + if !seen.iter().any(|s| s.contains("Git")) { + if let Some(bash) = find_on_path("bash.exe") { + if bash.to_lowercase().contains("git") { + push_shell( + &mut shells, + &mut seen, + bash, + default_command, + true, + Some("Git Bash"), + ); + } + } + } + + // 5. WSL — detect installed distros, each as a separate entry + let distros = detect_wsl_distros(); + if distros.is_empty() { + // No distros detected or wsl not available — still show raw wsl.exe if it exists + let wsl = r"C:\Windows\System32\wsl.exe"; + if Path::new(wsl).exists() { + push_shell( + &mut shells, + &mut seen, + wsl, + default_command, + false, + Some("WSL"), + ); + } + } else { + for distro in &distros { + let command = format!("wsl.exe -d {}", distro); + let label = format!("{} (WSL)", distro); + push_shell( + &mut shells, + &mut seen, + command, + default_command, + false, + Some(&label), + ); + } + } + + shells +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +pub fn load_available_shells() -> Vec { + let default_command = default_shell_command(); + + #[cfg(windows)] + { + load_windows_shells(&default_command) + } + + #[cfg(unix)] + { + load_unix_shells(&default_command) + } + + #[cfg(not(any(unix, windows)))] + { + let mut shells = Vec::new(); + let mut seen = HashSet::new(); + push_shell( + &mut shells, + &mut seen, + default_command.clone(), + &default_command, + true, + None, + ); + shells + } +} diff --git a/src-tauri/crates/infra/src/shell_init.rs b/src-tauri/crates/infra/src/shell_init.rs index 982cb1f6..99efce1e 100644 --- a/src-tauri/crates/infra/src/shell_init.rs +++ b/src-tauri/crates/infra/src/shell_init.rs @@ -5,9 +5,14 @@ use model::error::AppError; // 2code's own init scripts. // `common` is POSIX-sh compatible — works in bash and zsh (notify hook, claude wrapper, PATH). // `zsh` is zsh-only (zle keybindings, unsetopt). +// `fish` and `pwsh` only set env vars and PATH; file creation is done by setup_2code_home(). const DEFAULT_INIT_COMMON: &str = include_str!("../scripts/default_init_common.sh"); const DEFAULT_INIT_ZSH: &str = include_str!("../scripts/default_init_zsh.sh"); +const DEFAULT_INIT_FISH: &str = + include_str!("../scripts/default_init_fish.fish"); +const DEFAULT_INIT_PWSH: &str = + include_str!("../scripts/default_init_pwsh.ps1"); // VS Code shell integration scripts (MIT licensed, from microsoft/vscode) const VSC_BASH: &str = include_str!("../scripts/shellIntegration-bash.sh"); @@ -29,19 +34,72 @@ pub enum ShellType { Unknown, } +/// Extract just the executable path from a shell command string, handling paths with spaces. +/// E.g. `"C:\Program Files\PowerShell\7\pwsh.exe -NoLogo -NoProfile"` → `"C:\Program Files\PowerShell\7\pwsh.exe"` +pub(crate) fn extract_exe(cmd: &str) -> String { + let cmd = cmd.trim(); + let parts: Vec<&str> = cmd.split_whitespace().collect(); + if parts.is_empty() { + return cmd.to_string(); + } + let first = parts[0]; + let looks_like_path = (first.len() >= 2 && first.as_bytes()[1] == b':') + || first.contains('/') + || first.contains('\\'); + if !looks_like_path || parts.len() == 1 { + return first.to_string(); + } + let mut end_idx = 1; + let mut found = false; + while end_idx < parts.len() { + let candidate = parts[..=end_idx].join(" "); + let lower = candidate.to_lowercase(); + if lower.ends_with(".exe") + || lower.ends_with(".bat") + || lower.ends_with(".cmd") + || lower.ends_with(".ps1") + || lower.ends_with(".sh") + || Path::new(&candidate).is_file() + { + end_idx += 1; + found = true; + break; + } + end_idx += 1; + if end_idx > 6 { + break; + } + } + if !found { + end_idx = parts + .iter() + .position(|p| p.starts_with('-')) + .unwrap_or(parts.len()); + if end_idx == 0 { + end_idx = 1; + } + } + parts[..end_idx].join(" ") +} + /// Detect shell type from the shell command string. pub fn detect_shell_type(shell_cmd: &str) -> ShellType { - // Take the basename of the first token (the executable) - let exe = shell_cmd.split_whitespace().next().unwrap_or(shell_cmd); - let basename = Path::new(exe) + let exe = extract_exe(shell_cmd); + let executable_name = exe + .rsplit(['/', '\\']) + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(&exe); + let basename = Path::new(executable_name) .file_stem() .and_then(|s| s.to_str()) - .unwrap_or(exe) + .unwrap_or(executable_name) .to_lowercase(); match basename.as_str() { "zsh" => ShellType::Zsh, - "bash" | "sh" => ShellType::Bash, + "bash" => ShellType::Bash, + "sh" => ShellType::Unknown, "fish" => ShellType::Fish, "pwsh" | "powershell" => ShellType::Pwsh, _ => ShellType::Unknown, @@ -66,6 +124,136 @@ pub enum ShellInjection { None, } +/// Write the 2code home directory files (notify hook, claude wrapper, settings JSON) +/// so that fish and pwsh sessions have them available without running a bash script. +/// Skipped silently if HOME is unset or any I/O fails. +fn setup_2code_home() { + let home = std::env::var("HOME") + .ok() + .filter(|h| !h.is_empty()) + .or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty())) + .or_else(|| { + let drive = + std::env::var("HOMEDRIVE").ok().filter(|d| !d.is_empty()); + let path = std::env::var("HOMEPATH").ok().filter(|p| !p.is_empty()); + match (drive, path) { + (Some(d), Some(p)) => Some(format!("{d}{p}")), + _ => None, + } + }); + let home = match home { + Some(h) => PathBuf::from(h), + None => return, + }; + let hooks = home.join(".2code/hooks"); + let bin = home.join(".2code/bin"); + if std::fs::create_dir_all(&hooks).is_err() + || std::fs::create_dir_all(&bin).is_err() + { + return; + } + + let notify = hooks.join("notify.sh"); + if !notify.exists() { + let _ = std::fs::write( + ¬ify, + "#!/bin/bash\n[[ -z \"$_2CODE_HELPER\" ]] && exit 0\n\"$_2CODE_HELPER\" notify &>/dev/null &\n", + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(¬ify) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(¬ify, perms); + } + } + } + + let settings = hooks.join("claude-settings.json"); + if !settings.exists() { + let notify_str = notify.to_string_lossy().to_string(); + let json = serde_json::json!({ + "hooks": { + "Stop": [{ "hooks": [{ "type": "command", "command": ¬ify_str }] }], + "PermissionRequest": [{ "matcher": "*", "hooks": [{ "type": "command", "command": ¬ify_str }] }] + } + }); + if let Ok(content) = serde_json::to_string(&json) { + let _ = std::fs::write(&settings, content); + } + } + + let wrapper = bin.join("claude"); + if !wrapper.exists() { + let settings_str = settings.to_string_lossy(); + let script = format!( + concat!( + "#!/bin/bash\n", + "_SETTINGS=\"{s}\"\n", + "_find_real() {{\n", + " local IFS=:\n", + " for dir in $PATH; do\n", + " [ -z \"$dir\" ] && continue\n", + " case \"${{dir%/}}\" in\n", + " \"$HOME/.2code/bin\") continue ;;\n", + " esac\n", + " if [ -x \"$dir/claude\" ] && [ ! -d \"$dir/claude\" ]; then\n", + " printf '%s\\n' \"$dir/claude\"\n", + " return 0\n", + " fi\n", + " done\n", + " return 1\n", + "}}\n", + "_REAL=\"$(_find_real)\"\n", + "if [ -z \"$_REAL\" ]; then\n", + " echo \"2code: claude not found in PATH\" >&2\n", + " exit 127\n", + "fi\n", + "exec \"$_REAL\" --settings \"$_SETTINGS\" \"$@\"\n", + ), + s = settings_str + ); + let _ = std::fs::write(&wrapper, script); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&wrapper) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(&wrapper, perms); + } + } + } + + // Windows: PowerShell/CMD cannot execute extensionless files from PATH. + // Create a claude.cmd batch wrapper alongside the bash script. + #[cfg(windows)] + { + let cmd_wrapper = bin.join("claude.cmd"); + if !cmd_wrapper.exists() { + let settings_str = settings.to_string_lossy(); + let cmd_script = format!( + concat!( + "@echo off\r\n", + "setlocal\r\n", + "set \"_SETTINGS={s}\"\r\n", + "for /f \"delims=\" %%d in ('where claude 2^>nul') do (\r\n", + " if /i not \"%%~dpd\"==\"%~dp0\" (\r\n", + " \"%%d\" --settings \"%_SETTINGS%\" %*\r\n", + " exit /b %ERRORLEVEL%\r\n", + " )\r\n", + ")\r\n", + "echo 2code: claude not found in PATH >&2\r\n", + "exit /b 127\r\n", + ), + s = settings_str + ); + let _ = std::fs::write(&cmd_wrapper, cmd_script); + } + } +} + /// Prepare shell integration injection for the given shell type. /// This writes the necessary scripts to a temp directory and returns /// an injection descriptor telling the PTY layer what to do. @@ -74,14 +262,15 @@ pub fn prepare_shell_injection( shell_type: ShellType, project_init_scripts: &[String], ) -> Result { - let dir = std::env::temp_dir().join(format!("2code-init-{session_id}")); + let dir = std::env::temp_dir() + .join(format!("2code-init-{}-{session_id}", std::process::id())); std::fs::create_dir_all(&dir)?; match shell_type { ShellType::Zsh => prepare_zsh(&dir, project_init_scripts), ShellType::Bash => prepare_bash(&dir, project_init_scripts), - ShellType::Fish => prepare_fish(&dir, project_init_scripts), - ShellType::Pwsh => prepare_pwsh(&dir, project_init_scripts), + ShellType::Fish => prepare_fish(&dir), + ShellType::Pwsh => prepare_pwsh(&dir), ShellType::Unknown => Ok(ShellInjection::None), } } @@ -143,32 +332,30 @@ fn prepare_bash( } /// Fish: VS Code's approach — use `--init-command 'source ""'`. -fn prepare_fish( - dir: &Path, - project_init_scripts: &[String], -) -> Result { +/// Project init scripts are skipped because they are POSIX shell syntax, +/// incompatible with Fish. +fn prepare_fish(dir: &Path) -> Result { + setup_2code_home(); let init_script = dir.join("shellIntegration.fish"); - let project_init = project_init_scripts.join("\n"); let script = format!( - "{vsc}\n\n# === 2code project init ===\n{project_init}\n", + "{vsc}\n\n# === 2code common init ===\n{common}\n", vsc = VSC_FISH, - project_init = project_init.trim_end(), + common = DEFAULT_INIT_FISH.trim_end(), ); std::fs::write(&init_script, script)?; Ok(ShellInjection::Fish { init_script }) } /// Pwsh: VS Code's approach — use `-noexit -command '. ""'`. -fn prepare_pwsh( - dir: &Path, - project_init_scripts: &[String], -) -> Result { +/// Project init scripts are skipped because they are POSIX shell syntax, +/// incompatible with PowerShell. +fn prepare_pwsh(dir: &Path) -> Result { + setup_2code_home(); let init_script = dir.join("shellIntegration.ps1"); - let project_init = project_init_scripts.join("\n"); let script = format!( - "{vsc}\n\n# === 2code project init ===\n{project_init}\n", + "{vsc}\n\n# === 2code common init ===\n{common}\n", vsc = VSC_PWSH, - project_init = project_init.trim_end(), + common = DEFAULT_INIT_PWSH.trim_end(), ); std::fs::write(&init_script, script)?; Ok(ShellInjection::Pwsh { init_script }) @@ -177,7 +364,47 @@ fn prepare_pwsh( #[cfg(test)] mod tests { use super::*; - use std::process::Command; + + #[test] + fn extract_exe_simple_basename() { + assert_eq!(extract_exe("bash"), "bash"); + assert_eq!(extract_exe("pwsh -NoLogo"), "pwsh"); + } + + #[test] + fn extract_exe_unix_path_with_args() { + assert_eq!(extract_exe("/usr/bin/zsh -l"), "/usr/bin/zsh"); + assert_eq!(extract_exe("/bin/bash --login"), "/bin/bash"); + } + + #[test] + fn extract_exe_windows_path_with_spaces() { + assert_eq!( + extract_exe( + "C:\\Program Files\\PowerShell\\7\\pwsh.exe -NoLogo -NoProfile" + ), + "C:\\Program Files\\PowerShell\\7\\pwsh.exe" + ); + } + + #[test] + fn extract_exe_path_with_spaces_no_extension_with_flag() { + // Path containing spaces, no .exe/.sh suffix, followed by a flag. + // The fallback must split at the first flag-looking token, not swallow it. + assert_eq!( + extract_exe("C:\\Program Files\\nu\\nu --login"), + "C:\\Program Files\\nu\\nu" + ); + } + + #[test] + fn extract_exe_path_with_spaces_no_extension_no_flag() { + // Path containing spaces, no extension, no following flag — keep the whole thing. + assert_eq!( + extract_exe("C:\\Program Files\\nu\\nu"), + "C:\\Program Files\\nu\\nu" + ); + } #[test] fn detect_zsh() { @@ -205,6 +432,16 @@ mod tests { ); } + #[test] + fn detect_pwsh_windows_path_with_spaces() { + assert_eq!( + detect_shell_type( + "C:\\Program Files\\PowerShell\\7\\pwsh.exe -NoLogo -NoProfile" + ), + ShellType::Pwsh + ); + } + #[test] fn detect_unknown() { assert_eq!(detect_shell_type("nushell"), ShellType::Unknown); @@ -260,12 +497,21 @@ mod tests { } #[test] - fn prepare_fish_creates_script() { - let inj = prepare_shell_injection("test-fish-1", ShellType::Fish, &[]) + fn prepare_fish_includes_common_init() { + let inj = prepare_shell_injection("test-fish-2", ShellType::Fish, &[]) .unwrap(); match inj { ShellInjection::Fish { init_script } => { assert!(init_script.exists()); + let content = std::fs::read_to_string(&init_script).unwrap(); + // VS Code integration must be present. + assert!(content.contains("VSCODE_SHELL_INTEGRATION")); + // 2code common init must be present + assert!(content.contains("2code common init")); + assert!(content.contains("fish_add_path")); + assert!(content.contains("_2CODE_HOME")); + // Project init must NOT be present (POSIX syntax, incompatible) + assert!(!content.contains("2code project init")); std::fs::remove_dir_all(init_script.parent().unwrap()).ok(); } _ => panic!("Expected Fish injection"), @@ -273,126 +519,23 @@ mod tests { } #[test] - #[cfg(unix)] - fn default_common_init_wraps_claude_stop_hook_and_codex_notify() { - let temp = tempfile::tempdir().unwrap(); - let home = temp.path().join("home with space"); - let real_bin = temp.path().join("real-bin"); - let marker = temp.path().join("marker"); - std::fs::create_dir_all(&home).unwrap(); - std::fs::create_dir_all(&real_bin).unwrap(); - std::fs::create_dir_all(&marker).unwrap(); - - write_fake_executable( - &real_bin.join("claude"), - r#"#!/bin/sh -printf '%s\n' "$@" >"$MARKER/claude.args" -"#, - ); - write_fake_executable( - &real_bin.join("codex"), - r#"#!/bin/sh -printf '%s\n' "$@" >"$MARKER/codex.args" -"#, - ); - write_fake_executable( - &marker.join("helper"), - &format!( - r#"#!/bin/sh -printf '%s\n' "$@" >>"{}" -"#, - marker.join("helper.args").display() - ), - ); - - let init_file = temp.path().join("default_init_common.sh"); - std::fs::write(&init_file, DEFAULT_INIT_COMMON).unwrap(); - let shell = format!( - r#". "{}" -claude --version -codex exec "say ok" -"#, - init_file.display() - ); - - let output = Command::new("/bin/bash") - .arg("--noprofile") - .arg("--norc") - .arg("-c") - .arg(shell) - .env("HOME", &home) - .env("MARKER", &marker) - .env( - "PATH", - format!("{}:/usr/bin:/bin", real_bin.to_string_lossy()), - ) - .output() - .unwrap(); - assert!( - output.status.success(), - "init failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - let hooks_dir = home.join(".2code/hooks"); - let claude_args = - std::fs::read_to_string(marker.join("claude.args")).unwrap(); - assert!(claude_args.contains("--settings\n")); - assert!(claude_args.contains(&format!( - "{}\n", - hooks_dir.join("claude-settings.json").display() - ))); - - let codex_args = - std::fs::read_to_string(marker.join("codex.args")).unwrap(); - assert!(codex_args.contains("notify=[\"")); - assert!(codex_args - .contains(&hooks_dir.join("notify.sh").display().to_string())); - - let claude_settings: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(hooks_dir.join("claude-settings.json")) - .unwrap(), - ) - .unwrap(); - assert_eq!( - claude_settings["hooks"]["Stop"][0]["hooks"][0]["command"], - format!("'{}'", hooks_dir.join("notify.sh").display()) - ); - - let output = Command::new("/bin/bash") - .arg("--noprofile") - .arg("--norc") - .arg("-c") - .arg(format!( - "'{}'; sleep 1", - hooks_dir.join("notify.sh").display() - )) - .env("_2CODE_HELPER", marker.join("helper")) - .env("_2CODE_HELPER_URL", "http://127.0.0.1:1") - .env("_2CODE_SESSION_ID", "test-session") - .env("MARKER", &marker) - .output() + fn prepare_pwsh_includes_common_init() { + let inj = prepare_shell_injection("test-pwsh-1", ShellType::Pwsh, &[]) .unwrap(); - assert!( - output.status.success(), - "hook command failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert_eq!( - std::fs::read_to_string(marker.join("helper.args")).unwrap(), - "notify\n" - ); - } - - #[cfg(unix)] - fn write_fake_executable(path: &Path, content: &str) { - use std::os::unix::fs::PermissionsExt; - - std::fs::write(path, content).unwrap(); - let mut permissions = std::fs::metadata(path).unwrap().permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions).unwrap(); + match inj { + ShellInjection::Pwsh { init_script } => { + assert!(init_script.exists()); + let content = std::fs::read_to_string(&init_script).unwrap(); + // VS Code integration must be present. + assert!(content.contains("__VSCodeState")); + // 2code common init must be present + assert!(content.contains("2code common init")); + assert!(content.contains("_2CODE_HOME")); + // Project init must NOT be present (POSIX syntax, incompatible) + assert!(!content.contains("2code project init")); + std::fs::remove_dir_all(init_script.parent().unwrap()).ok(); + } + _ => panic!("Expected Pwsh injection"), + } } } diff --git a/src-tauri/crates/service/src/pty.rs b/src-tauri/crates/service/src/pty.rs index 3e1dcc53..f0a56497 100644 --- a/src-tauri/crates/service/src/pty.rs +++ b/src-tauri/crates/service/src/pty.rs @@ -320,8 +320,27 @@ pub fn create_session( let emitter = ctx.emitter.clone(); let db = ctx.db.clone(); let flush_senders = ctx.flush_senders.clone(); + // Extract the shell injection temp dir so the read thread can clean it up + // when the session exits (these dirs accumulate in %TEMP% otherwise). + let cleanup_dir = match &injection { + infra::shell_init::ShellInjection::Zsh { zdotdir, .. } => { + Some(zdotdir.clone()) + } + infra::shell_init::ShellInjection::Bash { init_file } + | infra::shell_init::ShellInjection::Fish { + init_script: init_file, + } + | infra::shell_init::ShellInjection::Pwsh { + init_script: init_file, + } => init_file.parent().map(|p| p.to_path_buf()), + infra::shell_init::ShellInjection::None => None, + }; let handle = std::thread::spawn(move || { read_pty_output(emitter, id, reader, db, flush_senders); + // Clean up shell injection temp directory + if let Some(dir) = cleanup_dir { + let _ = std::fs::remove_dir_all(&dir); + } }); // Track the thread handle so it can be joined on app exit @@ -331,7 +350,7 @@ pub fn create_session( if !config.startup_commands.is_empty() { if cfg!(windows) { - std::thread::sleep(Duration::from_secs(1)); + std::thread::sleep(Duration::from_millis(500)); } let startup_commands = build_startup_commands(&config.startup_commands, cfg!(windows)); @@ -531,6 +550,11 @@ fn read_pty_output( db: DbPool, flush_senders: PtyFlushSenders, ) { + // Windows ConPty (especially pwsh) delivers larger chunks than Unix ptys. + // A bigger buffer reduces the number of read() syscalls and event emissions. + #[cfg(windows)] + let mut buf = [0u8; 16384]; + #[cfg(not(windows))] let mut buf = [0u8; 4096]; let mut utf8_remainder: Vec = Vec::new(); diff --git a/src-tauri/src/handler/shell.rs b/src-tauri/src/handler/shell.rs index 8c7b59e7..ffd6eab5 100644 --- a/src-tauri/src/handler/shell.rs +++ b/src-tauri/src/handler/shell.rs @@ -1,219 +1,19 @@ -#[cfg(windows)] -use infra::no_window::command_without_windows_console; -use serde::Serialize; -use std::collections::HashSet; -use std::path::Path; +pub use infra::shell_detect::AvailableShell; -#[derive(Debug, Serialize, Clone)] -pub struct AvailableShell { - pub label: String, - pub command: String, - pub is_default: bool, -} - -fn shell_label(command: &str) -> String { - command.to_string() -} - -fn push_shell( - shells: &mut Vec, - seen: &mut HashSet, - command: impl Into, - default_command: &str, -) { - let command = command.into(); - if command.trim().is_empty() || !seen.insert(command.clone()) { - return; - } - - shells.push(AvailableShell { - label: shell_label(&command), - is_default: command == default_command, - command, - }); -} - -#[cfg(unix)] -fn command_exists(command: &str) -> bool { - Path::new(command).is_file() -} - -#[cfg(unix)] -fn push_existing_shell( - shells: &mut Vec, - seen: &mut HashSet, - command: &str, - default_command: &str, -) { - let command = command.trim(); - if command.is_empty() || command.starts_with('#') || seen.contains(command) +#[tauri::command] +pub async fn list_available_shells() -> Vec { + match tauri::async_runtime::spawn_blocking( + infra::shell_detect::load_available_shells, + ) + .await { - return; - } - if command_exists(command) { - push_shell(shells, seen, command, default_command); - } -} - -#[cfg(windows)] -fn find_pwsh_path() -> Option { - let candidates = [ - r"C:\Program Files\PowerShell\7\pwsh.exe", - r"C:\Program Files\PowerShell\7-preview\pwsh.exe", - r"C:\Program Files (x86)\PowerShell\7\pwsh.exe", - ]; - for path in &candidates { - if Path::new(path).exists() { - return Some(path.to_string()); - } - } - // Fallback: check PATH via `where` - let output = command_without_windows_console("where") - .arg("pwsh") - .output() - .ok()?; - if output.status.success() { - let first = String::from_utf8_lossy(&output.stdout) - .lines() - .next() - .unwrap_or("") - .trim() - .to_string(); - if !first.is_empty() { - return Some(first); + Ok(shells) => shells, + Err(err) => { + tracing::error!( + error = %err, + "list_available_shells: blocking task failed; returning empty list" + ); + Vec::new() } } - None -} - -#[cfg(target_os = "linux")] -fn default_shell_command() -> String { - std::env::var("SHELL") - .ok() - .filter(|shell| command_exists(shell)) - .unwrap_or_else(|| "/bin/bash".to_string()) -} - -#[cfg(target_os = "macos")] -fn default_shell_command() -> String { - "/bin/zsh".to_string() -} - -#[cfg(windows)] -fn default_shell_command() -> String { - if let Some(path) = find_pwsh_path() { - format!("{} -NoLogo -NoProfile", path) - } else { - "powershell.exe -NoLogo -NoProfile".to_string() - } -} - -#[cfg(all(not(target_os = "linux"), not(target_os = "macos"), not(windows)))] -fn default_shell_command() -> String { - std::env::var("SHELL") - .ok() - .filter(|shell| command_exists(shell)) - .unwrap_or_else(|| "/bin/sh".to_string()) -} - -#[cfg(unix)] -fn load_unix_shells(default_command: &str) -> Vec { - let mut shells = Vec::new(); - let mut seen = HashSet::new(); - - push_shell(&mut shells, &mut seen, default_command, default_command); - - if let Ok(contents) = std::fs::read_to_string("/etc/shells") { - for line in contents.lines() { - push_existing_shell(&mut shells, &mut seen, line, default_command); - } - } - - for command in [ - "/bin/bash", - "/usr/bin/bash", - "/bin/zsh", - "/usr/bin/zsh", - "/bin/fish", - "/usr/bin/fish", - "/bin/sh", - "/usr/bin/sh", - ] { - push_existing_shell(&mut shells, &mut seen, command, default_command); - } - - shells -} - -#[cfg(windows)] -fn load_windows_shells(default_command: &str) -> Vec { - let mut shells = Vec::new(); - let mut seen = HashSet::new(); - // PowerShell 7 (pwsh) — preferred over 5.1 - if let Some(pwsh_path) = find_pwsh_path() { - push_shell( - &mut shells, - &mut seen, - format!("{} -NoLogo -NoProfile", pwsh_path), - default_command, - ); - } - // Windows PowerShell 5.1 - push_shell( - &mut shells, - &mut seen, - "powershell.exe -NoLogo -NoProfile", - default_command, - ); - // cmd - push_shell(&mut shells, &mut seen, "cmd.exe", default_command); - // Git Bash - for path in &[ - r"C:\Program Files\Git\bin\bash.exe", - r"C:\Program Files (x86)\Git\bin\bash.exe", - ] { - if Path::new(path).exists() { - push_shell(&mut shells, &mut seen, *path, default_command); - } - } - // WSL - let wsl = r"C:\Windows\System32\wsl.exe"; - if Path::new(wsl).exists() { - push_shell(&mut shells, &mut seen, wsl, default_command); - } - shells -} - -fn load_available_shells() -> Vec { - let default_command = default_shell_command(); - - #[cfg(windows)] - { - load_windows_shells(&default_command) - } - - #[cfg(unix)] - { - load_unix_shells(&default_command) - } - - #[cfg(not(any(unix, windows)))] - { - let mut shells = Vec::new(); - let mut seen = HashSet::new(); - push_shell( - &mut shells, - &mut seen, - default_command.clone(), - &default_command, - ); - shells - } -} - -#[tauri::command] -pub async fn list_available_shells() -> Vec { - tauri::async_runtime::spawn_blocking(load_available_shells) - .await - .unwrap_or_default() } diff --git a/src/features/terminal/Terminal.tsx b/src/features/terminal/Terminal.tsx index 4d0647ea..1294f2f9 100644 --- a/src/features/terminal/Terminal.tsx +++ b/src/features/terminal/Terminal.tsx @@ -36,9 +36,25 @@ interface TerminalProps { profileId: string; sessionId: string; isActive: boolean; + shell?: string; } -export function Terminal({ profileId, sessionId, isActive }: TerminalProps) { +/** Map a shell command string to a friendly display name. */ +function friendlyShellName(shell: string): string | null { + const lower = shell.toLowerCase(); + if (lower.includes("pwsh")) return "PowerShell 7"; + if (lower.includes("powershell")) return "Windows PowerShell"; + if (/\bcmd(?:\.exe)?\b/.test(lower)) return "Command Prompt"; + if (lower.includes("wsl")) { + // Preserve distro label when launched as `wsl -d ` (case-insensitive) + const distro = shell.match(/-d\s+(\S+)/i)?.[1]; + return distro ? `WSL (${distro})` : "WSL"; + } + if (lower.includes("bash") && lower.includes("git")) return "Git Bash"; + return null; +} + +export function Terminal({ profileId, sessionId, isActive, shell }: TerminalProps) { const termRef = useRef(null); const fitAddonRef = useRef(null); const isStreamReadyRef = useRef(false); @@ -377,7 +393,16 @@ export function Terminal({ profileId, sessionId, isActive }: TerminalProps) { resizePty({ sessionId, rows, cols }); }); + const shellFriendlyName = shell ? friendlyShellName(shell) : null; term.onTitleChange((title) => { + // If we know the shell, use the friendly name instead of + // shell-set titles (which are often just CWD paths) + if (shellFriendlyName) { + useTerminalStore + .getState() + .updateTabTitle(profileId, sessionId, shellFriendlyName); + return; + } useTerminalStore .getState() .updateTabTitle(profileId, sessionId, title); @@ -422,6 +447,7 @@ export function Terminal({ profileId, sessionId, isActive }: TerminalProps) { increaseFontSize, profileId, sessionId, + shell, syncTerminalLayout, ], ); diff --git a/src/features/terminal/TerminalTabs.tsx b/src/features/terminal/TerminalTabs.tsx index 352f3e60..7d711483 100644 --- a/src/features/terminal/TerminalTabs.tsx +++ b/src/features/terminal/TerminalTabs.tsx @@ -42,6 +42,7 @@ import * as m from "@/paraglide/messages.js"; import { useCloseTerminalTab } from "./hooks"; import { useTerminalStore } from "./store"; import { TAB_STRIP_HEIGHT, TabStrip, type TabStripGroup } from "./TabStrip"; +import { useTerminalSettingsStore } from "@/features/settings/stores/terminalSettingsStore"; import TerminalTemplateMenu from "./TerminalTemplateMenu"; import { Terminal } from "./Terminal"; @@ -121,6 +122,7 @@ export default function TerminalTabs({ profile, emptyFallback, }: TerminalTabsProps) { + const defaultShell = useTerminalSettingsStore((s) => s.defaultShell); const { tabs, activeTabId } = useTerminalStore( useShallow((state) => state.profiles[profileId] ?? EMPTY_TERMINAL_PROFILE), ); @@ -460,6 +462,7 @@ export default function TerminalTabs({ profileId={profileId} sessionId={tab.id} isActive={tab.id === activeTabId && !fileTabActive && !notesActive} + shell={defaultShell} /> ))} diff --git a/src/features/watcher/fileWatcher.test.ts b/src/features/watcher/fileWatcher.test.ts index 8fe4c84f..68e43ebe 100644 --- a/src/features/watcher/fileWatcher.test.ts +++ b/src/features/watcher/fileWatcher.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { invalidateQueriesMock, watchProjectsMock } = vi.hoisted(() => ({ - invalidateQueriesMock: vi.fn(), - watchProjectsMock: vi.fn(), -})); +const { invalidateQueriesMock, getQueryDataMock, watchProjectsMock } = + vi.hoisted(() => ({ + invalidateQueriesMock: vi.fn(), + getQueryDataMock: vi.fn(), + watchProjectsMock: vi.fn(), + })); vi.mock("@/generated", () => ({ watchProjects: watchProjectsMock, @@ -12,6 +14,7 @@ vi.mock("@/generated", () => ({ vi.mock("@/shared/lib/queryClient", () => ({ queryClient: { invalidateQueries: invalidateQueriesMock, + getQueryData: getQueryDataMock, }, })); @@ -26,6 +29,8 @@ describe("fileWatcher", () => { vi.resetModules(); vi.useFakeTimers(); invalidateQueriesMock.mockClear(); + getQueryDataMock.mockClear(); + getQueryDataMock.mockReturnValue(undefined); watchProjectsMock.mockClear(); }); diff --git a/src/features/watcher/fileWatcher.ts b/src/features/watcher/fileWatcher.ts index 05a1fea3..5699b552 100644 --- a/src/features/watcher/fileWatcher.ts +++ b/src/features/watcher/fileWatcher.ts @@ -1,14 +1,21 @@ import { Channel } from "@tauri-apps/api/core"; import { watchProjects } from "@/generated"; import type { WatchEvent } from "@/generated/types"; +import type { ProjectWithProfiles } from "@/generated"; import { queryClient } from "@/shared/lib/queryClient"; -import { queryNamespaces } from "@/shared/lib/queryKeys"; +import { queryKeys, queryNamespaces } from "@/shared/lib/queryKeys"; const channel = new Channel(); const INVALIDATION_DEBOUNCE_MS = 1000; let invalidateTimer: number | null = null; +let pendingProjectIds = new Set(); + +channel.onmessage = (event: WatchEvent) => { + // Accumulate project IDs during burst events + if (event?.project_id) { + pendingProjectIds.add(event.project_id); + } -channel.onmessage = () => { // File watcher events arrive in bursts during builds/codegen. // Coalesce them so we don't repeatedly re-run full git commands. if (invalidateTimer !== null) { @@ -17,37 +24,95 @@ channel.onmessage = () => { invalidateTimer = window.setTimeout(() => { invalidateTimer = null; + const projectIds = pendingProjectIds; + pendingProjectIds = new Set(); + + // Look up profile IDs for the affected projects so we can scope + // git query invalidation instead of blanket-invalidating everything. + const projects = queryClient.getQueryData( + queryKeys.projects.all, + ); + const affectedProfileIds = new Set(); + const affectedWorktreePaths = new Set(); + const affectedFolderPaths = new Set(); + + if (projects) { + for (const project of projects) { + if (projectIds.has(project.id)) { + for (const profile of project.profiles) { + affectedProfileIds.add(profile.id); + affectedWorktreePaths.add(profile.worktree_path); + } + affectedFolderPaths.add(project.folder); + } + } + } + + // Invalidate git queries only for profiles belonging to changed projects + if (affectedProfileIds.size > 0) { + for (const profileId of affectedProfileIds) { + queryClient.invalidateQueries({ + queryKey: queryKeys.git.diff(profileId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.git.diffStats(profileId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.git.status(profileId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.git.log(profileId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.git.aheadCount(profileId), + }); + } + for (const folder of affectedWorktreePaths) { + queryClient.invalidateQueries({ + queryKey: queryKeys.git.branch(folder), + }); + } + } else { + // Fallback: if projects aren't loaded yet, invalidate all (old behavior) + queryClient.invalidateQueries({ + queryKey: [queryNamespaces["git-diff"]], + exact: false, + }); + queryClient.invalidateQueries({ + queryKey: [queryNamespaces["git-diff-stats"]], + exact: false, + }); + queryClient.invalidateQueries({ + queryKey: [queryNamespaces["git-status"]], + exact: false, + }); + queryClient.invalidateQueries({ + queryKey: [queryNamespaces["git-log"]], + exact: false, + }); + queryClient.invalidateQueries({ + queryKey: [queryNamespaces["git-branch"]], + exact: false, + }); + queryClient.invalidateQueries({ + queryKey: [queryNamespaces["git-ahead-count"]], + exact: false, + }); + } - // Invalidate all git queries by prefix — simple and correct since - // file watcher emits project_id but git queries use profileId. - queryClient.invalidateQueries({ - queryKey: [queryNamespaces["git-diff"]], - exact: false, - }); - queryClient.invalidateQueries({ - queryKey: [queryNamespaces["git-diff-stats"]], - exact: false, - }); - queryClient.invalidateQueries({ - queryKey: [queryNamespaces["git-status"]], - exact: false, - }); - queryClient.invalidateQueries({ - queryKey: [queryNamespaces["git-log"]], - exact: false, - }); - queryClient.invalidateQueries({ - queryKey: [queryNamespaces["git-branch"]], - exact: false, - }); - queryClient.invalidateQueries({ - queryKey: [queryNamespaces["git-ahead-count"]], - exact: false, - }); - queryClient.invalidateQueries({ - queryKey: [queryNamespaces["fs-tree"]], - exact: false, - }); + // Invalidate file tree queries for affected project folders + if (affectedFolderPaths.size > 0) { + for (const folder of affectedFolderPaths) { + queryClient.invalidateQueries({ + queryKey: queryKeys.fs.tree(folder), + }); + } + } else { + queryClient.invalidateQueries({ + queryKey: [queryNamespaces["fs-tree"]], + exact: false, + }); + } }, INVALIDATION_DEBOUNCE_MS); }; diff --git a/src/generated/.typecache b/src/generated/.typecache index a0b53068..f5374ce3 100644 --- a/src/generated/.typecache +++ b/src/generated/.typecache @@ -1,7 +1,7 @@ { "version": 1, - "commands_hash": "dfa2dcc99e865fce", - "structs_hash": "622310322dc3b33b", + "commands_hash": "749e067ae9013265", + "structs_hash": "866d180ff7c394f1", "config_hash": "c72a07caa5bc6ed4", - "combined_hash": "871e657ba94d9efe" + "combined_hash": "56520ca6d14f3cf" } \ No newline at end of file diff --git a/src/generated/commands.ts b/src/generated/commands.ts index 7a21d2a2..2132d78a 100644 --- a/src/generated/commands.ts +++ b/src/generated/commands.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-05-26T13:34:32.884367+00:00 + * Generated at: 2026-05-27T07:00:26.906185700+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -11,28 +11,91 @@ import { invoke } from '@tauri-apps/api/core'; import * as types from './types'; -export async function listAvailableShells(): Promise { - return invoke('list_available_shells'); +export async function listSystemFonts(): Promise { + return invoke('list_system_fonts'); } -export async function createProfile(params: types.CreateProfileParams): Promise { - return invoke('create_profile', params); + +export async function listSystemSounds(): Promise { + return invoke('list_system_sounds'); } -export async function deleteProfile(params: types.DeleteProfileParams): Promise { - return invoke('delete_profile', params); +export async function playSystemSound(params: types.PlaySystemSoundParams): Promise { + return invoke('play_system_sound', params); } -export async function getProfileDeleteCheck(params: types.GetProfileDeleteCheckParams): Promise { - return invoke('get_profile_delete_check', params); + +export async function listSupportedTopbarApps(): Promise { + return invoke('list_supported_topbar_apps'); } -export async function updateProfileNotes(params: types.UpdateProfileNotesParams): Promise { - return invoke('update_profile_notes', params); +export async function openTopbarApp(params: types.OpenTopbarAppParams): Promise { + return invoke('open_topbar_app', params); +} + + +export async function startDebugLog(params: types.StartDebugLogParams): Promise { + return invoke('start_debug_log', params); +} + + + +export async function stopDebugLog(): Promise { + return invoke('stop_debug_log'); +} + + +export async function createPtySession(params: types.CreatePtySessionParams): Promise { + return invoke('create_pty_session', params); +} + + +export async function writeToPty(params: types.WriteToPtyParams): Promise { + return invoke('write_to_pty', params); +} + + +export async function resizePty(params: types.ResizePtyParams): Promise { + return invoke('resize_pty', params); +} + + +export async function closePtySession(params: types.ClosePtySessionParams): Promise { + return invoke('close_pty_session', params); +} + + +export async function listProjectSessions(params: types.ListProjectSessionsParams): Promise { + return invoke('list_project_sessions', params); +} + + +export async function getPtySessionHistory(params: types.GetPtySessionHistoryParams): Promise { + return invoke('get_pty_session_history', params); +} + + +export async function deletePtySessionRecord(params: types.DeletePtySessionRecordParams): Promise { + return invoke('delete_pty_session_record', params); +} + + +export async function restorePtySession(params: types.RestorePtySessionParams): Promise { + return invoke('restore_pty_session', params); +} + + +export async function flushPtyOutput(params: types.FlushPtyOutputParams): Promise { + return invoke('flush_pty_output', params); +} + + +export async function clearPtyOutput(params: types.ClearPtyOutputParams): Promise { + return invoke('clear_pty_output', params); } @@ -148,64 +211,13 @@ export async function getProjectGithubAvatar(params: types.GetProjectGithubAvata } -export async function startDebugLog(params: types.StartDebugLogParams): Promise { - return invoke('start_debug_log', params); -} - - - -export async function stopDebugLog(): Promise { - return invoke('stop_debug_log'); -} - - -export async function createPtySession(params: types.CreatePtySessionParams): Promise { - return invoke('create_pty_session', params); -} - - -export async function writeToPty(params: types.WriteToPtyParams): Promise { - return invoke('write_to_pty', params); -} - - -export async function resizePty(params: types.ResizePtyParams): Promise { - return invoke('resize_pty', params); -} - - -export async function closePtySession(params: types.ClosePtySessionParams): Promise { - return invoke('close_pty_session', params); -} - - -export async function listProjectSessions(params: types.ListProjectSessionsParams): Promise { - return invoke('list_project_sessions', params); -} - - -export async function getPtySessionHistory(params: types.GetPtySessionHistoryParams): Promise { - return invoke('get_pty_session_history', params); -} - - -export async function deletePtySessionRecord(params: types.DeletePtySessionRecordParams): Promise { - return invoke('delete_pty_session_record', params); -} - - -export async function restorePtySession(params: types.RestorePtySessionParams): Promise { - return invoke('restore_pty_session', params); -} - - -export async function flushPtyOutput(params: types.FlushPtyOutputParams): Promise { - return invoke('flush_pty_output', params); +export async function checkUpdate(params: types.CheckUpdateParams): Promise { + return invoke('check_update', params); } -export async function clearPtyOutput(params: types.ClearPtyOutputParams): Promise { - return invoke('clear_pty_output', params); +export async function installUpdate(params: types.InstallUpdateParams): Promise { + return invoke('install_update', params); } @@ -220,49 +232,6 @@ export async function openUrlInBrowser(params: types.OpenUrlInBrowserParams): Pr } - -export async function listSupportedTopbarApps(): Promise { - return invoke('list_supported_topbar_apps'); -} - - -export async function openTopbarApp(params: types.OpenTopbarAppParams): Promise { - return invoke('open_topbar_app', params); -} - - - -export async function listSystemSounds(): Promise { - return invoke('list_system_sounds'); -} - - -export async function playSystemSound(params: types.PlaySystemSoundParams): Promise { - return invoke('play_system_sound', params); -} - - - -export async function listSystemFonts(): Promise { - return invoke('list_system_fonts'); -} - - -export async function watchProjects(params: types.WatchProjectsParams): Promise { - return invoke('watch_projects', params); -} - - -export async function checkUpdate(params: types.CheckUpdateParams): Promise { - return invoke('check_update', params); -} - - -export async function installUpdate(params: types.InstallUpdateParams): Promise { - return invoke('install_update', params); -} - - export async function listFileTreePaths(params: types.ListFileTreePathsParams): Promise { return invoke('list_file_tree_paths', params); } @@ -331,3 +300,34 @@ export async function getFileTreeGitStatus(params: types.GetFileTreeGitStatusPar export async function resolveTerminalFilePath(params: types.ResolveTerminalFilePathParams): Promise { return invoke('resolve_terminal_file_path', params); } + + +export async function watchProjects(params: types.WatchProjectsParams): Promise { + return invoke('watch_projects', params); +} + + + +export async function listAvailableShells(): Promise { + return invoke('list_available_shells'); +} + + +export async function createProfile(params: types.CreateProfileParams): Promise { + return invoke('create_profile', params); +} + + +export async function deleteProfile(params: types.DeleteProfileParams): Promise { + return invoke('delete_profile', params); +} + + +export async function getProfileDeleteCheck(params: types.GetProfileDeleteCheckParams): Promise { + return invoke('get_profile_delete_check', params); +} + + +export async function updateProfileNotes(params: types.UpdateProfileNotesParams): Promise { + return invoke('update_profile_notes', params); +} diff --git a/src/generated/events.ts b/src/generated/events.ts index f322f5a4..659d49f0 100644 --- a/src/generated/events.ts +++ b/src/generated/events.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-05-26T13:34:32.884972+00:00 + * Generated at: 2026-05-27T07:00:26.906775100+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/generated/index.ts b/src/generated/index.ts index 57c40c34..aa8e1c27 100644 --- a/src/generated/index.ts +++ b/src/generated/index.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-05-26T13:34:32.885148+00:00 + * Generated at: 2026-05-27T07:00:26.906973700+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/generated/types.ts b/src/generated/types.ts index f5a5443c..631fe9bd 100644 --- a/src/generated/types.ts +++ b/src/generated/types.ts @@ -60,6 +60,7 @@ export interface AvailableShell { label: string; command: string; is_default: boolean; + supports_integration: boolean; } export interface FilePreview { diff --git a/src/layout/sidebar/ProfileItem.tsx b/src/layout/sidebar/ProfileItem.tsx index 04d34f2e..f4b4d2b3 100644 --- a/src/layout/sidebar/ProfileItem.tsx +++ b/src/layout/sidebar/ProfileItem.tsx @@ -1,4 +1,5 @@ import { Circle, HStack, Icon, Menu, Portal } from "@chakra-ui/react"; +import { memo } from "react"; import { FiGitBranch } from "react-icons/fi"; import { NavLink } from "react-router"; import DeleteProfileDialog from "@/features/profiles/DeleteProfileDialog"; @@ -9,7 +10,7 @@ import OverflowTooltipText from "@/shared/components/OverflowTooltipText"; import { SidebarActiveIndicator } from "@/shared/components/SidebarActiveIndicator"; import { useDialogState } from "@/shared/hooks/useDialogState"; -export function ProfileItem({ +export const ProfileItem = memo(({ profile, projectId, isActive, @@ -17,7 +18,7 @@ export function ProfileItem({ profile: Profile; projectId: string; isActive: boolean; -}) { +}) => { const deleteDialog = useDialogState(); const hasNotification = useProfileHasNotification(profile.id); const markProfileRead = useTerminalStore((s) => s.markProfileRead); @@ -97,4 +98,4 @@ export function ProfileItem({ /> ); -} +}); diff --git a/src/layout/sidebar/ProjectGroupSection.tsx b/src/layout/sidebar/ProjectGroupSection.tsx index 66093bae..a6024de9 100644 --- a/src/layout/sidebar/ProjectGroupSection.tsx +++ b/src/layout/sidebar/ProjectGroupSection.tsx @@ -1,5 +1,6 @@ import { Box, HStack, Icon, Text } from "@chakra-ui/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { memo } from "react"; import { FiChevronDown, FiChevronRight } from "react-icons/fi"; import type { ProjectGroup, ProjectWithProfiles } from "@/generated"; import * as m from "@/paraglide/messages.js"; @@ -17,11 +18,11 @@ interface ProjectGroupSectionProps { projects: ProjectWithProfiles[]; } -export function ProjectGroupSection({ +export const ProjectGroupSection = memo(({ group, projectGroups, projects, -}: ProjectGroupSectionProps) { +}: ProjectGroupSectionProps) => { const collapsed = useAppSidebarStore((state) => state.collapsedProjectGroupIds.includes(group.id), ); @@ -131,4 +132,4 @@ export function ProjectGroupSection({ ); -} +}); diff --git a/src/layout/sidebar/ProjectMenuItem.tsx b/src/layout/sidebar/ProjectMenuItem.tsx index a57442fd..cc85f39a 100644 --- a/src/layout/sidebar/ProjectMenuItem.tsx +++ b/src/layout/sidebar/ProjectMenuItem.tsx @@ -8,7 +8,7 @@ import { Text, Tooltip, } from "@chakra-ui/react"; -import { useMemo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import { FiChevronDown, FiChevronRight, @@ -33,13 +33,13 @@ import { ProfileList } from "./ProfileList"; import { ProjectAvatar } from "./ProjectAvatar"; import { ProjectGroupMenu } from "./ProjectGroupMenu"; -export function ProjectMenuItem({ +export const ProjectMenuItem = memo(({ project, projectGroups, }: { project: ProjectWithProfiles; projectGroups: ProjectGroup[]; -}) { +}) => { const defaultProfile = useMemo( () => project.profiles.find((p) => p.is_default), [project.profiles], @@ -326,4 +326,4 @@ export function ProjectMenuItem({ )} ); -} +});