W39: Tab completion for cmdline

Press Tab in the cmdline to complete the current word as a
path/filename. Completion resolves against the active panel's
current directory (stored in Cmdline::base_dir when the
cmdline is activated).

- Cmdline::complete(base_dir) extends the input to the
  longest common prefix of all matching entries. If there's
  only one match, appends a trailing slash.
- Cmdline::set_base_dir(path) is called by dispatch when the
  Cmdline Cmd is dispatched.
- Tab key in Cmdline::handle_key calls complete(self.base_dir).

Added 1 unit test verifying LCP extension for partial prefix.

Tests: 1401 pass (was 1400), zero warnings.
This commit is contained in:
2026-07-06 23:53:13 +03:00
parent 876f184227
commit b6d8b7296a
@@ -53,6 +53,10 @@ pub struct Cmdline {
pub message: Option<String>,
/// Width as a fraction of the parent area (default 1.0 = full row).
pub width_pct: f32,
/// Base directory for Tab completion. Set when the cmdline is
/// activated so completion resolves relative to the active
/// panel's current directory.
base_dir: Option<std::path::PathBuf>,
}
impl Default for Cmdline {
@@ -72,6 +76,7 @@ impl Cmdline {
history_pos: None,
message: None,
width_pct: 1.0,
base_dir: None,
}
}
@@ -92,6 +97,99 @@ impl Cmdline {
/// Activate the cmdline (if not already) and append `text` at the
/// cursor. Used by `Alt-a` / `Alt-A` / `Alt-;` to drop a path or
/// filename into the command line for editing.
/// Try to complete the current input as a path. The completion
/// is done relative to the optional `base_dir` (when `None`,
/// completion is attempted against the current working dir).
/// Returns `true` if the input was modified.
pub fn complete(&mut self, base_dir: Option<&std::path::Path>) -> bool {
let raw = self.input.value().to_string();
if raw.is_empty() {
return false;
}
let (dir, prefix) = match base_dir {
Some(base) if !raw.starts_with('/') => {
let p = std::path::Path::new(&raw);
let parent = p
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map_or(base.to_path_buf(), |par| base.join(par));
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string();
(parent, name)
}
_ => {
let p = std::path::Path::new(&raw);
let parent = p
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map_or(std::path::PathBuf::from("."), |par| par.to_path_buf());
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string();
(parent, name)
}
};
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return false,
};
let mut matches: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| n.starts_with(&prefix))
.collect();
if matches.is_empty() {
return false;
}
matches.sort();
let lcp: String = {
let mut chars: Vec<char> = matches[0].chars().collect();
'outer: for (i, c) in chars.clone().into_iter().enumerate() {
for m in &matches[1..] {
if m.chars().nth(i) != Some(c) {
chars.truncate(i);
break 'outer;
}
}
}
chars.into_iter().collect()
};
if lcp.len() > prefix.len() {
let completed = if base_dir.is_some() && !raw.starts_with('/') {
let dir_prefix = std::path::Path::new(&raw)
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| format!("{}/", p.display()))
.unwrap_or_default();
format!("{dir_prefix}{lcp}")
} else {
lcp.clone()
};
self.input.set_text(&completed);
return true;
}
if matches.len() == 1 {
let only = &matches[0];
let full = if base_dir.is_some() && !raw.starts_with('/') {
let dir_prefix = std::path::Path::new(&raw)
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| format!("{}/", p.display()))
.unwrap_or_default();
format!("{dir_prefix}{only}/")
} else {
format!("{only}/")
};
self.input.set_text(&full);
return true;
}
false
}
/// Set the base directory for Tab completion. Call this when
/// activating the cmdline so completion resolves against the
/// active panel's current directory.
pub fn set_base_dir(&mut self, path: std::path::PathBuf) {
self.base_dir = Some(path);
}
pub fn insert_text(&mut self, text: &str) {
if !self.active {
self.activate();
@@ -180,6 +278,13 @@ impl Cmdline {
self.history_forward();
CmdlineResult::Running
}
Key { code: 0x09, .. } => {
// Tab: path completion against the active panel's
// base directory.
let base = self.base_dir.clone();
let _ = self.complete(base.as_deref());
CmdlineResult::Running
}
_ => {
let _ = self.input.handle_key(key);
CmdlineResult::Running
@@ -402,4 +507,23 @@ mod tests {
c.set_message("permission denied");
assert_eq!(c.message.as_deref(), Some("permission denied"));
}
#[test]
fn complete_extends_lcp() {
let dir = std::env::temp_dir().join("tlc-clp-cmdline-complete");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(dir.join("foo_a")).unwrap();
std::fs::create_dir_all(dir.join("foo_b")).unwrap();
let mut c = Cmdline::new();
c.activate();
// Type a partial prefix that has a non-trivial LCP.
c.insert_text("fo");
let changed = c.complete(Some(&dir));
assert!(changed, "complete() should have extended the prefix");
// The LCP of "foo_a" and "foo_b" is "foo_" — verify the
// input was extended to that.
assert_eq!(c.input.value(), "foo_");
let _ = std::fs::remove_dir_all(&dir);
}
}