Skip to content

Commit 7e87341

Browse files
pmaxhoganclaude
andauthored
feat(ui): folder picker sort/filter/create/rename and exclusions size rollups (#315)
## Summary Closes #305, #306, #307 (v2.12.0 wave, PR3). - **#306 destination folder picker**: client-side sort (Name asc/desc, Modified asc/desc, default Name asc), type-to-filter (substring, current folder only, auto-focus on open, resets on navigation), backend pagination verified complete on all three backends, near-fullscreen sizing (92vw x 85vh capped 1400px) in the add-source wizard modal specifically on the destination-folder and exclusions steps. - **#307 create/rename**: "New folder" button on every browsable backend (Drive, S3, SFTP - reuses `ensure_folder`'s idempotent search-then-create), inline hover rename on Drive + SFTP (`RemoteStore::rename_folder`: Drive is a `files.update` name-only PATCH; SFTP does a real `RENAME` + moves the sidecar, and can mint a new id since SFTP ids are path-derived - the UI replaces the row by its old id with the backend's returned entry). S3 stays on the trait's unsupported default (`BackendKind::supports_rename` = false) and shows a disabled control with an explanatory tooltip instead of hiding the affordance outright. All actions surface errors inline, next to the control that failed - never an alert dialog. - **#305 exclusions rollups**: the backend preview walker now streams a per-directory file-count + byte-size rollup with each batch - a descended directory's rollup starts at 0 and settles upward as its subtree streams in (re-emitting the same row without duplicating it), a pruned excluded directory gets its final answer immediately via one lightweight recursive disk count (no classification). The summary line gained an excluded-bytes "would be freed" stat to match the existing included-bytes one. ## Visual baselines Not touched in this PR. Per the wave lead's sequencing note: `#311` (bottleneck tile) and `#313` (queue nav button, which changes all 104 full-page screenshots) land first; I'll rebase and run `just visual-update` in a follow-up commit on this branch afterward. The one baseline my changes affect is `setup-wizard.spec.ts`'s `step3-source.png` (light + dark) - the `SetupWizard` step 3 embeds the picker's new toolbar (sort/filter/new-folder button). ## Test plan - [x] `cargo test` across touched crates (`driven-remote`, `driven-drive`, `driven-sftp`, `driven-backend`, `driven-core`) and `src-tauri` - 447 src-tauri tests + all crate tests green, including new coverage for rollup settling/pruning, SFTP rename (real move + sidecar + id change + clobber refusal + same-name no-op), and the fake Drive store's rename. - [x] `cargo clippy --all-targets` clean (`driven-remote`/`-drive`/`-sftp`/ `-backend`/`-core` and `src-tauri`). - [x] `cargo fmt --check` clean. - [x] `pnpm run test:unit` - 806 vitest tests green (added ~30: picker sort/filter/create/rename, tree rollup rendering + fill sizing, store rollup settling). - [x] `vue-tsc --noEmit`, `eslint .`, `prettier --check` all clean. - [x] `just e2e-run wizard-first-run local-folder-round-trip` - both PASS against the real containerized app (boots into the setup wizard, renders the picker, completes a real backup/restore round trip). Did not run the full `just e2e` suite (expensive); these two scenarios are the ones that exercise the wizard/picker paths this PR touches. - [ ] Visual regression (`just visual-update`) - deferred per the baseline sequencing above. ## README Updated: the live-exclusion-preview bullet now mentions the per-folder size rollup, and a new bullet describes the picker's sort/filter/new-folder/rename behavior including the S3 rename caveat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019xKUm9vH4ifb5LHR5szy1v Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f951cde commit 7e87341

30 files changed

Lines changed: 2066 additions & 219 deletions

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ These move: check each project's current docs before relying on a cell.
122122
exclude rules, and a configurable symlink policy, and that skips excluded
123123
directories instead of descending them.
124124
- Live exclusion preview that re-classifies the folder tree as you edit a rule,
125-
from an in-memory tree rather than a fresh walk of the disk.
125+
from an in-memory tree rather than a fresh walk of the disk, with a
126+
per-folder file-count and size rollup so an exclude rule's actual weight -
127+
or what re-including it would cost - is visible before you commit to it.
126128
- Exclusion rules that take effect immediately, even mid-backup: saving a new
127129
rule stops the running backup from uploading anything it newly excludes at the
128130
next file boundary (the file already in flight still finishes cleanly), and
@@ -200,6 +202,11 @@ These move: check each project's current docs before relying on a cell.
200202
plain local / removable folder (USB drive, external disk, NAS share) - all
201203
behind one pluggable backend trait, so adding the next one is a new backend
202204
crate plus a factory arm rather than a fork of every call site.
205+
- Destination folder picker with client-side sort (name or last-modified) and
206+
type-to-filter, a "New folder" button on every browsable destination, and
207+
inline rename on Google Drive and SFTP (S3's key-prefix "folders" have no
208+
separate identity to rename, so that control is disabled there with an
209+
explanation instead of hidden outright).
203210
- Scheduled integrity scrub: on top of the local re-hash and the
204211
remote-existence audit above, a rolling background pass re-checks each
205212
already-backed-up object's size, and its content checksum where the

crates/driven-backend/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ pub struct BackendDescriptor {
128128
/// file, so a point-in-time restore returns the older bytes rather than
129129
/// today's (`BackendKind::supports_version_history`; issue #220).
130130
pub supports_version_history: bool,
131+
/// Whether the destination picker's inline rename affordance applies to
132+
/// this backend's folder rows (`BackendKind::supports_rename`; issue #307).
133+
pub supports_rename: bool,
131134
}
132135

133136
/// Every destination this build can construct, in picker order. The first entry
@@ -142,6 +145,7 @@ pub fn descriptors() -> Vec<BackendDescriptor> {
142145
uses_oauth: kind.uses_oauth(),
143146
supports_folder_picker: kind.supports_folder_picker(),
144147
supports_version_history: kind.supports_version_history(),
148+
supports_rename: kind.supports_rename(),
145149
})
146150
.collect()
147151
}
@@ -551,6 +555,7 @@ mod tests {
551555
desc.supports_version_history,
552556
kind.supports_version_history()
553557
);
558+
assert_eq!(desc.supports_rename, kind.supports_rename());
554559
}
555560
assert_eq!(d[0].kind, BackendKind::default());
556561
}

