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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user