Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4f5190f
feat: file @-mention with fuzzy search in chat input
tellaho Apr 9, 2026
08be63c
fix: use Radix Popover for @-mention autocomplete positioning
tellaho Apr 9, 2026
7badd2c
feat(system): add file mention scan command
tellaho Apr 10, 2026
73e0ae0
feat(chat): include project files in @mentions
tellaho Apr 10, 2026
eefc508
fix: scroll selected mention item into view on arrow navigation
tellaho Apr 10, 2026
f344fd9
feat: use fuzzy subsequence matching for @-mention filtering
tellaho Apr 10, 2026
fb37126
fix: project files not appearing in @-mention popover
tellaho Apr 10, 2026
b9aaae1
style: fix biome formatting in ChatInput
tellaho Apr 10, 2026
b6b2cea
refactor: replace requestAnimationFrame with useEffect for cursor pla…
tellaho Apr 10, 2026
6114944
chore: remove stale comment in MentionAutocomplete
tellaho Apr 10, 2026
cb3bba8
fix: biome format and lint issues
tellaho Apr 10, 2026
0473358
fix: resolve TypeScript error in ChatInput test mock
tellaho Apr 10, 2026
f5b1988
style: fix biome formatting in ChatInput test
tellaho Apr 10, 2026
b87eb80
fix: add list_files_for_mentions to E2E Tauri mock
tellaho Apr 10, 2026
ea07cbf
refactor: use ignore crate for gitignore-aware file scanning
tellaho Apr 10, 2026
79ffc44
style: cargo fmt
tellaho Apr 10, 2026
41df71c
fix: harden file scanner and deduplicate mention filtering
tellaho Apr 10, 2026
5f4179c
fix: remove unused mentionQuery destructure
tellaho Apr 10, 2026
3c9a2c3
fix: simplify persona header logic and stop collapsing user spaces
tellaho Apr 10, 2026
649943d
fix: clear file suggestions immediately when project roots change
tellaho Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!src-tauri/gen", "!.agents"]
"includes": [
"**",
"!src-tauri/gen",
"!.agents",
"!.worktrees",
"!.claude/worktrees"
]
},
"formatter": {
"enabled": true,
Expand Down
59 changes: 59 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork
tokio-tungstenite = "0.21.0"
acp-client = { git = "https://github.com/block/builderbot", rev = "db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad" }
doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" }
ignore = "0.4.25"

[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] }
Expand Down
154 changes: 154 additions & 0 deletions src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
use tauri::Window;
use tauri_plugin_dialog::DialogExt;

use std::collections::HashSet;
use std::path::PathBuf;

const DEFAULT_FILE_MENTION_LIMIT: usize = 1500;
const MAX_FILE_MENTION_LIMIT: usize = 5000;
const MAX_SCAN_DEPTH: usize = 8;

#[tauri::command]
pub fn get_home_dir() -> Result<String, String> {
let home_dir = dirs::home_dir().ok_or("Could not determine home directory")?;
Expand Down Expand Up @@ -47,3 +54,150 @@ pub async fn save_exported_session_file(
pub fn path_exists(path: String) -> bool {
std::path::Path::new(&path).exists()
}

fn normalize_roots(roots: Vec<String>) -> Vec<PathBuf> {
let mut dedup = HashSet::new();
let mut normalized = Vec::new();
for root in roots {
let trimmed = root.trim();
if trimmed.is_empty() {
continue;
}
let path = PathBuf::from(trimmed);
let key = path.to_string_lossy().to_lowercase();
if dedup.insert(key) {
normalized.push(path);
}
}
normalized
}

fn scan_files_for_mentions(roots: Vec<String>, max_results: Option<usize>) -> Vec<String> {
let roots = normalize_roots(roots);
if roots.is_empty() {
return Vec::new();
}

let limit = max_results
.unwrap_or(DEFAULT_FILE_MENTION_LIMIT)
.clamp(1, MAX_FILE_MENTION_LIMIT);

let mut builder = ignore::WalkBuilder::new(&roots[0]);
for root in &roots[1..] {
builder.add(root);
}
builder
.max_depth(Some(MAX_SCAN_DEPTH))
.follow_links(false) // don't traverse symlinks
.hidden(true) // skip hidden files/dirs
.git_ignore(true) // respect .gitignore
.git_global(true) // respect global gitignore
.git_exclude(true); // respect .git/info/exclude

// Canonicalize roots so we can reject paths that escape via symlink targets
let canonical_roots: Vec<PathBuf> = roots
.iter()
.filter_map(|root| root.canonicalize().ok())
.collect();

let mut seen = HashSet::new();
let mut files = Vec::new();

for entry in builder.build().flatten() {
if files.len() >= limit {
break;
}
let Some(ft) = entry.file_type() else {
continue;
};
if !ft.is_file() {
continue;
}
// Reject any path that resolved outside the project roots
let canonical = match entry.path().canonicalize() {
Ok(c) => c,
Err(_) => continue,
};
if !canonical_roots
.iter()
.any(|root| canonical.starts_with(root))
{
continue;
}
let path_str = entry.path().to_string_lossy().to_string();
let dedup_key = path_str.to_lowercase();
if seen.insert(dedup_key) {
files.push(path_str);
}
}

files.sort_by_key(|path| path.to_lowercase());
files
}

#[tauri::command]
pub async fn list_files_for_mentions(
roots: Vec<String>,
max_results: Option<usize>,
) -> Result<Vec<String>, String> {
tokio::task::spawn_blocking(move || scan_files_for_mentions(roots, max_results))
.await
.map_err(|error| format!("Failed to scan files for mentions: {}", error))
}

#[cfg(test)]
mod tests {
use super::scan_files_for_mentions;
use std::fs;
use std::process::Command;
use tempfile::tempdir;

/// Create a temp dir with `git init` so the ignore crate picks up `.gitignore`.
fn git_tempdir() -> tempfile::TempDir {
let dir = tempdir().expect("tempdir");
Command::new("git")
.args(["init", "--quiet"])
.current_dir(dir.path())
.output()
.expect("git init");
dir
}

#[test]
fn respects_gitignore() {
let dir = git_tempdir();
let root = dir.path();
let src = root.join("src");
let ignored = root.join("node_modules").join("pkg");

fs::create_dir_all(&src).expect("src dir");
fs::create_dir_all(&ignored).expect("ignored dir");
fs::write(src.join("main.ts"), "export {}").expect("source file");
fs::write(ignored.join("index.js"), "module.exports = {}").expect("ignored file");
fs::write(root.join(".gitignore"), "node_modules/\n").expect(".gitignore");

let files = scan_files_for_mentions(vec![root.to_string_lossy().to_string()], Some(50));

let joined = files.join("\n");
assert!(joined.contains("main.ts"), "should include source files");
assert!(
!joined.contains("node_modules"),
"should respect .gitignore"
);
}

#[test]
fn skips_hidden_files() {
let dir = git_tempdir();
let root = dir.path();

fs::write(root.join("visible.ts"), "").expect("visible file");
fs::write(root.join(".hidden"), "").expect("hidden file");

let files = scan_files_for_mentions(vec![root.to_string_lossy().to_string()], Some(50));

let joined = files.join("\n");
assert!(joined.contains("visible.ts"));
assert!(!joined.contains(".hidden"));
}
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ pub fn run() {
commands::system::get_home_dir,
commands::system::save_exported_session_file,
commands::system::path_exists,
commands::system::list_files_for_mentions,
])
.setup(|_app| Ok(()))
.build(tauri::generate_context!())
Expand Down
Loading
Loading