crates/driven-core/src/types.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,6 +1266,13 @@ pub enum ErrorCode {
12661266
/// (the identity marker, then the write/remove round trip), since the
12671267
/// remedy is identical either way.
12681268
SftpRootNotWritable,
1269+
/// `remote.rename_unsupported` - the destination picker's inline rename
1270+
/// (issue #307) was called against a backend whose `RemoteStore` has no
1271+
/// rename primitive (S3, whose "folders" are key prefixes with no
1272+
/// separate identity to rename). The picker UI hides the affordance for
1273+
/// such backends already (`BackendKind::supports_rename`); reaching this
1274+
/// code means a stale client called it anyway.
1275+
RemoteRenameUnsupported,
12691276
}
12701277

12711278
impl ErrorCode {
@@ -1324,6 +1331,7 @@ impl ErrorCode {
13241331
ErrorCode::SftpRootNotADirectory => "sftp.root_not_a_directory",
13251332
ErrorCode::SftpDestMarkerMismatch => "sftp.dest_marker_mismatch",
13261333
ErrorCode::SftpRootNotWritable => "sftp.root_not_writable",
1334+
ErrorCode::RemoteRenameUnsupported => "remote.rename_unsupported",
13271335
}
13281336
}
13291337

@@ -1386,6 +1394,7 @@ impl ErrorCode {
13861394
"sftp.root_not_a_directory" => ErrorCode::SftpRootNotADirectory,
13871395
"sftp.dest_marker_mismatch" => ErrorCode::SftpDestMarkerMismatch,
13881396
"sftp.root_not_writable" => ErrorCode::SftpRootNotWritable,
1397+
"remote.rename_unsupported" => ErrorCode::RemoteRenameUnsupported,
13891398
_ => return None,
13901399
})
13911400
}

crates/driven-drive/src/fake/mod.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,6 +1110,34 @@ impl RemoteStore for InMemoryRemoteStore {
11101110
.collect())
11111111
}
11121112

