mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
3fb7b9be6b
commit
5f9d07aff1
23 files changed
+3853
-2673
No files matched your search
@@ -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`].
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user