Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ These move: check each project's current docs before relying on a cell.
exclude rules, and a configurable symlink policy, and that skips excluded
directories instead of descending them.
- Live exclusion preview that re-classifies the folder tree as you edit a rule,
from an in-memory tree rather than a fresh walk of the disk.
from an in-memory tree rather than a fresh walk of the disk, with a
per-folder file-count and size rollup so an exclude rule's actual weight -
or what re-including it would cost - is visible before you commit to it.
- Exclusion rules that take effect immediately, even mid-backup: saving a new
rule stops the running backup from uploading anything it newly excludes at the
next file boundary (the file already in flight still finishes cleanly), and
Expand Down Expand Up @@ -192,6 +194,11 @@ These move: check each project's current docs before relying on a cell.
plain local / removable folder (USB drive, external disk, NAS share) - all
behind one pluggable backend trait, so adding the next one is a new backend
crate plus a factory arm rather than a fork of every call site.
- Destination folder picker with client-side sort (name or last-modified) and
type-to-filter, a "New folder" button on every browsable destination, and
inline rename on Google Drive and SFTP (S3's key-prefix "folders" have no
separate identity to rename, so that control is disabled there with an
explanation instead of hidden outright).
- Scheduled integrity scrub: on top of the local re-hash and the
remote-existence audit above, a rolling background pass re-checks each
already-backed-up object's size, and its content checksum where the
Expand Down
5 changes: 5 additions & 0 deletions crates/driven-backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ pub struct BackendDescriptor {
/// file, so a point-in-time restore returns the older bytes rather than
/// today's (`BackendKind::supports_version_history`; issue #220).
pub supports_version_history: bool,
/// Whether the destination picker's inline rename affordance applies to
/// this backend's folder rows (`BackendKind::supports_rename`; issue #307).
pub supports_rename: bool,
}

/// Every destination this build can construct, in picker order. The first entry
Expand All @@ -142,6 +145,7 @@ pub fn descriptors() -> Vec<BackendDescriptor> {
uses_oauth: kind.uses_oauth(),
supports_folder_picker: kind.supports_folder_picker(),
supports_version_history: kind.supports_version_history(),
supports_rename: kind.supports_rename(),
})
.collect()
}
Expand Down Expand Up @@ -551,6 +555,7 @@ mod tests {
desc.supports_version_history,
kind.supports_version_history()
);
assert_eq!(desc.supports_rename, kind.supports_rename());
}
assert_eq!(d[0].kind, BackendKind::default());
}
Expand Down
9 changes: 9 additions & 0 deletions crates/driven-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,13 @@ pub enum ErrorCode {
/// (the identity marker, then the write/remove round trip), since the
/// remedy is identical either way.
SftpRootNotWritable,
/// `remote.rename_unsupported` - the destination picker's inline rename
/// (issue #307) was called against a backend whose `RemoteStore` has no
/// rename primitive (S3, whose "folders" are key prefixes with no
/// separate identity to rename). The picker UI hides the affordance for
/// such backends already (`BackendKind::supports_rename`); reaching this
/// code means a stale client called it anyway.
RemoteRenameUnsupported,
}

impl ErrorCode {
Expand Down Expand Up @@ -1311,6 +1318,7 @@ impl ErrorCode {
ErrorCode::SftpRootNotADirectory => "sftp.root_not_a_directory",
ErrorCode::SftpDestMarkerMismatch => "sftp.dest_marker_mismatch",
ErrorCode::SftpRootNotWritable => "sftp.root_not_writable",
ErrorCode::RemoteRenameUnsupported => "remote.rename_unsupported",
}
}

Expand Down Expand Up @@ -1373,6 +1381,7 @@ impl ErrorCode {
"sftp.root_not_a_directory" => ErrorCode::SftpRootNotADirectory,
"sftp.dest_marker_mismatch" => ErrorCode::SftpDestMarkerMismatch,
"sftp.root_not_writable" => ErrorCode::SftpRootNotWritable,
"remote.rename_unsupported" => ErrorCode::RemoteRenameUnsupported,
_ => return None,
})
}
Expand Down
28 changes: 28 additions & 0 deletions crates/driven-drive/src/fake/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1110,6 +1110,34 @@ impl RemoteStore for InMemoryRemoteStore {
.collect())
}

