diff --git a/src-tauri/src/local_pty.rs b/src-tauri/src/local_pty.rs index a67ffd6..6deb2c3 100644 --- a/src-tauri/src/local_pty.rs +++ b/src-tauri/src/local_pty.rs @@ -17,6 +17,20 @@ fn default_shell() -> String { std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) } +fn expand_tilde>(s: S) -> String { + let s = s.as_ref(); + if s == "~" { + if let Ok(home) = std::env::var("HOME") { + return home; + } + } else if let Some(rest) = s.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return format!("{}/{}", home.trim_end_matches('/'), rest); + } + } + s.to_string() +} + /// Spawn a local interactive shell on a fresh PTY. The session shows up in /// the same `state.sessions` map as SSH PTYs and is driven by ssh_write / /// ssh_resize / ssh_disconnect, so the frontend treats it identically. @@ -40,24 +54,58 @@ pub async fn local_open_pty( }) .map_err(|e| format!("openpty: {}", e))?; - let shell_path = shell + let shell_field = shell .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string) .unwrap_or_else(default_shell); - let mut builder = CommandBuilder::new(&shell_path); + // Whitespace-split the shell field into program + args so users can + // type things like `screen.sh 2` or `~/.local/bin/wrap.sh foo`. Tilde + // is expanded against $HOME โ€” portable_pty hands the path straight to + // the OS exec, which doesn't do any shell expansion. + let mut parts = shell_field.split_whitespace(); + let program_raw = parts.next().unwrap_or(""); + let program = expand_tilde(program_raw); + let args: Vec = parts.map(expand_tilde).collect(); + + let mut builder = CommandBuilder::new(&program); + for arg in &args { + builder.arg(arg); + } if let Some(d) = cwd.as_deref().map(str::trim).filter(|s| !s.is_empty()) { - builder.cwd(d); + builder.cwd(expand_tilde(d)); } // Forward TERM so colors etc. work like a normal terminal. builder.env("TERM", "xterm-256color"); + // GUI-launched apps inherit a stripped-down PATH that doesn't include + // user bin dirs, so `screen.sh` style entries can't be resolved. Prepend + // ~/.local/bin and ~/bin if they aren't already in PATH. + #[cfg(not(target_os = "windows"))] + if let Ok(home) = std::env::var("HOME") { + let current = std::env::var("PATH").unwrap_or_default(); + let extras = [format!("{}/.local/bin", home), format!("{}/bin", home)]; + let missing: Vec<&str> = extras + .iter() + .map(String::as_str) + .filter(|e| !current.split(':').any(|p| p == *e)) + .collect(); + if !missing.is_empty() { + let mut new_path = missing.join(":"); + if !current.is_empty() { + new_path.push(':'); + new_path.push_str(¤t); + } + builder.env("PATH", new_path); + } + } + let child = pair .slave .spawn_command(builder) - .map_err(|e| format!("spawn {}: {}", shell_path, e))?; + .map_err(|e| format!("spawn {}: {}", program, e))?; drop(pair.slave); let master = Arc::new(StdMutex::new(pair.master)); @@ -79,7 +127,7 @@ pub async fn local_open_pty( // Per-session logger โ€” same scheme as SSH sessions, label is the shell's // basename so files like `20260501_..._powershell.exe.log` show up in // ~/Documents/BOOKSHELL/logs. - let log_label = std::path::Path::new(&shell_path) + let log_label = std::path::Path::new(&program) .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "local".to_string()); diff --git a/src/components/Terminal.tsx b/src/components/Terminal.tsx index ad7f380..18b5a9e 100644 --- a/src/components/Terminal.tsx +++ b/src/components/Terminal.tsx @@ -38,6 +38,11 @@ export function TerminalView(props: Props) { const [query, setQuery] = createSignal(""); const [pwPrompt, setPwPrompt] = createSignal(""); const [reconnecting, setReconnecting] = createSignal(false); + // Reactive flag flipped at the end of onMount. Effects that need a live + // `term` instance must depend on this โ€” SolidJS runs createEffect bodies + // before onMount callbacks, so reading `term` directly in an effect's + // first pass would see undefined. + const [termReady, setTermReady] = createSignal(false); const profile = () => connections().find((c) => c.id === props.tab.connectionId) ?? null; @@ -255,6 +260,8 @@ export function TerminalView(props: Props) { }); ro.observe(host); onCleanup(() => ro.disconnect()); + + setTermReady(true); }); // Refit when activated @@ -273,9 +280,10 @@ export function TerminalView(props: Props) { // smaller than the visible viewport โ€” leaving dead rows at the bottom that // PTY never writes to. xterm.onResize only fires on size *changes*, so it // can't catch up after the fact. Push the current dims explicitly here. + // Depend on termReady so the effect re-runs once onMount has set up `term`. createEffect(() => { const sid = props.tab.sessionId; - if (!sid || !term) return; + if (!sid || !termReady() || !term) return; queueMicrotask(() => { fit?.fit(); if (term) api.sshResize(sid, term.cols, term.rows).catch(console.error); @@ -337,10 +345,12 @@ export function TerminalView(props: Props) { {(p) => ( <>
- {p().user}@{p().host}:{p().port} + {p().kind === "local" + ? `๐Ÿ“Ÿ local ยท ${p().shell ?? ""}` + : `${p().user}@${p().host}:${p().port}`}
0} + when={p().kind === "local" || (p().password && p().password!.length > 0)} fallback={