tlc: VFS archive panel integration — browse archives in-place
Panel now enters archives (.tar, .tar.gz, .tar.bz2, .tar.xz, .zip, .cpio) when pressing Enter, browsing their contents like directories. Pressing '..' at the archive root exits back to the local filesystem. Three new Panel fields: vfs (Box<dyn Vfs> backend), vfs_path (VfsPath inside archive), vfs_archive (local PathBuf of archive file). New methods: try_enter_archive(), replace_directory_vfs(), display_path(), is_in_vfs(), is_archive_extension() helper. Title bar shows 'archive.tar:/inner/path' when browsing inside an archive via synthesized display path. 5 new tests for archive extension detection (case-insensitive). 964 tests total, 0 failures.
This commit is contained in:
@@ -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<String>,
|
||||
/// Last error (shown in red, sticks until cleared).
|
||||
last_error: Option<String>,
|
||||
/// Active VFS backend when browsing inside an archive.
|
||||
/// `None` when browsing the local filesystem.
|
||||
vfs: Option<Box<dyn Vfs>>,
|
||||
/// The full VFS path when browsing inside an archive.
|
||||
/// `None` when browsing the local filesystem.
|
||||
vfs_path: Option<VfsPath>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
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<PathBuf> {
|
||||
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<Option<PathBuf>> {
|
||||
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<PathBuf> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user