use crate::activation::{DriveActivation, drive_activation, mounted_path, mounted_target_rel}; use crate::*; pub(crate) struct BrowserState { // Files (browser) page. /// The grid and list; the model is repopulated per directory. pub(crate) files: FileList, /// Folders left by navigating, newest last, and folders left by going back: /// the `files.back` and `files.forward` history. Up is Alt+Up or a /// breadcrumb. pub(crate) history: RefCell>, pub(crate) future: RefCell>, /// Clickable breadcrumb trail (a button per path segment); rebuilt per load /// by [`repaint_crumb`] so each ancestor folder navigates on click. pub(crate) crumb: gtk4::Box, /// Swaps the Files content area between the grid/list views and the status /// page below; see [`browser_status`]. pub(crate) content: gtk4::Stack, /// The Files empty/loading/error surface, shown in place of the views. pub(crate) status: adw::StatusPage, /// Sits in [`Self::status`]; shown when a load failed because the mount /// service is down (not merely starting); restarts the service and reloads. pub(crate) retry: gtk4::Button, /// The details pane's host; `show_sidebar` is what reveals/hides the pane. pub(crate) split: adw::OverlaySplitView, /// Mountpoint-relative path the browser is showing (empty = root). pub(crate) path: RefCell, /// Debounced full-text search box in the browser header. pub(crate) search: gtk4::SearchEntry, /// Beside the search box while a query is typed: on, hits are limited to /// the folder being shown; off, the whole Drive is searched. pub(crate) search_scope: gtk4::ToggleButton, /// The page's `files.*` actions: New and View menus, navigation, and the /// folder menu. The ones needing a daemon are disabled while the mount is /// down: without one they can only fail. pub(crate) actions: gio::SimpleActionGroup, /// Toggles grid/list; its arrow holds the sort options. pub(crate) view_button: adw::SplitButton, /// Layout and order, as last chosen; saved to the config on change. pub(crate) view: Cell, /// The folder listing as the daemon sent it, so a new sort order repaints /// without a round-trip. pub(crate) listing: RefCell>, /// Cancels a running daemon-side thumbnail build; sits in the progress row. pub(crate) build_thumbnails: gtk4::Button, pub(crate) thumbnail_build_row: gtk4::Box, pub(crate) thumbnail_progress: gtk4::ProgressBar, pub(crate) thumbnail_status: gtk4::Label, pub(crate) thumbnail_poll: RefCell>, pub(crate) thumbnail_build_running: Cell, pub(crate) thumbnail_cancel_pending: Cell, /// Pending debounce timer for the search box; replaced on every keystroke so /// only the last pause actually fires a [`Request::Search`]. pub(crate) search_source: RefCell>, /// Runs folder loads and searches. Paths and queries can be requested /// repeatedly, so only its tickets can tell an older reply that finishes /// after a manual refresh from the newest one. pub(crate) loader: Rc, /// The folder the model holds rows for, or `None` while it holds search /// hits or nothing. Reloading this folder keeps the rows up and usable; /// loading any other greys them out until the new ones arrive. pub(crate) listed: RefCell>, /// The bulk-action bar, revealed once more than one entry is selected. pub(crate) bulk: gtk4::Revealer, pub(crate) bulk_label: gtk4::Label, /// Bulk buttons, sensitive only where they apply (offline state is a /// file-only notion, so a folder in the selection disables those two). pub(crate) bulk_trash: gtk4::Button, pub(crate) bulk_pin: gtk4::Button, pub(crate) bulk_unpin: gtk4::Button, pub(crate) bulk_move: gtk4::Button, /// Upload / New folder offered on the *empty folder* status page, so that /// state is a place to act rather than a dead end. Hidden on every other /// status (a load error is not the moment to offer an upload). pub(crate) empty_actions: gtk4::Box, /// Dolphin-style bottom status bar: current listing counts, the icon-grid /// zoom value, and Proton account storage usage. pub(crate) summary: gtk4::Label, pub(crate) zoom: gtk4::Scale, } /// Idle pause after the last keystroke before a search query is sent, so typing /// doesn't fire a request per character. pub(crate) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250); /// Cap on search hits requested from the daemon. pub(crate) const SEARCH_LIMIT: usize = 200; /// Browser grid thumbnail size controlled by the bottom zoom slider. The /// default preserves the former fixed 72 px presentation. pub(crate) const GRID_THUMB_MIN: i32 = 48; pub(crate) const GRID_THUMB_MAX: i32 = 144; pub(crate) const GRID_THUMB_DEFAULT: i32 = 72; pub(crate) const GRID_THUMB_STEP: i32 = 8; /// The Files page: a Nautilus-style file manager. A back/breadcrumb header with /// a grid/list view toggle sits over a [`FileList`] with Name / Size / Modified /// columns, so a directory load repopulates one model and both views update. /// /// The factories that render entries — and the columns — need the [`Ui`] handle /// for activation and the right-click menu, so they're installed later in /// [`wire_browser`]; this builder only assembles the empty widgets. /// /// Empty / loading / error outcomes aren't a label under the header: the whole /// content area swaps to a centred [`adw::StatusPage`] (see [`browser_status`]), /// so "this folder is empty" and "the mount is down" read as first-class states /// rather than a stray line above a blank grid. pub(crate) struct BrowserWidgets { pub(crate) files: FileList, pub(crate) crumb: gtk4::Box, /// Swaps the content area between the grid/list views and the status page. pub(crate) content: gtk4::Stack, /// The empty/loading/error surface shown in place of the views. pub(crate) status: adw::StatusPage, /// Sits in the status page; shown only when the mount service is down. pub(crate) retry: gtk4::Button, pub(crate) search: gtk4::SearchEntry, pub(crate) search_scope: gtk4::ToggleButton, pub(crate) actions: gio::SimpleActionGroup, pub(crate) view_button: adw::SplitButton, pub(crate) build_thumbnails: gtk4::Button, pub(crate) thumbnail_build_row: gtk4::Box, pub(crate) thumbnail_progress: gtk4::ProgressBar, pub(crate) thumbnail_status: gtk4::Label, pub(crate) summary: gtk4::Label, pub(crate) zoom: gtk4::Scale, /// Wraps the views + the details pane; the pane shows the selection while its header toggle is on. pub(crate) split: adw::OverlaySplitView, pub(crate) details: DetailsWidgets, pub(crate) bulk: gtk4::Revealer, pub(crate) bulk_label: gtk4::Label, pub(crate) bulk_trash: gtk4::Button, pub(crate) bulk_pin: gtk4::Button, pub(crate) bulk_unpin: gtk4::Button, pub(crate) bulk_move: gtk4::Button, pub(crate) bulk_clear: gtk4::Button, pub(crate) empty_actions: gtk4::Box, pub(crate) empty_upload: gtk4::Button, pub(crate) empty_new_folder: gtk4::Button, } pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { let back = gtk4::Button::builder() .icon_name("go-previous-symbolic") .tooltip_text(gettext("Back (Alt+Left)")) .valign(gtk4::Align::Center) .action_name("files.back") .build(); back.add_css_class("flat"); let forward = gtk4::Button::builder() .icon_name("go-next-symbolic") .tooltip_text(gettext("Forward (Alt+Right)")) .valign(gtk4::Align::Center) .action_name("files.forward") .build(); forward.add_css_class("flat"); let nav = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); nav.add_css_class("linked"); nav.append(&back); nav.append(&forward); // Clickable breadcrumb trail; `repaint_crumb` fills it per load. Wrapped in a // horizontally-scrolling viewport so a deep path can't shove the search box // and view toggles off the right edge. let crumb = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); crumb.set_valign(gtk4::Align::Center); let crumb_scroll = gtk4::ScrolledWindow::builder() .hscrollbar_policy(gtk4::PolicyType::External) .vscrollbar_policy(gtk4::PolicyType::Never) .hexpand(true) .child(&crumb) .build(); // Everything that makes something new here, in one menu: three bare icons // side by side read as a toolbar puzzle. let new_menu = gio::Menu::new(); new_menu.append(Some(&gettext("New Folder")), Some("files.new-folder")); let uploads = gio::Menu::new(); uploads.append(Some(&gettext("Upload Files…")), Some("files.upload")); uploads.append( Some(&gettext("Upload Folder…")), Some("files.upload-folder"), ); new_menu.append_section(None, &uploads); let new_button = gtk4::MenuButton::builder() .icon_name("list-add-symbolic") // Translators: tooltip of the menu button that creates a folder or uploads files. .tooltip_text(pgettext("create", "New")) .menu_model(&new_menu) .valign(gtk4::Align::Center) .build(); new_button.add_css_class("suggested-action"); // Grid or list on a click; the order lives in the arrow's menu. let view_menu = gio::Menu::new(); let layouts = gio::Menu::new(); layouts.append(Some(&pgettext("layout", "Grid")), Some("files.view::grid")); layouts.append(Some(&pgettext("layout", "List")), Some("files.view::list")); view_menu.append_section(None, &layouts); let sorts = gio::Menu::new(); sorts.append(Some(&pgettext("column", "Name")), Some("files.sort::name")); sorts.append(Some(&pgettext("column", "Size")), Some("files.sort::size")); sorts.append( Some(&gettext("Last Modified")), Some("files.sort::modified"), ); view_menu.append_section(Some(&gettext("Sort By")), &sorts); let order = gio::Menu::new(); order.append(Some(&gettext("Reversed Order")), Some("files.descending")); order.append(Some(&gettext("Folders First")), Some("files.folders-first")); view_menu.append_section(None, &order); let view_button = adw::SplitButton::builder() .icon_name("view-list-symbolic") .tooltip_text(gettext("Show as list (Ctrl+2)")) .dropdown_tooltip(gettext("Sort and view options")) .menu_model(&view_menu) .valign(gtk4::Align::Center) .action_name("files.toggle-view") .build(); // What applies to the folder on screen rather than to a selection. let folder_menu = gio::Menu::new(); folder_menu.append( Some(&gettext("Open in File Manager")), Some("files.open-folder"), ); folder_menu.append( Some(&gettext("Build Thumbnails")), Some("files.build-thumbnails"), ); let folder_button = gtk4::MenuButton::builder() .icon_name("view-more-symbolic") .tooltip_text(gettext("Folder actions")) .menu_model(&folder_menu) .valign(gtk4::Align::Center) .build(); // Cancels a running build; only visible in the progress row, which only // shows while one runs. let build_thumbnails = gtk4::Button::builder() .icon_name("process-stop-symbolic") .tooltip_text(gettext("Cancel thumbnail build")) .valign(gtk4::Align::Center) .build(); build_thumbnails.add_css_class("flat"); let search = gtk4::SearchEntry::builder() .placeholder_text(gettext("Search Drive")) .valign(gtk4::Align::Center) .build(); search.set_width_chars(18); // Hidden until there is a query to scope, and at the root, where "this // folder" is everywhere. let search_scope = gtk4::ToggleButton::builder() .icon_name("folder-symbolic") .tooltip_text(gettext("Search this folder only")) .valign(gtk4::Align::Center) .visible(false) .build(); search_scope.add_css_class("flat"); // The path bar stays in the page, under the header bar: the header's title // slot carries the page name and the busy spinner. let path_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); path_bar.append(&nav); path_bar.append(&crumb_scroll); let thumbnail_status = gtk4::Label::builder() .halign(gtk4::Align::Start) .ellipsize(gtk4::pango::EllipsizeMode::End) .build(); thumbnail_status.add_css_class("caption"); let thumbnail_progress = gtk4::ProgressBar::builder().hexpand(true).build(); let thumbnail_build_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); thumbnail_build_row.append(&thumbnail_status); thumbnail_build_row.append(&thumbnail_progress); thumbnail_build_row.append(&build_thumbnails); thumbnail_build_row.set_visible(false); // Empty / loading / error surface, shown in place of the views. let retry = gtk4::Button::builder() .label(gettext("Retry")) .halign(gtk4::Align::Center) .build(); retry.add_css_class("pill"); retry.add_css_class("suggested-action"); retry.set_visible(false); // The empty-folder state's way out. `StatusPage` takes one child, so Retry // and these share a box; each is shown only for the state it belongs to. let empty_upload = gtk4::Button::builder() .label(gettext("Upload files")) .build(); empty_upload.add_css_class("pill"); empty_upload.add_css_class("suggested-action"); let empty_new_folder = gtk4::Button::builder().label(gettext("New folder")).build(); empty_new_folder.add_css_class("pill"); let empty_actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); empty_actions.set_halign(gtk4::Align::Center); empty_actions.set_visible(false); empty_actions.append(&empty_upload); empty_actions.append(&empty_new_folder); let status_child = gtk4::Box::new(gtk4::Orientation::Vertical, 12); status_child.append(&retry); status_child.append(&empty_actions); let status = adw::StatusPage::builder() .icon_name("folder-symbolic") .vexpand(true) .child(&status_child) .build(); status.add_css_class("compact"); let files = FileList::new(); let view_stack = files.views.clone(); // Outer stack: the views, or the status page when there's nothing to show. let content = gtk4::Stack::new(); content.set_vexpand(true); content.set_transition_type(gtk4::StackTransitionType::Crossfade); content.add_named(&view_stack, Some("views")); content.add_named(&status, Some("status")); // The details pane slides in from the right while its header toggle is on. let (details_pane, details) = build_details_pane(); let details_toggle = details.toggle.clone(); let split = adw::OverlaySplitView::builder() .sidebar_position(gtk4::PackType::End) .collapsed(true) .show_sidebar(false) // Unpinned, uncollapsing at the wide breakpoint would show the pane by // itself; the header toggle alone decides. .pin_sidebar(true) .max_sidebar_width(300.0) .content(&content) .sidebar(&details_pane) .build(); // Bulk-action bar. A revealer rather than a hidden box so it slides in // instead of making the whole view jump when a second entry is selected. let bulk_label = gtk4::Label::builder().hexpand(true).xalign(0.0).build(); let bulk_pin = gtk4::Button::builder() .label(gettext("Make available offline")) .valign(gtk4::Align::Center) .build(); bulk_pin.add_css_class("flat"); let bulk_unpin = gtk4::Button::builder() .label(gettext("Make online only")) .valign(gtk4::Align::Center) .build(); bulk_unpin.add_css_class("flat"); let bulk_move = gtk4::Button::builder() .label(gettext("Move to…")) .valign(gtk4::Align::Center) .build(); bulk_move.add_css_class("flat"); let bulk_trash = gtk4::Button::builder() .label(gettext("Move to Trash")) .valign(gtk4::Align::Center) .build(); bulk_trash.add_css_class("destructive-action"); let bulk_clear = gtk4::Button::builder() .icon_name("window-close-symbolic") .tooltip_text(gettext("Clear selection (Esc)")) .valign(gtk4::Align::Center) .build(); bulk_clear.add_css_class("flat"); bulk_clear.add_css_class("circular"); let bulk_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); bulk_box.add_css_class("toolbar"); bulk_box.add_css_class("bulk-bar"); bulk_box.append(&bulk_label); bulk_box.append(&bulk_pin); bulk_box.append(&bulk_unpin); bulk_box.append(&bulk_move); bulk_box.append(&bulk_trash); bulk_box.append(&bulk_clear); let bulk = gtk4::Revealer::builder() .transition_type(gtk4::RevealerTransitionType::SlideDown) .child(&bulk_box) .build(); // The status bar: contextual text (stretch 1), then the icon-size slider. // Account storage lives in the sidebar, so it isn't repeated here. let summary = gtk4::Label::builder() .label(gettext("Loading…")) .halign(gtk4::Align::Start) .valign(gtk4::Align::Center) .ellipsize(gtk4::pango::EllipsizeMode::End) .hexpand(true) .build(); let zoom = gtk4::Scale::with_range( gtk4::Orientation::Horizontal, f64::from(GRID_THUMB_MIN), f64::from(GRID_THUMB_MAX), f64::from(GRID_THUMB_STEP), ); zoom.set_value(f64::from(GRID_THUMB_DEFAULT)); zoom.set_draw_value(false); zoom.set_valign(gtk4::Align::Center); zoom.set_tooltip_text(Some(&ngettext_f( "Size: {n} pixel", "Size: {n} pixels", GRID_THUMB_DEFAULT as u64, &[], ))); zoom.add_css_class("browser-status-meter"); zoom.update_property(&[gtk4::accessible::Property::Label(&gettext("Icon size"))]); // Small and large icon glyphs at the ends of the slider say what it does // without a text label. let zoom_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); zoom_box.set_valign(gtk4::Align::Center); zoom_box.append(>k4::Image::from_icon_name("zoom-out-symbolic")); zoom_box.append(&zoom); zoom_box.append(>k4::Image::from_icon_name("zoom-in-symbolic")); let status_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); status_bar.add_css_class("browser-statusbar"); status_bar.set_margin_start(6); status_bar.set_margin_end(2); status_bar.append(&summary); status_bar.append(&zoom_box); let zoom_for_view = zoom_box.clone(); let content_for_view = content.clone(); view_stack.connect_visible_child_name_notify(move |stack| { let visible = content_for_view.visible_child_name().as_deref() == Some("views") && stack.visible_child_name().as_deref() == Some("grid"); zoom_for_view.set_visible(visible); }); let zoom_for_content = zoom_box.clone(); let view_for_content = view_stack.clone(); content.connect_visible_child_name_notify(move |stack| { let visible = stack.visible_child_name().as_deref() == Some("views") && view_for_content.visible_child_name().as_deref() == Some("grid"); zoom_for_content.set_visible(visible); }); let body = gtk4::Box::new(gtk4::Orientation::Vertical, 12); body.set_vexpand(true); body.set_margin_top(12); body.set_margin_bottom(6); body.set_margin_start(12); body.set_margin_end(12); body.append(&path_bar); body.append(&thumbnail_build_row); body.append(&bulk); body.append(&split); let page = gtk4::Box::new(gtk4::Orientation::Vertical, 0); page.append(&body); page.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); page.append(&status_bar); let (frame, header, _) = page_frame(&gettext("My files"), &page); header.pack_start(&new_button); header.pack_end(&folder_button); header.pack_end(&details_toggle); header.pack_end(&view_button); header.pack_end(&search); header.pack_end(&search_scope); let actions = gio::SimpleActionGroup::new(); frame.insert_action_group("files", Some(&actions)); ( frame.upcast(), BrowserWidgets { files, crumb, content, status, retry, search, search_scope, actions, view_button, build_thumbnails, thumbnail_build_row, thumbnail_progress, thumbnail_status, summary, zoom, split, details, bulk, bulk_label, bulk_trash, bulk_pin, bulk_unpin, bulk_move, bulk_clear, empty_actions, empty_upload, empty_new_folder, }, ) } /// Swap the Files content area to the status page, with a Retry button only when /// the failure is one the user can act on. pub(crate) fn browser_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { ui.browser.status.set_icon_name(Some(icon)); ui.browser.status.set_title(title); ui.browser.status.set_description(Some(description)); ui.browser.retry.set_visible(retry); // Only the empty-folder state offers a way to fill the folder; every other // status (loading, offline, error) turns them back off. ui.browser.empty_actions.set_visible(false); ui.browser.content.set_visible_child_name("status"); clear_details(ui); } /// Every entry highlighted in My files, in model order. pub(crate) fn selected_entries(ui: &Rc) -> Vec { ui.browser.files.selected() } /// Drop the selection, which also retracts the bulk bar. pub(crate) fn clear_selection(ui: &Rc) { ui.browser.files.selection.unselect_all(); } /// 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. pub(crate) fn sync_bulk_bar(ui: &Rc) { let entries = selected_entries(ui); // One selected entry is the details pane's job; the bar is for a batch. if entries.len() < 2 { 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_unpinned = entries.iter().any(|e| !e.pinned); let mounted = *ui.mounted.borrow(); ui.browser.bulk_label.set_label(&ngettext_f( "{n} selected", "{n} selected", entries.len() as u64, &[], )); 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.set_reveal_child(true); } /// Move every selected entry to Trash. /// /// No confirmation: Trash is itself the safety net, and the toast carries one /// Undo that restores the whole batch. Asking first *and* offering Undo after /// would put two hurdles in front of a reversible action. pub(crate) fn trash_entries(ui: &Rc, entries: Vec) { if !entries.is_empty() { run_bulk_delete(ui, entries); } } /// Trash a batch, one request at a time. /// /// Sequential rather than parallel: the daemon serialises these anyway, and a /// burst of threads against one socket buys nothing but a harder failure to /// report. Partial success is reported as such — the entries that did move are /// still restorable through the Undo. pub(crate) fn run_bulk_delete(ui: &Rc, entries: Vec) { if !*ui.mounted.borrow() { toast_error( ui, &gettext("Couldn't move to Trash"), &gettext("Proton Drive isn't connected."), ); return; } let socket = ui.dirs.control_socket(); // A one-entry batch is still a batch, but it is worth naming in the toast: // "Moved “notes.txt” to Trash" tells the user which file far better than a // count of one does. let single = (entries.len() == 1).then(|| entries[0].name.clone()); let paths: Vec<(String, String)> = entries .iter() .map(|e| (entry_rel(ui, e), e.uid.clone())) .collect(); ui.busy_begin(); clear_selection(ui); let ui = ui.clone(); glib::spawn_future_local(async move { let mut trashed: Vec = Vec::new(); let mut failure: Option = None; for (path, uid) in paths { let rx = spawn_request(socket.clone(), Request::Delete { path }); match rx.recv().await { Ok(Ok(Response::Ok { .. })) => trashed.push(uid), Ok(Ok(Response::Error { message, .. })) => { failure.get_or_insert(message); } _ => { failure .get_or_insert_with(|| gettext("The Proton Drive service didn't respond.")); } } } ui.busy_end(); reload_listing(&ui); match (trashed.len(), failure) { (0, Some(message)) => toast_error(&ui, &gettext("Couldn't move to Trash"), &message), (0, None) => {} (n, failure) => { let message = match (failure, &single) { (Some(_), _) => ngettext_f( "Moved {n} item to Trash — some couldn't be moved", "Moved {n} items to Trash — some couldn't be moved", n as u64, &[], ), (None, Some(name)) => gettext_f("Moved “{name}” to Trash", &[("name", name)]), (None, None) => ngettext_f( "Moved {n} item to Trash", "Moved {n} items to Trash", n as u64, &[], ), }; toast_action(&ui, &message, &gettext("Undo"), move |ui| { restore_uids(ui, trashed.clone(), n); }); } } }); } /// Put trashed nodes back where they came from — the Undo behind a trash toast. pub(crate) fn restore_uids(ui: &Rc, uids: Vec, count: usize) { run_mutation( ui, Request::Restore { uids }, if count == 1 { gettext("Restored from Trash") } else { ngettext_f( "Restored {n} item from Trash", "Restored {n} items from Trash", count as u64, &[], ) }, gettext_noop("Couldn't restore from Trash"), ); } /// Pin or unpin every selected file, one request at a time. pub(crate) fn run_bulk_pin(ui: &Rc, entries: Vec, 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 = entries .iter() .filter(|e| !e.is_dir && e.pinned != pin) .map(|e| entry_rel(ui, e)) .collect(); if paths.is_empty() { return; } ui.busy_begin(); clear_selection(ui); let ui = ui.clone(); glib::spawn_future_local(async move { let mut done = 0usize; let mut failure: Option = None; for path in paths { let req = if pin { Request::Pin { path } } else { Request::Unpin { path } }; let rx = spawn_request(socket.clone(), req); match rx.recv().await { Ok(Ok(Response::Ok { .. })) => done += 1, Ok(Ok(Response::Error { message, .. })) => { failure.get_or_insert(message); } _ => { failure .get_or_insert_with(|| gettext("The Proton Drive service didn't respond.")); } } } ui.busy_end(); load_browser(&ui); match (done, failure) { (0, Some(message)) => { toast_error(&ui, &gettext("Couldn't change offline state"), &message) } (0, None) => {} (n, _) => toast( &ui, &if pin { ngettext_f( "{n} file is now available offline", "{n} files are now available offline", n as u64, &[], ) } else { ngettext_f( "{n} file is now online only", "{n} files are now online only", n as u64, &[], ) }, ), } }); } /// Wire the bulk-action bar and the empty-folder state's buttons. pub(crate) fn wire_bulk( ui: &Rc, bulk_clear: >k4::Button, empty_upload: >k4::Button, empty_new_folder: >k4::Button, ) { let ui_trash = ui.clone(); ui.browser .bulk_trash .connect_clicked(move |_| trash_entries(&ui_trash, selected_entries(&ui_trash))); let ui_pin = ui.clone(); ui.browser .bulk_pin .connect_clicked(move |_| run_bulk_pin(&ui_pin, selected_entries(&ui_pin), true)); let ui_unpin = ui.clone(); ui.browser .bulk_unpin .connect_clicked(move |_| run_bulk_pin(&ui_unpin, selected_entries(&ui_unpin), false)); let ui_move = ui.clone(); ui.browser .bulk_move .connect_clicked(move |_| prompt_move(&ui_move, selected_entries(&ui_move))); let ui_clear = ui.clone(); bulk_clear.connect_clicked(move |_| { clear_selection(&ui_clear); sync_bulk_bar(&ui_clear); }); let ui_upload = ui.clone(); empty_upload.connect_clicked(move |_| prompt_upload(&ui_upload)); let ui_new = ui.clone(); empty_new_folder.connect_clicked(move |_| prompt_new_folder(&ui_new)); } /// Swap the Files content area back to the grid/list views. pub(crate) fn browser_views(ui: &Rc) { ui.browser.content.set_visible_child_name("views"); } /// Install the entry factories, columns, activation handlers and the zoom /// slider. Split out from [`build_browser_page`] because every renderer needs /// the [`Ui`] handle to open entries and raise the context menu. pub(crate) fn wire_browser(ui: &Rc) { let files = &ui.browser.files; files.wire( ui, FileListBehavior { activate: activate_entry, entry_menu: entry_context_menu, bulk_menu: bulk_context_menu, background_menu: background_context_menu, badges: true, drag_and_drop: true, }, ); // Resize only realised grid cells. Rebuilding the whole model for every // slider step would repeatedly tear down selection state while the pointer // is still moving. let ui_zoom = ui.clone(); ui.browser.zoom.connect_value_changed(move |scale| { let size = (scale.value().round() as i32).clamp(GRID_THUMB_MIN, GRID_THUMB_MAX); scale.set_tooltip_text(Some(&ngettext_f( "Size: {n} pixel", "Size: {n} pixels", size as u64, &[], ))); ui_zoom.browser.files.set_thumbnail_size(size); }); files .column_view .append_column(&text_column(&pgettext("column", "Size"), |e| { if e.is_dir { "—".to_string() } else { human_bytes(e.size) } })); files .column_view .append_column(&text_column(&pgettext("column", "Modified"), |e| { dates::short_date(e.modified) })); // Search hits come from anywhere in the Drive; this says where. let location = text_column(&gettext(LOCATION_COLUMN), |e| hit_location(&e.path)); location.set_visible(false); files.column_view.append_column(&location); } /// Title of the search-hit folder column, also how the column is found again. const LOCATION_COLUMN: &str = gettext_noop("Location"); /// The folder holding a search hit, as a mountpoint-relative path. fn hit_location(path: &str) -> String { match path.rsplit_once('/') { Some((parent, _)) => parent.to_string(), None => gettext("My files"), } } /// Show the Location column only while the list holds search hits. fn show_location_column(ui: &Rc, visible: bool) { let columns = ui.browser.files.column_view.columns(); for column in (0..columns.n_items()) .filter_map(|i| columns.item(i).and_downcast::()) { if column.title().as_deref() == Some(gettext(LOCATION_COLUMN).as_str()) { column.set_visible(visible); } } } /// The menu for one entry, in the order of `docs/UI_UX_PLAN.md` §6: open, /// offline, sharing, organising, and the destructive item last on its own. pub(crate) fn entry_context_menu(ui: &Rc, entry: &DirEntry) -> ActionMenu { let mut menu = ActionMenu::new(); let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&pgettext("verb", "Open"), move || { // Open always means "download a local copy and hand off", even for a // media file — `activate_entry` would otherwise stream it. if is_streamable_media_entry(&entry_c) { download_and_open(&ui_c, &entry_c); } else { activate_entry(&ui_c, &entry_c); } }); let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&gettext("Open With…"), move || { open_entry_with(&ui_c, &entry_c) }); // Play streams from the mount, no download; Open above still fetches a // local copy for anyone who wants one. if is_streamable_media_entry(entry) { let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&pgettext("verb", "Play"), move || { stream_entry(&ui_c, &entry_c) }); } menu.section(); if !entry.is_dir { 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(); } let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&gettext("Share…"), move || { open_share_dialog(&ui_c, &entry_c) }); let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&gettext("Copy Link"), move || { copy_entry_link(&ui_c, &entry_c) }); menu.section(); let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&gettext("Rename…"), move || { prompt_rename(&ui_c, &entry_c) }); let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&gettext("Move To…"), move || { prompt_move(&ui_c, vec![entry_c.clone()]) }); if !entry.is_dir { let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&gettext("Versions…"), move || { open_versions_dialog(&ui_c, &entry_c) }); } let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&gettext("Details"), move || open_details(&ui_c, &entry_c)); menu.section(); let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&gettext("Move to Trash"), move || { trash_entry(&ui_c, &entry_c) }); menu } /// The menu for a multi-selection: the batch-capable actions only. Rename, /// Share and the revision history all need a single subject, so they are /// simply absent here rather than offered and then refused. pub(crate) fn bulk_context_menu(ui: &Rc, entries: Vec) -> 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) { 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| { run_bulk_pin(&ui_c, batch.clone(), pin) }); } let (ui_c, batch) = (ui.clone(), entries.clone()); menu.item(&gettext("Move To…"), move || { prompt_move(&ui_c, batch.clone()) }); menu.labelled_section(&ngettext_f( "{n} selected", "{n} selected", entries.len() as u64, &[], )); let ui_c = ui.clone(); menu.item(&gettext("Move to Trash"), move || { trash_entries(&ui_c, entries.clone()) }); menu } /// The menu for the folder on screen, opened on empty space. pub(crate) fn background_context_menu(ui: &Rc) -> ActionMenu { let mut menu = ActionMenu::new(); let run = |name: &'static str| { let ui = ui.clone(); move || ui.browser.actions.activate_action(name, None) }; let mounted = *ui.mounted.borrow(); if mounted && ui.browser.search.text().is_empty() { menu.item(&gettext("New Folder…"), run("new-folder")); menu.item(&gettext("Upload Files…"), run("upload")); menu.item(&gettext("Upload Folder…"), run("upload-folder")); menu.section(); } let ui_c = ui.clone(); menu.item(&gettext("Select All"), move || { ui_c.browser.files.selection.select_all(); }); let ui_c = ui.clone(); menu.item(&gettext("Refresh"), move || reload_listing(&ui_c)); if mounted { menu.item(&gettext("Open in File Manager"), run("open-folder")); } menu } /// Let the user pick the application, through the mount where the file keeps /// its real name, so the chooser can tell what kind of file it is. pub(crate) fn open_entry_with(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); let Some(path) = mounted_target_rel(&mountpoint, &rel) else { toast_error( ui, &gettext("Couldn't open file"), &gettext("Open With needs the Proton Drive service to be running."), ); return; }; let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(path))); launcher.set_always_ask(true); let ui = ui.clone(); launcher.launch( ui_window(&ui).as_ref(), gio::Cancellable::NONE, move |result| { if let Err(e) = result && !e.matches(gtk4::DialogError::Dismissed) { toast_error(&ui, &gettext("Couldn't open file"), &e.to_string()); } }, ); } /// Copy the entry's public link, when it has one this client can read the /// password of. A listed link carries no URL, since the password fragment is /// only known when the link is made, so otherwise open the Share dialog. pub(crate) fn copy_entry_link(ui: &Rc, entry: &DirEntry) { let req = if entry.path.is_empty() && !entry.uid.is_empty() { Request::ListShareByUid { uid: entry.uid.clone(), } } else { Request::ListShare { path: entry_rel(ui, entry), } }; let rx = spawn_request(ui.dirs.control_socket(), req); let (ui, entry) = (ui.clone(), entry.clone()); glib::spawn_future_local(async move { match rx.recv().await { Ok(Ok(Response::Share { link: Some(PublicLinkInfo { url: Some(url), .. }), .. })) => { ui.stack.clipboard().set_text(&url); toast(&ui, &gettext("Link copied")); } Ok(Ok(Response::Share { .. })) => open_share_dialog(&ui, &entry), Ok(Ok(Response::Error { message, kind })) => { toast_failure(&ui, &gettext("Couldn't copy link"), &message, kind) } _ => toast_error( &ui, &gettext("Couldn't copy link"), &gettext("The Proton Drive service didn't respond."), ), } }); } /// Fetch the [`DirEntry`] backing the model item at `pos`, if any. pub(crate) fn entry_at(model: Option<&impl IsA>, pos: u32) -> Option { let obj = model?.item(pos).and_downcast::()?; let entry = obj.borrow::().clone(); Some(entry) } /// Whether an entry can be opened through FUSE instead of fully materialized. pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool { drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia } /// Stream media straight from the mount, no download. Drive folders *are* part /// of the FUSE mount, so a player pointed at `/` reads the file /// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it /// seeks and buffers — instead of waiting for the whole file to land like /// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC /// `.mkv` starts playing in seconds. pub(crate) fn stream_entry(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); let abs = mounted_path(&mountpoint, &rel); let Some(path) = abs.to_str() else { toast_error( ui, &gettext("Couldn't play media"), &gettext("The file path isn't valid UTF-8."), ); return; }; toast( ui, &gettext_f("Streaming “{name}”…", &[("name", &entry.name)]), ); play_external(path); } /// Open an entry the Nautilus way: folders descend, media streams from the mount, /// other files download-and-open. pub(crate) fn activate_entry(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); if entry.is_dir { // Descending into a search hit: clear the query so the folder listing // isn't immediately re-masked by a stale search. if !entry.path.is_empty() { ui.browser.search.set_text(""); } browse_to(ui, rel); } else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia { // Media streams rather than downloads: that is exactly the "play it, // don't fetch the whole thing" behaviour this is for. stream_entry(ui, entry); } else { download_and_open(ui, entry); } } /// Hand a file to the user's default application: through the mount when it is /// there, otherwise downloaded into the cache first. The open path behind both /// a plain double-click and the context menu's "Open" (including for a video, /// when the user wants their default application rather than the player). /// /// The mount comes first because the cache blob is a *copy* keyed by content /// hash — an application saving into it writes where Drive will never look, and /// the cache may evict it afterwards. See /// [`mounted_target`](crate::activation::mounted_target). pub(crate) fn download_and_open(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); if let Some(path) = mounted_target_rel(&mountpoint, &rel) { open_path(&path.to_string_lossy()); return; } // Ignore a repeat activation of a file already downloading, so an impatient // double-click doesn't kick off a second round-trip. if !ui.opening.borrow_mut().insert(rel.clone()) { return; } ui.busy_begin(); let name = entry.name.clone(); let rx = spawn_request( ui.dirs.control_socket(), Request::OpenFile { path: rel.clone(), uid: Some(entry.uid.clone()), }, ); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); ui.opening.borrow_mut().remove(&rel); match result { // A cache blob is named by content hash: match the open rules // against the Drive name the user clicked, not that path. Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name), Ok(Ok(Response::Error { message, kind })) => { toast_failure(&ui, &gettext("Couldn't open file"), &message, kind) } _ => toast_error( &ui, &gettext("Couldn't open file"), &gettext("The Proton Drive service didn't respond."), ), } }); } /// Pin or unpin an entry through the daemon, then reload to reflect the new /// state. pub(crate) fn toggle_pin(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); let req = if entry.pinned { Request::Unpin { path: rel } } else { Request::Pin { path: rel } }; let rx = spawn_request(ui.dirs.control_socket(), req); let name = entry.name.clone(); let pinned = entry.pinned; let ui = ui.clone(); glib::spawn_future_local(async move { match rx.recv().await { Ok(Ok(Response::Error { message, kind })) => { toast_failure( &ui, &gettext("Couldn't change offline state"), &message, kind, ); // The details switch may be showing the state we failed to reach. load_browser(&ui); } Ok(Ok(_)) => { load_browser(&ui); toast( &ui, &if pinned { gettext_f("“{name}” is now online only", &[("name", &name)]) } else { gettext_f("“{name}” is now available offline", &[("name", &name)]) }, ); } _ => { toast_error( &ui, &gettext("Couldn't change offline state"), &gettext("The Proton Drive service didn't respond."), ); load_browser(&ui); } } }); } /// Join the entry name onto the current browser directory to get its /// mountpoint-relative path. pub(crate) fn entry_rel(ui: &Rc, entry: &DirEntry) -> String { // Search hits carry an absolute (mountpoint-relative) path since they can // live anywhere; plain listing entries derive it from the current folder. if !entry.path.is_empty() { return entry.path.clone(); } let base = ui.browser.path.borrow(); if base.is_empty() { entry.name.clone() } else { format!("{base}/{}", entry.name) } } /// Rebuild the clickable breadcrumb trail for the mountpoint-relative `path`. The /// root is always present ("Proton Drive"); each segment becomes a flat button /// that navigates to that ancestor, except the last (the current folder), shown /// as a plain heading label. pub(crate) fn repaint_crumb(ui: &Rc, path: &str) { while let Some(child) = ui.browser.crumb.first_child() { ui.browser.crumb.remove(&child); } let segments: Vec<&str> = if path.is_empty() { Vec::new() } else { path.split('/').collect() }; ui.browser.crumb.append(&crumb_node( ui, &gettext("My files"), "", segments.is_empty(), )); let mut acc = String::new(); for (i, seg) in segments.iter().enumerate() { let sep = gtk4::Label::new(Some("›")); sep.add_css_class("dim-label"); ui.browser.crumb.append(&sep); acc = if acc.is_empty() { seg.to_string() } else { format!("{acc}/{seg}") }; let current = i == segments.len() - 1; ui.browser.crumb.append(&crumb_node(ui, seg, &acc, current)); } } /// One breadcrumb segment: a plain heading label for the current folder, or a /// flat button that navigates to `target` (clearing any active search first). pub(crate) fn crumb_node(ui: &Rc, label: &str, target: &str, current: bool) -> gtk4::Widget { if current { let l = gtk4::Label::builder() .label(label) .ellipsize(gtk4::pango::EllipsizeMode::Start) .build(); l.add_css_class("heading"); return l.upcast(); } let button = gtk4::Button::builder().label(label).build(); button.add_css_class("flat"); let ui = ui.clone(); let target = target.to_string(); button.connect_clicked(move |_| { ui.browser.search.set_text(""); browse_to(&ui, target.clone()); }); button.upcast() } /// The `files.*` actions behind the header menus, the navigation buttons and /// the keyboard shortcuts, plus the saved view applied on start. pub(crate) fn wire_browser_actions(ui: &Rc, build_thumbnails: >k4::Button) { let actions = &ui.browser.actions; let simple = |name: &str, run: fn(&Rc)| { let action = gio::SimpleAction::new(name, None); let ui = ui.clone(); action.connect_activate(move |_, _| run(&ui)); actions.add_action(&action); }; simple("new-folder", prompt_new_folder); simple("upload", prompt_upload); simple("upload-folder", prompt_upload_folder); simple("build-thumbnails", start_thumbnail_build); simple("open-folder", open_current_folder); simple("back", browse_back); simple("forward", browse_forward); simple("up", browse_up); simple("toggle-view", |ui| { let mut view = ui.browser.view.get(); view.list = !view.list; set_files_view(ui, view); }); let view = ui.dirs.load_config().files_view; ui.browser.view.set(view); let layout = gio::SimpleAction::new_stateful( "view", Some(glib::VariantTy::STRING), &layout_name(view).to_variant(), ); let ui_layout = ui.clone(); layout.connect_change_state(move |_, value| { let mut view = ui_layout.browser.view.get(); view.list = value.and_then(|v| v.str()) == Some("list"); set_files_view(&ui_layout, view); }); actions.add_action(&layout); let sort = gio::SimpleAction::new_stateful( "sort", Some(glib::VariantTy::STRING), &view.sort.as_str().to_variant(), ); let ui_sort = ui.clone(); sort.connect_change_state(move |_, value| { let Some(key) = value.and_then(|v| v.str()).and_then(FileSort::parse) else { return; }; let mut view = ui_sort.browser.view.get(); view.sort = key; set_files_view(&ui_sort, view); }); actions.add_action(&sort); let toggle = |name: &str, state: bool, flip: fn(&mut FilesView)| { let action = gio::SimpleAction::new_stateful(name, None, &state.to_variant()); let ui = ui.clone(); action.connect_activate(move |_, _| { let mut view = ui.browser.view.get(); flip(&mut view); set_files_view(&ui, view); }); actions.add_action(&action); }; toggle("descending", view.descending, |view| { view.descending = !view.descending }); toggle("folders-first", view.folders_first, |view| { view.folders_first = !view.folders_first }); apply_files_view(ui, view); sync_history_actions(ui); let ui_thumbs = ui.clone(); build_thumbnails.connect_clicked(move |_| cancel_thumbnail_build(&ui_thumbs)); // The mouse's back and forward buttons, anywhere on the page. let buttons = gtk4::GestureClick::builder().button(0).build(); let ui_buttons = ui.clone(); buttons.connect_pressed(move |gesture, _, _, _| match gesture.current_button() { 8 => browse_back(&ui_buttons), 9 => browse_forward(&ui_buttons), _ => {} }); ui.browser.content.add_controller(buttons); // Files dropped in from a file manager upload into the folder on screen. let drop = gtk4::DropTarget::new( gtk4::gdk::FileList::static_type(), gtk4::gdk::DragAction::COPY, ); let ui_enter = ui.clone(); drop.connect_enter(move |_, _, _| { ui_enter.browser.content.add_css_class("files-drop-active"); gtk4::gdk::DragAction::COPY }); let ui_leave = ui.clone(); drop.connect_leave(move |_| { ui_leave .browser .content .remove_css_class("files-drop-active"); }); let ui_drop = ui.clone(); drop.connect_drop(move |_, value, _, _| { ui_drop .browser .content .remove_css_class("files-drop-active"); let Ok(files) = value.get::() else { return false; }; let sources: Vec = files .files() .iter() .filter_map(|f| f.path()) .filter_map(|p| p.to_str().map(str::to_string)) .collect(); if sources.is_empty() || !ui_drop.browser.search.text().trim().is_empty() { // Search results have no one folder to upload into. return false; } start_upload(&ui_drop, sources); true }); ui.browser.content.add_controller(drop); } fn layout_name(view: FilesView) -> &'static str { if view.list { "list" } else { "grid" } } /// Take a new view choice: show it, remember it, and repaint the listing in the /// new order. pub(crate) fn set_files_view(ui: &Rc, view: FilesView) { let old = ui.browser.view.replace(view); if old == view { return; } apply_files_view(ui, view); let mut config = ui.dirs.load_config(); config.files_view = view; if let Err(e) = ui.dirs.save_config(&config) { toast_error(ui, &gettext("Couldn't save the view"), &e.to_string()); } if (old.sort, old.descending, old.folders_first) != (view.sort, view.descending, view.folders_first) && ui.browser.search.text().trim().is_empty() && ui.browser.content.visible_child_name().as_deref() == Some("views") { let listing = ui.browser.listing.borrow().clone(); repaint_browser(ui, &listing); } } /// Bring the view stack, the view button and the actions' check marks in line /// with `view`. fn apply_files_view(ui: &Rc, view: FilesView) { ui.browser.files.show_list(view.list); // The button offers the other layout, the way a toggle reads. ui.browser.view_button.set_icon_name(if view.list { "view-grid-symbolic" } else { "view-list-symbolic" }); ui.browser.view_button.set_tooltip_text(Some(&if view.list { gettext("Show as grid (Ctrl+1)") } else { gettext("Show as list (Ctrl+2)") })); let actions = &ui.browser.actions; for (name, state) in [ ("view", layout_name(view).to_variant()), ("sort", view.sort.as_str().to_variant()), ("descending", view.descending.to_variant()), ("folders-first", view.folders_first.to_variant()), ] { if let Some(action) = actions .lookup_action(name) .and_downcast::() { action.set_state(&state); } } } /// Order a listing the way `view` says. Names compare case-insensitively, and /// break ties in the other two orders, so equal sizes still read alphabetically. pub(crate) fn sort_entries(entries: &mut [DirEntry], view: FilesView) { entries.sort_by(|a, b| { let by_name = || a.name.to_lowercase().cmp(&b.name.to_lowercase()); let key = match view.sort { FileSort::Name => by_name(), FileSort::Size => a.size.cmp(&b.size).then_with(by_name), FileSort::Modified => a.modified.cmp(&b.modified).then_with(by_name), }; let key = if view.descending { key.reverse() } else { key }; if view.folders_first { b.is_dir.cmp(&a.is_dir).then(key) } else { key } }); } /// Open the folder on screen in a new place, remembering where we were. pub(crate) fn browse_to(ui: &Rc, target: String) { let left = ui.browser.path.replace(target.clone()); if left != target { ui.browser.history.borrow_mut().push(left); ui.browser.future.borrow_mut().clear(); } load_browser(ui); } /// Switch to My files and open `folder`, a mountpoint-relative path. pub(crate) fn open_in_my_files(ui: &Rc, folder: String) { ui.stack.set_visible_child_name("browser"); ui.browser.search.set_text(""); browse_to(ui, folder); } /// Back: out of a search first, then to the previous folder. fn browse_back(ui: &Rc) { if !ui.browser.search.text().is_empty() { ui.browser.search.set_text(""); return; } let Some(previous) = ui.browser.history.borrow_mut().pop() else { return; }; let left = ui.browser.path.replace(previous); ui.browser.future.borrow_mut().push(left); load_browser(ui); } fn browse_forward(ui: &Rc) { let Some(next) = ui.browser.future.borrow_mut().pop() else { return; }; ui.browser.search.set_text(""); let left = ui.browser.path.replace(next); ui.browser.history.borrow_mut().push(left); load_browser(ui); } fn browse_up(ui: &Rc) { let path = ui.browser.path.borrow().clone(); if path.is_empty() { return; } ui.browser.search.set_text(""); let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or_default(); browse_to(ui, parent.to_string()); } /// Enable Back, Forward and Up for where the browser is now. pub(crate) fn sync_history_actions(ui: &Rc) { let set = |name: &str, enabled: bool| { if let Some(action) = ui .browser .actions .lookup_action(name) .and_downcast::() { action.set_enabled(enabled); } }; set( "back", !ui.browser.history.borrow().is_empty() || !ui.browser.search.text().is_empty(), ); set("forward", !ui.browser.future.borrow().is_empty()); set("up", !ui.browser.path.borrow().is_empty()); } /// Enable the actions that need the daemon only while the mount is up. pub(crate) fn sync_mounted_actions(ui: &Rc, mounted: bool) { for name in [ "new-folder", "upload", "upload-folder", "build-thumbnails", "open-folder", ] { if let Some(action) = ui .browser .actions .lookup_action(name) .and_downcast::() { action.set_enabled( mounted && !(name == "build-thumbnails" && ui.browser.thumbnail_build_running.get()), ); } } } /// Show the folder on screen in the desktop's file manager, through the mount. fn open_current_folder(ui: &Rc) { let path = ui.browser.path.borrow().clone(); let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); let dir = mounted_path(&mountpoint, &path); let uri = gio::File::for_path(&dir).uri(); if let Err(e) = gio::AppInfo::launch_default_for_uri(&uri, gio::AppLaunchContext::NONE) { toast_error(ui, &gettext("Couldn't open the folder"), &e.to_string()); } } const THUMBNAIL_BUILD_POLL: Duration = Duration::from_millis(500); /// Start the daemon's deliberate recursive job. Opportunistic requests for /// visible rows are cancelled first; the explicit job has its own lifetime and /// continues if the user navigates while watching its progress. fn start_thumbnail_build(ui: &Rc) { if !*ui.mounted.borrow() { toast_error( ui, &gettext("Couldn't build thumbnails"), &gettext("Proton Drive isn't connected."), ); return; } cancel_file_thumbnails(ui); ui.browser.thumbnail_build_running.set(true); // Do not expose Cancel until the daemon has acknowledged Start; the two // requests use separate control connections and could otherwise reorder. ui.browser.thumbnail_cancel_pending.set(true); repaint_thumbnail_build_action(ui, true); ui.browser.thumbnail_build_row.set_visible(true); ui.browser.thumbnail_progress.set_fraction(0.0); ui.browser .thumbnail_status .set_label(&gettext("Starting thumbnail build…")); let path = ui.browser.path.borrow().clone(); let rx = spawn_request( ui.dirs.control_socket(), Request::StartThumbnailBuild { path }, ); let ui = ui.clone(); glib::spawn_future_local(async move { match rx.recv().await { Ok(Ok(Response::ThumbnailBuild { status })) => { ui.browser.thumbnail_cancel_pending.set(false); repaint_thumbnail_build(&ui, &status); } Ok(Ok(Response::Error { message, kind })) => { thumbnail_build_failed(&ui); toast_failure(&ui, &gettext("Couldn't build thumbnails"), &message, kind); } _ => { let message = gettext("The Proton Drive service didn't respond."); thumbnail_build_failed(&ui); toast_error(&ui, &gettext("Couldn't build thumbnails"), &message); } } }); } fn cancel_thumbnail_build(ui: &Rc) { if ui.browser.thumbnail_cancel_pending.replace(true) { return; } ui.browser.build_thumbnails.set_sensitive(false); ui.browser .thumbnail_status .set_label(&gettext("Stopping thumbnail build…")); let rx = spawn_request(ui.dirs.control_socket(), Request::CancelThumbnailBuild); let ui = ui.clone(); glib::spawn_future_local(async move { match rx.recv().await { Ok(Ok(Response::ThumbnailBuild { status })) if !status.running => { repaint_thumbnail_build(&ui, &status) } Ok(Ok(Response::ThumbnailBuild { .. })) => schedule_thumbnail_build_poll(&ui), Ok(Ok(Response::Error { message, kind })) => { thumbnail_cancel_failed(&ui); toast_failure( &ui, &gettext("Couldn't cancel thumbnail build"), &message, kind, ); } _ => { thumbnail_cancel_failed(&ui); toast_error( &ui, &gettext("Couldn't cancel thumbnail build"), &gettext("The Proton Drive service didn't respond."), ); } } }); } fn thumbnail_cancel_failed(ui: &Rc) { ui.browser.thumbnail_cancel_pending.set(false); ui.browser.thumbnail_build_running.set(true); repaint_thumbnail_build_action(ui, true); schedule_thumbnail_build_poll(ui); } fn schedule_thumbnail_build_poll(ui: &Rc) { if let Some(source) = ui.browser.thumbnail_poll.borrow_mut().take() { source.remove(); } let ui_poll = ui.clone(); let source = glib::timeout_add_local_once(THUMBNAIL_BUILD_POLL, move || { ui_poll.browser.thumbnail_poll.borrow_mut().take(); let rx = spawn_request(ui_poll.dirs.control_socket(), Request::ThumbnailBuildStatus); let ui_result = ui_poll.clone(); glib::spawn_future_local(async move { match rx.recv().await { Ok(Ok(Response::ThumbnailBuild { status })) => { repaint_thumbnail_build(&ui_result, &status) } Ok(Ok(Response::Error { message, .. })) => { thumbnail_build_failed(&ui_result); toast_error(&ui_result, &gettext("Thumbnail build stopped"), &message); } _ => { thumbnail_build_failed(&ui_result); toast_error( &ui_result, &gettext("Thumbnail build stopped"), &gettext("The Proton Drive service stopped reporting thumbnail progress."), ); } } }); }); *ui.browser.thumbnail_poll.borrow_mut() = Some(source); } fn repaint_thumbnail_build(ui: &Rc, status: &ThumbnailBuildStatus) { ui.browser .thumbnail_build_row .set_visible(show_thumbnail_build_progress(status)); let root = if status.path.is_empty() { "Proton Drive" } else { &status.path }; let text = if status.scanning { ui.browser.thumbnail_progress.pulse(); let folders = ngettext_f("{n} folder", "{n} folders", status.folders_scanned, &[]); let images = ngettext_f("{n} image", "{n} images", status.images_found, &[]); // Translators: {root} is a folder path or "Proton Drive"; {folders} and {images} are counts such as "3 folders" and "12 images". gettext_f( "Scanning {root}… {folders}, {images}", &[("root", root), ("folders", &folders), ("images", &images)], ) } else if status.running { let fraction = if status.images_found == 0 { 0.0 } else { status.completed as f64 / status.images_found as f64 }; ui.browser .thumbnail_progress .set_fraction(fraction.clamp(0.0, 1.0)); // Translators: {root} is a folder path or "Proton Drive"; {done} and {total} are image counts. gettext_f( "Building thumbnails in {root}… {done} of {total}", &[ ("root", root), ("done", &status.completed.to_string()), ("total", &status.images_found.to_string()), ], ) } else { ui.browser.thumbnail_progress.set_fraction(1.0); let available = status.completed.saturating_sub(status.failed); match status.failed { // Translators: {root} is a folder path or "Proton Drive". 0 => ngettext_f( "Thumbnails ready for {n} image in {root}", "Thumbnails ready for {n} images in {root}", available, &[("root", root)], ), // Translators: {root} is a folder path or "Proton Drive"; {failed} is how many images could not get a thumbnail. failed => ngettext_f( "Thumbnails ready for {n} image in {root}; {failed} unavailable", "Thumbnails ready for {n} images in {root}; {failed} unavailable", available, &[("root", root), ("failed", &failed.to_string())], ), } }; let text = match status.message.as_deref() { // Translators: {status} is the thumbnail build progress; {message} is a problem the mount service reported. Some(message) => gettext_f( "{status}. {message}", &[("status", &text), ("message", message)], ), None => text, }; ui.browser.thumbnail_status.set_label(&text); ui.browser.thumbnail_status.set_tooltip_text(Some(&text)); if status.running { ui.browser.thumbnail_build_running.set(true); repaint_thumbnail_build_action(ui, true); schedule_thumbnail_build_poll(ui); } else { // The progress row leaves with the run, so the outcome is handed to a // toast instead — otherwise the build ends without a word. Only on the // running → finished edge: a status read at startup is not news. if ui.browser.thumbnail_build_running.get() { toast(ui, &text); } ui.browser.thumbnail_build_running.set(false); ui.browser.thumbnail_cancel_pending.set(false); repaint_thumbnail_build_action(ui, false); if ui.stack.visible_child_name().as_deref() == Some("browser") { reload_listing(ui); } } } /// The progress row is transient: once the daemon reports completion, both its /// label and bar leave the browser instead of retaining a stale success state. fn show_thumbnail_build_progress(status: &ThumbnailBuildStatus) -> bool { status.running } fn repaint_thumbnail_build_action(ui: &Rc, running: bool) { sync_mounted_actions(ui, *ui.mounted.borrow()); ui.browser .build_thumbnails .set_sensitive(running && !ui.browser.thumbnail_cancel_pending.get()); } fn thumbnail_build_failed(ui: &Rc) { ui.browser.thumbnail_build_running.set(false); ui.browser.thumbnail_cancel_pending.set(false); repaint_thumbnail_build_action(ui, false); ui.browser.thumbnail_build_row.set_visible(false); ui.browser.thumbnail_progress.set_fraction(0.0); ui.browser.thumbnail_status.set_label(""); ui.browser.thumbnail_status.set_tooltip_text(None); } /// Send a mutating request (rename / move / delete / mkdir / upload, or a trash /// restore / purge) on a worker thread, then reload the listing it changed and /// confirm with a toast, or report the daemon's error in one. `done` is the /// past-tense confirmation ("Renamed to “x”"); `failed` names the attempt /// ("Couldn't rename"). /// /// `failed` is an untranslated msgid: callers mark it with [`gettext_noop`] and /// it is translated here. pub(crate) fn run_mutation(ui: &Rc, req: Request, done: String, failed: &'static str) { run_mutation_then(ui, req, failed, move |ui| toast(ui, &done)); } /// [`run_mutation`] with the confirmation left to `done`, for an outcome that /// deserves more than a plain toast. pub(crate) fn run_mutation_then( ui: &Rc, req: Request, failed: &'static str, done: impl FnOnce(&Rc) + 'static, ) { let failed = gettext(failed); if !*ui.mounted.borrow() { toast_error(ui, &failed, &gettext("Proton Drive isn't connected.")); return; } ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), req); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); match result { Ok(Ok(Response::Ok { .. })) => { // The listing the mutation changed is stale now; reload it, then // confirm, so the toast lands over the updated view. reload_listing(&ui); done(&ui); } Ok(Ok(Response::Error { message, kind })) => { toast_failure(&ui, &failed, &message, kind) } _ => toast_error( &ui, &failed, &gettext("The Proton Drive service didn't respond."), ), } }); } /// Reload the listing a completed mutation invalidated: whichever of the two /// listing pages is on screen, since that is the one the action was raised from. pub(crate) fn reload_listing(ui: &Rc) { match ui.stack.visible_child_name().as_deref() { Some("browser") => load_browser(ui), Some("trash") => load_trash(ui), Some("sharedbyme") => load_shared_by_me(ui), _ => {} } } /// Prompt for a new name and rename the entry through the daemon. pub(crate) fn prompt_rename(ui: &Rc, entry: &DirEntry) { let parent = ui_window(ui); let rel = entry_rel(ui, entry); let original = entry.name.clone(); let dialog = adw::AlertDialog::builder() .heading(gettext("Rename")) .build(); let group = adw::PreferencesGroup::new(); let row = adw::EntryRow::builder() .title(gettext("New name")) .activates_default(true) .build(); row.set_text(&original); group.add(&row); dialog.set_extra_child(Some(&group)); // Select the name without its extension, the part a rename usually // changes; a folder's whole name is the name. let stem = match original.rfind('.') { Some(dot) if dot > 0 && !entry.is_dir => original[..dot].chars().count(), _ => original.chars().count(), } as i32; let row_focus = row.clone(); glib::idle_add_local_once(move || { row_focus.grab_focus(); row_focus.select_region(0, stem); }); dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("confirm", &gettext("Rename")); dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("confirm")); dialog.set_close_response("cancel"); let ui = ui.clone(); dialog.connect_response(None, move |_, resp| { if resp != "confirm" { return; } let new_name = row.text().trim().to_string(); if new_name.is_empty() || new_name == original { return; } let done = gettext_f("Renamed to “{name}”", &[("name", &new_name)]); run_mutation( &ui, Request::Rename { path: rel.clone(), new_name, }, done, gettext_noop("Couldn't rename"), ); }); dialog.present(parent.as_ref()); } /// Pick a destination folder by browsing Drive's folders, and move `entries` /// there through the daemon. /// /// Besides My files the picker offers every synced folder. A move into one is /// still made on Proton Drive, so nothing is downloaded or uploaded again; it /// is sent with absolute paths, which is how the daemon tells locations apart. pub(crate) fn prompt_move(ui: &Rc, entries: Vec) { if entries.is_empty() { return; } let sources: Vec = entries.iter().map(|e| entry_rel(ui, e)).collect(); let heading = match entries.as_slice() { [one] => gettext_f("Move “{name}”", &[("name", &one.name)]), many => ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]), }; let dialog = adw::AlertDialog::builder() .heading(heading) .body(gettext("Choose the folder to move into.")) .build(); let up = gtk4::Button::builder() .icon_name("go-up-symbolic") .tooltip_text(gettext("Parent folder")) .build(); up.add_css_class("flat"); let location = gtk4::Label::builder() .xalign(0.0) .hexpand(true) .ellipsize(gtk4::pango::EllipsizeMode::Start) .build(); location.add_css_class("heading"); let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); bar.append(&up); bar.append(&location); let list = gtk4::ListBox::builder() .selection_mode(gtk4::SelectionMode::None) .build(); list.add_css_class("boxed-list"); let scroll = gtk4::ScrolledWindow::builder() .hscrollbar_policy(gtk4::PolicyType::Never) .min_content_height(240) .child(&list) .build(); let body = gtk4::Box::new(gtk4::Orientation::Vertical, 8); body.append(&bar); body.append(&scroll); dialog.set_extra_child(Some(&body)); dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("move", &gettext("Move Here")); dialog.set_response_appearance("move", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("move")); dialog.set_close_response("cancel"); let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); let picker = Rc::new(MovePicker { ui: ui.clone(), dialog: dialog.clone(), location, list, up: up.clone(), sources: sources.clone(), folder: RefCell::new(MoveTarget::Locations), generation: Cell::new(0), }); let picker_up = picker.clone(); up.connect_clicked(move |_| { let parent = picker_up.folder.borrow().parent(); picker_up.open(parent); }); // Start where the browser is: moving is most often one level up or down. picker.open(MoveTarget::Drive(ui.browser.path.borrow().clone())); let ui = ui.clone(); let picker_done = picker.clone(); dialog.connect_response(None, move |_, resp| { if resp != "move" { return; } match picker_done.folder.borrow().clone() { MoveTarget::Locations => {} MoveTarget::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder), MoveTarget::Synced { path, .. } => { let absolute = sources .iter() .map(|rel| mountpoint.join(rel).to_string_lossy().into_owned()) .collect(); run_bulk_move(&ui, absolute, path); } } }); dialog.present(ui_window(&picker.ui).as_ref()); } /// Where the Move dialog is looking, and the destination if the user confirms. #[derive(Clone, Debug, PartialEq)] pub(crate) enum MoveTarget { /// The list of places to move to: My files and each synced folder. Locations, /// A folder in My files, mountpoint-relative; empty for My files itself. Drive(String), /// A folder inside a synced folder, as an absolute local path. `root` is /// the synced folder. A mirror folder is a plain directory, read from disk. Synced { root: String, path: String, mirror: bool, }, } impl MoveTarget { /// One level up. The top of a location leads back to the list of them. pub(crate) fn parent(&self) -> MoveTarget { match self { MoveTarget::Locations => MoveTarget::Locations, MoveTarget::Drive(folder) if folder.is_empty() => MoveTarget::Locations, MoveTarget::Drive(folder) => MoveTarget::Drive( folder .rfind('/') .map(|i| folder[..i].to_string()) .unwrap_or_default(), ), MoveTarget::Synced { root, path, .. } if path == root => MoveTarget::Locations, MoveTarget::Synced { root, path, mirror } => MoveTarget::Synced { root: root.clone(), path: Path::new(path) .parent() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|| root.clone()), mirror: *mirror, }, } } /// `name` inside this folder, in the same form. fn child(&self, name: &str) -> MoveTarget { match self { MoveTarget::Locations => MoveTarget::Locations, MoveTarget::Drive(folder) if folder.is_empty() => MoveTarget::Drive(name.to_string()), MoveTarget::Drive(folder) => MoveTarget::Drive(format!("{folder}/{name}")), MoveTarget::Synced { root, path, mirror } => MoveTarget::Synced { root: root.clone(), path: Path::new(path).join(name).to_string_lossy().into_owned(), mirror: *mirror, }, } } } /// The Move dialog's folder browser. struct MovePicker { ui: Rc, dialog: adw::AlertDialog, location: gtk4::Label, list: gtk4::ListBox, up: gtk4::Button, /// What is being moved, mountpoint-relative. sources: Vec, /// The folder on show, and the destination if the user confirms now. folder: RefCell, /// Drops a listing that arrives after the user went elsewhere. generation: Cell, } impl MovePicker { fn open(self: &Rc, folder: MoveTarget) { let generation = self.generation.get().wrapping_add(1); self.generation.set(generation); let (label, tooltip) = match &folder { MoveTarget::Locations => (gettext("Locations"), String::new()), MoveTarget::Drive(rel) if rel.is_empty() => (gettext("My files"), String::new()), MoveTarget::Drive(rel) => (rel.clone(), rel.clone()), MoveTarget::Synced { path, .. } => (path.clone(), path.clone()), }; self.location.set_label(&label); self.location.set_tooltip_text(Some(&tooltip)); self.up .set_sensitive(!matches!(folder, MoveTarget::Locations)); let allowed = match &folder { MoveTarget::Locations => false, MoveTarget::Drive(rel) => move_target_allowed(&self.sources, rel), // What is moved is in My files, so it cannot be in a synced folder. MoveTarget::Synced { .. } => true, }; self.dialog.set_response_enabled("move", allowed); *self.folder.borrow_mut() = folder.clone(); while let Some(row) = self.list.first_child() { self.list.remove(&row); } self.list.append(&picker_note(&gettext("Loading…"))); let picker = self.clone(); glib::spawn_future_local(async move { let listed = match &folder { MoveTarget::Locations => picker.locations().await, MoveTarget::Drive(rel) => picker.drive_folders(&folder, rel.clone()).await, MoveTarget::Synced { path, mirror: true, .. } => local_folders(&folder, path.clone()).await, MoveTarget::Synced { path, .. } => { picker.drive_folders(&folder, path.clone()).await } }; if picker.generation.get() != generation { return; } while let Some(row) = picker.list.first_child() { picker.list.remove(&row); } let Some(rows) = listed else { picker .list .append(&picker_note(&gettext("Couldn't read this folder."))); return; }; let mut shown = 0; for (title, subtitle, target) in rows { // A folder cannot go inside itself, so do not offer to open it. if let MoveTarget::Drive(path) = &target && picker.sources.contains(path) { continue; } let row = adw::ActionRow::builder() .title(glib::markup_escape_text(&title).as_str()) .activatable(true) .build(); if let Some(subtitle) = subtitle { row.set_subtitle(glib::markup_escape_text(&subtitle).as_str()); } row.add_prefix(>k4::Image::from_icon_name("folder-symbolic")); row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic")); let open = picker.clone(); row.connect_activated(move |_| open.open(target.clone())); picker.list.append(&row); shown += 1; } if shown == 0 { picker .list .append(&picker_note(&gettext("No folders here."))); } }); } /// My files and every synced folder, as rows of the top level. async fn locations(&self) -> Option> { let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))]; let rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders); let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else { // My files alone is still somewhere to move to. return Some(rows); }; items.sort_by_key(|f| f.local_path.to_lowercase()); for folder in items { let name = Path::new(&folder.local_path) .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| folder.local_path.clone()); rows.push(( name, Some(folder.local_path.clone()), MoveTarget::Synced { root: folder.local_path.clone(), path: folder.local_path, mirror: folder.mode == "mirror", }, )); } Some(rows) } /// The folders in a mounted folder: `folder` is mountpoint-relative for My /// files and absolute for an on-demand synced folder. async fn drive_folders(&self, here: &MoveTarget, folder: String) -> Option> { let rx = spawn_request( self.ui.dirs.control_socket(), Request::ListDir { path: folder.clone(), }, ); let Ok(Ok(Response::Entries { entries })) = rx.recv().await else { return None; }; let mut names: Vec = entries .into_iter() .filter(|e| e.is_dir) .map(|e| e.name) .collect(); names.sort_by_key(|name| name.to_lowercase()); Some( names .into_iter() .map(|name| { let target = here.child(&name); (name, None, target) }) .collect(), ) } } /// One row of the picker: its title, an optional subtitle, and where it leads. type PickerRow = (String, Option, MoveTarget); /// The folders in a mirror folder's directory `path`, read from disk off the /// main thread. A mirror folder has no mount for `ListDir` to read. async fn local_folders(here: &MoveTarget, path: String) -> Option> { let names = gio::spawn_blocking(move || { let mut names: Vec = std::fs::read_dir(&path) .ok()? .flatten() .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) .filter_map(|e| e.file_name().into_string().ok()) .filter(|name| !name.contains(".pdfs-tmp-")) .collect(); names.sort_by_key(|name| name.to_lowercase()); Some(names) }) .await .ok()??; Some( names .into_iter() .map(|name| { let target = here.child(&name); (name, None, target) }) .collect(), ) } fn picker_note(text: &str) -> gtk4::ListBoxRow { let label = gtk4::Label::builder() .label(text) .margin_top(12) .margin_bottom(12) .build(); label.add_css_class("dim-label"); gtk4::ListBoxRow::builder() .child(&label) .activatable(false) .selectable(false) .build() } /// Whether `target` is somewhere `sources` can move to: not into one of /// themselves, and not the folder every one of them is already in. pub(crate) fn move_target_allowed(sources: &[String], target: &str) -> bool { let parent = |path: &str| { path.rfind('/') .map(|i| path[..i].to_string()) .unwrap_or_default() }; let inside = sources .iter() .any(|src| target == src || target.starts_with(&format!("{src}/"))); let all_here = sources.iter().all(|src| parent(src) == target); !inside && !all_here } /// Move each of `sources` into `target`, one request at a time, then report /// once for the lot. pub(crate) fn run_bulk_move(ui: &Rc, sources: Vec, target: String) { if !*ui.mounted.borrow() { toast_error( ui, &gettext("Couldn't move"), &gettext("Proton Drive isn't connected."), ); return; } let socket = ui.dirs.control_socket(); ui.busy_begin(); clear_selection(ui); let ui = ui.clone(); glib::spawn_future_local(async move { let mut done = 0usize; let mut failure: Option<(String, ErrorKind)> = None; for path in sources { let rx = spawn_request( socket.clone(), Request::Move { path, new_parent: target.clone(), }, ); match rx.recv().await { Ok(Ok(Response::Ok { .. })) => done += 1, Ok(Ok(Response::Error { message, kind })) => { failure.get_or_insert((message, kind)); } _ => { failure.get_or_insert_with(|| { ( gettext("The Proton Drive service didn't respond."), ErrorKind::Internal, ) }); } } } ui.busy_end(); reload_listing(&ui); let place = match target.rsplit('/').next() { // Translators: a folder name in quotes, filled in as {place} in "Moved to {place}". Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]), _ => "Proton Drive".to_string(), }; match (done, failure) { (0, Some((message, kind))) => { toast_failure(&ui, &gettext("Couldn't move"), &message, kind) } (0, None) => {} (n, Some((message, _))) => toast_error( &ui, // Translators: {place} is a quoted folder name or "Proton Drive". &ngettext_f( "Moved {n} item to {place}, but not all", "Moved {n} items to {place}, but not all", n as u64, &[("place", &place)], ), &message, ), // Translators: {place} is a quoted folder name or "Proton Drive". (1, None) => toast(&ui, &gettext_f("Moved to {place}", &[("place", &place)])), (n, None) => toast( &ui, // Translators: {place} is a quoted folder name or "Proton Drive". &ngettext_f( "Moved {n} item to {place}", "Moved {n} items to {place}", n as u64, &[("place", &place)], ), ), } }); } /// Move one entry to Trash, through the batch path so it gets the same Undo. pub(crate) fn trash_entry(ui: &Rc, entry: &DirEntry) { run_bulk_delete(ui, vec![entry.clone()]); } /// Prompt for a folder name and create it under the current browser directory. pub(crate) fn prompt_new_folder(ui: &Rc) { let win = ui_window(ui); let parent = ui.browser.path.borrow().clone(); let dialog = adw::AlertDialog::builder() .heading(gettext("New Folder")) .body(gettext("Create a folder in the current directory.")) .build(); let group = adw::PreferencesGroup::new(); let row = adw::EntryRow::builder() .title(gettext("Folder name")) .activates_default(true) .build(); group.add(&row); dialog.set_extra_child(Some(&group)); dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("confirm", &gettext("Create")); dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("confirm")); dialog.set_close_response("cancel"); let ui = ui.clone(); dialog.connect_response(None, move |_, resp| { if resp != "confirm" { return; } let name = row.text().trim().to_string(); if name.is_empty() { return; } let done = gettext_f("Created “{name}”", &[("name", &name)]); run_mutation( &ui, Request::CreateFolder { parent: parent.clone(), name, }, done, gettext_noop("Couldn't create folder"), ); }); dialog.present(win.as_ref()); } /// Pick one or more local files and upload them into the current browser /// directory. The daemon streams them from disk itself, so nothing is read into /// the GUI — even a large multi-file selection. pub(crate) fn prompt_upload(ui: &Rc) { let win = ui_window(ui); let dialog = gtk4::FileDialog::builder() .title(gettext("Upload Files")) .build(); let ui = ui.clone(); dialog.open_multiple(win.as_ref(), gio::Cancellable::NONE, move |res| { let Ok(files) = res else { return }; let sources: Vec = files .into_iter() .filter_map(|f| f.ok()) .filter_map(|obj| obj.downcast::().ok()) .filter_map(|f| f.path()) .filter_map(|p| p.to_str().map(str::to_string)) .collect(); start_upload(&ui, sources); }); } /// Pick a local folder and upload it — with its whole subtree — into the current /// browser directory. The daemon recreates the directory structure remotely. pub(crate) fn prompt_upload_folder(ui: &Rc) { let win = ui_window(ui); let dialog = gtk4::FileDialog::builder() .title(gettext("Upload Folder")) .build(); let ui = ui.clone(); dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| { let Ok(folder) = res else { return }; let Some(path) = folder.path().and_then(|p| p.to_str().map(str::to_string)) else { return; }; start_upload(&ui, vec![path]); }); } /// Hand a set of local source paths to the daemon for background bulk upload. /// The daemon acks at once and does the work off-socket, so we confirm the /// hand-off with a toast; the Activity group then shows live progress and the /// listing refreshes itself when the transfers finish (see [`repaint_transfers`]). pub(crate) fn start_upload(ui: &Rc, sources: Vec) { if sources.is_empty() { return; } if !*ui.mounted.borrow() { toast_error( ui, &gettext("Couldn't upload"), &gettext("Proton Drive isn't connected."), ); return; } let parent = ui.browser.path.borrow().clone(); let n = sources.len(); let rx = spawn_request( ui.dirs.control_socket(), Request::UploadPaths { parent, sources }, ); let ui = ui.clone(); glib::spawn_future_local(async move { match rx.recv().await { Ok(Ok(Response::Ok { .. })) => { let what = if n == 1 { gettext("Uploading…") } else { ngettext_f("Uploading {n} item…", "Uploading {n} items…", n as u64, &[]) }; // Progress lives on the Sync page; say where. toast_action(&ui, &what, &pgettext("verb", "View"), |ui| { ui.stack.set_visible_child_name("locations") }); } Ok(Ok(Response::Error { message, kind })) => { toast_failure(&ui, &gettext("Couldn't upload"), &message, kind) } _ => toast_error( &ui, &gettext("Couldn't upload"), &gettext("The Proton Drive service didn't respond."), ), } }); } /// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders /// (which carry no per-file cache state). 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.is_dir { return None; } Some(if entry.pinned { ("pdfs-offline-symbolic", "badge-pinned") } else if entry.cached { ("pdfs-cached-symbolic", "badge-cached") } else { ("pdfs-online-only-symbolic", "badge-cloud") }) } /// Paint `badge` to reflect the entry's sync state (see [`badge_for`]). Clears any /// prior colour class first, since list factories recycle cells. pub(crate) fn apply_badge(badge: >k4::Image, entry: &DirEntry) { for class in ["badge-pinned", "badge-cached", "badge-cloud"] { badge.remove_css_class(class); } match badge_for(entry) { Some((icon, class)) => { badge.set_icon_name(Some(icon)); badge.add_css_class(class); badge.set_visible(true); } None => badge.set_visible(false), } } /// Make a browser cell draggable, carrying the bound entry's mountpoint-relative /// path as the drag payload. Reads the entry live at drag time (via the captured /// [`gtk4::ListItem`]) so a recycled cell drags whatever it currently shows. pub(crate) fn attach_drag(ui: &Rc, item: >k4::ListItem, anchor: >k4::Box) { let source = gtk4::DragSource::new(); source.set_actions(gtk4::gdk::DragAction::MOVE); let ui = ui.clone(); let item = item.clone(); source.connect_prepare(move |_, _, _| { let obj = item.item().and_downcast::()?; let rel = entry_rel(&ui, &obj.borrow::()); Some(gtk4::gdk::ContentProvider::for_value(&glib::Value::from( rel.as_str(), ))) }); anchor.add_controller(source); } /// Make a browser cell a drop target: dropping a dragged path onto a *folder* cell /// moves the source into it through the daemon. Drops onto files, onto the item /// itself, or that would move a folder into its own subtree are rejected. pub(crate) fn attach_drop(ui: &Rc, item: >k4::ListItem, anchor: >k4::Box) { let target = gtk4::DropTarget::new(glib::types::Type::STRING, gtk4::gdk::DragAction::MOVE); let ui = ui.clone(); let item = item.clone(); target.connect_drop(move |_, value, _, _| { let Some(obj) = item.item().and_downcast::() else { return false; }; let dest = obj.borrow::(); if !dest.is_dir { return false; } let Ok(src) = value.get::() else { return false; }; let dest_path = entry_rel(&ui, &dest); // No-op onto self, and never move a folder into itself or a descendant. if src == dest_path || dest_path.starts_with(&format!("{src}/")) { return false; } let done = gettext_f("Moved into “{name}”", &[("name", &dest.name)]); run_mutation( &ui, Request::Move { path: src, new_parent: dest_path, }, done, gettext_noop("Couldn't move"), ); true }); anchor.add_controller(target); } /// The full-colour icon for an entry: [`icon_base_for`], except that folders /// are Proton's own purple ones while the Proton theme is on. pub(crate) fn icon_for(entry: &DirEntry) -> &'static str { if entry.is_dir && proton_theme_active() { return "pdfs-proton-folder"; } icon_base_for(entry) } /// Pick a freedesktop icon base name for an entry from its kind / extension. /// Callers append `-symbolic` for the column view's small icons. pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str { if entry.is_dir { return "folder"; } let ext = entry .name .rsplit('.') .next() .unwrap_or("") .to_ascii_lowercase(); match ext.as_str() { "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => { "image-x-generic" } "mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" => "video-x-generic", "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic", "pdf" | "doc" | "docx" | "odt" => "x-office-document", "xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet", "ppt" | "pptx" | "odp" => "x-office-presentation", "zip" | "tar" | "gz" | "xz" | "bz2" | "7z" | "rar" => "package-x-generic", _ => "text-x-generic", } } /// Request the current browser directory from the daemon and repaint both views. pub(crate) fn load_browser(ui: &Rc) { cancel_file_thumbnails(ui); let path = ui.browser.path.borrow().clone(); repaint_crumb(ui, &path); sync_history_actions(ui); sync_search_scope(ui); // A reload of the folder on screen leaves its rows alone. Any other load // keeps the old rows up only greyed out, with nothing selected: their // paths resolve against the new folder, so acting on one would hit the // wrong file. let ui_p = ui.clone(); let placeholder = move || { ui_p.browser.files.model.remove_all(); ui_p.browser.summary.set_label(&gettext("Loading…")); browser_status( &ui_p, "folder-symbolic", &gettext("Loading…"), &gettext("Reading this folder."), false, ); }; let ticket = if ui.browser.listed.borrow().as_deref() == Some(path.as_str()) { ui.browser.loader.refresh(placeholder) } else { forget_listing(ui); ui.browser.loader.replace(placeholder) }; ui.busy_begin(); let rx = spawn_request( ui.dirs.control_socket(), Request::ListDir { path: path.clone() }, ); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); // The user may have navigated on while this folder was loading. A stale // out-of-order reply must not repaint rows for a folder we've left, or // the breadcrumb and the grid would disagree. if !ticket.is_current() || *ui.browser.path.borrow() != path { return; } match result { Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries), Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind), Ok(Ok(_)) => browser_failed( &ui, &gettext("Unexpected reply from the Proton Drive service."), ErrorKind::Internal, ), Ok(Err(_)) | Err(_) => browser_unreachable(&ui), } }); } /// Clear the model and show the daemon's error on the status page. Used for /// in-band failures (a bad path, a permission error) — the mount is up, so Retry /// (which restarts the service) wouldn't help and isn't offered. pub(crate) fn browser_failed(ui: &Rc, message: &str, kind: ErrorKind) { forget_listing(ui); ui.browser.files.model.remove_all(); ui.browser.summary.set_label(&gettext("Folder unavailable")); browser_status( ui, "dialog-warning-symbolic", error_headline(kind, &gettext("Couldn't open this folder")), message, // Offer Retry only where repeating the request could actually work. // A folder that is gone stays gone however many times it is asked for. kind.retryable(), ); } /// The daemon didn't answer. Distinguish *still starting* (auto-retry, no /// button) from *down* (actionable error + Retry), so a cold start self-heals /// once the systemd mount comes up but a real failure stays visible. pub(crate) fn browser_unreachable(ui: &Rc) { service_unreachable( ui, "browser", |ui, icon, title, description, retry| { ui.browser.summary.set_label(title); browser_status(ui, icon, title, description, retry); }, load_browser, ); } /// Repopulate the shared model — folders first, then case-insensitive by name — /// which refreshes both the grid and the column list. pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { show_location_column(ui, false); *ui.browser.listing.borrow_mut() = entries.to_vec(); *ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone()); ui.browser.summary.set_label(&listing_summary( entries.iter().filter(|entry| !entry.is_dir).count(), entries.iter().filter(|entry| entry.is_dir).count(), entries .iter() .filter(|entry| !entry.is_dir) .map(|entry| entry.size) .sum(), )); if entries.is_empty() { browser_status( ui, "folder-open-symbolic", &gettext("This folder is empty"), &gettext("Drop files here, or upload a file or create a folder to get started."), false, ); ui.browser.files.model.remove_all(); ui.browser.empty_actions.set_visible(true); return; } browser_views(ui); let mut sorted = entries.to_vec(); sort_entries(&mut sorted, ui.browser.view.get()); // Only the rows that changed are swapped, so a refresh keeps the // selection, the scroll position and the thumbnails already drawn. replace_items(&ui.browser.files.model, &sorted); } /// Forget which folder the rows belong to and drop the selection, before the /// rows stop matching the folder on screen. fn forget_listing(ui: &Rc) { ui.browser.listed.borrow_mut().take(); clear_selection(ui); clear_details(ui); } /// Wire the browser header's search box: debounce keystrokes, then either run a /// search or — when cleared — restore the current directory listing. pub(crate) fn wire_search(ui: &Rc) { let ui_s = ui.clone(); ui.browser.search.connect_search_changed(move |_| { sync_history_actions(&ui_s); sync_search_scope(&ui_s); // Replace any pending debounce so only the last keystroke's pause fires. if let Some(src) = ui_s.browser.search_source.borrow_mut().take() { src.remove(); } let ui_t = ui_s.clone(); let src = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || { ui_t.browser.search_source.borrow_mut().take(); let query = ui_t.browser.search.text().trim().to_string(); if query.is_empty() { load_browser(&ui_t); } else { run_search(&ui_t, &query); } }); *ui_s.browser.search_source.borrow_mut() = Some(src); }); let ui_s = ui.clone(); ui.browser.search_scope.connect_toggled(move |scope| { scope.set_tooltip_text(Some(&if scope.is_active() { gettext("Search everywhere") } else { gettext("Search this folder only") })); let query = ui_s.browser.search.text().trim().to_string(); if !query.is_empty() { run_search(&ui_s, &query); } }); } /// Offer the scope toggle only while there is a query and a folder to scope it to. pub(crate) fn sync_search_scope(ui: &Rc) { let offered = !ui.browser.search.text().trim().is_empty() && !ui.browser.path.borrow().is_empty(); ui.browser.search_scope.set_visible(offered); } /// The folder a search is limited to, or `None` to search everywhere. fn search_scope(ui: &Rc) -> Option { let path = ui.browser.path.borrow(); (ui.browser.search_scope.is_active() && !path.is_empty()).then(|| path.clone()) } /// Send a [`Request::Search`] to the daemon and render the hits in the browser /// views, reusing the same row model so click-to-open and pin work unchanged /// (each hit carries its full path; see [`entry_rel`]). pub(crate) fn run_search(ui: &Rc, query: &str) { cancel_file_thumbnails(ui); // Hits carry their own paths, but the old rows may not: grey them out and // drop the selection until the hits arrive. forget_listing(ui); let ui_p = ui.clone(); let looking_for = query.to_string(); let ticket = ui.browser.loader.replace(move || { ui_p.browser.files.model.remove_all(); ui_p.browser.summary.set_label(&gettext("Searching…")); browser_status( &ui_p, "system-search-symbolic", &gettext("Searching…"), &gettext_f("Looking for “{query}”.", &[("query", &looking_for)]), false, ); }); ui.busy_begin(); let query = query.to_string(); let rx = spawn_request( ui.dirs.control_socket(), Request::Search { query: query.clone(), limit: SEARCH_LIMIT, scope: search_scope(ui), }, ); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); // The box may have been cleared or typed past while the reply was in // flight; if the query no longer matches, a fresher load/search already // owns the model — drop this stale, possibly out-of-order result. if !ticket.is_current() || ui.browser.search.text().trim() != query { return; } match result { Ok(Ok(Response::SearchResults { hits })) => repaint_search(&ui, &hits), Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind), Ok(Ok(_)) => browser_failed( &ui, &gettext("Unexpected reply from the Proton Drive service."), ErrorKind::Internal, ), Ok(Err(_)) | Err(_) => browser_unreachable(&ui), } }); } /// Repopulate the model with search hits — folders first, then by name — mapping /// each [`SearchHit`] to a path-carrying [`DirEntry`] the existing renderers and /// handlers already understand. pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { show_location_column(ui, true); let counts = listing_summary( hits.iter().filter(|hit| !hit.is_dir).count(), hits.iter().filter(|hit| hit.is_dir).count(), hits.iter() .filter(|hit| !hit.is_dir) .map(|hit| hit.size) .sum(), ); ui.browser .summary .set_label(&if hits.len() >= SEARCH_LIMIT { // Translators: {summary} counts the hits, such as "2 folders, 5 files (3.0 MB)". ngettext_f( "{summary} — showing the first {n} search result", "{summary} — showing the first {n} search results", SEARCH_LIMIT as u64, &[("summary", &counts)], ) } else { // Translators: {summary} counts the hits, such as "2 folders, 5 files (3.0 MB)". gettext_f("{summary} — search results", &[("summary", &counts)]) }); if hits.is_empty() { browser_status( ui, "system-search-symbolic", &gettext("No matches"), &gettext("No files or folders match that search."), false, ); ui.browser.files.model.remove_all(); return; } browser_views(ui); let mut entries: Vec = hits .iter() .map(|h| DirEntry { name: h.name.clone(), is_dir: h.is_dir, size: h.size, modified: h.modified, pinned: h.pinned, cached: h.cached, uid: h.uid.clone(), path: h.path.clone(), role: String::new(), shared_by: String::new(), shared_at: 0, shared_by_unverified: false, trashed_at: 0, trashed_from: None, }) .collect(); sort_entries(&mut entries, ui.browser.view.get()); replace_items(&ui.browser.files.model, &entries); } fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String { let folder_count = ngettext_f("{n} folder", "{n} folders", folders as u64, &[]); let file_count = ngettext_f("{n} file", "{n} files", files as u64, &[]); let size = human_bytes(file_bytes); match (folders, files) { (1.., 0) => folder_count, // Translators: {files} is a count such as "3 files"; {size} is their total size such as "4.2 MB". (0, 1..) => gettext_f( "{files} ({size})", &[("files", &file_count), ("size", &size)], ), // Translators: {folders} and {files} are counts such as "2 folders" and "0 files". (0, 0) => gettext_f( "{folders}, {files}", &[("folders", &folder_count), ("files", &file_count)], ), // Translators: {folders} and {files} are counts such as "1 folder" and "3 files"; {size} is the files' total size such as "4.2 MB". _ => gettext_f( "{folders}, {files} ({size})", &[ ("folders", &folder_count), ("files", &file_count), ("size", &size), ], ), } } #[cfg(test)] mod tests { use super::{ MoveTarget, hit_location, listing_summary, move_target_allowed, show_thumbnail_build_progress, sort_entries, }; use pdfs_core::config::{FileSort, FilesView}; use pdfs_core::control::{DirEntry, ThumbnailBuildStatus}; fn entry(name: &str, is_dir: bool, size: u64, modified: i64) -> DirEntry { DirEntry { name: name.to_string(), is_dir, size, modified, pinned: false, cached: false, uid: String::new(), path: String::new(), role: String::new(), shared_by: String::new(), shared_at: 0, shared_by_unverified: false, trashed_at: 0, trashed_from: None, } } fn names(entries: &[DirEntry]) -> Vec<&str> { entries.iter().map(|e| e.name.as_str()).collect() } #[test] fn a_search_hit_location_is_its_parent_folder() { assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old"); assert_eq!(hit_location("a.txt"), "My files"); } #[test] fn listing_sorts_by_the_chosen_key_with_folders_first() { let mut entries = vec![ entry("b.txt", false, 10, 300), entry("Docs", true, 0, 100), entry("a.txt", false, 30, 200), entry("c.txt", false, 10, 100), ]; let mut view = FilesView::default(); sort_entries(&mut entries, view); assert_eq!(names(&entries), ["Docs", "a.txt", "b.txt", "c.txt"]); view.sort = FileSort::Size; view.descending = true; sort_entries(&mut entries, view); assert_eq!(names(&entries), ["Docs", "a.txt", "c.txt", "b.txt"]); view.sort = FileSort::Modified; view.descending = false; view.folders_first = false; sort_entries(&mut entries, view); assert_eq!(names(&entries), ["c.txt", "Docs", "a.txt", "b.txt"]); } #[test] fn move_refuses_its_own_subtree_and_the_folder_it_is_in() { let sources = ["Docs/Work".to_string()]; assert!(!move_target_allowed(&sources, "Docs/Work")); assert!(!move_target_allowed(&sources, "Docs/Work/Old")); assert!(!move_target_allowed(&sources, "Docs")); assert!(move_target_allowed(&sources, "")); assert!(move_target_allowed(&sources, "Docs/Workshop")); let mixed = ["a.txt".to_string(), "Docs/b.txt".to_string()]; assert!(move_target_allowed(&mixed, "")); } #[test] fn listing_summary_matches_dolphin_order_and_wording() { assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files"); assert_eq!(listing_summary(0, 2, 0), "2 folders"); assert_eq!(listing_summary(1, 0, 1024), "1 file (1.0 KiB)"); assert_eq!(listing_summary(3, 1, 3072), "1 folder, 3 files (3.0 KiB)"); } #[test] fn thumbnail_progress_disappears_when_build_finishes() { let mut status = ThumbnailBuildStatus { running: true, scanning: true, ..Default::default() }; assert!(show_thumbnail_build_progress(&status)); status.scanning = false; assert!(show_thumbnail_build_progress(&status)); status.running = false; assert!(!show_thumbnail_build_progress(&status)); } #[test] fn move_picker_climbs_from_a_location_top_to_the_list_of_locations() { assert_eq!( MoveTarget::Drive(String::new()).parent(), MoveTarget::Locations ); let root = MoveTarget::Synced { root: "/home/u/Docs".into(), path: "/home/u/Docs".into(), mirror: true, }; assert_eq!(root.parent(), MoveTarget::Locations); } #[test] fn move_picker_climbs_one_folder_at_a_time_inside_a_location() { assert_eq!( MoveTarget::Drive("a/b".into()).parent(), MoveTarget::Drive("a".into()) ); assert_eq!( MoveTarget::Drive("a".into()).parent(), MoveTarget::Drive(String::new()) ); let inner = MoveTarget::Synced { root: "/home/u/Docs".into(), path: "/home/u/Docs/x/y".into(), mirror: false, }; assert_eq!( inner.parent(), MoveTarget::Synced { root: "/home/u/Docs".into(), path: "/home/u/Docs/x".into(), mirror: false, } ); } }