-
Notifications
You must be signed in to change notification settings - Fork 3.6k
fix: sync shell history with standard history files #9855
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HashSet<String>> = | ||
| std::sync::Mutex::new(HashSet::new()); | ||
| } | ||
|
|
||
| pub fn ensure_shell_history_synced(session_id: SessionId, shell_type: ShellType, ctx: &mut AppContext) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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<String> = { | ||
| let history = History::handle(ctx); | ||
| history | ||
| .commands_shared(session_id) | ||
| .map(|commands| { | ||
| commands | ||
| .iter() | ||
| .map(|c| c.command.clone()) | ||
| .collect::<HashSet<_>>() | ||
| }) | ||
| .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<HistoryEntry> = 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<FairMutex<TerminalModel>>, | ||
|
|
@@ -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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| log::debug!("Failed to write command to shell history: {}", e); | ||
| } | ||
| }) | ||
| .detach(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PathBuf, ShellHistorySyncError> { | ||
| 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<String>, | ||
| ) -> Result<Vec<String>, ShellHistorySyncError> { | ||
| let history_files = shell_type.history_files(); | ||
| let mut all_commands: Vec<String> = 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<String> = HashSet::new(); | ||
|
|
||
| for line in reader.lines() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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 => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| format!("{}\n", command) | ||
| } | ||
| }; | ||
|
|
||
| file.write_all(line.as_bytes())?; | ||
| file.flush()?; | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| #[path = "mod_tests.rs"] | ||
| mod tests; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ensure_shell_history_syncedis never called in this PR, so shell-history → Warp sync never runs; invoke it during session initialization before claiming bidirectional sync.