405 lines
13 KiB
Rust
405 lines
13 KiB
Rust
use std::collections::HashSet;
|
|
|
|
use chrono::Local;
|
|
use galaxy_completer::completer::{
|
|
EngineFileType, Match, MatchStrategy, MatchedSuggestion, Priority, Suggestion,
|
|
SuggestionResults, SuggestionType, TopLevelCommandCaseSensitivity,
|
|
};
|
|
use galaxy_completer::meta::Span;
|
|
use galaxy_core::ui::appearance::Appearance;
|
|
use galaxyui::platform::WindowStyle;
|
|
use galaxyui::App;
|
|
|
|
use super::{HistoryInputSuggestion, InputSuggestions, TabCompletionsPreselectOption};
|
|
use crate::ai::blocklist::AIQueryHistory;
|
|
use crate::input_suggestions::{filter_tab_suggestions, HistoryOrder};
|
|
use crate::terminal::model::session::SessionId;
|
|
use crate::terminal::HistoryEntry;
|
|
|
|
fn prefix_matched_suggestion(name: &str) -> MatchedSuggestion {
|
|
let suggestion = Suggestion::with_same_display_and_replacement(
|
|
name,
|
|
None,
|
|
SuggestionType::Command(TopLevelCommandCaseSensitivity::CaseInsensitive),
|
|
Priority::default(),
|
|
);
|
|
MatchedSuggestion::new(
|
|
suggestion,
|
|
Match::Prefix {
|
|
is_case_sensitive: true,
|
|
},
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_basic_tab_prefix() {
|
|
let suggestions = vec![
|
|
prefix_matched_suggestion("stash"),
|
|
prefix_matched_suggestion("status"),
|
|
prefix_matched_suggestion("stats/")
|
|
.with_file_type(galaxy_completer::completer::EngineFileType::Directory),
|
|
];
|
|
|
|
let suggestion_results = SuggestionResults {
|
|
replacement_span: Span::default(),
|
|
suggestions,
|
|
match_strategy: MatchStrategy::CaseSensitive,
|
|
};
|
|
let matches = filter_tab_suggestions(&suggestion_results, "st", &['/'])
|
|
.into_iter()
|
|
.map(|item| item.matches)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
matches,
|
|
vec![
|
|
Some((0..2).collect()),
|
|
Some((0..2).collect()),
|
|
Some((0..2).collect()),
|
|
]
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_basic_tab_fuzzy() {
|
|
let suggestions = vec![
|
|
prefix_matched_suggestion("fast"),
|
|
prefix_matched_suggestion("salt"),
|
|
prefix_matched_suggestion("must").with_file_type(EngineFileType::File),
|
|
prefix_matched_suggestion("first/").with_file_type(EngineFileType::Directory),
|
|
];
|
|
|
|
let suggestion_results = SuggestionResults {
|
|
replacement_span: Span::default(),
|
|
suggestions,
|
|
match_strategy: MatchStrategy::Fuzzy,
|
|
};
|
|
let matches = filter_tab_suggestions(&suggestion_results, "st", &['/'])
|
|
.into_iter()
|
|
.map(|item| (item.display, item.matches))
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
matches,
|
|
vec![
|
|
(Some("salt".to_owned()), Some(vec![0, 3])),
|
|
(Some("first/".to_owned()), Some(vec![3, 4])),
|
|
(Some("must".to_owned()), Some(vec![2, 3])),
|
|
(Some("fast".to_owned()), Some(vec![2, 3])),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_case_insensitive_filter() {
|
|
let suggestions = vec![
|
|
prefix_matched_suggestion("/Users/"),
|
|
prefix_matched_suggestion("/usr/"),
|
|
prefix_matched_suggestion("/uninstalled/"),
|
|
prefix_matched_suggestion("first/"),
|
|
];
|
|
|
|
let suggestion_results = SuggestionResults {
|
|
replacement_span: Span::default(),
|
|
suggestions,
|
|
match_strategy: MatchStrategy::CaseInsensitive,
|
|
};
|
|
let matches = filter_tab_suggestions(&suggestion_results, "/us", &['/']);
|
|
|
|
assert_eq!(matches.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
// TODO This delta exists right now for filepath completions as a quirk
|
|
// of how our tab completions work. In the future, we won't be
|
|
// including the entire filepath in the replacement and this code
|
|
// will have to change accordingly.
|
|
fn test_display_replacement_prefix() {
|
|
let suggestions = vec![
|
|
prefix_matched_suggestion("rest/")
|
|
.with_replacement("src/rest/")
|
|
.with_file_type(EngineFileType::Directory),
|
|
prefix_matched_suggestion("rust/")
|
|
.with_replacement("src/rust/")
|
|
.with_file_type(EngineFileType::Directory),
|
|
];
|
|
|
|
let suggestion_results = SuggestionResults {
|
|
replacement_span: Span::default(),
|
|
suggestions,
|
|
match_strategy: MatchStrategy::CaseSensitive,
|
|
};
|
|
let matches = filter_tab_suggestions(&suggestion_results, "src/r", &['/'])
|
|
.into_iter()
|
|
.map(|item| item.matches)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(matches, vec![Some(vec![0]), Some(vec![0]),])
|
|
}
|
|
|
|
#[test]
|
|
fn test_display_replacement_fuzzy() {
|
|
// It doesn't actually matter that we use `prefix_matched_suggestion`s
|
|
// here because we recompute the match type in filter by query anyways.
|
|
let suggestions = vec![
|
|
prefix_matched_suggestion("stutter/")
|
|
.with_replacement("src/stutter/")
|
|
.with_file_type(EngineFileType::Directory),
|
|
prefix_matched_suggestion("rust/")
|
|
.with_replacement("src/rust/")
|
|
.with_file_type(EngineFileType::Directory),
|
|
];
|
|
|
|
let suggestion_results = SuggestionResults {
|
|
replacement_span: Span::default(),
|
|
suggestions,
|
|
match_strategy: MatchStrategy::Fuzzy,
|
|
};
|
|
let matches = filter_tab_suggestions(&suggestion_results, "src/ut", &['/'])
|
|
.into_iter()
|
|
.map(|item| (item.display, item.matches))
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
matches,
|
|
vec![
|
|
(Some("stutter/".to_owned()), Some(vec![2, 3])),
|
|
(Some("rust/".to_owned()), Some(vec![1, 3])),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_substring_search_with_whitespace() {
|
|
App::test((), |mut app| async move {
|
|
app.add_singleton_model(|_| Appearance::mock());
|
|
let (_, suggestions) = app.add_window(WindowStyle::NotStealFocus, InputSuggestions::new);
|
|
|
|
let options = vec!["axbycz".to_string(), " axbycz".to_string()];
|
|
suggestions.update(&mut app, |input_suggestions, ctx_input_suggestions| {
|
|
input_suggestions.fuzzy_substring_search(
|
|
"abc".to_string(),
|
|
options,
|
|
ctx_input_suggestions,
|
|
);
|
|
});
|
|
suggestions.read(&app, |suggestions, _| {
|
|
let highlights = suggestions
|
|
.items()
|
|
.iter()
|
|
.map(|item| item.matches())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(highlights, [Some(&vec![0, 2, 4]), Some(&vec![0, 2, 4])]);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_preselect_first_item() {
|
|
App::test((), |mut app| async move {
|
|
app.add_singleton_model(|_| Appearance::mock());
|
|
let (_, suggestions) = app.add_window(WindowStyle::NotStealFocus, InputSuggestions::new);
|
|
|
|
let options = vec![
|
|
prefix_matched_suggestion("a1"),
|
|
prefix_matched_suggestion("a2"),
|
|
];
|
|
let suggestion_results = SuggestionResults {
|
|
replacement_span: Span::default(),
|
|
suggestions: options,
|
|
match_strategy: MatchStrategy::CaseSensitive,
|
|
};
|
|
|
|
suggestions.update(&mut app, |input_suggestions, ctx| {
|
|
input_suggestions.prefix_search_for_tab_completion(
|
|
"a",
|
|
&suggestion_results,
|
|
TabCompletionsPreselectOption::First,
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
suggestions.read(&app, |suggestions, _| {
|
|
assert_eq!(suggestions.get_selected_item().unwrap().text.as_str(), "a1");
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_unselected_state() {
|
|
App::test((), |mut app| async move {
|
|
app.add_singleton_model(|_| Appearance::mock());
|
|
let (_, suggestions) = app.add_window(WindowStyle::NotStealFocus, InputSuggestions::new);
|
|
|
|
let options = vec![
|
|
prefix_matched_suggestion("a1"),
|
|
prefix_matched_suggestion("a2"),
|
|
];
|
|
let suggestion_results = SuggestionResults {
|
|
replacement_span: Span::default(),
|
|
suggestions: options,
|
|
match_strategy: MatchStrategy::CaseSensitive,
|
|
};
|
|
|
|
suggestions.update(&mut app, |input_suggestions, ctx| {
|
|
input_suggestions.prefix_search_for_tab_completion(
|
|
"a",
|
|
&suggestion_results,
|
|
TabCompletionsPreselectOption::Unselected,
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
suggestions.read(&app, |suggestions, _| {
|
|
assert!(suggestions.get_selected_item().is_none());
|
|
});
|
|
|
|
suggestions.update(&mut app, |input_suggestions, ctx| {
|
|
input_suggestions.select_next(ctx);
|
|
});
|
|
|
|
suggestions.read(&app, |suggestions, _| {
|
|
assert_eq!(suggestions.get_selected_item().unwrap().text.as_str(), "a1");
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_unchanged_preselect_option() {
|
|
App::test((), |mut app| async move {
|
|
app.add_singleton_model(|_| Appearance::mock());
|
|
let (_, suggestions) = app.add_window(WindowStyle::NotStealFocus, InputSuggestions::new);
|
|
|
|
let options = vec![
|
|
prefix_matched_suggestion("a1"),
|
|
prefix_matched_suggestion("a2"),
|
|
];
|
|
let suggestion_results = SuggestionResults {
|
|
replacement_span: Span::default(),
|
|
suggestions: options,
|
|
match_strategy: MatchStrategy::CaseSensitive,
|
|
};
|
|
|
|
suggestions.update(&mut app, |input_suggestions, ctx| {
|
|
input_suggestions.prefix_search_for_tab_completion(
|
|
"a",
|
|
&suggestion_results,
|
|
TabCompletionsPreselectOption::First,
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
suggestions.update(&mut app, |input_suggestions, ctx| {
|
|
input_suggestions.select_next(ctx);
|
|
});
|
|
|
|
suggestions.read(&app, |suggestions, _| {
|
|
assert_eq!(suggestions.get_selected_item().unwrap().text.as_str(), "a2");
|
|
});
|
|
|
|
suggestions.update(&mut app, |input_suggestions, ctx| {
|
|
input_suggestions.exit(true, ctx);
|
|
});
|
|
|
|
suggestions.update(&mut app, |input_suggestions, ctx| {
|
|
input_suggestions.prefix_search_for_tab_completion(
|
|
"a",
|
|
&suggestion_results,
|
|
TabCompletionsPreselectOption::Unchanged,
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
suggestions.read(&app, |suggestions, _| {
|
|
assert_eq!(suggestions.get_selected_item().unwrap().text.as_str(), "a2");
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_history_order() {
|
|
let mut live_sessions = HashSet::new();
|
|
let current_session_id = SessionId::from(3);
|
|
live_sessions.insert(SessionId::from(1));
|
|
live_sessions.insert(SessionId::from(2));
|
|
live_sessions.insert(current_session_id);
|
|
let now = Local::now();
|
|
|
|
// Commands in current session
|
|
let current_session_cmd = HistoryInputSuggestion::Command {
|
|
entry: &HistoryEntry::command_at_time(
|
|
"echo current session".to_string(),
|
|
now,
|
|
Some(current_session_id),
|
|
false,
|
|
),
|
|
};
|
|
assert_eq!(
|
|
current_session_cmd.history_order(Some(current_session_id), &live_sessions,),
|
|
HistoryOrder::CurrentSession
|
|
);
|
|
|
|
// Commands in different live session
|
|
let different_session_cmd = HistoryInputSuggestion::Command {
|
|
entry: &HistoryEntry::command_at_time(
|
|
"echo different session".to_string(),
|
|
now,
|
|
Some(SessionId::from(1)),
|
|
false,
|
|
),
|
|
};
|
|
assert_eq!(
|
|
different_session_cmd.history_order(Some(current_session_id), &live_sessions,),
|
|
HistoryOrder::DifferentSession
|
|
);
|
|
|
|
// Restored commands in current session are treated as CurrentSession
|
|
let restored_cmd = HistoryInputSuggestion::Command {
|
|
entry: &HistoryEntry::command_at_time("echo restored".to_string(), now, None, true),
|
|
};
|
|
assert_eq!(
|
|
restored_cmd.history_order(Some(current_session_id), &live_sessions,),
|
|
HistoryOrder::CurrentSession
|
|
);
|
|
|
|
// Commands with no session are treated as DifferentSession
|
|
let no_session_cmd = HistoryInputSuggestion::Command {
|
|
entry: &HistoryEntry::command_at_time(
|
|
"echo no session".to_string(),
|
|
now - chrono::Duration::seconds(10),
|
|
None,
|
|
false,
|
|
),
|
|
};
|
|
assert_eq!(
|
|
no_session_cmd.history_order(Some(current_session_id), &live_sessions,),
|
|
HistoryOrder::DifferentSession
|
|
);
|
|
|
|
// AI queries from current session
|
|
let ai_query_current = HistoryInputSuggestion::AIQuery {
|
|
entry: AIQueryHistory::new_for_test(
|
|
"ai query current session",
|
|
now,
|
|
HistoryOrder::CurrentSession,
|
|
),
|
|
};
|
|
assert_eq!(
|
|
ai_query_current.history_order(Some(current_session_id), &live_sessions,),
|
|
HistoryOrder::CurrentSession
|
|
);
|
|
|
|
// AI queries from different session
|
|
let ai_query_different = HistoryInputSuggestion::AIQuery {
|
|
entry: AIQueryHistory::new_for_test(
|
|
"ai query different session",
|
|
now,
|
|
HistoryOrder::DifferentSession,
|
|
),
|
|
};
|
|
assert_eq!(
|
|
ai_query_different.history_order(Some(current_session_id), &live_sessions,),
|
|
HistoryOrder::DifferentSession
|
|
);
|
|
}
|