feat(fuse,core,cli,gui): make folders available offline from the app

The daemon has kept recursive folder pins for a long time, but the app
offered "Available offline" for files only. The context menu, the
details pane and the selection bar now offer it for folders too, and a
pinned folder carries the offline badge in the browser and the launcher.

A folder pin used to download the whole subtree inside the control
request, so a large folder outlasted the client's 120 s read timeout,
and every pinned file was read into memory before it was cached. The
daemon now records the pin, answers at once and downloads on a
background thread as a job the tray and the app show, stopping when the
folder is unpinned or the daemon stops. Pins of files and folders both
go through fetch_content, which streams to disk and skips fresh blobs.
A folder pin is refused while offline instead of looking kept.

A file kept only because a folder above it is pinned has no pin of its
own, and unpinning it silently did nothing. ListDir now reports
pinned_by_folder; the app shows such an entry as available offline with
the toggle greyed out, and the daemon refuses the unpin with a message
naming the folder. Unpinning a folder no longer evicts files that are
pinned on their own.

Requested on Reddit.
This commit is contained in:
nvrl committed 2026-10-12 00:07:18 +02:00
1 parent e0442c56b9
commit d481dcc15c
24 files changed
+3353 -2856

No files matched your search

+8 -5
View File
@@ -172,17 +172,19 @@ enum Command {
/// Mountpoint; defaults to ~/ProtonDrive.
mountpoint: Option<PathBuf>,
},
/// Keep a file on this device (download + cache it). Needs a running mount.
/// Keep a file or folder on this device (download + cache it). A folder's
/// files download in the background; `pdfs transfers` shows the progress.
/// Needs a running mount.
Pin {
/// File path, inside the mountpoint or relative to it.
/// File or folder path, inside the mountpoint or relative to it.
path: PathBuf,
},
/// Stop keeping a file on this device, evicting its cached content.
/// Stop keeping a file or folder on this device, evicting its cached content.
Unpin {
/// File path, inside the mountpoint or relative to it.
/// File or folder path, inside the mountpoint or relative to it.
path: PathBuf,
},
/// List files pinned to this device.
/// List files and folders pinned to this device.
Pins,
/// List a directory via the running daemon (in-app browser backend).
Ls {
@@ -3793,6 +3795,7 @@ mod login_wait_tests {
size: 1,
modified: 0,
pinned: false,
pinned_by_folder: false,
cached: false,
uid: "vol~link".into(),
path: String::new(),
+8 -1
View File
@@ -1260,8 +1260,15 @@ pub struct DirEntry {
pub size: u64,
/// Modification time, epoch seconds.
pub modified: i64,
/// Whether the file is pinned to this device.
/// Whether the node is pinned to this device, on its own or through a
/// pinned folder above it.
pub pinned: bool,
/// Whether the node is kept on this device because a folder above it is
/// pinned. Such a node cannot be unpinned on its own. Set in
/// [`Request::ListDir`] listings only. Defaulted for wire-compat with
/// daemons predating the field.
#[serde(default)]
pub pinned_by_folder: bool,
/// Whether the file's full content is present in the local cache (a current,
/// non-stale blob). Always false for folders. Defaulted for wire-compat with
/// clients/daemons predating the field.
+87 -34
View File
@@ -4227,8 +4227,10 @@ impl Core {
}
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
/// plaintext into the content cache; a folder records a recursive pin and
/// downloads every descendant file (selective sync). Returns a human message.
/// plaintext into the content cache before this returns. A folder records a
/// recursive pin and downloads every descendant file on a background thread,
/// reported as a job: a big folder far outlasts the socket read timeout.
/// Returns a human message.
fn pin(&self, rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?;
let (name, is_folder, mtime, size) = {
@@ -4245,34 +4247,69 @@ impl Core {
)
};
if is_folder {
// Offline, every download would fail and the folder would only look
// kept. Refuse instead, as a file pin does.
if !self.is_online() {
return Err(CoreError::offline());
}
// Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree.
self.cache
.add_pin(&uid, rel, true)
.map_err(|e| CoreError::from_api(&e, "pin"))?;
let n = self.pin_subtree(ino)?;
return Ok(format!("{name} ({n} files)"));
let core = self.clone();
let folder = name.clone();
std::thread::Builder::new()
.name("pin-folder".into())
.spawn(move || core.pin_subtree(ino, &uid, &folder))
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
return Ok(format!("{name}: downloading its files in the background"));
}
let bytes = self
.download_file_tracked(&uid, &name, size)
.map_err(|e| CoreError::from_api(&e, "download"))?;
self.cache
.store(&uid, mtime, size, &bytes)
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
self.fetch_content(&uid, &name, mtime, size)?;
self.cache
.add_pin(&uid, rel, false)
.map_err(|e| CoreError::from_api(&e, "pin"))?;
Ok(name)
}
/// Download and cache every file in the subtree rooted at folder `ino`,
/// returning the count cached (already-fresh blobs counted, not re-fetched).
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
/// fully discovered; the lock is dropped before each network download.
fn pin_subtree(&self, ino: u64) -> CoreResult<usize> {
/// Download and cache every file in the subtree rooted at the pinned folder
/// `ino` (`folder`, named `name`), as a job the front ends show. Walks the
/// tree depth-first, enumerating each folder so a cold subtree is fully
/// discovered; the lock is dropped before each network download. Stops when
/// the folder is unpinned or the daemon stops.
fn pin_subtree(&self, ino: u64, folder: &NodeUid, name: &str) {
let job = self
.transfers
.begin_job(format!("Making {name} available offline"));
match self.pin_subtree_files(ino) {
Ok(files) => {
job.set_total(files.len() as u64);
let mut failed = 0usize;
for (uid, file, mtime, size) in files {
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
return;
}
job.detail(file.clone());
if let Err(e) = self.fetch_content(&uid, &file, mtime, size) {
warn!(%uid, error = %e, "pin folder: download failed");
failed += 1;
}
job.step();
}
info!(folder = %name, failed, "pin folder: done");
}
Err(e) => warn!(folder = %name, error = %e, "pin folder: enumerate failed"),
}
}
/// Every file in the subtree rooted at folder `ino`.
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
let mut stack = vec![ino];
while let Some(dir) = stack.pop() {
if self.shutdown.is_stopping() {
break;
}
self.ensure_children(dir)
.map_err(|e| self.errno_error(e, "enumerate"))?;
let st = self.state();
@@ -4293,22 +4330,7 @@ impl Core {
}
}
}
let mut count = 0;
for (uid, name, mtime, size) in files {
if self.cache.is_cached(&uid, mtime, size) {
count += 1;
continue;
}
match self.download_file_tracked(&uid, &name, size) {
Ok(bytes) => {
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
count += 1;
}
}
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
}
}
Ok(count)
Ok(files)
}
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
@@ -4354,6 +4376,8 @@ impl Core {
/// Unpin the node at `rel`, evicting its cached content. For a folder, also
/// evicts every descendant's cached blob (the subtree is no longer kept).
/// A node kept only because a folder above it is pinned is refused: there
/// is no pin of its own to remove, and it would stay kept all the same.
fn unpin(&self, rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?;
let (name, is_folder) = {
@@ -4363,14 +4387,24 @@ impl Core {
.map(|e| (e.node.name.clone(), e.node.is_folder()))
.unwrap_or_default()
};
if self.pinned_by_folder(ino) {
return Err(CoreError::conflict(format!(
"{name} is kept offline because a folder above it is; unpin that folder instead"
)));
}
self.cache
.remove_pin(&uid)
.map_err(|e| CoreError::from_api(&e, "unpin"))?;
// A recursively-pinned folder's descendants were eviction-exempt; now
// that the pin is gone, reclaim their blobs eagerly instead of waiting
// for budget pressure. Descendants come from the DB node tree.
// for budget pressure. Descendants come from the DB node tree. One
// pinned on its own, or under a pinned subfolder, is still kept.
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
let still_pinned = self.pinned_set();
for s in uids {
if still_pinned.contains(&s) {
continue;
}
if let Some(u) = parse_uid(&s) {
self.cache.evict(&u);
self.evict_reader(&u);
@@ -4380,6 +4414,20 @@ impl Core {
Ok(name)
}
/// Whether the node at `ino` is kept offline because a folder above it is
/// pinned, whether or not it is also pinned on its own.
fn pinned_by_folder(&self, ino: u64) -> bool {
let parent = {
let st = self.state();
st.entries
.get(&ino)
.filter(|e| e.parent != ino)
.and_then(|e| st.entries.get(&e.parent))
.map(|p| p.uid.clone())
};
parent.is_some_and(|uid| self.cache.is_pinned(&uid))
}
/// A Photos API handle sharing this Core's Drive client and session, so it
/// reuses the daemon's single authenticated session rather than logging in
/// again (Proton refresh tokens are single-use). Cheap — the Drive client
@@ -4392,10 +4440,13 @@ impl Core {
/// the same lazy remote enumeration `readdir` uses, projected into
/// serializable [`DirEntry`]s (with per-file pin state).
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
let (ino, _uid) = self.resolve(rel)?;
let (ino, uid) = self.resolve(rel)?;
self.ensure_children(ino)
.map_err(|e| self.errno_error(e, "enumerate"))?;
self.settle_listing_sizes(ino);
// Every entry shares this folder as its parent, so one lookup says
// whether they are all kept through it.
let pinned_by_folder = self.cache.is_pinned(&uid);
// Snapshot the listing, then drop the lock before touching the on-disk
// pin registry so a slow disk read doesn't block FUSE metadata ops.
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
@@ -4427,7 +4478,8 @@ impl Core {
is_dir,
size,
modified,
pinned: self.cache.is_pinned(&uid),
pinned: pinned_by_folder || self.cache.is_pinned(&uid),
pinned_by_folder,
cached: !is_dir && self.cache.is_cached(&uid, modified, size),
uid: uid.to_string(),
// Listing entries live in the requested dir; the caller derives
@@ -5319,6 +5371,7 @@ impl Core {
// A trashed node can't be pinned or served from the mount, so its
// content is never current cache: report neither.
pinned: false,
pinned_by_folder: false,
cached: false,
uid: item.uid,
path: String::new(),
+1
View File
@@ -66,6 +66,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
size,
modified: n.modification_time,
pinned: false,
pinned_by_folder: false,
cached: false,
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
uid: n.uid.to_string(),
+58
View File
@@ -2099,4 +2099,62 @@ mod tests {
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned() {
let drive = FakeDrive::new();
let dir = scratch("pin-folder");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir_all(daemon.mountpoint.join("Docs/sub")).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
std::fs::write(daemon.mountpoint.join("Docs/sub/b.txt"), b"beta").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Docs/sub/b.txt").is_some()
}));
daemon.request(&Request::PurgeCache).unwrap();
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
path: folder.into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
_ => None,
};
assert!(entry("Docs", "a.txt").is_some_and(|e| !e.cached && !e.pinned));
daemon
.request(&Request::Pin {
path: "Docs".into(),
})
.unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached)
&& entry("Docs/sub", "b.txt").is_some_and(|e| e.cached)
}),
"the folder's files were downloaded in the background"
);
let a = entry("Docs", "a.txt").unwrap();
assert!(a.pinned && a.pinned_by_folder);
assert!(entry("", "Docs").is_some_and(|e| e.pinned && !e.pinned_by_folder));
assert!(
daemon
.request(&Request::Unpin {
path: "Docs/a.txt".into(),
})
.is_err(),
"a file kept through its folder cannot be unpinned on its own"
);
daemon
.request(&Request::Unpin {
path: "Docs".into(),
})
.unwrap();
let a = entry("Docs", "a.txt").unwrap();
assert!(!a.pinned && !a.pinned_by_folder && !a.cached);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
}
+53 -27
View File
@@ -520,9 +520,9 @@ pub(crate) fn clear_selection(ui: &Rc<Ui>) {
}
/// Reflect the current selection in the bulk bar: how many are selected, and
/// which bulk actions apply to that mix. Offline state is a file-only notion, so
/// a folder anywhere in the selection disables the two pin buttons rather than
/// half-failing once pressed.
/// which bulk actions apply to that mix. An entry kept offline through a pinned
/// folder above it cannot be made online only on its own, so it does not count
/// towards Make online only.
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
let entries = selected_entries(ui);
// One selected entry is the details pane's job; the bar is for a batch.
@@ -530,8 +530,7 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
ui.browser.bulk.set_reveal_child(false);
return;
}
let files_only = entries.iter().all(|e| !e.is_dir);
let any_pinned = entries.iter().any(|e| e.pinned);
let any_pinned = entries.iter().any(|e| e.pinned && !e.pinned_by_folder);
let any_unpinned = entries.iter().any(|e| !e.pinned);
let mounted = *ui.mounted.borrow();
ui.browser.bulk_label.set_label(&ngettext_f(
@@ -541,12 +540,8 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
&[],
));
ui.browser.bulk_trash.set_sensitive(mounted);
ui.browser
.bulk_pin
.set_sensitive(mounted && files_only && any_unpinned);
ui.browser
.bulk_unpin
.set_sensitive(mounted && files_only && any_pinned);
ui.browser.bulk_pin.set_sensitive(mounted && any_unpinned);
ui.browser.bulk_unpin.set_sensitive(mounted && any_pinned);
ui.browser.bulk.set_reveal_child(true);
}
@@ -652,16 +647,19 @@ pub(crate) fn restore_uids(ui: &Rc<Ui>, uids: Vec<String>, count: usize) {
);
}
/// Pin or unpin every selected file, one request at a time.
/// Pin or unpin every selected entry, one request at a time. A folder's files
/// download in the background, so its request returns before they are there.
pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
let socket = ui.dirs.control_socket();
// Only the entries that need changing: re-pinning an already-pinned file is
// a wasted round-trip, and it would inflate the count the toast reports.
let paths: Vec<String> = entries
// a wasted round-trip, and it would inflate the count the toast reports. One
// kept through a pinned folder above it has no pin of its own to remove.
let changing: Vec<&DirEntry> = entries
.iter()
.filter(|e| !e.is_dir && e.pinned != pin)
.map(|e| entry_rel(ui, e))
.filter(|e| !e.pinned_by_folder && e.pinned != pin)
.collect();
let any_folder = changing.iter().any(|e| e.is_dir);
let paths: Vec<String> = changing.iter().map(|e| entry_rel(ui, e)).collect();
if paths.is_empty() {
return;
}
@@ -698,13 +696,27 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
(0, None) => {}
(n, _) => toast(
&ui,
&if pin {
&if pin && any_folder {
ngettext_f(
"Making {n} item available offline…",
"Making {n} items available offline…",
n as u64,
&[],
)
} else if pin {
ngettext_f(
"{n} file is now available offline",
"{n} files are now available offline",
n as u64,
&[],
)
} else if any_folder {
ngettext_f(
"{n} item is now online only",
"{n} items are now online only",
n as u64,
&[],
)
} else {
ngettext_f(
"{n} file is now online only",
@@ -860,13 +872,17 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
}
menu.section();
if !entry.is_dir {
// Kept through a pinned folder above it, the entry has no pin of its own
// to remove: the check shows that state without offering to change it.
if entry.pinned_by_folder {
menu.locked_toggle(&gettext("Available offline"), true);
} else {
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
toggle_pin(&ui_c, &entry_c)
});
menu.section();
}
menu.section();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Share…"), move || {
@@ -908,9 +924,8 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
/// simply absent here rather than offered and then refused.
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
let mut menu = ActionMenu::new();
// Offline state is a file-only notion; the check shows whether the whole
// batch already is.
if entries.iter().all(|e| !e.is_dir) {
// The check shows whether the whole batch already is offline.
{
let (ui_c, batch) = (ui.clone(), entries.clone());
let all_pinned = entries.iter().all(|e| e.pinned);
menu.toggle(&gettext("Available offline"), all_pinned, move |pin| {
@@ -1147,6 +1162,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
let rx = spawn_request(ui.dirs.control_socket(), req);
let name = entry.name.clone();
let pinned = entry.pinned;
let is_dir = entry.is_dir;
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
@@ -1166,6 +1182,9 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
&ui,
&if pinned {
gettext_f("“{name}” is now online only", &[("name", &name)])
} else if is_dir {
// The daemon downloads a folder's files in the background.
gettext_f("Making “{name}” available offline…", &[("name", &name)])
} else {
gettext_f("“{name}” is now available offline", &[("name", &name)])
},
@@ -2487,16 +2506,19 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
});
}
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders
/// (which carry no per-file cache state). A sync issue ranks first, on folders
/// too: the change waits for the user. Pinned (kept offline) ranks above merely
/// cached (downloaded, evictable); everything else is online-only.
/// The sync-state badge for an entry: `(icon, css-class)`. A folder carries no
/// cache state of its own, so it has one only when kept offline. A sync issue
/// ranks first, on folders too: the change waits for the user. Pinned (kept
/// offline) ranks above merely cached (downloaded, evictable); everything else
/// is online-only.
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
if entry.issue.is_some() {
return Some(("dialog-warning-symbolic", "badge-issue"));
}
if entry.is_dir {
return None;
return entry
.pinned
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
}
Some(if entry.pinned {
("pdfs-offline-symbolic", "badge-pinned")
@@ -2905,6 +2927,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
size: h.size,
modified: h.modified,
pinned: h.pinned,
pinned_by_folder: false,
cached: h.cached,
uid: h.uid.clone(),
path: h.path.clone(),
@@ -2965,6 +2988,7 @@ mod tests {
size,
modified,
pinned: false,
pinned_by_folder: false,
cached: false,
uid: String::new(),
path: String::new(),
@@ -2992,6 +3016,8 @@ mod tests {
let mut folder = entry("d", true, 0, 0);
assert_eq!(badge_for(&folder), None);
folder.pinned = true;
assert_eq!(badge_for(&folder).unwrap().1, "badge-pinned");
folder.issue = Some(SyncIssue::Missing);
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
}
@@ -622,6 +622,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
size: 0,
modified: item.modified,
pinned: false,
pinned_by_folder: false,
cached: false,
uid: item.uid.clone(),
path: item.path.clone(),
+1 -1
View File
@@ -1538,7 +1538,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
let row = adw::ActionRow::builder()
.title(gettext("No files kept offline"))
.subtitle(gettext(
"Right-click a file and choose “Make available offline”.",
"Right-click a file or folder and choose “Available offline”.",
))
.build();
ui.status.pins_group.add(&row);
+1
View File
@@ -524,6 +524,7 @@ mod tests {
size: 0,
modified,
pinned: false,
pinned_by_folder: false,
cached: false,
uid: name.into(),
path: String::new(),
+13 -4
View File
@@ -384,11 +384,20 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
} else {
parent.to_string()
});
// Pinning is per file: the context menu and the selection bar offer it for
// files only, so the pane must not offer it for folders either.
// Kept through a pinned folder above it, the entry has no pin of its own
// to remove, so the switch shows that state without offering to change it.
d.pin_row.set_active(entry.pinned);
d.pin_row.set_visible(!entry.is_dir);
d.pin_row.set_sensitive(*ui.mounted.borrow());
d.pin_row.set_subtitle(&if entry.pinned_by_folder {
gettext("Kept on this computer because a folder it is in is available offline.")
} else if entry.is_dir {
gettext(
"Keep a copy of everything in this folder on this computer, even without a connection.",
)
} else {
gettext("Keep a copy on this computer, even without a connection.")
});
d.pin_row
.set_sensitive(*ui.mounted.borrow() && !entry.pinned_by_folder);
d.open_button.set_label(&if entry.is_dir {
gettext("Open folder")
} else {
+12
View File
@@ -61,6 +61,18 @@ impl ActionMenu {
self
}
/// A greyed-out check item showing `active`, for a state the user cannot
/// change from here.
pub(crate) fn locked_toggle(&mut self, label: &str, active: bool) -> &mut Self {
let name = self.next_action();
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
action.set_enabled(false);
self.group.add_action(&action);
self.section
.append(Some(label), Some(&format!("menu.{name}")));
self
}
/// Close the current section; the next item starts a new one below a
/// separator. An empty section is skipped, so callers can end a group of
/// conditional items unconditionally.
+5 -2
View File
@@ -77,13 +77,16 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
}
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
/// or online only. Folders and local files have none.
/// or online only. A folder has one only when kept offline; local files have
/// none.
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
let Hit::Drive(drive) = hit else {
return None;
};
if drive.is_dir {
return None;
return drive
.pinned
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
}
Some(if drive.pinned {
("pdfs-offline-symbolic", "badge-pinned")
+6
View File
@@ -16,6 +16,12 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
`proton-drive-linux.cachix.org`, so NixOS installs download the client instead of compiling
it. See [Install → Binary cache](INSTALL.md#binary-cache).
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
- **Folders can be made available offline in the app.** The context menu, the details pane and
the selection bar now offer it for folders too. Every file in the folder and its subfolders
downloads in the background with its progress in the tray and the app, and pinned folders carry
the offline badge. A file kept through its folder shows as available offline but cannot be
made online only on its own. `pdfs pin` on a folder now returns at once instead of timing out
on a large folder, and pinned files download to disk instead of into memory.
### Fixed
- **Removing the package left the drive mounted, and deletes there still reached Proton Drive.**
+3 -3
View File
@@ -65,9 +65,9 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description |
|---|---|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. |
| `pdfs unpin PATH` | Release it back to online only. |
| `pdfs pins` | List files kept available offline. |
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
| `pdfs pins` | List files and folders kept available offline. |
## Synced folders
+8 -5
View File
@@ -44,7 +44,7 @@ The app uses these words consistently, and so does this guide:
| **My files** | The root of your Drive |
| **Proton Drive folder** | Where My files appears on disk, `~/ProtonDrive` by default |
| **Online only** | A file or folder whose content downloads when opened |
| **Available offline** | A single file kept on this computer |
| **Available offline** | A file, or a folder with everything in it, kept on this computer |
| **Synced folder** | A local folder with a full copy on this computer, backed up to Proton Drive |
| **Proton Drive service** | The background service (`proton-drive.service`) |
@@ -75,11 +75,14 @@ try to create them get a clean error, not a hang or `EIO`.
## Keeping files on this computer
Turn on **Make available offline** for a file in My files, or run `pdfs pin <path>`. The file is
downloaded completely and never evicted from the cache. `pdfs pins` lists these files, and
**Preferences → Storage → Available offline** shows them in the app.
Turn on **Available offline** for a file or folder in My files, or run `pdfs pin <path>`. A file
is downloaded completely and never evicted from the cache. For a folder, every file in it and in
its subfolders downloads in the background, and the tray and the app show the progress.
`pdfs pins` lists what is kept, and **Preferences → Storage → Available offline** shows it in the
app.
Turning it off (`pdfs unpin <path>`) frees the space. The file stays on Proton Drive.
Turning it off (`pdfs unpin <path>`) frees the space. The files stay on Proton Drive. A file inside
a folder kept offline stays with its folder: turn the folder off instead.
## Syncing local folders
+344 -308
View File
File diff suppressed because it is too large. Load diff
+341 -308
View File
File diff suppressed because it is too large. Load diff
+344 -309
View File
File diff suppressed because it is too large. Load diff
+346 -310
View File
File diff suppressed because it is too large. Load diff
+344 -308
View File
File diff suppressed because it is too large. Load diff
+343 -308
View File
File diff suppressed because it is too large. Load diff
+338 -307
View File
File diff suppressed because it is too large. Load diff
+345 -308
View File
File diff suppressed because it is too large. Load diff
+343 -308
View File
File diff suppressed because it is too large. Load diff