diff --git a/local/recipes/tui/tlc/source/src/filemanager/panel.rs b/local/recipes/tui/tlc/source/src/filemanager/panel.rs index ccd918b69b..dc26432b2b 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/panel.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/panel.rs @@ -13,6 +13,8 @@ use anyhow::Result; use crate::config::FilemanagerConfig; use crate::key::Key; use crate::vfs::local::{read_dir, Entry}; +use crate::vfs::path::VfsPath; +use crate::vfs::traits::Vfs; /// How many entries to scroll on PageUp / PageDown. const PAGE_STEP: usize = 10; @@ -121,6 +123,16 @@ pub struct Panel { message: Option, /// Last error (shown in red, sticks until cleared). last_error: Option, + /// Active VFS backend when browsing inside an archive. + /// `None` when browsing the local filesystem. + vfs: Option>, + /// The full VFS path when browsing inside an archive. + /// `None` when browsing the local filesystem. + vfs_path: Option, + /// Local path of the archive that is currently being browsed. + /// `None` when browsing the local filesystem. Used to exit the + /// archive back to the local parent directory. + vfs_archive: Option, } impl Panel { @@ -143,6 +155,9 @@ impl Panel { history_depth: cfg.history_depth.max(1), message: None, last_error: None, + vfs: None, + vfs_path: None, + vfs_archive: None, }; p.read_directory(&path)?; Ok(p) @@ -154,6 +169,35 @@ impl Panel { &self.path } + /// Human-readable path for the panel header. When browsing an + /// archive, returns the `archive.tar:/inner/path` form. When + /// browsing the local filesystem, returns the local path. + #[must_use] + pub fn display_path(&self) -> String { + if let (Some(_vfs), Some(vp), Some(archive)) = + (self.vfs.as_deref(), self.vfs_path.as_ref(), self.vfs_archive.as_ref()) + { + let archive_name = archive + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| vp.scheme().to_string()); + let inner = vp.as_path().to_string_lossy(); + if inner == "/" || inner.is_empty() { + format!("{archive_name}:/") + } else { + format!("{archive_name}:{inner}") + } + } else { + self.path.display().to_string() + } + } + + /// True if the panel is currently browsing inside a VFS archive. + #[must_use] + pub fn is_in_vfs(&self) -> bool { + self.vfs.is_some() + } + /// Number of entries. #[must_use] pub fn len(&self) -> usize { @@ -484,12 +528,30 @@ impl Panel { /// Go into the directory under the cursor. Returns Ok(new_path) on /// success. On error, sets `last_error` and leaves the panel unchanged. pub fn enter(&mut self) -> Result { - let Some(entry) = self.entries.get(self.cursor) else { + let Some(entry) = self.entries.get(self.cursor).cloned() else { return Ok(self.path.clone()); }; if entry.name == ".." { return self.parent(); } + + if self.vfs.is_none() { + let target = self.path.join(&entry.name); + if let Some(target_path) = self.try_enter_archive(&target, &entry.name)? { + return Ok(target_path); + } + } + + if let (Some(_vfs), Some(vp)) = (self.vfs.as_deref(), self.vfs_path.as_ref()) { + if entry.is_dir() { + let new_vp = vp.join(&entry.name); + self.replace_directory_vfs(&new_vp)?; + return Ok(self.path.clone()); + } + self.last_error = Some(format!("not a directory: {}", entry.name)); + return Ok(self.path.clone()); + } + let target = self.path.join(&entry.name); let s = crate::fs::stat(&target)?; if s.is_dir() { @@ -501,8 +563,56 @@ impl Panel { } } + /// Attempt to open `target` as a VFS archive. If successful, install + /// the VFS backend and re-read the directory at the archive root. + /// Returns `Ok(Some(target))` when the archive was entered, + /// `Ok(None)` when `target` is not an archive, or an error when the + /// archive exists but cannot be opened. + fn try_enter_archive(&mut self, target: &Path, name: &str) -> Result> { + let Some(_scheme) = is_archive_extension(name) else { + return Ok(None); + }; + let vfs_path = match VfsPath::parse(&target.to_string_lossy()) { + Ok(p) => p, + Err(_) => return Ok(None), + }; + match crate::vfs::for_path(&vfs_path) { + Ok(Some(backend)) => { + self.vfs = Some(backend); + self.vfs_path = Some(vfs_path); + self.vfs_archive = Some(target.to_path_buf()); + let vp = self + .vfs_path + .clone() + .ok_or_else(|| anyhow::anyhow!("vfs_path missing after entering archive"))?; + self.replace_directory_vfs(&vp)?; + Ok(Some(target.to_path_buf())) + } + Ok(None) => Ok(None), + Err(e) => { + self.last_error = Some(format!("cannot open archive: {e}")); + Ok(None) + } + } + } + /// Go to the parent directory. pub fn parent(&mut self) -> Result { + if let (Some(vp), Some(archive)) = (self.vfs_path.as_ref(), self.vfs_archive.as_ref()) { + let inner = vp.as_path(); + let at_root = inner == Path::new("/") || inner.as_os_str().is_empty(); + if at_root { + let target = archive.parent().map_or_else(|| archive.clone(), |p| p.to_path_buf()); + self.vfs = None; + self.vfs_path = None; + self.vfs_archive = None; + self.read_directory(&target)?; + return Ok(target); + } + let parent_vp = vp.parent(); + self.replace_directory_vfs(&parent_vp)?; + return Ok(self.path.clone()); + } let Some(p) = self.path.parent().map(Path::to_path_buf) else { return Ok(self.path.clone()); }; @@ -600,6 +710,75 @@ impl Panel { Ok(()) } + /// Replace the directory contents for a VFS path, dispatching to the + /// active VFS backend. Inserts the `..` entry when the VFS path is + /// not the archive root. + fn replace_directory_vfs(&mut self, vp: &VfsPath) -> Result<()> { + let vfs = self + .vfs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("replace_directory_vfs called with no vfs backend"))?; + let mut entries = Vec::new(); + let inner = vp.as_path(); + let at_root = inner == Path::new("/") || inner.as_os_str().is_empty(); + if !at_root { + entries.push(Entry { + name: "..".to_string(), + stat: crate::fs::Stat { + file_type: crate::fs::FileType::Directory, + size: 0, + mtime: 0, + atime: 0, + ctime: 0, + permissions: crate::fs::Permissions::default(), + nlinks: 2, + uid: 0, + gid: 0, + inode: 0, + }, + }); + } + let mut kids = vfs + .read_dir(vp, self.show_hidden) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + entries.append(&mut kids); + self.entries = entries; + let has_parent = self + .entries + .first() + .is_some_and(|e| e.name == ".."); + self.cursor = if has_parent && self.entries.len() > 1 { + 1 + } else { + 0 + }; + self.top = 0; + self.path = self.synthesize_vfs_display(vp); + self.vfs_path = Some(vp.clone()); + self.unmark_all(); + self.sort_in_place(); + Ok(()) + } + + /// Build a display path of the form `archive.tar:/inner/path` for + /// VFS browsing. The synthesized path is used by `path()` so that + /// the panel title and other path display surfaces can show the + /// archive contents without touching the rendering layer. + fn synthesize_vfs_display(&self, vp: &VfsPath) -> PathBuf { + let archive_name = self + .vfs_archive + .as_ref() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| vp.scheme().to_string()); + let inner = vp.as_path().to_string_lossy(); + if inner == "/" || inner.is_empty() { + PathBuf::from(format!("{archive_name}:/")) + } else { + PathBuf::from(format!("{archive_name}:{inner}")) + } + } + fn sort_in_place(&mut self) { // Skip the synthetic ".." entry at index 0 (it must always be first). let (head, tail) = self.entries.split_at_mut(1); @@ -715,6 +894,26 @@ fn ext(name: &str) -> String { } } +/// Check if a filename has a recognised archive extension. Returns +/// the VFS scheme prefix that should be used to open the archive, or +/// `None` for non-archive files. +fn is_archive_extension(name: &str) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".tar") || lower.ends_with(".tar.gz") || lower.ends_with(".tgz") { + Some("tar") + } else if lower.ends_with(".tar.bz2") || lower.ends_with(".tbz") { + Some("tar") + } else if lower.ends_with(".tar.xz") || lower.ends_with(".txz") { + Some("tar") + } else if lower.ends_with(".zip") { + Some("zip") + } else if lower.ends_with(".cpio") { + Some("cpio") + } else { + None + } +} + /// A very small subset of Unix glob matching: `*` matches any string, /// `?` matches a single char, everything else matches literally. Used /// by `mark_pattern` only — TLC's filter UI is free-form text. @@ -889,4 +1088,43 @@ mod tests { assert_eq!(p.marked_count(), 1); let _ = fs::remove_dir_all(&dir); } + + #[test] + fn test_is_archive_extension_tar() { + assert_eq!(is_archive_extension("foo.tar"), Some("tar")); + assert_eq!(is_archive_extension("foo.tar.gz"), Some("tar")); + assert_eq!(is_archive_extension("foo.tgz"), Some("tar")); + assert_eq!(is_archive_extension("foo.tar.bz2"), Some("tar")); + assert_eq!(is_archive_extension("foo.tbz"), Some("tar")); + assert_eq!(is_archive_extension("foo.tar.xz"), Some("tar")); + assert_eq!(is_archive_extension("foo.txz"), Some("tar")); + } + + #[test] + fn test_is_archive_extension_zip() { + assert_eq!(is_archive_extension("foo.zip"), Some("zip")); + assert_eq!(is_archive_extension("archive.zip"), Some("zip")); + } + + #[test] + fn test_is_archive_extension_cpio() { + assert_eq!(is_archive_extension("foo.cpio"), Some("cpio")); + } + + #[test] + fn test_is_archive_extension_none() { + assert_eq!(is_archive_extension("foo.txt"), None); + assert_eq!(is_archive_extension("foo.rs"), None); + assert_eq!(is_archive_extension("README"), None); + assert_eq!(is_archive_extension(""), None); + assert_eq!(is_archive_extension("foo.tardot"), None); + } + + #[test] + fn test_is_archive_extension_case_insensitive() { + assert_eq!(is_archive_extension("foo.TAR"), Some("tar")); + assert_eq!(is_archive_extension("foo.Tar.Gz"), Some("tar")); + assert_eq!(is_archive_extension("foo.ZIP"), Some("zip")); + assert_eq!(is_archive_extension("foo.Cpio"), Some("cpio")); + } }