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:
1 parent
ca677a8bc1
commit
00aceecf70
7 files changed
+317
-15
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user