mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
A move request with two absolute paths is resolved against every location (My files, on-demand and mirror synced folders) and made as a server-side move_node. The local side is levelled afterwards: mounts forget and re-list, a mirror folder renames its copy and baseline along or drops the copy once Drive holds it. Anything only this disk holds is refused up front: queued ops, open writes, unsynced or ignored mirror files and symlinks. The GUI Move dialog gains a top-level list of locations.
3089 lines
115 KiB
Rust
3089 lines
115 KiB
Rust
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<Vec<String>>,
|
||
pub(crate) future: RefCell<Vec<String>>,
|
||
/// 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<String>,
|
||
/// 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<FilesView>,
|
||
/// The folder listing as the daemon sent it, so a new sort order repaints
|
||
/// without a round-trip.
|
||
pub(crate) listing: RefCell<Vec<DirEntry>>,
|
||
/// 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<Option<glib::SourceId>>,
|
||
pub(crate) thumbnail_build_running: Cell<bool>,
|
||
pub(crate) thumbnail_cancel_pending: Cell<bool>,
|
||
/// 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<Option<glib::SourceId>>,
|
||
/// 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<Loader>,
|
||
/// 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<Option<String>>,
|
||
/// 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<Ui>, 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<Ui>) -> Vec<DirEntry> {
|
||
ui.browser.files.selected()
|
||
}
|
||
|
||
/// Drop the selection, which also retracts the bulk bar.
|
||
pub(crate) fn clear_selection(ui: &Rc<Ui>) {
|
||
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<Ui>) {
|
||
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<Ui>, entries: Vec<DirEntry>) {
|
||
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<Ui>, entries: Vec<DirEntry>) {
|
||
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<String> = Vec::new();
|
||
let mut failure: Option<String> = 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<Ui>, uids: Vec<String>, 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<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
||
let socket = ui.dirs.control_socket();
|
||
// Only the entries that need changing: re-pinning an already-pinned file is
|
||
// a wasted round-trip, and it would inflate the count the toast reports.
|
||
let paths: Vec<String> = entries
|
||
.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<String> = 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<Ui>,
|
||
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>) {
|
||
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<Ui>) {
|
||
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<Ui>, 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::<gtk4::ColumnViewColumn>())
|
||
{
|
||
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<Ui>, 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<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||
let mut menu = ActionMenu::new();
|
||
// Offline state is a file-only notion; the check shows whether the whole
|
||
// batch already is.
|
||
if entries.iter().all(|e| !e.is_dir) {
|
||
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<Ui>) -> 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<Ui>, 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<Ui>, 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<gio::ListModel>>, pos: u32) -> Option<DirEntry> {
|
||
let obj = model?.item(pos).and_downcast::<BoxedAnyObject>()?;
|
||
let entry = obj.borrow::<DirEntry>().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 `<mountpoint>/<rel>` 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, build_thumbnails: >k4::Button) {
|
||
let actions = &ui.browser.actions;
|
||
let simple = |name: &str, run: fn(&Rc<Ui>)| {
|
||
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::<gtk4::gdk::FileList>() else {
|
||
return false;
|
||
};
|
||
let sources: Vec<String> = 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<Ui>, 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<Ui>, 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::<gio::SimpleAction>()
|
||
{
|
||
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<Ui>, 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<Ui>, 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<Ui>) {
|
||
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<Ui>) {
|
||
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<Ui>) {
|
||
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<Ui>) {
|
||
let set = |name: &str, enabled: bool| {
|
||
if let Some(action) = ui
|
||
.browser
|
||
.actions
|
||
.lookup_action(name)
|
||
.and_downcast::<gio::SimpleAction>()
|
||
{
|
||
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<Ui>, 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::<gio::SimpleAction>()
|
||
{
|
||
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<Ui>) {
|
||
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<Ui>) {
|
||
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<Ui>) {
|
||
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>) {
|
||
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<Ui>) {
|
||
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<Ui>, 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<Ui>, 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>) {
|
||
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<Ui>, 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<Ui>,
|
||
req: Request,
|
||
failed: &'static str,
|
||
done: impl FnOnce(&Rc<Ui>) + '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<Ui>) {
|
||
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<Ui>, 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<Ui>, entries: Vec<DirEntry>) {
|
||
if entries.is_empty() {
|
||
return;
|
||
}
|
||
let sources: Vec<String> = 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<Ui>,
|
||
dialog: adw::AlertDialog,
|
||
location: gtk4::Label,
|
||
list: gtk4::ListBox,
|
||
up: gtk4::Button,
|
||
/// What is being moved, mountpoint-relative.
|
||
sources: Vec<String>,
|
||
/// The folder on show, and the destination if the user confirms now.
|
||
folder: RefCell<MoveTarget>,
|
||
/// Drops a listing that arrives after the user went elsewhere.
|
||
generation: Cell<u64>,
|
||
}
|
||
|
||
impl MovePicker {
|
||
fn open(self: &Rc<Self>, 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<Vec<PickerRow>> {
|
||
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<Vec<PickerRow>> {
|
||
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<String> = 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<String>, 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<Vec<PickerRow>> {
|
||
let names = gio::spawn_blocking(move || {
|
||
let mut names: Vec<String> = 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<Ui>, sources: Vec<String>, 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<Ui>, 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<Ui>) {
|
||
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<Ui>) {
|
||
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<String> = files
|
||
.into_iter()
|
||
.filter_map(|f| f.ok())
|
||
.filter_map(|obj| obj.downcast::<gio::File>().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<Ui>) {
|
||
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<Ui>, sources: Vec<String>) {
|
||
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<Ui>, 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::<BoxedAnyObject>()?;
|
||
let rel = entry_rel(&ui, &obj.borrow::<DirEntry>());
|
||
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<Ui>, 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::<BoxedAnyObject>() else {
|
||
return false;
|
||
};
|
||
let dest = obj.borrow::<DirEntry>();
|
||
if !dest.is_dir {
|
||
return false;
|
||
}
|
||
let Ok(src) = value.get::<String>() 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<Ui>) {
|
||
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<Ui>, 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<Ui>) {
|
||
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<Ui>, 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>) {
|
||
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<Ui>) {
|
||
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<Ui>) {
|
||
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<Ui>) -> Option<String> {
|
||
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<Ui>, 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<Ui>, 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<DirEntry> = 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,
|
||
}
|
||
);
|
||
}
|
||
}
|