feat(ui): browse and recover by folder

A Folders mode next to grid and list shows one level at a time: subfolders
first with their file count and size, then the files, under a clickable
breadcrumb. Folders and files are marked with tri-state boxes that persist
while navigating; the bar shows the exact total from the core, and the
selection is recovered relative to each marked folder. The sidebar folder
tree navigates in this mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5.5 committed 2026-11-03 13:38:33 -03:00
1 parent ca677a8bc1
commit 00aceecf70
7 files changed
+317 -15

No files matched your search

+55 -5
View File
@@ -242,7 +242,7 @@ pub fn browse(found: &[Found], path: &str, q: &Query) -> Value {
/// What the user marked in folder browsing: whole folders (recursive; `""` is
/// everything, `"@carved"` the files found by signature), single files, and
/// files unmarked inside a marked folder.
/// files or subfolders unmarked inside a marked folder.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Selection {
#[serde(default)]
@@ -251,6 +251,8 @@ pub struct Selection {
pub ids: Vec<u64>,
#[serde(default)]
pub exclude: Vec<u64>,
#[serde(default)]
pub exclude_folders: Vec<String>,
}
/// Whether `f` is inside the browsing folder `folder` (recursively).
@@ -265,19 +267,43 @@ pub fn in_folder(f: &Found, folder: &str) -> bool {
}
}
/// Files a selection resolves to, in discovery order. Marked folders take
/// everything inside them, whatever filters the view had; an excluded id
/// always wins.
/// Files a selection resolves to, in discovery order. For each file the
/// deepest marked or unmarked folder holding it decides; marked folders take
/// everything inside them, whatever filters the view had. A file marked on its
/// own is in, and an excluded id always wins.
pub fn resolve_selection<'a>(found: &'a [Found], sel: &Selection) -> Vec<&'a Found> {
let ids: std::collections::HashSet<u64> = sel.ids.iter().copied().collect();
let exclude: std::collections::HashSet<u64> = sel.exclude.iter().copied().collect();
let marks: Vec<(&str, bool)> = sel
.folders
.iter()
.map(|d| (d.trim_matches('/'), true))
.chain(
sel.exclude_folders
.iter()
.map(|d| (d.trim_matches('/'), false)),
)
.collect();
found
.iter()
.filter(|f| !exclude.contains(&f.id))
.filter(|f| ids.contains(&f.id) || sel.folders.iter().any(|d| in_folder(f, d)))
.filter(|f| {
ids.contains(&f.id)
|| marks
.iter()
.filter(|(d, _)| in_folder(f, d))
.max_by_key(|(d, included)| (d.len(), !included))
.is_some_and(|(_, included)| *included)
})
.collect()
}
/// Files and bytes a selection resolves to.
pub fn selection_count(found: &[Found], sel: &Selection) -> Value {
let files = resolve_selection(found, sel);
json!({ "files": files.len(), "bytes": files.iter().map(|f| f.size).sum::<u64>() })
}
/// Facet counts for sidebar navigation.
pub fn facets(found: &[Found]) -> Value {
let mut cats: HashMap<&'static str, usize> = HashMap::new();
@@ -581,6 +607,7 @@ mod tests {
folders: vec!["DCIM/100CANON".into()],
ids: vec![3],
exclude: vec![],
exclude_folders: vec![],
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1, 3, 5]);
let all = Selection {
@@ -597,10 +624,33 @@ mod tests {
folders: vec!["DCIM".into()],
ids: vec![5],
exclude: vec![5, 2],
exclude_folders: vec![],
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1]);
}
#[test]
fn excluded_subfolder_is_left_out() {
let found = sample();
let sel = Selection {
folders: vec!["DCIM".into()],
exclude_folders: vec!["DCIM/100CANON".into()],
..Selection::default()
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![2]);
// a folder marked again inside the excluded one comes back
let sel = Selection {
folders: vec!["".into(), "DCIM/100CANON".into()],
exclude_folders: vec!["DCIM".into()],
..Selection::default()
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1, 3, 4, 5]);
assert_eq!(
selection_count(&found, &sel),
json!({ "files": 4, "bytes": 113 })
);
}
#[test]
fn selection_carved_folder() {
let found = sample();
+11
View File
@@ -1262,6 +1262,17 @@ impl Engine {
let path = p.get("path").and_then(|v| v.as_str()).unwrap_or("");
self.browse(&s("job")?, path, &q)?
}
"selection_count" => {
let sel: crate::explorer::Selection = p
.get("selection")
.cloned()
.map(serde_json::from_value)
.transpose()?
.unwrap_or_default();
let j = self.job(&s("job")?)?;
let st = j.st.lock().unwrap();
crate::explorer::selection_count(&st.found, &sel)
}
"results" => {
let q: Query = p
.get("query")
+1
View File
@@ -783,6 +783,7 @@ fn navegacao_por_pastas_e_recuperacao_da_selecao() {
folders: vec!["Documentos/Trabalho".into()],
ids: vec![id_of("_raia.jpg")],
exclude: vec![id_of("tese.docx")],
exclude_folders: vec![],
};
let out = tempfile::tempdir().unwrap();
let rj = eng