use super::{
parse_goto_line_input, GotoLineTarget, QuickOpenContext, QuickOpenProvider, QuickOpenResult,
};
use crate::input::commands::Suggestion;
use crate::input::fuzzy::FuzzyMatcher;
use rust_i18n::t;
pub struct CommandProvider {
command_registry:
std::sync::Arc<std::sync::RwLock<crate::input::command_registry::CommandRegistry>>,
keybinding_resolver:
std::sync::Arc<std::sync::RwLock<crate::input::keybindings::KeybindingResolver>>,
}
impl CommandProvider {
pub fn new(
command_registry: std::sync::Arc<
std::sync::RwLock<crate::input::command_registry::CommandRegistry>,
>,
keybinding_resolver: std::sync::Arc<
std::sync::RwLock<crate::input::keybindings::KeybindingResolver>,
>,
) -> Self {
Self {
command_registry,
keybinding_resolver,
}
}
}
impl QuickOpenProvider for CommandProvider {
fn prefix(&self) -> &str {
">"
}
fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
let registry = self.command_registry.read().unwrap();
let keybindings = self.keybinding_resolver.read().unwrap();
registry.filter(
query,
context.key_context.clone(),
&keybindings,
context.has_selection,
&context.custom_contexts,
context.buffer_mode.as_deref(),
context.has_lsp_config,
)
}
fn on_select(
&self,
suggestion: Option<&Suggestion>,
_query: &str,
_context: &QuickOpenContext,
) -> QuickOpenResult {
let suggestion = match suggestion {
Some(s) if !s.disabled => s,
Some(_) => {
return QuickOpenResult::Error(t!("status.command_not_available").to_string())
}
None => return QuickOpenResult::None,
};
let registry = self.command_registry.read().unwrap();
let cmd = registry
.get_all()
.into_iter()
.find(|c| c.get_localized_name() == suggestion.text);
let Some(cmd) = cmd else {
return QuickOpenResult::None;
};
let action = cmd.action.clone();
let name = cmd.name.clone();
drop(registry);
if let Ok(mut reg) = self.command_registry.write() {
reg.record_usage(&name);
}
QuickOpenResult::ExecuteAction(action)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
pub struct BufferProvider;
impl BufferProvider {
pub fn new() -> Self {
Self
}
}
impl Default for BufferProvider {
fn default() -> Self {
Self::new()
}
}
impl QuickOpenProvider for BufferProvider {
fn prefix(&self) -> &str {
"#"
}
fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
let mut matcher = FuzzyMatcher::new(query);
let mut scored: Vec<(Suggestion, i32, usize)> = context
.open_buffers
.iter()
.filter(|buf| !buf.path.is_empty())
.filter_map(|buf| {
let m = matcher.match_target(&buf.name);
if !m.matched {
return None;
}
let display_name = if buf.modified {
format!("{} [+]", buf.name)
} else {
buf.name.clone()
};
let suggestion = Suggestion::new(display_name)
.with_description(buf.path.clone())
.with_value(buf.id.to_string());
Some((suggestion, m.score, buf.id))
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
scored.into_iter().map(|(s, _, _)| s).collect()
}
fn on_select(
&self,
suggestion: Option<&Suggestion>,
_query: &str,
_context: &QuickOpenContext,
) -> QuickOpenResult {
suggestion
.and_then(|s| s.value.as_deref())
.and_then(|v| v.parse::<usize>().ok())
.map(QuickOpenResult::ShowBuffer)
.unwrap_or(QuickOpenResult::None)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
pub struct GotoLineProvider;
impl GotoLineProvider {
pub fn new() -> Self {
Self
}
}
impl Default for GotoLineProvider {
fn default() -> Self {
Self::new()
}
}
impl QuickOpenProvider for GotoLineProvider {
fn prefix(&self) -> &str {
":"
}
fn suggestions(&self, query: &str, _context: &QuickOpenContext) -> Vec<Suggestion> {
if query.is_empty() {
return vec![
Suggestion::disabled(t!("quick_open.goto_line_hint").to_string())
.with_description(t!("quick_open.goto_line_desc").to_string()),
];
}
if query == "-" || query == "+" {
return vec![
Suggestion::disabled(t!("quick_open.goto_line_hint").to_string())
.with_description(t!("quick_open.relative_line_desc").to_string()),
];
}
match parse_goto_line_input(query) {
Some(target) => {
let label = match target {
GotoLineTarget::Absolute(n) => {
t!("quick_open.goto_line", line = n.to_string()).to_string()
}
GotoLineTarget::Relative(d) => {
t!("quick_open.goto_line", line = format!("{:+}", d)).to_string()
}
};
vec![Suggestion::new(label)
.with_description(t!("quick_open.press_enter").to_string())
.with_value(query.to_string())]
}
None => vec![
Suggestion::disabled(t!("quick_open.invalid_line").to_string())
.with_description(query.to_string()),
],
}
}
fn on_select(
&self,
suggestion: Option<&Suggestion>,
_query: &str,
_context: &QuickOpenContext,
) -> QuickOpenResult {
suggestion
.and_then(|s| s.value.as_deref())
.and_then(parse_goto_line_input)
.map(QuickOpenResult::GotoLine)
.unwrap_or(QuickOpenResult::None)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
const IGNORED_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
"__pycache__",
".hg",
".svn",
".DS_Store",
];
const MAX_FILES: usize = 50_000;
#[derive(Clone, Debug)]
pub struct FileEntry {
relative_path: String,
frecency_score: f64,
}
#[derive(Clone)]
struct FrecencyData {
access_count: u32,
last_access: std::time::Instant,
}
struct FileCache {
files: Option<std::sync::Arc<Vec<FileEntry>>>,
loading: bool,
}
#[derive(Clone)]
pub struct FileProvider {
cache: std::sync::Arc<std::sync::Mutex<FileCache>>,
frecency: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>>,
filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
runtime_handle: Option<tokio::runtime::Handle>,
async_sender: Option<std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>>,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl FileProvider {
pub fn new(
filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
runtime_handle: Option<tokio::runtime::Handle>,
async_sender: Option<std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>>,
) -> Self {
Self {
cache: std::sync::Arc::new(std::sync::Mutex::new(FileCache {
files: None,
loading: false,
})),
frecency: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
filesystem,
process_spawner,
runtime_handle,
async_sender,
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
pub fn clear_cache(&self) {
self.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut c) = self.cache.lock() {
c.files = None;
c.loading = false;
}
}
pub fn cancel_loading(&self) {
self.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut c) = self.cache.lock() {
c.loading = false;
}
}
pub fn set_cache(&self, files: std::sync::Arc<Vec<FileEntry>>) {
if let Ok(mut c) = self.cache.lock() {
c.files = Some(files);
c.loading = false;
}
}
pub fn set_partial_cache(&self, files: std::sync::Arc<Vec<FileEntry>>) {
if let Ok(mut c) = self.cache.lock() {
c.files = Some(files);
}
}
fn is_loading(&self) -> bool {
self.cache.lock().is_ok_and(|c| c.loading)
}
pub fn record_access(&self, path: &str) {
if let Ok(mut frecency) = self.frecency.write() {
let entry = frecency.entry(path.to_string()).or_insert(FrecencyData {
access_count: 0,
last_access: std::time::Instant::now(),
});
entry.access_count += 1;
entry.last_access = std::time::Instant::now();
}
}
fn get_frecency_score(&self, path: &str) -> f64 {
self.frecency
.read()
.ok()
.and_then(|m| m.get(path).map(frecency_score))
.unwrap_or(0.0)
}
fn probe_prefix(&self, cwd: &str, query: &str) -> Vec<FileEntry> {
use std::path::Path;
if query.is_empty() {
return vec![];
}
let abs_path = Path::new(cwd).join(query);
let mut results = Vec::new();
if let Ok(entries) = self.filesystem.read_dir(&abs_path) {
let query_trimmed = query.trim_end_matches('/');
for entry in entries {
if entry.is_file() && !entry.name.starts_with('.') {
let rel = format!("{}/{}", query_trimmed, entry.name);
results.push(FileEntry {
frecency_score: self.get_frecency_score(&rel),
relative_path: rel,
});
}
}
results.truncate(50);
return results;
}
let parent = match abs_path.parent() {
Some(p) => p,
None => return results,
};
let basename = match abs_path.file_name().and_then(|n| n.to_str()) {
Some(b) => b,
None => return results,
};
let rel_parent = match parent.strip_prefix(cwd) {
Ok(p) => {
let s = p.to_string_lossy().replace('\\', "/");
s
}
Err(_) => return results,
};
if let Ok(entries) = self.filesystem.read_dir(parent) {
for entry in entries {
if entry.name.starts_with('.') {
continue;
}
if !entry.name.starts_with(basename) {
continue;
}
if entry.is_file() {
let rel = if rel_parent.is_empty() {
entry.name.clone()
} else {
format!("{}/{}", rel_parent, entry.name)
};
results.push(FileEntry {
frecency_score: self.get_frecency_score(&rel),
relative_path: rel,
});
}
}
}
results
}
fn get_or_start_loading(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
let mut cache = self.cache.lock().ok()?;
if let Some(files) = &cache.files {
return Some(std::sync::Arc::clone(files));
}
if cache.loading {
return None; }
let (sender, handle) = match (&self.async_sender, &self.runtime_handle) {
(Some(s), Some(h)) => (s.clone(), h.clone()),
_ => {
drop(cache);
return self.load_files_sync(cwd);
}
};
cache.loading = true;
self.cancel
.store(false, std::sync::atomic::Ordering::Relaxed);
let cancel = std::sync::Arc::clone(&self.cancel);
let frecency = std::sync::Arc::clone(&self.frecency);
let filesystem = std::sync::Arc::clone(&self.filesystem);
let process_spawner = std::sync::Arc::clone(&self.process_spawner);
let cwd = cwd.to_string();
handle.spawn_blocking(move || {
if let Some(files) = try_git_files_blocking(&process_spawner, &cwd) {
let frecency_map = frecency.read().ok();
let entries: Vec<FileEntry> = files
.into_iter()
.map(|path| {
let score = frecency_map
.as_ref()
.and_then(|m| m.get(&path))
.map(frecency_score)
.unwrap_or(0.0);
FileEntry {
relative_path: path,
frecency_score: score,
}
})
.collect();
drop(sender.send(
crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
files: std::sync::Arc::new(entries),
complete: true,
},
));
return;
}
walk_dir_with_updates(&*filesystem, &cwd, &cancel, &frecency, &sender);
});
None
}
fn load_files_sync(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
let files = self
.try_git_files(cwd)
.or_else(|| self.try_walk_dir(cwd))
.unwrap_or_default();
let entries: Vec<FileEntry> = files
.into_iter()
.map(|path| FileEntry {
frecency_score: self.get_frecency_score(&path),
relative_path: path,
})
.collect();
let files = std::sync::Arc::new(entries);
self.set_cache(std::sync::Arc::clone(&files));
Some(files)
}
fn try_git_files(&self, cwd: &str) -> Option<Vec<String>> {
let handle = self.runtime_handle.as_ref()?;
try_git_files_with_handle(&self.process_spawner, cwd, handle)
}
fn try_walk_dir(&self, cwd: &str) -> Option<Vec<String>> {
let cancel = std::sync::atomic::AtomicBool::new(false);
try_walk_dir_blocking(&*self.filesystem, cwd, &cancel)
}
}
fn try_git_files_blocking(
spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
cwd: &str,
) -> Option<Vec<String>> {
let handle = tokio::runtime::Handle::try_current().ok()?;
try_git_files_with_handle(spawner, cwd, &handle)
}
fn try_git_files_with_handle(
spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
cwd: &str,
handle: &tokio::runtime::Handle,
) -> Option<Vec<String>> {
let result = handle
.block_on(spawner.spawn(
"git".to_string(),
vec![
"ls-files".to_string(),
"--cached".to_string(),
"--others".to_string(),
"--exclude-standard".to_string(),
],
Some(cwd.to_string()),
))
.ok()?;
if result.exit_code != 0 {
return None;
}
let files: Vec<String> = result
.stdout
.lines()
.filter(|line| !line.is_empty() && !line.starts_with(".git/"))
.map(|s| s.to_string())
.collect();
Some(files)
}
fn try_walk_dir_blocking(
fs: &dyn crate::model::filesystem::FileSystem,
cwd: &str,
cancel: &std::sync::atomic::AtomicBool,
) -> Option<Vec<String>> {
use std::path::Path;
let base = Path::new(cwd);
let mut files = Vec::new();
drop(
fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
files.push(rel.to_string());
files.len() < MAX_FILES
}),
);
if files.is_empty() {
None
} else {
Some(files)
}
}
const WALK_UPDATE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300);
fn walk_dir_with_updates(
fs: &dyn crate::model::filesystem::FileSystem,
cwd: &str,
cancel: &std::sync::atomic::AtomicBool,
frecency: &std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>,
sender: &std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>,
) {
use std::path::Path;
let base = Path::new(cwd);
let mut paths: Vec<String> = Vec::new();
let mut last_send = std::time::Instant::now();
let mut receiver_gone = false;
if let Err(e) = fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
paths.push(rel.to_string());
if last_send.elapsed() >= WALK_UPDATE_INTERVAL {
let frecency_map = frecency.read().ok();
let entries: Vec<FileEntry> = paths
.iter()
.map(|p| FileEntry {
frecency_score: frecency_map
.as_ref()
.and_then(|m| m.get(p).map(frecency_score))
.unwrap_or(0.0),
relative_path: p.clone(),
})
.collect();
if sender
.send(
crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
files: std::sync::Arc::new(entries),
complete: false,
},
)
.is_err()
{
receiver_gone = true;
return false;
}
last_send = std::time::Instant::now();
}
paths.len() < MAX_FILES
}) {
tracing::debug!("Quick Open walk_files failed: {}", e);
}
if receiver_gone {
return;
}
let frecency_map = frecency.read().ok();
let entries: Vec<FileEntry> = paths
.into_iter()
.map(|p| {
let score = frecency_map
.as_ref()
.and_then(|m| m.get(&p).map(frecency_score))
.unwrap_or(0.0);
FileEntry {
relative_path: p,
frecency_score: score,
}
})
.collect();
drop(sender.send(
crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
files: std::sync::Arc::new(entries),
complete: true,
},
));
}
fn frecency_score(data: &FrecencyData) -> f64 {
let hours_since_access = data.last_access.elapsed().as_secs_f64() / 3600.0;
let recency_weight = if hours_since_access < 4.0 {
100.0
} else if hours_since_access < 24.0 {
70.0
} else if hours_since_access < 24.0 * 7.0 {
50.0
} else if hours_since_access < 24.0 * 30.0 {
30.0
} else if hours_since_access < 24.0 * 90.0 {
10.0
} else {
1.0
};
data.access_count as f64 * recency_weight
}
impl QuickOpenProvider for FileProvider {
fn prefix(&self) -> &str {
""
}
fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
let (path_part, _, _) = super::parse_path_line_col(query);
let search_query = if path_part.is_empty() {
query
} else {
&path_part
};
if !self.filesystem.is_remote_connected() {
return vec![Suggestion::disabled(
"Remote connection lost — cannot list files".to_string(),
)];
}
let files = self.get_or_start_loading(&context.cwd);
let still_loading = self.is_loading();
let prefix_entries = if !search_query.is_empty() {
self.probe_prefix(&context.cwd, search_query)
} else {
vec![]
};
let has_files = files.as_ref().is_some_and(|f| !f.is_empty());
if !has_files && prefix_entries.is_empty() {
if still_loading {
return vec![Suggestion::disabled("Loading files…".to_string())];
} else {
return vec![Suggestion::disabled(t!("quick_open.no_files").to_string())];
}
}
let max_results = 100;
let prefix_set: std::collections::HashSet<&str> = prefix_entries
.iter()
.map(|e| e.relative_path.as_str())
.collect();
const PREFIX_PROBE_BOOST: i32 = 200;
let mut matcher = FuzzyMatcher::new(search_query);
let mut scored: Vec<(String, i32)> = Vec::new();
for entry in &prefix_entries {
let m = matcher.match_target(&entry.relative_path);
let base_score = if m.matched { m.score } else { 0 };
let frecency_boost = (entry.frecency_score / 100.0).min(20.0) as i32;
scored.push((
entry.relative_path.clone(),
base_score + frecency_boost + PREFIX_PROBE_BOOST,
));
}
if let Some(files) = &files {
if search_query.is_empty() {
let mut entries: Vec<_> = files.iter().map(|f| (f, 0i32)).collect();
entries.sort_by(|a, b| {
b.0.frecency_score
.partial_cmp(&a.0.frecency_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
entries.truncate(max_results);
for (f, s) in entries {
scored.push((f.relative_path.clone(), s));
}
} else {
for file in files.iter() {
if prefix_set.contains(file.relative_path.as_str()) {
continue;
}
let m = matcher.match_target(&file.relative_path);
if !m.matched {
continue;
}
let frecency_boost = (file.frecency_score / 100.0).min(20.0) as i32;
let mut score = m.score + frecency_boost;
if file.relative_path.starts_with(search_query) {
score += PREFIX_PROBE_BOOST;
}
scored.push((file.relative_path.clone(), score));
}
}
}
scored.sort_by(|a, b| b.1.cmp(&a.1));
scored.truncate(max_results);
let mut suggestions: Vec<Suggestion> = scored
.into_iter()
.map(|(path, _)| Suggestion::new(path.clone()).with_value(path))
.collect();
if still_loading {
let msg = if suggestions.is_empty() {
"Loading files…"
} else {
"Scanning for more files…"
};
suggestions.push(Suggestion::disabled(msg.to_string()));
}
suggestions
}
fn on_select(
&self,
suggestion: Option<&Suggestion>,
query: &str,
_context: &QuickOpenContext,
) -> QuickOpenResult {
let (path_part, line, column) = super::parse_path_line_col(query);
if let Some(path) = suggestion.and_then(|s| s.value.as_deref()) {
self.record_access(path);
return QuickOpenResult::OpenFile {
path: path.to_string(),
line,
column,
};
}
if line.is_some() && !path_part.is_empty() {
self.record_access(&path_part);
return QuickOpenResult::OpenFile {
path: path_part,
line,
column,
};
}
QuickOpenResult::None
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::quick_open::BufferInfo;
fn make_test_context(cwd: &str) -> QuickOpenContext {
QuickOpenContext {
cwd: cwd.to_string(),
open_buffers: vec![
BufferInfo {
id: 1,
path: "/tmp/main.rs".to_string(),
name: "main.rs".to_string(),
modified: false,
},
BufferInfo {
id: 2,
path: "/tmp/lib.rs".to_string(),
name: "lib.rs".to_string(),
modified: true,
},
],
active_buffer_id: 1,
active_buffer_path: Some("/tmp/main.rs".to_string()),
has_selection: false,
key_context: crate::input::keybindings::KeyContext::Normal,
custom_contexts: std::collections::HashSet::new(),
buffer_mode: None,
has_lsp_config: true,
relative_line_numbers: false,
}
}
#[test]
fn test_buffer_provider_suggestions() {
let provider = BufferProvider::new();
let context = make_test_context("/tmp");
let suggestions = provider.suggestions("", &context);
assert_eq!(suggestions.len(), 2);
let lib_suggestion = suggestions
.iter()
.find(|s| s.text.contains("lib.rs"))
.unwrap();
assert!(lib_suggestion.text.contains("[+]"));
}
#[test]
fn test_buffer_provider_filter() {
let provider = BufferProvider::new();
let context = make_test_context("/tmp");
let suggestions = provider.suggestions("main", &context);
assert_eq!(suggestions.len(), 1);
assert!(suggestions[0].text.contains("main.rs"));
}
#[test]
fn test_goto_line_provider() {
let provider = GotoLineProvider::new();
let context = make_test_context("/tmp");
let suggestions = provider.suggestions("42", &context);
assert_eq!(suggestions.len(), 1);
assert!(!suggestions[0].disabled);
let suggestions = provider.suggestions("", &context);
assert_eq!(suggestions.len(), 1);
assert!(suggestions[0].disabled);
let suggestions = provider.suggestions("abc", &context);
assert_eq!(suggestions.len(), 1);
assert!(suggestions[0].disabled);
}
#[test]
fn test_goto_line_on_select() {
let provider = GotoLineProvider::new();
let context = make_test_context("/tmp");
let suggestions = provider.suggestions("42", &context);
let result = provider.on_select(suggestions.first(), "42", &context);
match result {
QuickOpenResult::GotoLine(GotoLineTarget::Absolute(line)) => assert_eq!(line, 42),
other => panic!("expected absolute GotoLine result, got {:?}", other),
}
}
#[test]
fn test_goto_line_signed_is_relative_regardless_of_setting() {
let provider = GotoLineProvider::new();
for relative_setting in [false, true] {
let mut context = make_test_context("/tmp");
context.relative_line_numbers = relative_setting;
for query in ["-5", "+3"] {
let suggestions = provider.suggestions(query, &context);
assert_eq!(suggestions.len(), 1, "query {query:?}");
assert!(!suggestions[0].disabled, "query {query:?}");
}
let suggestions = provider.suggestions("+3", &context);
match provider.on_select(suggestions.first(), "+3", &context) {
QuickOpenResult::GotoLine(GotoLineTarget::Relative(d)) => assert_eq!(d, 3),
other => panic!("expected relative GotoLine, got {:?}", other),
}
let suggestions = provider.suggestions("-7", &context);
match provider.on_select(suggestions.first(), "-7", &context) {
QuickOpenResult::GotoLine(GotoLineTarget::Relative(d)) => assert_eq!(d, -7),
other => panic!("expected relative GotoLine, got {:?}", other),
}
for bare in ["-", "+"] {
let suggestions = provider.suggestions(bare, &context);
assert_eq!(suggestions.len(), 1);
assert!(suggestions[0].disabled);
}
}
}
#[test]
fn test_goto_line_unsigned_is_absolute_regardless_of_setting() {
let provider = GotoLineProvider::new();
for relative_setting in [false, true] {
let mut context = make_test_context("/tmp");
context.relative_line_numbers = relative_setting;
let suggestions = provider.suggestions("42", &context);
assert_eq!(suggestions.len(), 1);
assert!(!suggestions[0].disabled);
match provider.on_select(suggestions.first(), "42", &context) {
QuickOpenResult::GotoLine(GotoLineTarget::Absolute(n)) => assert_eq!(n, 42),
other => panic!("expected absolute GotoLine, got {:?}", other),
}
}
}
struct FailingSpawner;
#[async_trait::async_trait]
impl crate::services::remote::ProcessSpawner for FailingSpawner {
async fn spawn(
&self,
_command: String,
_args: Vec<String>,
_cwd: Option<String>,
) -> Result<crate::services::remote::SpawnResult, crate::services::remote::SpawnError>
{
Err(crate::services::remote::SpawnError::Process(
"no git in test".to_string(),
))
}
}
fn make_file_provider() -> FileProvider {
FileProvider::new(
std::sync::Arc::new(crate::model::filesystem::StdFileSystem),
std::sync::Arc::new(FailingSpawner),
None, None, )
}
#[test]
fn test_file_provider_discovers_files_via_walk() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("main.rs"), b"fn main() {}").unwrap();
std::fs::write(base.join("lib.rs"), b"pub mod foo;").unwrap();
std::fs::create_dir(base.join("src")).unwrap();
std::fs::write(base.join("src").join("foo.rs"), b"// foo").unwrap();
let provider = make_file_provider();
let context = make_test_context(&base.display().to_string());
let suggestions = provider.suggestions("", &context);
assert_eq!(suggestions.len(), 3);
let paths: Vec<&str> = suggestions
.iter()
.filter_map(|s| s.value.as_deref())
.collect();
assert!(paths.contains(&"main.rs"));
assert!(paths.contains(&"lib.rs"));
assert!(paths.contains(&"src/foo.rs"));
}
#[test]
fn test_file_provider_skips_ignored_dirs() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("app.rs"), b"").unwrap();
std::fs::create_dir(base.join("node_modules")).unwrap();
std::fs::write(base.join("node_modules").join("pkg.js"), b"").unwrap();
std::fs::create_dir(base.join("target")).unwrap();
std::fs::write(base.join("target").join("debug.o"), b"").unwrap();
let provider = make_file_provider();
let context = make_test_context(&base.display().to_string());
let suggestions = provider.suggestions("", &context);
assert_eq!(suggestions.len(), 1);
assert_eq!(suggestions[0].value.as_deref(), Some("app.rs"));
}
#[test]
fn test_file_provider_skips_hidden_files() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("visible.txt"), b"").unwrap();
std::fs::write(base.join(".hidden"), b"").unwrap();
std::fs::create_dir(base.join(".git")).unwrap();
std::fs::write(base.join(".git").join("config"), b"").unwrap();
let provider = make_file_provider();
let context = make_test_context(&base.display().to_string());
let suggestions = provider.suggestions("", &context);
assert_eq!(suggestions.len(), 1);
assert_eq!(suggestions[0].value.as_deref(), Some("visible.txt"));
}
#[test]
fn test_file_provider_fuzzy_filter() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("main.rs"), b"").unwrap();
std::fs::write(base.join("lib.rs"), b"").unwrap();
std::fs::write(base.join("README.md"), b"").unwrap();
let provider = make_file_provider();
let context = make_test_context(&base.display().to_string());
let suggestions = provider.suggestions("main", &context);
assert_eq!(suggestions.len(), 1);
assert_eq!(suggestions[0].value.as_deref(), Some("main.rs"));
}
#[test]
fn test_file_provider_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let provider = make_file_provider();
let context = make_test_context(&dir.path().display().to_string());
let suggestions = provider.suggestions("", &context);
assert_eq!(suggestions.len(), 1);
assert!(suggestions[0].disabled);
}
#[test]
fn test_probe_prefix_all_shapes() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::create_dir(base.join("etc")).unwrap();
std::fs::write(base.join("etc").join("hosts"), b"").unwrap();
std::fs::write(base.join("etc").join("hosts.allow"), b"").unwrap();
std::fs::write(base.join("etc").join("hosts.deny"), b"").unwrap();
std::fs::write(base.join("etc").join("passwd"), b"").unwrap();
std::fs::create_dir(base.join("src")).unwrap();
std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
std::fs::write(base.join("src").join("lib.rs"), b"").unwrap();
std::fs::write(base.join("Makefile"), b"").unwrap();
std::fs::write(base.join("Makefile.bak"), b"").unwrap();
std::fs::write(base.join("README.md"), b"").unwrap();
let provider = make_file_provider();
let cwd = base.display().to_string();
let r = provider.probe_prefix(&cwd, "etc/hosts");
let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
assert!(
paths.contains(&"etc/hosts"),
"missing etc/hosts in {paths:?}"
);
assert!(
paths.contains(&"etc/hosts.allow"),
"missing etc/hosts.allow in {paths:?}"
);
assert!(
paths.contains(&"etc/hosts.deny"),
"missing etc/hosts.deny in {paths:?}"
);
assert!(
!paths.contains(&"etc/passwd"),
"passwd shouldn't match prefix 'hosts': {paths:?}"
);
let r = provider.probe_prefix(&cwd, "src");
let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
assert!(
paths.contains(&"src/main.rs"),
"missing src/main.rs in {paths:?}"
);
assert!(
paths.contains(&"src/lib.rs"),
"missing src/lib.rs in {paths:?}"
);
let r = provider.probe_prefix(&cwd, "nonexistent/path/to/file");
assert!(
r.is_empty(),
"nonexistent query should return empty, got {:?}",
r.iter().map(|e| &e.relative_path).collect::<Vec<_>>()
);
let r = provider.probe_prefix(&cwd, "Makefile");
let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
assert!(paths.contains(&"Makefile"), "missing Makefile in {paths:?}");
assert!(
paths.contains(&"Makefile.bak"),
"missing Makefile.bak in {paths:?}"
);
assert!(
!paths.contains(&"README.md"),
"README.md shouldn't match prefix 'Makefile': {paths:?}"
);
}
#[test]
fn test_prefix_match_ranks_above_fuzzy_match() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::create_dir(base.join("src")).unwrap();
std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
std::fs::write(base.join("src").join("manager.rs"), b"").unwrap();
let provider = make_file_provider();
let context = make_test_context(&base.display().to_string());
let suggestions = provider.suggestions("src/main", &context);
assert!(!suggestions.is_empty());
assert_eq!(suggestions[0].value.as_deref(), Some("src/main.rs"));
}
#[test]
fn test_set_partial_cache_keeps_loading() {
let provider = make_file_provider();
{
let mut cache = provider.cache.lock().unwrap();
cache.loading = true;
}
let partial = std::sync::Arc::new(vec![FileEntry {
relative_path: "foo.rs".to_string(),
frecency_score: 0.0,
}]);
provider.set_partial_cache(partial);
assert!(provider.is_loading());
assert!(provider.cache.lock().unwrap().files.is_some());
let final_files = std::sync::Arc::new(vec![FileEntry {
relative_path: "foo.rs".to_string(),
frecency_score: 0.0,
}]);
provider.set_cache(final_files);
assert!(!provider.is_loading());
}
}