/// Renames a folder in place, mirroring `GoogleDriveStore::rename_folder`
/// (issue #307): only `name` changes, id and `parent_id` are untouched.
async fn rename_folder(
&self,
folder_id: &str,
new_name: &str,
drive_context: &DriveContext,
) -> anyhow::Result<RemoteEntry> {
self.record_context(drive_context);
self.check_request_faults(RequestKind::WriteTarget).await?;
let mut guard = self.inner.lock();
if !guard
.objects
.get(folder_id)
.is_some_and(FileEntry::is_folder)
{
anyhow::bail!("fake: no folder with file_id {folder_id}");
}
let new_now = guard.tick();
let entry = guard
.objects
.get_mut(folder_id)
.expect("presence just checked above");
entry.name = new_name.to_string();
entry.modified_time_ms = new_now;
Ok(entry.to_remote_entry())
}

async fn create(
&self,
parent_id: &str,
Expand Down
27 changes: 27 additions & 0 deletions crates/driven-drive/src/google/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,33 @@ impl RemoteStore for GoogleDriveStore {
self.list_query(&q, drive_context).await
}

/// Renames a folder via `PATCH /files/{id}` with just the `name` field -
/// the id and `parents` are untouched (issue #307). Unlike `create_folder`
/// this is a PATCH-by-id, which is idempotent, so it is safe to blind-retry
/// through the normal `send_json` path.
async fn rename_folder(
&self,
folder_id: &str,
new_name: &str,
_drive_context: &DriveContext,
) -> anyhow::Result<RemoteEntry> {
let body = json_body(&serde_json::json!({ "name": new_name }))?;
let file: DriveFile = self
.send_json(|token| {
self.http
.patch(format!("{DRIVE_API_BASE}/files/{folder_id}"))
.query(&[("fields", pagination::FILE_FIELDS), SUPPORTS_ALL_DRIVES])
.bearer_auth(token)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.clone())
})
.await
// The target is `folder_id`; a 404 / unclassified 403 is a
// dest-folder condition, not a transient (mirrors `create_folder`).
.map_err(map_parent_write_error)?;
Ok(file.into_remote_entry())
}

async fn create(
&self,
parent_id: &str,
Expand Down
66 changes: 66 additions & 0 deletions crates/driven-drive/tests/fake_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1004,3 +1004,69 @@ async fn fake_with_fileid_recycle_reuses_trashed_id() {
"exactly one live object now holds the recycled id"
);
}

// ---------------------------------------------------------------------------
// rename_folder (issue #307 - the destination picker's inline rename).
// ---------------------------------------------------------------------------

#[tokio::test]
async fn fake_rename_folder_changes_only_the_name() {
let store = fake();
let root = store.root_id().to_string();
let folder = store
.ensure_folder(&root, "Old machines", &DriveContext::MyDrive)
.await
.expect("ensure_folder");

let renamed = store
.rename_folder(&folder.id, "Archive", &DriveContext::MyDrive)
.await
.expect("rename_folder");

assert_eq!(renamed.id, folder.id, "id is unchanged by a rename");
assert_eq!(renamed.name, "Archive");
assert_eq!(renamed.parents, folder.parents, "location is unchanged");

// ...and list_folder agrees.
let listing = store
.list_folder(&root, &DriveContext::MyDrive)
.await
.expect("list root");
let entry = listing
.iter()
.find(|e| e.id == folder.id)
.expect("the renamed folder is still listed under the same id");
assert_eq!(entry.name, "Archive");
}

