From 95cae8fd8a8ce737ba60c3ac8bea77f4c2e14b1a Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 15 Jul 2026 05:49:08 +0700 Subject: [PATCH] feat: Tambah MentionIndex, AutocompleteKind, dan deteksi @mention di InputState Menambahkan MentionIndex (indeks path file thread-safe untuk fitur autocomplete @file-mention), enum AutocompleteKind untuk membedakan dropdown slash-command dan file-mention, serta method baru pada InputState: mention_query_at_cursor untuk deteksi token @mention di posisi cursor, dan open_mention_autocomplete untuk fuzzy-match file via nucleo-matcher. select_autocomplete kini kind-aware: menyisipkan path file ke posisi mention alih-alih mengganti seluruh buffer. --- src/app/state/misc.rs | 187 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 9 deletions(-) diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index 921281e..9db4a3b 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -27,6 +27,51 @@ impl DirCache { } } +/// A shared, whole-workspace file-path index used for `@file` mention +/// autocomplete. Built once by a background thread at startup (see +/// `AppStateRest::new`) and incrementally appended to when tools create +/// new files (see `tool/fs/write.rs`). +#[derive(Clone)] +pub struct MentionIndex { + entries: Arc>>, +} + +impl MentionIndex { + /// Create an empty `MentionIndex`. + pub fn new() -> Self { + MentionIndex { + entries: Arc::new(std::sync::RwLock::new(Vec::new())), + } + } + + /// Replace the indexed paths (used by the startup background walk). + pub fn set(&self, paths: Vec) { + if let Ok(mut w) = self.entries.write() { + *w = paths; + } + } + + /// Append a single newly created file's path (used by the `write` tool). + pub fn push(&self, path: String) { + if let Ok(mut w) = self.entries.write() { + w.push(path); + } + } + + /// Take a snapshot of the current indexed paths for fuzzy matching. + pub fn snapshot(&self) -> Vec { + self.entries.read().map(|r| r.clone()).unwrap_or_default() + } +} + +/// Which source populated the autocomplete dropdown, since selecting a +/// candidate is spliced into the buffer differently for each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutocompleteKind { + Command, + FileMention, +} + /// Manages the viewport scroll offset. #[derive(Debug, Clone)] pub struct ScrollState { @@ -72,6 +117,8 @@ pub struct InputState { pub autocomplete_candidates: Vec, pub autocomplete_idx: usize, pub autocomplete_visible: bool, + pub autocomplete_kind: AutocompleteKind, + pub mention_start: usize, pub history_file: Option, } @@ -106,6 +153,8 @@ impl InputState { autocomplete_candidates: Vec::new(), autocomplete_idx: 0, autocomplete_visible: false, + autocomplete_kind: AutocompleteKind::Command, + mention_start: 0, history_file: None, } } @@ -116,6 +165,8 @@ impl InputState { self.autocomplete_candidates.clear(); self.autocomplete_prefix.clear(); self.autocomplete_idx = 0; + self.autocomplete_kind = AutocompleteKind::Command; + self.mention_start = 0; } /// Open or refresh the autocomplete dropdown by filtering `COMMANDS` @@ -138,6 +189,54 @@ impl InputState { .map(std::string::ToString::to_string) .collect(); self.autocomplete_prefix = prefix; + self.autocomplete_kind = AutocompleteKind::Command; + self.autocomplete_idx = 0; + self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); + } + + /// Find the `@mention` token (if any) immediately before the cursor. + /// + /// Flow: find the nearest `@` before the cursor → if there's whitespace + /// between that `@` and the cursor, no trigger → the `@` only counts as + /// a trigger if it's at buffer start or immediately preceded by + /// whitespace (so `foo@bar` mid-word never triggers). + /// + /// Return: `Some((byte offset of '@', query text between '@' and cursor))` + /// or `None` if the cursor isn't inside a mention token. + pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> { + let before_cursor = &self.buffer[..self.cursor]; + let at_pos = before_cursor.rfind('@')?; + let between = &before_cursor[at_pos + 1..]; + if between.chars().any(char::is_whitespace) { + return None; + } + let boundary_ok = at_pos == 0 + || before_cursor[..at_pos].chars().next_back().is_some_and(char::is_whitespace); + if !boundary_ok { + return None; + } + Some((at_pos, between.to_string())) + } + + /// Open or refresh the `@file` mention dropdown from `files`, fuzzy-matched + /// against the mention query at the cursor. + /// + /// Flow: `mention_query_at_cursor` finds the trigger `@` and query text → + /// if none, close and return → otherwise fuzzy-match `query` against + /// `files` via `nucleo-matcher`, keep the top 10 by score. + pub fn open_mention_autocomplete(&mut self, files: &[String]) { + let Some((start, query)) = self.mention_query_at_cursor() else { + self.close_autocomplete(); + return; + }; + use nucleo_matcher::{Config, Matcher}; + use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; + let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); + let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart); + let matches = pattern.match_list(files.iter(), &mut matcher); + self.autocomplete_candidates = matches.into_iter().take(10).map(|(f, _)| f.clone()).collect(); + self.autocomplete_kind = AutocompleteKind::FileMention; + self.mention_start = start; self.autocomplete_idx = 0; self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); } @@ -154,19 +253,30 @@ impl InputState { } } - /// Accept the currently selected autocomplete candidate, placing it - /// in the buffer and closing the dropdown. + /// Accept the currently selected autocomplete candidate. + /// + /// `Command` candidates replace the whole buffer; `FileMention` + /// candidates splice `@path ` in at the mention's start position so the + /// rest of the sentence around it is preserved. /// /// Return: `true` if a candidate was selected, `false` if none existed. pub fn select_autocomplete(&mut self) -> bool { - if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) { - self.buffer = candidate.clone(); - self.cursor = self.buffer.len(); - self.close_autocomplete(); - true - } else { - false + let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx).cloned() else { + return false; + }; + match self.autocomplete_kind { + AutocompleteKind::Command => { + self.buffer = candidate; + self.cursor = self.buffer.len(); + } + AutocompleteKind::FileMention => { + let replacement = format!("@{candidate} "); + self.buffer.replace_range(self.mention_start..self.cursor, &replacement); + self.cursor = self.mention_start + replacement.len(); + } } + self.close_autocomplete(); + true } /// Legacy inline tab-complete — opens the dropdown on first Tab press, @@ -325,3 +435,62 @@ impl MiscState { expired } } + +#[cfg(test)] +mod tests { + use super::*; + + fn input_with(buffer: &str, cursor: usize) -> InputState { + let mut input = InputState::new(); + input.buffer = buffer.to_string(); + input.cursor = cursor; + input + } + + #[test] + fn mention_at_buffer_start_triggers() { + let input = input_with("@mai", 4); + assert_eq!(input.mention_query_at_cursor(), Some((0, "mai".to_string()))); + } + + #[test] + fn mention_after_space_mid_sentence_triggers() { + let input = input_with("look at @read", 13); + assert_eq!(input.mention_query_at_cursor(), Some((8, "read".to_string()))); + } + + #[test] + fn mid_word_at_does_not_trigger() { + let input = input_with("foo@bar", 7); + assert_eq!(input.mention_query_at_cursor(), None); + } + + #[test] + fn whitespace_between_at_and_cursor_does_not_trigger() { + let input = input_with("@foo bar", 8); + assert_eq!(input.mention_query_at_cursor(), None); + } + + #[test] + fn select_file_mention_splices_into_buffer() { + let mut input = input_with("look at @rea and fix it", 12); + input.autocomplete_candidates = vec!["src/main.rs".to_string()]; + input.autocomplete_idx = 0; + input.autocomplete_kind = AutocompleteKind::FileMention; + input.mention_start = 8; + assert!(input.select_autocomplete()); + assert_eq!(input.buffer, "look at @src/main.rs and fix it"); + assert_eq!(input.cursor, 8 + "@src/main.rs ".len()); + } + + #[test] + fn select_command_still_replaces_whole_buffer() { + let mut input = input_with("/mo", 3); + input.autocomplete_candidates = vec!["/model".to_string()]; + input.autocomplete_idx = 0; + input.autocomplete_kind = AutocompleteKind::Command; + assert!(input.select_autocomplete()); + assert_eq!(input.buffer, "/model"); + assert_eq!(input.cursor, "/model".len()); + } +}