1113+
/// Renames a folder in place, mirroring `GoogleDriveStore::rename_folder`
1114+
/// (issue #307): only `name` changes, id and `parent_id` are untouched.
1115+
async fn rename_folder(
1116+
&self,
1117+
folder_id: &str,
1118+
new_name: &str,
1119+
drive_context: &DriveContext,
1120+
) -> anyhow::Result<RemoteEntry> {
1121+
self.record_context(drive_context);
1122+
self.check_request_faults(RequestKind::WriteTarget).await?;
1123+
let mut guard = self.inner.lock();
1124+
if !guard
1125+
.objects
1126+
.get(folder_id)
1127+
.is_some_and(FileEntry::is_folder)
1128+
{
1129+
anyhow::bail!("fake: no folder with file_id {folder_id}");
1130+
}
1131+
let new_now = guard.tick();
1132+
let entry = guard
1133+
.objects
1134+
.get_mut(folder_id)
1135+
.expect("presence just checked above");
1136+
entry.name = new_name.to_string();
1137+
entry.modified_time_ms = new_now;
1138+
Ok(entry.to_remote_entry())
1139+
}
1140+
11131141
async fn create(
11141142
&self,
11151143
parent_id: &str,

crates/driven-drive/src/google/mod.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,33 @@ impl RemoteStore for GoogleDriveStore {
11081108
self.list_query(&q, drive_context).await
11091109
}
11101110

1111+
/// Renames a folder via `PATCH /files/{id}` with just the `name` field -
1112+
/// the id and `parents` are untouched (issue #307). Unlike `create_folder`
1113+
/// this is a PATCH-by-id, which is idempotent, so it is safe to blind-retry
1114+
/// through the normal `send_json` path.
1115+
async fn rename_folder(
1116+
&self,
1117+
folder_id: &str,
1118+
new_name: &str,
1119+
_drive_context: &DriveContext,
1120+
) -> anyhow::Result<RemoteEntry> {
1121+
let body = json_body(&serde_json::json!({ "name": new_name }))?;
1122+
let file: DriveFile = self
1123+
.send_json(|token| {
1124+
self.http
1125+
.patch(format!("{DRIVE_API_BASE}/files/{folder_id}"))
1126+
.query(&[("fields", pagination::FILE_FIELDS), SUPPORTS_ALL_DRIVES])
1127+
.bearer_auth(token)
1128+
.header(reqwest::header::CONTENT_TYPE, "application/json")
1129+
.body(body.clone())
1130+
})
1131+
.await
1132+
// The target is `folder_id`; a 404 / unclassified 403 is a
1133+
// dest-folder condition, not a transient (mirrors `create_folder`).
1134+
.map_err(map_parent_write_error)?;
1135+
Ok(file.into_remote_entry())
1136+
}
1137+
11111138
async fn create(
11121139
&self,
11131140
parent_id: &str,

crates/driven-drive/tests/fake_contract.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,3 +1004,69 @@ async fn fake_with_fileid_recycle_reuses_trashed_id() {
10041004
"exactly one live object now holds the recycled id"
10051005
);
10061006
}
1007+
1008+
// ---------------------------------------------------------------------------
1009+
// rename_folder (issue #307 - the destination picker's inline rename).
1010+
// ---------------------------------------------------------------------------
1011+
1012+
#[tokio::test]
1013+
async fn fake_rename_folder_changes_only_the_name() {
1014+
let store = fake();
1015+
let root = store.root_id().to_string();
1016+
let folder = store
1017+
.ensure_folder(&root, "Old machines", &DriveContext::MyDrive)
1018+
.await
1019+
.expect("ensure_folder");
1020+
1021+
let renamed = store
1022+
.rename_folder(&folder.id, "Archive", &DriveContext::MyDrive)
1023+
.await
1024+
.expect("rename_folder");
1025+
1026+
assert_eq!(renamed.id, folder.id, "id is unchanged by a rename");
1027+
assert_eq!(renamed.name, "Archive");
1028+
assert_eq!(renamed.parents, folder.parents, "location is unchanged");
1029+
1030+
// ...and list_folder agrees.
1031+
let listing = store
1032+
.list_folder(&root, &DriveContext::MyDrive)
1033+
.await
1034+
.expect("list root");
1035+
let entry = listing
1036+
.iter()
1037+
.find(|e| e.id == folder.id)
1038+
.expect("the renamed folder is still listed under the same id");
1039+
assert_eq!(entry.name, "Archive");
1040+
}
1041+
1042+
#[tokio::test]
1043+
async fn fake_rename_folder_rejects_an_unknown_id() {
1044+
let store = fake();
1045+
let err = store
1046+
.rename_folder("does-not-exist", "New name", &DriveContext::MyDrive)
1047+
.await
1048+
.expect_err("renaming an unknown id must Err");
1049+
assert!(err.to_string().contains("does-not-exist"));
1050+
}
1051+
1052+
#[tokio::test]
1053+
async fn fake_rename_folder_refuses_to_rename_a_file() {
1054+
let store = fake();
1055+
let root = store.root_id().to_string();
1056+
let file = store
1057+
.create(
1058+
&root,
1059+
"a.txt",
1060+
"text/plain",
1061+
UploadBody::Bytes(Bytes::from_static(b"x")),
1062+
props(&[]),
1063+
)
1064+
.await
1065+
.expect("create");
1066+
1067+
let err = store
1068+
.rename_folder(&file.id, "b.txt", &DriveContext::MyDrive)
1069+
.await
1070+
.expect_err("renaming a FILE via rename_folder must Err");
1071+
assert!(err.to_string().contains(&file.id));
1072+
}

crates/driven-remote/src/backend.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,33 @@ impl BackendKind {
122122
}
123123
}
124124