#[tokio::test]
async fn fake_rename_folder_rejects_an_unknown_id() {
let store = fake();
let err = store
.rename_folder("does-not-exist", "New name", &DriveContext::MyDrive)
.await
.expect_err("renaming an unknown id must Err");
assert!(err.to_string().contains("does-not-exist"));
}

#[tokio::test]
async fn fake_rename_folder_refuses_to_rename_a_file() {
let store = fake();
let root = store.root_id().to_string();
let file = store
.create(
&root,
"a.txt",
"text/plain",
UploadBody::Bytes(Bytes::from_static(b"x")),
props(&[]),
)
.await
.expect("create");

let err = store
.rename_folder(&file.id, "b.txt", &DriveContext::MyDrive)
.await
.expect_err("renaming a FILE via rename_folder must Err");
assert!(err.to_string().contains(&file.id));
}
27 changes: 27 additions & 0 deletions crates/driven-remote/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,33 @@ impl BackendKind {
}
}

/// Whether this backend's destination picker can offer an inline RENAME
/// on a folder row (issue #307). Mirrors
/// [`crate::RemoteStore::rename_folder`]'s implementations one-for-one -
/// this is the flag the picker UI reads to
/// decide whether to show the affordance at all, so it must never say
/// `true` for a backend whose store still falls through to the trait's
/// unsupported default.
pub const fn supports_rename(self) -> bool {
match self {
// `files.update` with just a `name` patch - id and parents
// untouched.
BackendKind::GoogleDrive => true,
// S3 "folders" are key prefixes with no separate identity to
// rename; doing so for real would mean copying every object under
// the prefix to a new key and deleting the old ones, a bulk
// operation the picker's single-row rename does not attempt.
BackendKind::S3 => false,
// The destination folder has no browsable tree at all
// (`supports_folder_picker` is false), so there is no row to
// rename.
BackendKind::LocalFolder => false,
// An SFTP RENAME of the directory, with its sidecar (if any)
// moved alongside it.
BackendKind::Sftp => true,
}
}

/// Whether this backend can honour per-source VERSION HISTORY: keeping the
/// bytes of a superseded file so a point-in-time restore ("restore this
/// source's files as they were on an earlier date") really returns the older
Expand Down
31 changes: 31 additions & 0 deletions crates/driven-remote/src/remote_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,37 @@ pub trait RemoteStore: Send + Sync {
drive_context: &DriveContext,
) -> anyhow::Result<Vec<RemoteEntry>>;

/// Renames a folder IN PLACE - its LOCATION (parent) never changes, only
/// its display name (issue #307, the destination picker's inline rename).
///
/// The returned [`RemoteEntry::id`] is the folder's id to use from now on.
/// For Drive this is always the SAME id the caller passed in (a rename is
/// a pure metadata patch by opaque file id). For SFTP it can DIFFER: an
/// SFTP id is built from encoded path components, so a rename that
/// actually changes the encoded name moves the real directory and mints a
/// new id at the new path - the caller MUST replace any id it was holding
/// with the one this call returns, never assume the input `folder_id`
/// still resolves.
///
/// The default degrades to an explicit, stably-coded "unsupported" error
/// rather than a silent no-op: [`BackendKind::supports_rename`] is what
/// tells the picker UI whether to offer the affordance at all, so reaching
/// this default means a stale client called it anyway and must be told
/// clearly, not left to wonder why nothing changed. S3 "folders" are key
/// prefixes with no separate identity to rename (renaming one would mean
/// copying every object under the prefix to a new key and deleting the
/// old ones - a bulk operation the picker's inline affordance does not
/// attempt), so it keeps this default.
async fn rename_folder(
&self,
folder_id: &str,
new_name: &str,
drive_context: &DriveContext,
) -> anyhow::Result<RemoteEntry> {
let _ = (folder_id, new_name, drive_context);
anyhow::bail!("remote.rename_unsupported: this destination cannot rename folders")
}

/// Enumerates the Shared Drives the authenticated account can access
/// (Drive `drives.list`), for the destination picker to show Shared Drive
/// roots beside My Drive (issue #7).
Expand Down
Loading