use super::{App, FocusTarget, dialog::DialogState};
use ignore::WalkBuilder;
use std::path::Path;
use std::time::SystemTime;
pub const MAX_VISIBLE: usize = 8;
const MAX_CANDIDATES: usize = 50;
pub struct MentionState {
pub trigger_row: usize,
pub trigger_col: usize,
pub query: String,
pub candidates: Vec<FileCandidate>,
pub dialog: DialogState,
}
#[derive(Clone)]
pub struct FileCandidate {
pub rel_path: String,
pub depth: usize,
pub modified: SystemTime,
pub is_dir: bool,
}
pub fn scan_files(cwd: &str) -> Vec<FileCandidate> {
let cwd_path = Path::new(cwd);
let mut candidates = Vec::new();
let walker = WalkBuilder::new(cwd_path)
.hidden(false) .git_ignore(true)
.git_global(true)
.git_exclude(true)
.build();
for entry in walker.flatten() {
let is_file = entry.file_type().is_some_and(|ft| ft.is_file());
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
if !is_file && !is_dir {
continue;
}
let path = entry.path();
let Ok(rel) = path.strip_prefix(cwd_path) else {
continue;
};
let rel_str = rel.to_string_lossy().replace('\\', "/");
if rel_str.is_empty() {
continue; }
let depth = rel_str.matches('/').count();
let rel_path = if is_dir { format!("{rel_str}/") } else { rel_str };
let modified =
entry.metadata().ok().and_then(|m| m.modified().ok()).unwrap_or(SystemTime::UNIX_EPOCH);
candidates.push(FileCandidate { rel_path, depth, modified, is_dir });
}
candidates.sort_by(|a, b| {
a.depth
.cmp(&b.depth)
.then_with(|| b.is_dir.cmp(&a.is_dir)) .then_with(|| b.modified.cmp(&a.modified))
});
candidates
}
pub fn filter_candidates(cache: &[FileCandidate], query: &str) -> Vec<FileCandidate> {
if query.is_empty() {
return cache.iter().take(MAX_CANDIDATES).cloned().collect();
}
let query_lower = query.to_lowercase();
cache
.iter()
.filter(|c| c.rel_path.to_lowercase().contains(&query_lower))
.take(MAX_CANDIDATES)
.cloned()
.collect()
}
pub fn detect_mention_at_cursor(
lines: &[String],
cursor_row: usize,
cursor_col: usize,
) -> Option<(usize, usize, String)> {
let line = lines.get(cursor_row)?;
let chars: Vec<char> = line.chars().collect();
let mut i = cursor_col;
while i > 0 {
i -= 1;
let ch = *chars.get(i)?;
if ch == '@' {
if i == 0 || chars.get(i - 1).is_some_and(|c| c.is_whitespace()) {
let query: String = chars[i + 1..cursor_col].iter().collect();
if query.chars().all(|c| !c.is_whitespace()) {
return Some((cursor_row, i, query));
}
}
return None;
}
if ch.is_whitespace() {
return None;
}
}
None
}
pub fn activate(app: &mut App) {
let detection =
detect_mention_at_cursor(&app.input.lines, app.input.cursor_row, app.input.cursor_col);
let Some((trigger_row, trigger_col, query)) = detection else {
return;
};
app.file_cache = Some(scan_files(&app.cwd_raw));
let candidates =
app.file_cache.as_ref().map(|cache| filter_candidates(cache, &query)).unwrap_or_default();
app.mention = Some(MentionState {
trigger_row,
trigger_col,
query,
candidates,
dialog: DialogState::default(),
});
app.slash = None;
app.claim_focus_target(FocusTarget::Mention);
}
pub fn update_query(app: &mut App) {
let detection =
detect_mention_at_cursor(&app.input.lines, app.input.cursor_row, app.input.cursor_col);
let Some((trigger_row, trigger_col, query)) = detection else {
deactivate(app);
return;
};
let candidates =
app.file_cache.as_ref().map(|cache| filter_candidates(cache, &query)).unwrap_or_default();
if let Some(ref mut mention) = app.mention {
mention.trigger_row = trigger_row;
mention.trigger_col = trigger_col;
mention.query = query;
mention.candidates = candidates;
mention.dialog.clamp(mention.candidates.len(), MAX_VISIBLE);
}
}
pub fn sync_with_cursor(app: &mut App) {
let in_mention =
detect_mention_at_cursor(&app.input.lines, app.input.cursor_row, app.input.cursor_col)
.is_some();
match (in_mention, app.mention.is_some()) {
(true, true) => update_query(app),
(true, false) => activate(app),
(false, true) => deactivate(app),
(false, false) => {}
}
}
pub fn confirm_selection(app: &mut App) {
let Some(mention) = app.mention.take() else {
return;
};
app.release_focus_target(FocusTarget::Mention);
let Some(candidate) = mention.candidates.get(mention.dialog.selected) else {
return;
};
let rel_path = candidate.rel_path.clone();
let trigger_row = mention.trigger_row;
let trigger_col = mention.trigger_col;
let line = &mut app.input.lines[trigger_row];
let chars: Vec<char> = line.chars().collect();
if trigger_col >= chars.len() || chars[trigger_col] != '@' {
return;
}
let mention_end =
(trigger_col + 1..chars.len()).find(|&i| chars[i].is_whitespace()).unwrap_or(chars.len());
let before: String = chars[..trigger_col].iter().collect();
let after: String = chars[mention_end..].iter().collect();
let replacement =
if after.is_empty() { format!("@{rel_path} ") } else { format!("@{rel_path}") };
let new_line = format!("{before}{replacement}{after}");
let new_cursor_col = trigger_col + replacement.chars().count();
app.input.lines[trigger_row] = new_line;
app.input.cursor_col = new_cursor_col;
app.input.version += 1;
app.input.sync_textarea_engine();
}
pub fn deactivate(app: &mut App) {
app.mention = None;
if app.slash.is_none() {
app.release_focus_target(FocusTarget::Mention);
}
}
pub fn move_up(app: &mut App) {
if let Some(ref mut mention) = app.mention {
mention.dialog.move_up(mention.candidates.len(), MAX_VISIBLE);
}
}
pub fn move_down(app: &mut App) {
if let Some(ref mut mention) = app.mention {
mention.dialog.move_down(mention.candidates.len(), MAX_VISIBLE);
}
}
pub fn find_mention_spans(text: &str) -> Vec<(usize, usize, String)> {
let mut spans = Vec::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '@' && (i == 0 || chars[i - 1].is_whitespace()) {
let start = i;
i += 1; let path_start = i;
while i < chars.len() && !chars[i].is_whitespace() {
i += 1;
}
if i > path_start {
let path: String = chars[path_start..i].iter().collect();
let byte_start: usize = chars[..start].iter().map(|c| c.len_utf8()).sum();
let byte_end: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
spans.push((byte_start, byte_end, path));
}
} else {
i += 1;
}
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
fn app_with_temp_files(files: &[&str]) -> (App, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
for f in files {
let path = tmp.path().join(f);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, "").unwrap();
}
let mut app = App::test_default();
app.cwd_raw = tmp.path().to_string_lossy().into_owned();
(app, tmp)
}
#[test]
fn sync_with_cursor_activates_inside_existing_mention() {
let (mut app, _tmp) = app_with_temp_files(&["src/main.rs", "tests/integration.rs"]);
app.input.set_text("open @src/main.rs now");
app.input.cursor_row = 0;
app.input.cursor_col = "open @src".chars().count();
sync_with_cursor(&mut app);
let mention = app.mention.as_ref().expect("mention should be active");
assert_eq!(mention.query, "src");
assert!(!mention.candidates.is_empty());
}
#[test]
fn confirm_selection_replaces_full_existing_token_without_double_space() {
let (mut app, _tmp) = app_with_temp_files(&["src/lib.rs"]);
app.input.set_text("open @src/lib.txt now");
app.input.cursor_row = 0;
app.input.cursor_col = "open @src/lib".chars().count();
activate(&mut app);
confirm_selection(&mut app);
assert_eq!(app.input.lines[0], "open @src/lib.rs now");
assert!(app.mention.is_none());
}
#[test]
fn confirm_selection_at_end_keeps_trailing_space() {
let (mut app, _tmp) = app_with_temp_files(&["src/main.rs"]);
app.input.set_text("@src/mai");
app.input.cursor_row = 0;
app.input.cursor_col = app.input.lines[0].chars().count();
activate(&mut app);
confirm_selection(&mut app);
assert_eq!(app.input.lines[0], "@src/main.rs ");
}
#[test]
fn activate_with_empty_query_shows_all_candidates() {
let (mut app, _tmp) = app_with_temp_files(&["src/main.rs"]);
app.input.set_text("@");
app.input.cursor_row = 0;
app.input.cursor_col = 1;
activate(&mut app);
let mention = app.mention.as_ref().expect("mention should be active");
assert_eq!(mention.query, "");
assert!(!mention.candidates.is_empty());
}
#[test]
fn update_query_keeps_active_when_query_becomes_empty() {
let (mut app, _tmp) = app_with_temp_files(&["src/main.rs"]);
app.input.set_text("@src");
app.input.cursor_row = 0;
app.input.cursor_col = app.input.lines[0].chars().count();
activate(&mut app);
assert!(app.mention.is_some());
app.input.cursor_col = 1;
update_query(&mut app);
let mention = app.mention.as_ref().expect("mention should stay active");
assert_eq!(mention.query, "");
assert!(!mention.candidates.is_empty());
}
}