diff --git a/app/src/search/command_search/history/history_data_source.rs b/app/src/search/command_search/history/history_data_source.rs index 7295222a592..33895ea60b5 100644 --- a/app/src/search/command_search/history/history_data_source.rs +++ b/app/src/search/command_search/history/history_data_source.rs @@ -12,7 +12,7 @@ use crate::search::async_snapshot_data_source::AsyncSnapshotDataSource; use crate::search::command_search::searcher::CommandSearchItemAction; use crate::search::data_source::{Query, QueryResult}; use crate::search::mixer::{BoxFuture, DataSourceRunErrorWrapper}; -use crate::settings::AISettings; +use crate::settings::{AISettings, InputSettings}; use crate::terminal; use crate::terminal::HistoryEntry; use crate::terminal::model::session::SessionId; @@ -23,12 +23,23 @@ pub(crate) struct HistorySnapshot { commands: Arc<[Arc]>, query_text: String, current_session_id: SessionId, + fuzzy_matching_enabled: bool, } /// Creates an async data source for shell history commands. #[cfg(test)] pub fn history_data_source( commands: Vec, +) -> AsyncSnapshotDataSource { + history_data_source_with_fuzzy_matching(commands, true) +} + +/// Test-only variant of [`history_data_source`] with an explicit fuzzy-matching setting, for +/// exercising the literal-substring path in [`fuzzy_match_history_literal`]. +#[cfg(test)] +pub fn history_data_source_with_fuzzy_matching( + commands: Vec, + fuzzy_matching_enabled: bool, ) -> AsyncSnapshotDataSource { let commands: Arc<[Arc]> = commands.into_iter().map(Arc::new).collect(); AsyncSnapshotDataSource::new( @@ -36,6 +47,7 @@ pub fn history_data_source( commands: commands.clone(), query_text: query.text.clone(), current_session_id: SessionId::from(0), + fuzzy_matching_enabled, }, fuzzy_match_history, ) @@ -57,6 +69,8 @@ pub(crate) fn history_data_source_for_session( commands, query_text: query.text.clone(), current_session_id: session_id, + fuzzy_matching_enabled: *InputSettings::as_ref(app) + .command_search_fuzzy_matching_enabled, } }, fuzzy_match_history, @@ -67,6 +81,10 @@ pub(crate) fn fuzzy_match_history( snapshot: HistorySnapshot, ) -> BoxFuture<'static, Result>, DataSourceRunErrorWrapper>> { + if !snapshot.fuzzy_matching_enabled { + return fuzzy_match_history_literal(snapshot); + } + if !FeatureFlag::HistorySearchRankingV2.is_enabled() { return fuzzy_match_history_legacy(snapshot); } @@ -143,3 +161,35 @@ fn fuzzy_match_history_legacy( Ok(results) }) } + +fn fuzzy_match_history_literal( + snapshot: HistorySnapshot, +) -> BoxFuture<'static, Result>, DataSourceRunErrorWrapper>> +{ + Box::pin(async move { + let mut results = Vec::new(); + let query = snapshot.query_text.trim(); + let score = rank::literal_match_score(query); + + for chunk in snapshot.commands.chunks(CHUNK_SIZE) { + for entry in chunk { + let Some(match_result) = rank::match_literal_substring(&entry.command, query) + else { + continue; + }; + + results.push( + HistorySearchItem { + entry: entry.clone(), + match_result, + score, + } + .into(), + ); + } + yield_now().await; + } + + Ok(results) + }) +} diff --git a/app/src/search/command_search/history/rank.rs b/app/src/search/command_search/history/rank.rs index 98bb0f94944..d7e09ecbd57 100644 --- a/app/src/search/command_search/history/rank.rs +++ b/app/src/search/command_search/history/rank.rs @@ -41,6 +41,14 @@ const CONSECUTIVE_BONUS_PER_CHAR: f64 = 4.0; /// prefix of a longer one. const EXACT_WHOLE_LINE_BONUS: f64 = 12.0; +/// Fraction of a query's self-match score (see `literal_match_score`) used as every +/// literal-substring match's shared score. The full self-match score is the ceiling for a query +/// of that length -- using it outright would let a merely-containing history match tie the best +/// possible match from every other Command Search source. `0.5` instead matches the highest +/// per-field weight `FuzzyMatchWorkflowResult` already gives its own strongest field, so a +/// literal match competes with, rather than always beating, an equally strong match elsewhere. +const LITERAL_MATCH_SCORE_FRACTION: f64 = 0.5; + /// Recency assigned to entries with no timestamp (history-file rows with no matching sqlite /// record), i.e. exactly between "as fresh as possible" (1.0) and "as stale as possible" (0.0): /// there's no data to justify treating an untracked entry as either. @@ -225,6 +233,48 @@ fn age_days(start_ts: DateTime, now: DateTime) -> f64 { ((now - start_ts).num_seconds() as f64 / seconds_per_day).max(0.0) } +/// Matches `query` against `command` as a literal, contiguous, ASCII-case-insensitive substring +/// rather than as a fuzzy subsequence -- the semantics of bash/zsh's reverse history search. An +/// empty `query` matches everything at the start, the same as `str::contains("")`. +pub(crate) fn match_literal_substring(command: &str, query: &str) -> Option { + let command_chars: Vec = command.chars().collect(); + let query_chars: Vec = query.chars().collect(); + if query_chars.is_empty() { + return Some(FuzzyMatchResult { + score: 0, + matched_indices: vec![], + }); + } + if query_chars.len() > command_chars.len() { + return None; + } + + let start = (0..=command_chars.len() - query_chars.len()).find(|&start| { + command_chars[start..start + query_chars.len()] + .iter() + .zip(&query_chars) + .all(|(command_char, query_char)| command_char.eq_ignore_ascii_case(query_char)) + })?; + + Some(FuzzyMatchResult { + score: 0, + matched_indices: (start..start + query_chars.len()).collect(), + }) +} + +/// Score shared by every literal-substring match for a given `query`, derived from the raw Skim +/// score of matching `query` against itself (see [`LITERAL_MATCH_SCORE_FRACTION`]). Every +/// candidate that passes [`match_literal_substring`] gets this identical, genuinely +/// raw-Skim-scale score, so ties break via the mixer's stable sort, which preserves +/// `History::commands_shared`'s already-chronological candidate order -- ordering results +/// most-recent-first, the way bash/zsh's non-ranking reverse search does, while staying +/// comparable to every other Command Search source's own Skim-based score. +pub(crate) fn literal_match_score(query: &str) -> OrderedFloat { + let self_match_score = + fuzzy_match::match_indices_case_insensitive(query, query).map_or(0, |result| result.score); + OrderedFloat(self_match_score as f64 * LITERAL_MATCH_SCORE_FRACTION) +} + #[cfg(test)] #[path = "rank_tests.rs"] mod tests; diff --git a/app/src/search/command_search/history/rank_tests.rs b/app/src/search/command_search/history/rank_tests.rs index 792094b32a2..1f3e6ff0a64 100644 --- a/app/src/search/command_search/history/rank_tests.rs +++ b/app/src/search/command_search/history/rank_tests.rs @@ -240,3 +240,60 @@ fn blank_query_ignores_priors_and_yields_a_result() { chronological order, not just bypass the score floor" ); } + +#[test] +fn literal_substring_matches_regardless_of_case() { + assert!(match_literal_substring("git STATUS", "status").is_some()); + assert!(match_literal_substring("GIT status", "GIT").is_some()); +} + +#[test] +fn literal_substring_does_not_match_a_subsequence() { + assert!( + match_literal_substring("list docker containers", "ldc").is_none(), + "a fuzzy subsequence match must not count as a literal substring match" + ); +} + +#[test] +fn literal_substring_does_not_tokenize_the_query() { + assert!( + match_literal_substring("cd ~/projects/history_orm", "cd hi orm").is_none(), + "unlike the fuzzy path's whitespace-AND tokenization, the literal query must occur \ + verbatim, not as separately-matched terms" + ); + assert!(match_literal_substring("cd hi orm now", "cd hi orm").is_some()); +} + +#[test] +fn literal_substring_match_indices_span_the_match() { + let result = match_literal_substring("git checkout master", "checkout").expect("should match"); + assert_eq!(result.matched_indices, vec![4, 5, 6, 7, 8, 9, 10, 11]); +} + +#[test] +fn literal_substring_blank_query_matches_with_no_highlight() { + let result = match_literal_substring("anything", "").expect("blank query matches everything"); + assert!(result.matched_indices.is_empty()); +} + +#[test] +fn literal_match_score_is_half_the_query_matched_against_itself() { + let self_match_score = fuzzy_match::match_indices_case_insensitive("git status", "git status") + .expect("a string should fuzzy-match itself") + .score; + assert_eq!( + literal_match_score("git status"), + OrderedFloat(self_match_score as f64 * 0.5) + ); +} + +#[test] +fn literal_match_score_is_identical_for_every_query_length() { + assert_eq!(literal_match_score("foo"), literal_match_score("foo")); + assert_ne!( + literal_match_score("foo"), + literal_match_score("foo bar baz"), + "a longer query should score higher, since it's matched against itself" + ); +} diff --git a/app/src/search/command_search/searcher_tests.rs b/app/src/search/command_search/searcher_tests.rs index 99e8696d7d8..7d3be31d0d9 100644 --- a/app/src/search/command_search/searcher_tests.rs +++ b/app/src/search/command_search/searcher_tests.rs @@ -19,7 +19,7 @@ use crate::auth::auth_manager::AuthManager; use crate::search::ai_queries::fuzzy_match::FuzzyMatchAIQueryResults; use crate::search::command_search::ai_queries::AIQuerySearchResultItem; use crate::search::command_search::history::{ - history_data_source, history_data_source_for_session, + history_data_source, history_data_source_for_session, history_data_source_with_fuzzy_matching, }; use crate::search::command_search::searcher::CommandSearchMixer; use crate::search::command_search::workflows::{WorkflowIdentity, WorkflowSearchItem}; @@ -496,6 +496,384 @@ fn test_history_score_stays_comparable_to_other_sources_raw_skim_scale() { }); } +#[test] +fn disabled_fuzzy_matching_does_not_tokenize_the_query() { + // V2 is the path with AND-tokenization; without this override the test would run against + // the legacy default and prove nothing about V2 being short-circuited. + let _flag = FeatureFlag::HistorySearchRankingV2.override_enabled(true); + App::test((), |mut app| async move { + initialize_app(&mut app); + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_async_source( + history_data_source_with_fuzzy_matching( + vec![HistoryEntry::command_only( + "cd ~/projects/history_orm".to_owned(), + )], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query("cd hi orm".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + assert!( + mixer.as_ref(app).results().is_empty(), + "disabled fuzzy matching shouldn't AND-tokenize a multi-word query into \ + separately-matched terms, the way V2's enabled tokenization would" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_short_circuits_the_v2_ranking_path() { + let _flag = FeatureFlag::HistorySearchRankingV2.override_enabled(true); + App::test((), |mut app| async move { + initialize_app(&mut app); + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_async_source( + history_data_source_with_fuzzy_matching( + vec![HistoryEntry::command_only("git status".to_owned())], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + // "gts" is a fuzzy subsequence of "git status" (g...t...s), which V2's Skim matching + // would find; a literal-substring match would not, since it isn't contiguous. + mixer.run_query("gts".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + assert!( + mixer.as_ref(app).results().is_empty(), + "the disabled setting must short-circuit the V2 ranking path, not just leave a \ + fuzzy subsequence match in place" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_short_circuits_the_legacy_ranking_path() { + let _flag = FeatureFlag::HistorySearchRankingV2.override_enabled(false); + App::test((), |mut app| async move { + initialize_app(&mut app); + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_async_source( + history_data_source_with_fuzzy_matching( + vec![HistoryEntry::command_only("git status".to_owned())], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + // "gts" is a fuzzy subsequence of "git status" (g...t...s), which the legacy raw-Skim + // path would find; a literal-substring match would not, since it isn't contiguous. + mixer.run_query("gts".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + assert!( + mixer.as_ref(app).results().is_empty(), + "the disabled setting must short-circuit the legacy raw-Skim path too, not just \ + leave a fuzzy subsequence match in place" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_orders_results_most_recent_first() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_async_source( + // "git status" is a whole-line exact match while "sudo git status" merely + // contains it; the fuzzy-enabled path would score the exact match higher via + // EXACT_WHOLE_LINE_BONUS. The literal path assigns both the same score, so this + // only orders correctly if it's actually falling back to insertion order. + history_data_source_with_fuzzy_matching( + vec![ + HistoryEntry::command_only("git status".to_owned()), + HistoryEntry::command_only("sudo git status".to_owned()), + ], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query("git status".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + let results = mixer.as_ref(app).results(); + assert_eq!(results.len(), 2); + assert!( + matches!( + results.last().map(|result| result.accept_result()), + Some(CommandSearchItemAction::AcceptHistory(AcceptedHistoryItem { + command, + .. + })) if command == "sudo git status" + ), + "the more recently-run command should rank highest despite scoring no higher, \ + the way bash/zsh's reverse search orders purely by recency" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_score_stays_comparable_to_other_sources_raw_skim_scale() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let history_command = "npm test -- widgets".to_owned(); + let weak_match_text = "archive old logs then send email summary tonight"; + + let literal_score = fuzzy_match::match_indices_case_insensitive("test", "test") + .expect("a query should fuzzy-match itself") + .score; + let weak_raw_score = fuzzy_match::match_indices_case_insensitive(weak_match_text, "test") + .expect("the weak match text should fuzzy-match \"test\"") + .score; + assert!( + weak_raw_score < literal_score, + "fixture premise: the competitor's raw Skim score must be lower than a query's \ + self-match score (weak={weak_raw_score}, literal={literal_score})" + ); + + let weak_workflow = Workflow::Command { + name: "Unrelated maintenance task".to_owned(), + command: weak_match_text.to_owned(), + tags: vec![], + description: None, + arguments: vec![], + source_url: None, + author: None, + author_url: None, + shells: vec![], + environment_variables: None, + }; + let fuzzy_matched_workflow = + FuzzyMatchWorkflowResult::try_match("test", &weak_workflow, "") + .expect("the workflow's command should fuzzy-match \"test\""); + let workflow_item = WorkflowSearchItem { + identity: WorkflowIdentity::Local(Box::new(WorkflowType::Local(weak_workflow))), + source: WorkflowSource::Local, + fuzzy_matched_workflow, + }; + + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_sync_source( + FixedResults(vec![workflow_item]), + HashSet::from([QueryFilter::Workflows]), + ); + mixer.add_async_source( + history_data_source_with_fuzzy_matching( + vec![HistoryEntry::command_only(history_command.clone())], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query("test".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + let results = mixer.as_ref(app).results(); + assert_eq!(results.len(), 2); + assert!( + matches!( + results.last().map(|result| result.accept_result()), + Some(CommandSearchItemAction::AcceptHistory(AcceptedHistoryItem { + command, + .. + })) if command == history_command + ), + "a literal-substring history match should still outrank a weaker fuzzy match \ + from another source, proving its score stays on the same raw-Skim scale rather \ + than e.g. being pinned to a fixed low value" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_does_not_unconditionally_outrank_a_strong_competitor() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + // The history command only contains "test" in passing; the AI query text is an exact + // match. If disabled-mode history scored at its own self-match ceiling instead of a + // fraction of it, this merely-containing match would tie the strongest possible + // competitor from every other source, regardless of how well that competitor matches. + let history_command = "run integration tests for module".to_owned(); + let strong_ai_query = "test".to_owned(); + + let ai_prompt_item = AIQuerySearchResultItem { + query_text: strong_ai_query.clone(), + start_time: Local::now(), + output_status: AIQueryHistoryOutputStatus::Completed, + working_directory: None, + fuzzy_match_results: FuzzyMatchAIQueryResults::try_match("test", &strong_ai_query) + .expect("an exact match should fuzzy-match itself"), + }; + + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_sync_source( + FixedResults(vec![ai_prompt_item]), + HashSet::from([QueryFilter::PromptHistory]), + ); + mixer.add_async_source( + history_data_source_with_fuzzy_matching( + vec![HistoryEntry::command_only(history_command)], + false, + ), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: false, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query("test".into(), ctx); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the query should finish loading" + ); + + app.read(|app| { + let results = mixer.as_ref(app).results(); + assert_eq!(results.len(), 2); + assert!( + !matches!( + results.last().map(|result| result.accept_result()), + Some(CommandSearchItemAction::AcceptHistory(_)) + ), + "an exact match from another source should still outrank a merely-containing \ + literal history match" + ); + }); + }); +} + +#[test] +fn disabled_fuzzy_matching_still_populates_the_zero_state() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let same_ts = Local::now(); + let mut older = HistoryEntry::command_only("git status".to_owned()); + older.start_ts = Some(same_ts); + let mut newer = HistoryEntry::command_only("git log".to_owned()); + newer.start_ts = Some(same_ts); + + let mixer = app.add_model(|_| CommandSearchMixer::new()); + mixer.update(&mut app, |mixer, ctx| { + mixer.add_async_source( + history_data_source_with_fuzzy_matching(vec![older, newer], false), + HashSet::from([QueryFilter::History]), + AddAsyncSourceOptions { + debounce_interval: None, + run_in_zero_state: true, + run_when_unfiltered: true, + }, + ctx, + ); + mixer.run_query( + Query { + text: "".to_owned(), + filters: HashSet::new(), + }, + ctx, + ); + }); + + assert_eventually!( + app.read(|app| !mixer.as_ref(app).is_loading()), + "the history query should finish loading" + ); + + app.read(|app| { + let results = mixer.as_ref(app).results(); + assert_eq!( + results.len(), + 2, + "history should still populate the zero state when fuzzy matching is disabled" + ); + assert!(matches!( + results.last().map(|result| result.accept_result()), + Some(CommandSearchItemAction::AcceptHistory(AcceptedHistoryItem { + command, + .. + })) if command == "git log" + )); + }); + }); +} + #[test] fn test_no_query_filter_runs_all_data_sources() { let _flag = FeatureFlag::HistorySearchRankingV2.override_enabled(true); diff --git a/app/src/settings/input.rs b/app/src/settings/input.rs index 7fc5a27b4d6..b9cbc4343e4 100644 --- a/app/src/settings/input.rs +++ b/app/src/settings/input.rs @@ -229,6 +229,16 @@ define_settings_group!(InputSettings, surface: settings::SettingSurfaces::GUI, private: true, }, + command_search_fuzzy_matching_enabled: CommandSearchFuzzyMatchingEnabled { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::ALL, + sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes), + surface: settings::SettingSurfaces::GUI, + private: false, + toml_path: "terminal.input.command_search_fuzzy_matching_enabled", + description: "Whether command history results in Command Search (Ctrl+R) use fuzzy matching, or fall back to a literal, case-insensitive substring search like bash/zsh's reverse history search. Other Command Search results are unaffected.", + }, ] ); diff --git a/app/src/settings_view/features_page.rs b/app/src/settings_view/features_page.rs index 1ca19482b78..d47dbba74c2 100644 --- a/app/src/settings_view/features_page.rs +++ b/app/src/settings_view/features_page.rs @@ -67,17 +67,17 @@ use crate::settings::{ AISettingsChangedEvent, AliasExpansionEnabled, AliasExpansionSettings, AppEditorSettings, AtContextMenuInTerminalMode, AutocompleteSymbols, AutosuggestionKeybindingHint, ChangelogSettings, CloudPreferencesSettings, CodeSettings, CommandCorrections, - CompletionsOpenWhileTyping, CopyOnSelect, CtrlTabBehavior, DEFAULT_QUAKE_MODE_SIZE_PERCENTAGES, - DefaultSessionMode, EnableSlashCommandsInTerminal, ErrorUnderliningEnabled, ExtraMetaKeys, - GPUSettings, GlobalHotkeyMode, InputSettings, InputSettingsChangedEvent, - LinuxSelectionClipboard, MiddleClickPasteEnabled, MouseScrollMultiplier, - NativeShellCompletionsEnabled, OutlineCodebaseSymbolsForAtContextMenu, PreferLowPowerGPU, - PreferredGraphicsBackend, QUAKE_WINDOW_AUTOHIDE_SUPPORTED, QuakeModeSettings, - RightClickBehavior, RightClickBehaviorSetting, ScrollSettings, ScrollSettingsChangedEvent, - SelectionSettings, SelectionSettingsChangedEvent, ShowAutosuggestionIgnoreButton, - ShowChangelogAfterUpdate, ShowTerminalInputMessageBar, SshSettings, SyntaxHighlighting, - TabBehavior, UserNativeRedirectPreference, VimModeEnabled, VimStatusBar, - VimUnnamedSystemClipboard, WarpCompletionsEnabled, + CommandSearchFuzzyMatchingEnabled, CompletionsOpenWhileTyping, CopyOnSelect, CtrlTabBehavior, + DEFAULT_QUAKE_MODE_SIZE_PERCENTAGES, DefaultSessionMode, EnableSlashCommandsInTerminal, + ErrorUnderliningEnabled, ExtraMetaKeys, GPUSettings, GlobalHotkeyMode, InputSettings, + InputSettingsChangedEvent, LinuxSelectionClipboard, MiddleClickPasteEnabled, + MouseScrollMultiplier, NativeShellCompletionsEnabled, OutlineCodebaseSymbolsForAtContextMenu, + PreferLowPowerGPU, PreferredGraphicsBackend, QUAKE_WINDOW_AUTOHIDE_SUPPORTED, + QuakeModeSettings, RightClickBehavior, RightClickBehaviorSetting, ScrollSettings, + ScrollSettingsChangedEvent, SelectionSettings, SelectionSettingsChangedEvent, + ShowAutosuggestionIgnoreButton, ShowChangelogAfterUpdate, ShowTerminalInputMessageBar, + SshSettings, SyntaxHighlighting, TabBehavior, UserNativeRedirectPreference, VimModeEnabled, + VimStatusBar, VimUnnamedSystemClipboard, WarpCompletionsEnabled, }; use crate::terminal::alt_screen_reporting::{ AltScreenReporting, FocusReportingEnabled, MouseReportingEnabled, ScrollReportingEnabled, @@ -257,6 +257,19 @@ pub fn init_actions_from_parent_view( .command_corrections .is_supported_on_current_platform(), ), + ToggleSettingActionPair::new( + "fuzzy matching of command history in command search", + builder(SettingsAction::FeaturesPageToggle( + FeaturesPageAction::ToggleCommandSearchFuzzyMatching, + )), + context, + flags::COMMAND_SEARCH_FUZZY_MATCHING_FLAG, + ) + .is_supported_on_current_platform( + InputSettings::as_ref(app) + .command_search_fuzzy_matching_enabled + .is_supported_on_current_platform(), + ), ToggleSettingActionPair::new( "error underlining", builder(SettingsAction::FeaturesPageToggle( @@ -760,6 +773,7 @@ pub enum FeaturesPageAction { ToggleWarpCompletions, ToggleNativeShellCompletions, ToggleCommandCorrections, + ToggleCommandSearchFuzzyMatching, ToggleErrorUnderlining, ToggleSyntaxHighlighting, ToggleAliasExpansion, @@ -976,6 +990,10 @@ impl FeaturesPageAction { action: "ToggleCommandCorrections".to_string(), value: to_string(*input_settings.command_corrections.value()), }, + Self::ToggleCommandSearchFuzzyMatching => TelemetryEvent::FeaturesPageAction { + action: "ToggleCommandSearchFuzzyMatching".to_string(), + value: to_string(*input_settings.command_search_fuzzy_matching_enabled.value()), + }, Self::ToggleErrorUnderlining => TelemetryEvent::FeaturesPageAction { action: "ToggleErrorUnderlining".to_string(), value: to_string(*input_settings.error_underlining.value()), @@ -1895,6 +1913,15 @@ impl TypedActionView for FeaturesPageView { ); }); } + ToggleCommandSearchFuzzyMatching => { + InputSettings::handle(ctx).update(ctx, |input_settings, ctx| { + report_if_error!( + input_settings + .command_search_fuzzy_matching_enabled + .toggle_and_save_value(ctx) + ); + }); + } ToggleErrorUnderlining => { InputSettings::handle(ctx).update(ctx, |input_settings, ctx| { report_if_error!(input_settings.error_underlining.toggle_and_save_value(ctx)); @@ -2949,6 +2976,13 @@ impl FeaturesPageView { editor_widgets.push(Box::new(CommandCorrectionsWidget::default())); } + if input_settings + .command_search_fuzzy_matching_enabled + .is_supported_on_current_platform() + { + editor_widgets.push(Box::new(CommandSearchFuzzyMatchingWidget::default())); + } + let alias_expansion_settings = AliasExpansionSettings::as_ref(ctx); if alias_expansion_settings .alias_expansion_enabled @@ -6141,6 +6175,57 @@ impl SettingsWidget for CommandCorrectionsWidget { } } +#[derive(Default)] +struct CommandSearchFuzzyMatchingWidget { + switch_state: SwitchStateHandle, +} + +impl SettingsWidget for CommandSearchFuzzyMatchingWidget { + type View = FeaturesPageView; + + fn search_terms(&self) -> &str { + "command search history fuzzy matching ctrl+r bash zsh literal substring" + } + + fn render( + &self, + view: &Self::View, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + let ui_builder = appearance.ui_builder(); + render_body_item::( + "Fuzzy match command history in Command Search".into(), + None, + LocalOnlyIconState::for_setting( + CommandSearchFuzzyMatchingEnabled::storage_key(), + CommandSearchFuzzyMatchingEnabled::sync_to_cloud(), + &mut view + .button_mouse_states + .local_only_icon_tooltip_states + .borrow_mut(), + app, + ), + ToggleState::Enabled, + appearance, + ui_builder + .switch(self.switch_state.clone()) + .check(*InputSettings::as_ref(app).command_search_fuzzy_matching_enabled) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(FeaturesPageAction::ToggleCommandSearchFuzzyMatching); + }) + .finish(), + Some( + "When disabled, command history results in Command Search fall back to a \ + literal substring search, like bash and zsh's history search. Other Command \ + Search results are unaffected." + .to_owned(), + ), + ) + } +} + #[derive(Default)] struct AliasExpansionWidget { switch_state: SwitchStateHandle, diff --git a/app/src/settings_view/mod.rs b/app/src/settings_view/mod.rs index d58ba4168da..33a8904e932 100644 --- a/app/src/settings_view/mod.rs +++ b/app/src/settings_view/mod.rs @@ -525,6 +525,7 @@ pub mod flags { pub const WARP_COMPLETIONS_CONTEXT_FLAG: &str = "Warp_Completions"; pub const NATIVE_SHELL_COMPLETIONS_CONTEXT_FLAG: &str = "Native_Shell_Completions"; pub const COMMAND_CORRECTIONS_CONTEXT_FLAG: &str = "Command_Corrections"; + pub const COMMAND_SEARCH_FUZZY_MATCHING_FLAG: &str = "Command_Search_Fuzzy_Matching"; pub const ERROR_UNDERLINING_FLAG: &str = "error_underlining"; pub const SYNTAX_HIGHLIGHTING_FLAG: &str = "syntax_highlighting"; pub const SAME_LINE_PROMPT: &str = "Same_Line_Prompt_Enabled"; diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 2876acf6f1a..3824bb61fa1 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -23058,6 +23058,12 @@ impl Workspace { context.set.insert(flags::COMMAND_CORRECTIONS_CONTEXT_FLAG); } + if *input_settings.command_search_fuzzy_matching_enabled.value() { + context + .set + .insert(flags::COMMAND_SEARCH_FUZZY_MATCHING_FLAG); + } + if *input_settings.error_underlining.value() { context.set.insert(flags::ERROR_UNDERLINING_FLAG); }