Add TAB autocompletion for /project command

- Complete project names from ~/projects/ directory
- Display shows project name, replacement uses ~/projects/<name> path
- Projects sorted alphabetically
- Added test for project completion
This commit is contained in:
Dhanji R. Prasanna
2026-01-27 12:43:24 +11:00
parent cdb8b0f5eb
commit 8389b0d652

View File

@@ -5,6 +5,7 @@
//! - Command completion for `/` commands at line start
//! - File path completion for `./`, `../`, `~/`, `/` prefixes
//! - Session ID completion for `/resume` command
//! - Project name completion for `/project` command (from ~/projects/)
use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::error::ReadlineError;
@@ -153,6 +154,33 @@ impl G3Helper {
sessions
}
/// List project directories from ~/projects/, sorted alphabetically.
fn list_projects(&self, prefix: &str) -> Vec<String> {
let projects_dir = match dirs::home_dir() {
Some(home) => home.join("projects"),
None => return Vec::new(),
};
if !projects_dir.is_dir() {
return Vec::new();
}
let mut projects: Vec<String> = std::fs::read_dir(&projects_dir)
.ok()
.map(|entries| {
entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| Some(entry.file_name().to_string_lossy().to_string()))
.filter(|name| name.starts_with(prefix))
.collect()
})
.unwrap_or_default();
projects.sort();
projects
}
}
impl Default for G3Helper {
@@ -261,6 +289,23 @@ impl Completer for G3Helper {
return Ok((word_start, matches));
}
// Case 5: Project name completion for /project command
if line_to_cursor.starts_with("/project ") {
let partial = word;
let projects = self.list_projects(partial);
let matches: Vec<Pair> = projects
.into_iter()
.map(|name| {
let full_path = format!("~/projects/{}", name);
Pair {
display: name,
replacement: full_path,
}
})
.collect();
return Ok((word_start, matches));
}
// No completion for regular text
Ok((pos, vec![]))
}
@@ -502,4 +547,36 @@ mod tests {
let sessions = helper.list_sessions(None);
let _ = sessions; // Just verify no panic
}
#[test]
fn test_project_completion_lists_projects() {
let helper = G3Helper::new();
let history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&history);
let line = "/project ";
let pos = line.len();
let (start, completions) = helper.complete(line, pos, &ctx).unwrap();
let _ = start;
// If ~/projects exists and has directories, we should get completions
if let Some(home) = dirs::home_dir() {
let projects_dir = home.join("projects");
if projects_dir.is_dir() {
// Verify completions have the right format (display is name, replacement is ~/projects/name)
for completion in &completions {
assert!(completion.replacement.starts_with("~/projects/"),
"Replacement should start with ~/projects/, got: {}", completion.replacement);
assert!(!completion.display.contains('/'),
"Display should be just the project name, got: {}", completion.display);
}
}
}
// Test with a prefix that won't match anything
let line = "/project zzz_nonexistent_prefix_";
let pos = line.len();
let (_, completions) = helper.complete(line, pos, &ctx).unwrap();
assert_eq!(completions.len(), 0, "Non-matching prefix should return empty");
}
}