diff --git a/local/recipes/tui/tlc/source/src/filemanager/cmdline.rs b/local/recipes/tui/tlc/source/src/filemanager/cmdline.rs index 500b1ac01e..0860c397b9 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/cmdline.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/cmdline.rs @@ -53,6 +53,10 @@ pub struct Cmdline { pub message: Option, /// 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, } 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 = 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 = 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); + } }