feat(fuse,core,cli,gui): move between locations without transferring content

A move request with two absolute paths is resolved against every
location (My files, on-demand and mirror synced folders) and made as a
server-side move_node. The local side is levelled afterwards: mounts
forget and re-list, a mirror folder renames its copy and baseline along
or drops the copy once Drive holds it.

Anything only this disk holds is refused up front: queued ops, open
writes, unsynced or ignored mirror files and symlinks. The GUI Move
dialog gains a top-level list of locations.
This commit is contained in:
nvrl committed 2026-09-29 00:26:56 +02:00
1 parent 3fb7b9be6b
commit 5f9d07aff1
23 files changed
+3853 -2673

No files matched your search

+4 -1
View File
@@ -339,7 +339,10 @@ pub enum Request {
/// single path component (no separators). Replies with [`Response::Ok`].
Rename { path: String, new_name: String },
/// Move a file or folder into a new parent folder. Both `path` and
/// `new_parent` are mountpoint-relative. Replies with [`Response::Ok`].
/// `new_parent` are mountpoint-relative, or both absolute local paths; two
/// absolute paths may lie in different locations (My files, an on-demand
/// or a mirror synced folder), and the move is still made on Proton Drive
/// without transferring content. Replies with [`Response::Ok`].
Move { path: String, new_parent: String },
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
/// [`Response::Ok`].
+82
View File
@@ -260,6 +260,88 @@ impl Db {
Ok(())
}
/// Drop the baseline rows of `rel_path` and everything under it.
///
/// Used when a subtree leaves a mirror folder through a move on Drive: its
/// local copy is about to be removed, and rows left behind would read as
/// "deleted locally" and trash the moved nodes at their new place.
pub fn sync_entries_remove_subtree(&self, folder_id: i64, rel_path: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM sync_entry
WHERE folder_id = ?1
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
params![folder_id, rel_path],
)?;
Ok(())
}
/// Move the baseline rows of `from_rel` and everything under it to `to_rel`
/// in folder `to_folder`, in one transaction.
///
/// Used when a move on Drive took a mirror subtree along with its local
/// copy: the rows still describe both sides correctly, only under a new
/// path, so the next pass on either folder has nothing to do. The two
/// folders may be the same one.
pub fn sync_entries_move(
&self,
from_folder: i64,
from_rel: &str,
to_folder: i64,
to_rel: &str,
) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
{
let mut select = tx.prepare(
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
local_mtime_ns
FROM sync_entry
WHERE folder_id = ?1
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
)?;
let rows = select
.query_map(params![from_folder, from_rel], |r| {
Ok(StoredSyncEntry {
rel_path: r.get(0)?,
remote_uid: r.get(1)?,
local_mtime: r.get(2)?,
local_size: r.get(3)?,
remote_rev: r.get(4)?,
remote_hash: r.get(5)?,
local_mtime_ns: r.get(6)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut delete =
tx.prepare("DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2")?;
for row in &rows {
delete.execute(params![from_folder, row.rel_path])?;
}
let mut insert = tx.prepare(
"INSERT OR REPLACE INTO sync_entry
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
remote_hash, local_mtime_ns)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)?;
for row in rows {
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
insert.execute(params![
to_folder,
rel,
row.remote_uid,
row.local_mtime,
row.local_size,
row.remote_rev,
row.remote_hash,
row.local_mtime_ns,
])?;
}
}
tx.commit()?;
Ok(())
}
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
/// the local tree was evicted, so the old baseline is stale and would make the
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
+88
View File
@@ -2920,6 +2920,94 @@ fn clearing_a_stale_pending_mode_preserves_the_newer_intent() {
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None);
}
fn baseline_row(rel: &str) -> StoredSyncEntry {
StoredSyncEntry {
rel_path: rel.into(),
remote_uid: Some(format!("vol~{rel}")),
local_mtime: 1_700_000_000,
local_mtime_ns: Some(1_700_000_000_000_000_001),
local_size: 7,
remote_rev: Some("1700000000".into()),
remote_hash: Some("7".into()),
}
}
#[test]
fn moving_a_baseline_subtree_takes_its_rows_and_leaves_name_prefixed_siblings() {
let db = Db::open_in_memory().unwrap();
let from = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap();
let to = db.sync_folder_add("/home/me/B", "vol~b", "share").unwrap();
for rel in [
"docs",
"docs/x.txt",
"docs/sub/y.txt",
"docsextra.txt",
"other",
] {
db.sync_entry_upsert(from, &baseline_row(rel)).unwrap();
}
db.sync_entry_upsert(to, &baseline_row("inbox")).unwrap();
db.sync_entries_move(from, "docs", to, "inbox/docs")
.unwrap();
let left = db.sync_entries(from).unwrap();
let mut left: Vec<_> = left.keys().cloned().collect();
left.sort();
assert_eq!(left, ["docsextra.txt", "other"]);
let moved = db.sync_entries(to).unwrap();
let mut keys: Vec<_> = moved.keys().cloned().collect();
keys.sort();
assert_eq!(
keys,
[
"inbox",
"inbox/docs",
"inbox/docs/sub/y.txt",
"inbox/docs/x.txt"
]
);
// Only the path changes: the row still describes the same node on both sides.
assert_eq!(
moved["inbox/docs/x.txt"],
StoredSyncEntry {
rel_path: "inbox/docs/x.txt".into(),
..baseline_row("docs/x.txt")
}
);
}
#[test]
fn moving_a_baseline_subtree_within_one_folder_renames_it() {
let db = Db::open_in_memory().unwrap();
let folder = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap();
for rel in ["old", "old/f.txt", "new"] {
db.sync_entry_upsert(folder, &baseline_row(rel)).unwrap();
}
db.sync_entries_move(folder, "old", folder, "new/old")
.unwrap();
let mut keys: Vec<_> = db.sync_entries(folder).unwrap().into_keys().collect();
keys.sort();
assert_eq!(keys, ["new", "new/old", "new/old/f.txt"]);
}
#[test]
fn removing_a_baseline_subtree_keeps_name_prefixed_siblings() {
let db = Db::open_in_memory().unwrap();
let folder = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap();
for rel in ["docs", "docs/x.txt", "docsextra.txt"] {
db.sync_entry_upsert(folder, &baseline_row(rel)).unwrap();
}
db.sync_entries_remove_subtree(folder, "docs").unwrap();
let keys: Vec<_> = db.sync_entries(folder).unwrap().into_keys().collect();
assert_eq!(keys, ["docsextra.txt"]);
}
#[test]
fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
let db = Db::open_in_memory().unwrap();