diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7197583..97ecef4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
+## [1.4.0] - 2026-11-03
+
+### Added
+- **Browse by folder.** A Folders view next to grid and list shows what was found one folder at a time, like a file manager: subfolders first with their file count and size, then the files, under a clickable breadcrumb. The sidebar folder tree navigates in this view, and filters still apply.
+- **Recover whole folders or parts of them.** Folders and files are ticked with tri-state boxes that persist while navigating; a ticked folder takes everything inside it, and unticked subfolders or files stay out. Each ticked folder is recreated in the destination from itself down (`Documents/Clients/Project` → `Project/...`), with the usual hash manifest.
+
## [1.3.5] - 2026-10-31
### Added
diff --git a/CHANGELOG.pt-BR.md b/CHANGELOG.pt-BR.md
index 3d6763b..cd00724 100644
--- a/CHANGELOG.pt-BR.md
+++ b/CHANGELOG.pt-BR.md
@@ -4,6 +4,12 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
+## [1.4.0] - 2026-11-03
+
+### Adicionado
+- **Navegação por pastas.** Uma visualização Pastas, ao lado de grade e lista, mostra o que foi encontrado uma pasta por vez, como num gerenciador de arquivos: primeiro as subpastas, com número de arquivos e tamanho, depois os arquivos, sob um caminho clicável. A árvore de pastas da barra lateral navega nessa visualização, e os filtros continuam valendo.
+- **Recuperar pastas inteiras ou partes delas.** Pastas e arquivos são marcados por caixas de três estados que continuam ao navegar; uma pasta marcada leva tudo o que está dentro dela, e subpastas ou arquivos desmarcados ficam de fora. Cada pasta marcada é recriada no destino a partir dela mesma (`Documentos/Clientes/Projeto` → `Projeto/...`), com o manifesto de hashes de sempre.
+
## [1.3.5] - 2026-10-31
### Adicionado
diff --git a/Cargo.lock b/Cargo.lock
index fbc5d6a..8a33440 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -416,7 +416,7 @@ dependencies = [
[[package]]
name = "guara-cli"
-version = "1.3.5"
+version = "1.4.0"
dependencies = [
"clap",
"ctrlc",
@@ -429,7 +429,7 @@ dependencies = [
[[package]]
name = "guara-core"
-version = "1.3.5"
+version = "1.4.0"
dependencies = [
"aes",
"argon2",
diff --git a/Cargo.toml b/Cargo.toml
index e75668c..e462cd9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package]
-version = "1.3.5"
+version = "1.4.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
diff --git a/README.md b/README.md
index 5a90f3c..80e23f3 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,10 @@ A desktop app (Tauri) in the style of a file manager, designed for when Guará f
- **Thumbnail grid** or **list**, grouping by day or month and virtual scrolling for tens of thousands of files.
+- **Folder browsing:** open what was found one folder at a time, like a file manager, and recover whole folders or only some subfolders and files, recreated from the ticked folder down.
+
+
+
- **Sidebar navigation** by type, real photo date (EXIF), folder, camera, confidence and GPS, with an option to hide duplicates.
- **Finder or Explorer style selection** and full-screen **quick look** (Space, Enter or double click).
- **Details panel** with preview, dates and metadata.
diff --git a/README.pt-BR.md b/README.pt-BR.md
index 19fc1f0..df37f82 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -77,6 +77,10 @@ App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando
- **Grade de miniaturas** ou **lista**, agrupamento por dia ou mês e rolagem virtual para dezenas de milhares de arquivos.
+- **Navegação por pastas:** abra o que foi encontrado uma pasta por vez, como num gerenciador de arquivos, e recupere pastas inteiras ou só algumas subpastas e arquivos, recriados a partir da pasta marcada.
+
+
+
- **Navegação lateral** por tipo, data real da foto (EXIF), pasta, câmera, confiança e GPS, com opção de ocultar duplicadas.
- **Seleção como no Finder ou Explorer** e **visualização rápida** em tela cheia (Espaço, Enter ou duplo clique).
- **Painel de detalhes** com pré-visualização, datas e metadados.
diff --git a/apps/gui/src-tauri/Cargo.lock b/apps/gui/src-tauri/Cargo.lock
index 28a5186..e9ec621 100644
--- a/apps/gui/src-tauri/Cargo.lock
+++ b/apps/gui/src-tauri/Cargo.lock
@@ -1342,7 +1342,7 @@ dependencies = [
[[package]]
name = "guara-core"
-version = "1.3.5"
+version = "1.4.0"
dependencies = [
"aes",
"argon2",
@@ -1373,7 +1373,7 @@ dependencies = [
[[package]]
name = "guara-gui"
-version = "1.3.5"
+version = "1.4.0"
dependencies = [
"guara-core",
"libc",
diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml
index 4598033..96ea3eb 100644
--- a/apps/gui/src-tauri/Cargo.toml
+++ b/apps/gui/src-tauri/Cargo.toml
@@ -3,7 +3,7 @@
[package]
name = "guara-gui"
description = "Guará: graphical file recovery interface"
-version = "1.3.5"
+version = "1.4.0"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["Guará contributors"]
diff --git a/apps/gui/src-tauri/tauri.conf.json b/apps/gui/src-tauri/tauri.conf.json
index d28711b..b5cb1cf 100644
--- a/apps/gui/src-tauri/tauri.conf.json
+++ b/apps/gui/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Guará",
- "version": "1.3.5",
+ "version": "1.4.0",
"identifier": "dev.guara.app",
"build": {
"frontendDist": "../ui"
diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css
index 88fc6ce..544be89 100644
--- a/apps/gui/ui/app.css
+++ b/apps/gui/ui/app.css
@@ -422,6 +422,21 @@ dialog.lightbox[open] { display: grid; }
.xp-insp .viewer { max-height: 38vh; min-height: 150px; }
.xp-ititle { display: flex; gap: 8px; align-items: flex-start; justify-content: space-between; } .xp-ititle b { overflow-wrap: anywhere; font-size: 15px; }
.xp-meta h5 { margin: 6px 0 6px; font-size: 12px; color: var(--text-3); font-weight: 600; }
+/* folder browsing */
+.xp-path { display: flex; align-items: center; gap: 2px; min-width: 0; overflow: hidden; }
+.xp-cpart { border: 0; background: none; color: var(--text-2); font: inherit; font-size: 14px; padding: 4px 6px; border-radius: 6px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
+.xp-cpart:hover { background: var(--surface-2); color: var(--text); }
+.xp-cpart:last-child { color: var(--text); font-weight: 600; }
+.xp-csep { color: var(--text-3); }
+.xp-lth .xp-fico { color: var(--accent); background: var(--accent-soft); border-radius: 8px; }
+.xp-dir .xp-lnm b { font-weight: 600; }
+.xp-dir[data-state="mixed"] .xp-chk { opacity: 1; background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round'%3E%3Cpath d='M7 12h10'/%3E%3C/svg%3E") center / 14px no-repeat; }
+.xp-lrow[data-cur="1"] { box-shadow: inset 0 0 0 2px var(--accent); }
+.xp-folder-insp { display: grid; gap: 14px; padding: 20px 4px; }
+.xp-folder-insp h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
+.xp-fbig { width: 64px; height: 64px; display: grid; place-items: center; border-radius: 16px; color: var(--accent); background: var(--accent-soft); }
+.xp-fbig svg { width: 34px; height: 34px; }
+.xp-folder-insp .row { display: flex; gap: 8px; flex-wrap: wrap; }
.xp-multi { display: grid; gap: 12px; padding: 20px 4px; text-align: center; } .xp-multi b { font-size: 18px; }
.xp-insp-x { display: none; align-self: flex-end; }
.xp-veil { display: none; }
diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js
index 7ec58f7..201dd96 100644
--- a/apps/gui/ui/app.js
+++ b/apps/gui/ui/app.js
@@ -687,19 +687,25 @@ async function onProgressClick(e) {
}
async function recover(sel) {
+ // From folder browsing: each marked folder is recreated from itself down
+ // (a/b/Projeto -> Projeto/...), so there is no layout to choose.
+ const bySelection = !!sel.selection;
const f = await askForm({
title: 'Recuperar para…',
- text: 'Escolha uma pasta em outro disco. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.',
+ text: 'Escolha uma pasta em outro disco. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.'
+ + (bySelection ? ' Cada pasta marcada é recriada no destino a partir dela mesma, com todas as subpastas. Arquivos marcados soltos vão para a raiz do destino.' : '')
+ + (bySelection && sel.filtered ? ' Atenção: pastas marcadas são recuperadas inteiras; os filtros ativos não se aplicam a elas.' : ''),
ok: 'Recuperar',
fields: [
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
- { name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] },
+ ...(bySelection ? [] : [{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] }]),
],
});
if (!f || !f.dest) return;
const dest = f.dest;
+ const { filtered, ...target } = sel;
try {
- const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout, ...sel });
+ const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout || 'folders', ...target });
watchAux(job, 'Recuperando arquivos', (st) => {
const r = st.report || {};
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
diff --git a/apps/gui/ui/explorer.js b/apps/gui/ui/explorer.js
index c64093f..5fef63d 100644
--- a/apps/gui/ui/explorer.js
+++ b/apps/gui/ui/explorer.js
@@ -4,7 +4,7 @@
// fullscreen quick look.
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
-import { LOC } from './i18n.js';
+import { LOC, tr } from './i18n.js';
const CAT = {
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
@@ -28,6 +28,8 @@ const I = {
pin: svg('', 14),
warn: svg('', 22),
close: svg('', 20),
+ folder: svg(''),
+ folderBig: svg('', 22),
};
export function createExplorer(deps) {
@@ -62,6 +64,14 @@ export function createExplorer(deps) {
lastFound: -1,
wasEnriching: false,
status: null,
+ // folder browsing: current folder ('' = root, '@carved' = found by
+ // signature), its subfolders, and a selection of its own that keeps
+ // marked and unmarked folders and files across levels
+ path: '',
+ dirs: [],
+ curDir: null,
+ fs: { folders: new Set(), xfolders: new Set(), files: new Map(), xfiles: new Map() },
+ fsCount: null,
};
try {
E.view = localStorage.getItem('guara-view') || 'grid';
@@ -74,7 +84,7 @@ export function createExplorer(deps) {
-
+
@@ -112,7 +122,10 @@ export function createExplorer(deps) {
};
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
+ const folderMode = () => E.view === 'folders';
+
async function reload() {
+ if (folderMode()) return reloadFolder();
const token = ++E.reloadToken;
E.lastReload = Date.now();
const base = { ...toQuery(), sort: E.sort };
@@ -132,6 +145,29 @@ export function createExplorer(deps) {
paintAll();
}
+ async function reloadFolder() {
+ const token = ++E.reloadToken;
+ E.lastReload = Date.now();
+ const { folder, ...filters } = toQuery();
+ const base = { ...filters, sort: E.sort === 'date' || E.sort === 'date_asc' ? E.sort : E.sort || 'name' };
+ let items = [], total = 0, dirs = null;
+ for (;;) {
+ let r;
+ try { r = await rpc('browse', { job, path: E.path, query: { ...base, offset: items.length, limit: 2000 } }); } catch (e) { toast(e.message); return; }
+ if (token !== E.reloadToken) return;
+ if (!dirs) dirs = r.folders;
+ items = items.concat(r.files);
+ total = r.total_files;
+ if (!r.files.length || items.length >= total || items.length >= 30000) break;
+ }
+ E.dirs = dirs || [];
+ E.items = items;
+ E.total = total;
+ paintCrumb();
+ relayout();
+ paintAll();
+ }
+
async function loadFacets() {
try { E.facets = await rpc('facets', { job }); } catch { return; }
paintNav();
@@ -157,7 +193,7 @@ export function createExplorer(deps) {
function geometry() {
const W = Math.max(200, scroll.clientWidth - PAD * 2);
- if (E.view === 'list') return { W, cols: 1, tw: W, cap: 0, rowH: 54 };
+ if (E.view === 'list' || E.view === 'folders') return { W, cols: 1, tw: W, cap: 0, rowH: 54 };
const T = E.tile;
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
const tw = Math.floor((W - GAP * (cols - 1)) / cols);
@@ -176,6 +212,9 @@ export function createExplorer(deps) {
}
const rows = [];
let y = PAD;
+ if (folderMode()) {
+ E.dirs.forEach((_, d) => { rows.push({ t: 'dir', y, h: G.rowH, d }); y += G.rowH; });
+ }
let cur = null, rowItems = [];
const flush = () => {
if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; }
@@ -238,7 +277,7 @@ export function createExplorer(deps) {
el.style.top = `${row.y}px`;
el.style.height = `${row.h}px`;
el.dataset.ri = ri;
- el.innerHTML = row.t === 'head' ? headHtml(row) : row.idx.map((i) => (E.view === 'list' ? listHtml(i) : tileHtml(i))).join('');
+ el.innerHTML = row.t === 'head' ? headHtml(row) : row.t === 'dir' ? dirHtml(row.d) : row.idx.map((i) => (E.view === 'grid' ? tileHtml(i) : listHtml(i))).join('');
E.rendered.set(ri, el);
frag.appendChild(el);
}
@@ -246,7 +285,82 @@ export function createExplorer(deps) {
}
// ------------------------------------------------------------ items
- const isSel = (id) => E.allFiltered || E.sel.has(id);
+ // `p` is inside `d` (or is `d`); '' holds everything, '@carved' only itself.
+ const under = (p, d) => d === '' || p === d || (d !== '@carved' && p.startsWith(d + '/'));
+ // Deepest marked/unmarked folder holding `p` decides, as in the core.
+ const covered = (p) => {
+ let best = null;
+ for (const d of E.fs.folders) if (under(p, d) && (best == null || d.length > best[0].length)) best = [d, true];
+ for (const d of E.fs.xfolders) if (under(p, d) && (best == null || d.length >= best[0].length)) best = [d, false];
+ return !!best && best[1];
+ };
+ const dirOf = (it) => (it.origin === 'carved' ? '@carved' : (it.dir || '').replace(/^\/+|\/+$/g, ''));
+ const fileOn = (it) => !E.fs.xfiles.has(it.id) && (E.fs.files.has(it.id) || covered(dirOf(it)));
+ // 'on', 'off' or 'mixed' for a folder row
+ const dirState = (d) => {
+ const on = covered(d);
+ const inside = (p) => p !== d && under(p, d);
+ const marks = on
+ ? [...E.fs.xfolders].some(inside) || [...E.fs.xfiles.values()].some((p) => under(p, d)) || [...E.fs.folders].some(inside)
+ : [...E.fs.folders].some(inside) || [...E.fs.files.values()].some((p) => under(p, d));
+ return marks ? 'mixed' : on ? 'on' : 'off';
+ };
+ const fsEmpty = () => !E.fs.folders.size && !E.fs.files.size;
+ const dropInside = (d) => {
+ for (const k of [...E.fs.folders]) if (under(k, d)) E.fs.folders.delete(k);
+ for (const k of [...E.fs.xfolders]) if (under(k, d)) E.fs.xfolders.delete(k);
+ for (const [id, p] of [...E.fs.files]) if (under(p, d)) E.fs.files.delete(id);
+ for (const [id, p] of [...E.fs.xfiles]) if (under(p, d)) E.fs.xfiles.delete(id);
+ };
+ // Like a file manager's tri-state box: a fully marked folder is unmarked;
+ // an unmarked or partly marked one becomes fully marked.
+ function toggleDir(d) {
+ const unmark = dirState(d) === 'on';
+ dropInside(d);
+ if (unmark) { if (covered(d)) E.fs.xfolders.add(d); } else if (!covered(d)) E.fs.folders.add(d);
+ fsChanged();
+ }
+ function toggleFile(it) {
+ const p = dirOf(it);
+ if (fileOn(it)) { E.fs.files.delete(it.id); if (covered(p)) E.fs.xfiles.set(it.id, p); } else { E.fs.xfiles.delete(it.id); if (!covered(p)) E.fs.files.set(it.id, p); }
+ fsChanged();
+ }
+ const fsSelection = () => ({
+ folders: [...E.fs.folders], ids: [...E.fs.files.keys()], exclude: [...E.fs.xfiles.keys()], exclude_folders: [...E.fs.xfolders],
+ });
+ let countT;
+ function fsChanged() {
+ paintSel();
+ clearTimeout(countT);
+ if (fsEmpty()) { E.fsCount = null; paintActions(); return; }
+ countT = setTimeout(async () => {
+ try { E.fsCount = await rpc('selection_count', { job, selection: fsSelection() }); } catch { E.fsCount = null; }
+ paintActions();
+ }, 120);
+ }
+ function goTo(path) {
+ E.path = path; E.curDir = null; E.curId = null;
+ scroll.scrollTop = 0;
+ paintInspector();
+ reload();
+ }
+ const parentOf = (p) => (p === '@carved' || !p.includes('/') ? '' : p.slice(0, p.lastIndexOf('/')));
+ const dirLabel = (p) => (p === '' ? 'Raiz' : p === '@carved' ? 'Por assinatura' : p.split('/').pop());
+
+ function dirHtml(d) {
+ const it = E.dirs[d];
+ const st = dirState(it.path);
+ return `