125+
/// Whether this backend's destination picker can offer an inline RENAME
126+
/// on a folder row (issue #307). Mirrors
127+
/// [`crate::RemoteStore::rename_folder`]'s implementations one-for-one -
128+
/// this is the flag the picker UI reads to
129+
/// decide whether to show the affordance at all, so it must never say
130+
/// `true` for a backend whose store still falls through to the trait's
131+
/// unsupported default.
132+
pub const fn supports_rename(self) -> bool {
133+
match self {
134+
// `files.update` with just a `name` patch - id and parents
135+
// untouched.
136+
BackendKind::GoogleDrive => true,
137+
// S3 "folders" are key prefixes with no separate identity to
138+
// rename; doing so for real would mean copying every object under
139+
// the prefix to a new key and deleting the old ones, a bulk
140+
// operation the picker's single-row rename does not attempt.
141+
BackendKind::S3 => false,
142+
// The destination folder has no browsable tree at all
143+
// (`supports_folder_picker` is false), so there is no row to
144+
// rename.
145+
BackendKind::LocalFolder => false,
146+
// An SFTP RENAME of the directory, with its sidecar (if any)
147+
// moved alongside it.
148+
BackendKind::Sftp => true,
149+
}
150+
}
151+
125152
/// Whether this backend can honour per-source VERSION HISTORY: keeping the
126153
/// bytes of a superseded file so a point-in-time restore ("restore this
127154
/// source's files as they were on an earlier date") really returns the older

crates/driven-remote/src/remote_store.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,37 @@ pub trait RemoteStore: Send + Sync {
294294
drive_context: &DriveContext,
295295
) -> anyhow::Result<Vec<RemoteEntry>>;
296296

297+
/// Renames a folder IN PLACE - its LOCATION (parent) never changes, only
298+
/// its display name (issue #307, the destination picker's inline rename).
299+
///
300+
/// The returned [`RemoteEntry::id`] is the folder's id to use from now on.
301+
/// For Drive this is always the SAME id the caller passed in (a rename is
302+
/// a pure metadata patch by opaque file id). For SFTP it can DIFFER: an
303+
/// SFTP id is built from encoded path components, so a rename that
304+
/// actually changes the encoded name moves the real directory and mints a
305+
/// new id at the new path - the caller MUST replace any id it was holding
306+
/// with the one this call returns, never assume the input `folder_id`
307+
/// still resolves.
308+
///
309+
/// The default degrades to an explicit, stably-coded "unsupported" error
310+
/// rather than a silent no-op: [`BackendKind::supports_rename`] is what
311+
/// tells the picker UI whether to offer the affordance at all, so reaching
312+
/// this default means a stale client called it anyway and must be told
313+
/// clearly, not left to wonder why nothing changed. S3 "folders" are key
314+
/// prefixes with no separate identity to rename (renaming one would mean
315+
/// copying every object under the prefix to a new key and deleting the
316+
/// old ones - a bulk operation the picker's inline affordance does not
317+
/// attempt), so it keeps this default.
318+
async fn rename_folder(
319+
&self,
320+
folder_id: &str,
321+
new_name: &str,
322+
drive_context: &DriveContext,
323+
) -> anyhow::Result<RemoteEntry> {
324+
let _ = (folder_id, new_name, drive_context);
325+
anyhow::bail!("remote.rename_unsupported: this destination cannot rename folders")
326+
}
327+
297328
/// Enumerates the Shared Drives the authenticated account can access
298329
/// (Drive `drives.list`), for the destination picker to show Shared Drive
299330
/// roots beside My Drive (issue #7).

0 commit comments

Comments
 (0)