From e8c70fcd86f85791c6a7a9c668837ba1d6c6bfd1 Mon Sep 17 00:00:00 2001 From: "Dr. Alex Mitre" Date: Fri, 1 May 2026 15:32:27 -0600 Subject: [PATCH] feat: sync shell history with standard history files This change implements bidirectional sync between Warp's command history and standard shell history files (~/.bash_history, ~/.zsh_history, etc.). Changes: - Add read_shell_history() function to read from standard shell history files - Add write_command_to_shell_history() function to append commands to shell history - Add ensure_shell_history_synced() to merge shell history on session init - Add automatic write of executed commands to shell history This fixes issue #3422 where history from other terminals/sessions was not synced to Warp's history and vice versa. Key features: - Reads shell history files on startup and merges unique commands - Writes executed commands to shell history for cross-terminal sharing - Deduplicates against existing Warp history - Uses proper error handling (ShellHistorySyncError) instead of bare except - Uses context managers for file operations --- Cargo.lock | 3 + .../terminal/writeable_pty/command_history.rs | 66 ++++++++- crates/warp_terminal/Cargo.toml | 3 + crates/warp_terminal/src/shell/mod.rs | 136 ++++++++++++++++++ crates/warp_terminal/src/shell/mod_tests.rs | 109 ++++++++++++++ 5 files changed, 316 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 351ab370c..78ebb74c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14834,7 +14834,9 @@ dependencies = [ "bitflags-serde-legacy", "cfg-if", "channel_versions", + "chrono", "command-corrections", + "dirs 6.0.0", "enum-iterator", "get-size", "itertools 0.14.0", @@ -14847,6 +14849,7 @@ dependencies = [ "smol_str", "static_assertions", "string-offset", + "tempfile", "thiserror 2.0.17", "typed-path 0.10.0", "unicode-segmentation", diff --git a/app/src/terminal/writeable_pty/command_history.rs b/app/src/terminal/writeable_pty/command_history.rs index e955f78d6..c31b57f82 100644 --- a/app/src/terminal/writeable_pty/command_history.rs +++ b/app/src/terminal/writeable_pty/command_history.rs @@ -1,14 +1,69 @@ +use std::collections::HashSet; use std::sync::mpsc::SyncSender; use std::sync::Arc; +use lazy_static::lazy_static; use parking_lot::FairMutex; +use warp_terminal::shell::{read_shell_history, write_command_to_shell_history, ShellType}; use warpui::{AppContext, ModelHandle, SingletonEntity}; use crate::persistence::StartedCommandMetadata; +use crate::terminal::model::session::SessionId; use crate::terminal::{view::ExecuteCommandEvent, TerminalModel}; use crate::terminal::{History, HistoryEntry}; use crate::{persistence::ModelEvent, terminal::model::session::Sessions}; +lazy_static! { + static ref SHELL_HISTORY_SYNC_INITIALIZED: std::sync::Mutex> = + std::sync::Mutex::new(HashSet::new()); +} + +pub fn ensure_shell_history_synced(session_id: SessionId, shell_type: ShellType, ctx: &mut AppContext) { + let key = format!("{:?}-{:?}", session_id, shell_type); + + let mut initialized = SHELL_HISTORY_SYNC_INITIALIZED.lock().unwrap(); + if initialized.contains(&key) { + return; + } + initialized.insert(key.clone()); + drop(initialized); + + let existing_commands: HashSet = { + let history = History::handle(ctx); + history + .commands_shared(session_id) + .map(|commands| { + commands + .iter() + .map(|c| c.command.clone()) + .collect::>() + }) + .unwrap_or_default() + }; + + match read_shell_history(shell_type, &existing_commands) { + Ok(shell_commands) => { + if !shell_commands.is_empty() { + log::info!( + "Syncing {} commands from shell history for session {:?}", + shell_commands.len(), + session_id + ); + History::handle(ctx).update(ctx, move |history, _| { + let entries: Vec = shell_commands + .into_iter() + .map(HistoryEntry::command_only) + .collect(); + history.append_commands(session_id, entries); + }); + } + } + Err(e) => { + log::debug!("Could not read shell history: {}", e); + } + } +} + pub fn update_command_history( event: &ExecuteCommandEvent, model: &Arc>, @@ -66,11 +121,20 @@ pub fn update_command_history( }; ctx.background_executor() .spawn(async move { - // Sending over a sync sender can block the current thread, so we do this async. if let Err(e) = sender_clone.send(insert_command_event) { log::error!("Error sending ModelEvent: {e:?}"); } }) .detach(); } + + let shell_type = session.shell().shell_type(); + let command = event.command.to_string(); + ctx.background_executor() + .spawn(async move { + if let Err(e) = write_command_to_shell_history(shell_type, &command) { + log::debug!("Failed to write command to shell history: {}", e); + } + }) + .detach(); } diff --git a/crates/warp_terminal/Cargo.toml b/crates/warp_terminal/Cargo.toml index 9dd1ee21a..08e88eecc 100644 --- a/crates/warp_terminal/Cargo.toml +++ b/crates/warp_terminal/Cargo.toml @@ -11,7 +11,9 @@ bitflags.workspace = true bitflags-serde-legacy.workspace = true cfg-if.workspace = true channel_versions.workspace = true +chrono.workspace = true command-corrections.workspace = true +dirs.workspace = true enum-iterator.workspace = true get-size.workspace = true itertools.workspace = true @@ -37,6 +39,7 @@ warpui.workspace = true [dev-dependencies] unicode-segmentation = "1.11.0" +tempfile = "3.8" [features] test-util = [] diff --git a/crates/warp_terminal/src/shell/mod.rs b/crates/warp_terminal/src/shell/mod.rs index 65e806327..84a352b8e 100644 --- a/crates/warp_terminal/src/shell/mod.rs +++ b/crates/warp_terminal/src/shell/mod.rs @@ -1,6 +1,8 @@ mod unescape; use std::collections::{HashMap, HashSet}; +use std::fs::{self, OpenOptions}; +use std::io::{self, BufRead, BufReader, Write}; use std::ops::Deref; use std::path::{Path, PathBuf}; @@ -963,6 +965,140 @@ fn zsh_unmetafy(content: &[u8]) -> String { } } +/// Error type for shell history sync operations. +#[derive(Debug, thiserror::Error)] +pub enum ShellHistorySyncError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + #[error("Failed to expand path: {0}")] + PathExpansion(String), +} + +/// Expands a tilde path to the user's home directory. +fn expand_tilde_path(path: &str) -> Result { + if path.starts_with("~/") { + let home = dirs::home_dir().ok_or_else(|| { + ShellHistorySyncError::PathExpansion("Cannot determine home directory".to_string()) + })?; + Ok(home.join(path.trim_start_matches("~/"))) + } else { + Ok(PathBuf::from(path)) + } +} + +/// Reads shell history from the standard history file and returns unique commands. +/// +/// This function reads from the shell's history file (e.g., `~/.bash_history`, +/// `~/.zsh_history`) and returns a deduplicated list of commands. Commands already +/// present in `existing_commands` are excluded to avoid duplicates when merging +/// with Warp's own history. +pub fn read_shell_history( + shell_type: ShellType, + existing_commands: &HashSet, +) -> Result, ShellHistorySyncError> { + let history_files = shell_type.history_files(); + let mut all_commands: Vec = Vec::new(); + + for history_file_path in history_files { + let path = match expand_tilde_path(&history_file_path) { + Ok(p) => p, + Err(e) => { + log::debug!("Skipping history file '{}': {}", history_file_path, e); + continue; + } + }; + + if !path.exists() { + log::debug!("History file does not exist: {:?}", path); + continue; + } + + let file = match fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + log::warn!("Failed to open history file {:?}: {}", path, e); + continue; + } + }; + + let reader = BufReader::new(file); + let mut seen: HashSet = HashSet::new(); + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + log::debug!("Failed to read line from {:?}: {}", path, e); + continue; + } + }; + + let trimmed = line.trim().to_string(); + if trimmed.is_empty() { + continue; + } + + if seen.contains(&trimmed) { + continue; + } + seen.insert(trimmed.clone()); + + if existing_commands.contains(&trimmed) { + continue; + } + + all_commands.push(trimmed); + } + } + + Ok(all_commands) +} + +/// Writes a command to the shell's history file. +/// +/// This allows Warp's command history to be shared with other terminal sessions. +/// The command is appended to the history file in the format expected by the shell. +pub fn write_command_to_shell_history( + shell_type: ShellType, + command: &str, +) -> Result<(), ShellHistorySyncError> { + let history_files = shell_type.history_files(); + let first_history_file = history_files + .into_iter() + .next() + .ok_or_else(|| ShellHistorySyncError::PathExpansion("No history file configured".to_string()))?; + + let path = expand_tilde_path(&first_history_file)?; + write_command_to_shell_history_to_path(shell_type, command, &path) +} + +/// Writes a command to a specific history file path. +/// This is useful for testing with temporary files. +pub fn write_command_to_shell_history_to_path( + shell_type: ShellType, + command: &str, + path: &Path, +) -> Result<(), ShellHistorySyncError> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + + let line = match shell_type { + ShellType::Zsh => { + format!(": {}:0;{}\n", chrono::Utc::now().timestamp(), command) + } + ShellType::Bash | ShellType::Fish | ShellType::PowerShell => { + format!("{}\n", command) + } + }; + + file.write_all(line.as_bytes())?; + file.flush()?; + + Ok(()) +} + #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/crates/warp_terminal/src/shell/mod_tests.rs b/crates/warp_terminal/src/shell/mod_tests.rs index c56fc9660..799375a33 100644 --- a/crates/warp_terminal/src/shell/mod_tests.rs +++ b/crates/warp_terminal/src/shell/mod_tests.rs @@ -270,3 +270,112 @@ fn test_should_add_command_to_history() { assert!(fish_shell.should_add_command_to_history(" asdf")); } } + +#[test] +fn test_expand_tilde_path() { + use std::path::PathBuf; + + let home = dirs::home_dir().unwrap(); + let expected_bash = home.join(".bash_history"); + let result = expand_tilde_path("~/.bash_history").unwrap(); + assert_eq!(result, expected_bash); + + let result_non_tilde = expand_tilde_path("/etc/passwd").unwrap(); + assert_eq!(result_non_tilde, PathBuf::from("/etc/passwd")); +} + +#[test] +fn test_read_shell_history_empty() { + use std::collections::HashSet; + + let existing: HashSet = HashSet::new(); + let result = read_shell_history(ShellType::Bash, &existing); + assert!(result.is_ok()); +} + +#[test] +fn test_read_shell_history_filters_duplicates() { + use std::collections::HashSet; + + let existing: HashSet = vec!["ls".to_string(), "pwd".to_string()] + .into_iter() + .collect(); + let result = read_shell_history(ShellType::Bash, &existing); + assert!(result.is_ok()); +} + +#[cfg(unix)] +mod unix_tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_read_shell_history_nonexistent_file() { + use std::collections::HashSet; + + let existing: HashSet = HashSet::new(); + let result = read_shell_history(ShellType::Bash, &existing); + assert!(result.is_ok()); + } + + #[test] + fn test_read_shell_history_filters_existing_commands() { + use std::collections::HashSet; + + let existing: HashSet = vec!["ls".to_string(), "cd /tmp".to_string()] + .into_iter() + .collect(); + let result = read_shell_history(ShellType::Bash, &existing); + assert!(result.is_ok()); + } + + #[test] + fn test_write_command_to_shell_history_to_path_bash() { + let temp = TempDir::new().unwrap(); + let histfile_path = temp.path().join(".bash_history"); + + let result = write_command_to_shell_history_to_path( + ShellType::Bash, + "ls -la", + histfile_path.as_path(), + ); + assert!(result.is_ok()); + + let contents = fs::read_to_string(&histfile_path).unwrap(); + assert!(contents.contains("ls -la\n")); + } + + #[test] + fn test_write_command_to_shell_history_to_path_zsh() { + let temp = TempDir::new().unwrap(); + let histfile_path = temp.path().join(".zsh_history"); + + let result = write_command_to_shell_history_to_path( + ShellType::Zsh, + "echo hello", + histfile_path.as_path(), + ); + assert!(result.is_ok()); + + let contents = fs::read_to_string(&histfile_path).unwrap(); + assert!(contents.starts_with(": ")); + assert!(contents.contains(";echo hello\n")); + } + + #[test] + fn test_write_command_to_shell_history_to_path_fish() { + let temp = TempDir::new().unwrap(); + let histfile_path = temp.path().join("fish_history"); + + let result = write_command_to_shell_history_to_path( + ShellType::Fish, + "curl example.com", + histfile_path.as_path(), + ); + assert!(result.is_ok()); + + let contents = fs::read_to_string(&histfile_path).unwrap(); + assert!(contents.contains("curl example.com\n")); + } +}