mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
1456 lines
54 KiB
Rust
1456 lines
54 KiB
Rust
use crate::activation::{DriveActivation, drive_activation, mounted_path};
|
||
use crate::*;
|
||
|
||
pub(crate) struct BrowserState {
|
||
// Files (browser) page.
|
||
/// Shared model behind the grid and column views; repopulated per directory.
|
||
pub(crate) model: gio::ListStore,
|
||
pub(crate) back: gtk4::Button,
|
||
/// 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,
|
||
/// The folder-level actions, insensitive while the mount is down: without a
|
||
/// daemon they can only fail, and a greyed button says so before the click.
|
||
pub(crate) new_folder: gtk4::Button,
|
||
pub(crate) upload: gtk4::Button,
|
||
pub(crate) upload_folder: gtk4::Button,
|
||
/// 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>>,
|
||
}
|
||
|
||
/// 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;
|
||
|
||
/// The Files page: a Nautilus-style file manager. A back/breadcrumb header with
|
||
/// a grid/list view toggle sits over a [`gtk4::Stack`] that swaps between an
|
||
/// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`]
|
||
/// with Name / Size / Modified columns). Both views are driven by one shared
|
||
/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`DirEntry`]s, so a directory
|
||
/// load repopulates the model once 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) model: gio::ListStore,
|
||
pub(crate) back: gtk4::Button,
|
||
pub(crate) crumb: gtk4::Box,
|
||
pub(crate) grid: gtk4::GridView,
|
||
pub(crate) column_view: gtk4::ColumnView,
|
||
/// 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) new_folder: gtk4::Button,
|
||
pub(crate) upload: gtk4::Button,
|
||
pub(crate) upload_folder: gtk4::Button,
|
||
pub(crate) refresh: gtk4::Button,
|
||
/// Wraps the views + the details pane; the pane slides in on selection.
|
||
pub(crate) split: adw::OverlaySplitView,
|
||
pub(crate) details: DetailsWidgets,
|
||
/// The two selection models, so a selection change can drive the details pane
|
||
/// and so an action can re-read the entry the user has highlighted.
|
||
pub(crate) grid_selection: gtk4::SingleSelection,
|
||
pub(crate) list_selection: gtk4::SingleSelection,
|
||
}
|
||
|
||
pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||
|
||
let back = gtk4::Button::builder()
|
||
.icon_name("go-previous-symbolic")
|
||
.tooltip_text("Up one folder")
|
||
.valign(gtk4::Align::Center)
|
||
.sensitive(false)
|
||
.build();
|
||
back.add_css_class("flat");
|
||
// 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();
|
||
|
||
// Folder-level actions: create a subfolder / upload a file into the current
|
||
// directory. Wired in `wire_browser_actions`.
|
||
let new_folder = gtk4::Button::builder()
|
||
.icon_name("folder-new-symbolic")
|
||
.tooltip_text("New folder")
|
||
.valign(gtk4::Align::Center)
|
||
.build();
|
||
new_folder.add_css_class("flat");
|
||
let upload = gtk4::Button::builder()
|
||
.icon_name("document-send-symbolic")
|
||
.tooltip_text("Upload files")
|
||
.valign(gtk4::Align::Center)
|
||
.build();
|
||
upload.add_css_class("flat");
|
||
let upload_folder = gtk4::Button::builder()
|
||
.icon_name("pdfs-folder-upload-symbolic")
|
||
.tooltip_text("Upload folder")
|
||
.valign(gtk4::Align::Center)
|
||
.build();
|
||
upload_folder.add_css_class("flat");
|
||
|
||
// Linked grid/list toggle, top-right, Nautilus-style.
|
||
let grid_toggle = gtk4::ToggleButton::builder()
|
||
.icon_name("view-grid-symbolic")
|
||
.tooltip_text("Grid view")
|
||
.active(true)
|
||
.build();
|
||
let list_toggle = gtk4::ToggleButton::builder()
|
||
.icon_name("view-list-symbolic")
|
||
.tooltip_text("List view")
|
||
.build();
|
||
list_toggle.set_group(Some(&grid_toggle));
|
||
let toggles = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||
toggles.add_css_class("linked");
|
||
toggles.append(&grid_toggle);
|
||
toggles.append(&list_toggle);
|
||
|
||
let search = gtk4::SearchEntry::builder()
|
||
.placeholder_text("Search Drive")
|
||
.valign(gtk4::Align::Center)
|
||
.build();
|
||
search.set_width_chars(18);
|
||
|
||
let refresh = refresh_button();
|
||
|
||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||
header.append(&back);
|
||
header.append(&crumb_scroll);
|
||
header.append(&refresh);
|
||
header.append(&new_folder);
|
||
header.append(&upload);
|
||
header.append(&upload_folder);
|
||
header.append(&search);
|
||
header.append(&toggles);
|
||
|
||
// Empty / loading / error surface, shown in place of the views.
|
||
let retry = gtk4::Button::builder()
|
||
.label("Retry")
|
||
.halign(gtk4::Align::Center)
|
||
.build();
|
||
retry.add_css_class("pill");
|
||
retry.add_css_class("suggested-action");
|
||
retry.set_visible(false);
|
||
let status = adw::StatusPage::builder()
|
||
.icon_name("folder-symbolic")
|
||
.vexpand(true)
|
||
.child(&retry)
|
||
.build();
|
||
status.add_css_class("compact");
|
||
|
||
// Icon grid.
|
||
let grid_selection = gtk4::SingleSelection::builder()
|
||
.model(&model)
|
||
.autoselect(false)
|
||
.can_unselect(true)
|
||
.build();
|
||
let grid = gtk4::GridView::builder()
|
||
.model(&grid_selection)
|
||
.min_columns(2)
|
||
.max_columns(10)
|
||
.build();
|
||
grid.add_css_class("file-grid");
|
||
let grid_scroll = gtk4::ScrolledWindow::builder()
|
||
.vexpand(true)
|
||
.child(&grid)
|
||
.build();
|
||
|
||
// Column list.
|
||
let list_selection = gtk4::SingleSelection::builder()
|
||
.model(&model)
|
||
.autoselect(false)
|
||
.can_unselect(true)
|
||
.build();
|
||
let column_view = gtk4::ColumnView::builder().model(&list_selection).build();
|
||
column_view.add_css_class("data-table");
|
||
let column_scroll = gtk4::ScrolledWindow::builder()
|
||
.vexpand(true)
|
||
.child(&column_view)
|
||
.build();
|
||
|
||
// Stack swapped by the toggle buttons.
|
||
let view_stack = gtk4::Stack::new();
|
||
view_stack.set_vexpand(true);
|
||
view_stack.add_named(&grid_scroll, Some("grid"));
|
||
view_stack.add_named(&column_scroll, Some("list"));
|
||
let vs = view_stack.clone();
|
||
grid_toggle.connect_toggled(move |b| {
|
||
if b.is_active() {
|
||
vs.set_visible_child_name("grid");
|
||
}
|
||
});
|
||
let vs = view_stack.clone();
|
||
list_toggle.connect_toggled(move |b| {
|
||
if b.is_active() {
|
||
vs.set_visible_child_name("list");
|
||
}
|
||
});
|
||
|
||
// 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 when an entry is selected.
|
||
let (details_pane, details) = build_details_pane();
|
||
let split = adw::OverlaySplitView::builder()
|
||
.sidebar_position(gtk4::PackType::End)
|
||
.collapsed(true)
|
||
.show_sidebar(false)
|
||
.max_sidebar_width(300.0)
|
||
.content(&content)
|
||
.sidebar(&details_pane)
|
||
.build();
|
||
|
||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||
inner.set_margin_top(12);
|
||
inner.set_margin_bottom(12);
|
||
inner.set_margin_start(12);
|
||
inner.set_margin_end(12);
|
||
inner.append(&header);
|
||
inner.append(&split);
|
||
|
||
(
|
||
inner.upcast(),
|
||
BrowserWidgets {
|
||
model,
|
||
back,
|
||
crumb,
|
||
grid,
|
||
column_view,
|
||
content,
|
||
status,
|
||
retry,
|
||
search,
|
||
new_folder,
|
||
upload,
|
||
upload_folder,
|
||
refresh,
|
||
split,
|
||
details,
|
||
grid_selection,
|
||
list_selection,
|
||
},
|
||
)
|
||
}
|
||
|
||
/// 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);
|
||
ui.browser.content.set_visible_child_name("status");
|
||
hide_details(ui);
|
||
}
|
||
|
||
/// 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 back
|
||
/// button. 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>, grid: >k4::GridView, column_view: >k4::ColumnView) {
|
||
// Back: pop one path segment and reload.
|
||
let ui_back = ui.clone();
|
||
ui.browser.back.clone().connect_clicked(move |_| {
|
||
{
|
||
let mut path = ui_back.browser.path.borrow_mut();
|
||
*path = match path.rfind('/') {
|
||
Some(i) => path[..i].to_string(),
|
||
None => String::new(),
|
||
};
|
||
}
|
||
load_browser(&ui_back);
|
||
});
|
||
|
||
// Grid tiles: a big icon over an ellipsized name, with a right-click menu.
|
||
let factory = gtk4::SignalListItemFactory::new();
|
||
factory.connect_setup({
|
||
let ui = ui.clone();
|
||
move |_, item| {
|
||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||
let icon = gtk4::Image::builder().pixel_size(64).build();
|
||
// Corner sync-state badge, overlaid on the big icon.
|
||
let badge = gtk4::Image::builder()
|
||
.pixel_size(18)
|
||
.halign(gtk4::Align::End)
|
||
.valign(gtk4::Align::End)
|
||
.build();
|
||
badge.add_css_class("file-badge");
|
||
let overlay = gtk4::Overlay::new();
|
||
overlay.set_child(Some(&icon));
|
||
overlay.add_overlay(&badge);
|
||
// `WordChar` rather than the default `Word`: a name with no spaces
|
||
// offers no word-break opportunity, so word wrapping cannot break it
|
||
// at all and the label asks for its full natural width instead —
|
||
// one tile stretches to the width of the window and the grid
|
||
// collapses to a single column. Allowing a mid-word break is what
|
||
// keeps the two-line-then-ellipsis budget below enforceable for
|
||
// *every* name rather than only the ones that happen to have spaces.
|
||
let label = gtk4::Label::builder()
|
||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||
.justify(gtk4::Justification::Center)
|
||
.max_width_chars(13)
|
||
.width_chars(13)
|
||
.wrap(true)
|
||
.wrap_mode(gtk4::pango::WrapMode::WordChar)
|
||
.lines(2)
|
||
.build();
|
||
let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||
tile.add_css_class("file-tile");
|
||
tile.append(&overlay);
|
||
tile.append(&label);
|
||
attach_context_menu(&ui, item, &tile);
|
||
attach_drag(&ui, item, &tile);
|
||
attach_drop(&ui, item, &tile);
|
||
item.set_child(Some(&tile));
|
||
}
|
||
});
|
||
factory.connect_bind(|_, item| {
|
||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||
let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||
let overlay = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
||
let icon = overlay.first_child().and_downcast::<gtk4::Image>().unwrap();
|
||
let badge = overlay.last_child().and_downcast::<gtk4::Image>().unwrap();
|
||
let label = tile.last_child().and_downcast::<gtk4::Label>().unwrap();
|
||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||
let entry = obj.borrow::<DirEntry>();
|
||
icon.set_icon_name(Some(icon_base_for(&entry)));
|
||
label.set_label(&entry.name);
|
||
// The tile shows at most two lines of it, so the full name has to be
|
||
// reachable somehow.
|
||
label.set_tooltip_text(Some(&entry.name));
|
||
apply_badge(&badge, &entry);
|
||
});
|
||
grid.set_factory(Some(&factory));
|
||
|
||
let ui_grid = ui.clone();
|
||
grid.connect_activate(move |grid, pos| {
|
||
if let Some(entry) = entry_at(grid.model().as_ref(), pos) {
|
||
activate_entry(&ui_grid, &entry);
|
||
}
|
||
});
|
||
|
||
// Column list: Name (icon + label, right-clickable), Size, Modified.
|
||
column_view.append_column(&name_column(ui));
|
||
column_view.append_column(&text_column("Size", |e| {
|
||
if e.is_dir {
|
||
"—".to_string()
|
||
} else {
|
||
human_bytes(e.size)
|
||
}
|
||
}));
|
||
column_view.append_column(&text_column("Modified", |e| format_modified(e.modified)));
|
||
|
||
let ui_col = ui.clone();
|
||
column_view.connect_activate(move |view, pos| {
|
||
if let Some(entry) = entry_at(view.model().as_ref(), pos) {
|
||
activate_entry(&ui_col, &entry);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Build the Name column: a small icon plus the (star-prefixed when pinned) name,
|
||
/// with the same right-click menu the grid tiles carry.
|
||
pub(crate) fn name_column(ui: &Rc<Ui>) -> gtk4::ColumnViewColumn {
|
||
let factory = gtk4::SignalListItemFactory::new();
|
||
factory.connect_setup({
|
||
let ui = ui.clone();
|
||
move |_, item| {
|
||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||
let icon = gtk4::Image::builder().pixel_size(16).build();
|
||
// Ellipsized so the Name column can be *narrower* than its longest
|
||
// name. Without it the label's minimum width is the whole string,
|
||
// the column inherits that minimum, and one long name pushes Size
|
||
// and Modified off the right edge of the window for every row.
|
||
let label = gtk4::Label::builder()
|
||
.halign(gtk4::Align::Start)
|
||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||
.build();
|
||
let badge = gtk4::Image::builder().pixel_size(14).build();
|
||
badge.add_css_class("file-badge");
|
||
let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||
cell.append(&icon);
|
||
cell.append(&label);
|
||
cell.append(&badge);
|
||
attach_context_menu(&ui, item, &cell);
|
||
attach_drag(&ui, item, &cell);
|
||
attach_drop(&ui, item, &cell);
|
||
item.set_child(Some(&cell));
|
||
}
|
||
});
|
||
factory.connect_bind(|_, item| {
|
||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||
let icon = cell.first_child().and_downcast::<gtk4::Image>().unwrap();
|
||
let badge = cell.last_child().and_downcast::<gtk4::Image>().unwrap();
|
||
let label = icon.next_sibling().and_downcast::<gtk4::Label>().unwrap();
|
||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||
let entry = obj.borrow::<DirEntry>();
|
||
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
|
||
label.set_label(&entry.name);
|
||
label.set_tooltip_text(Some(&entry.name));
|
||
apply_badge(&badge, &entry);
|
||
});
|
||
let column = gtk4::ColumnViewColumn::new(Some("Name"), Some(factory));
|
||
column.set_expand(true);
|
||
column
|
||
}
|
||
|
||
/// Build a trailing text column whose cell text is derived from each [`DirEntry`]
|
||
/// by `render`.
|
||
pub(crate) fn text_column(
|
||
title: &str,
|
||
render: impl Fn(&DirEntry) -> String + 'static,
|
||
) -> gtk4::ColumnViewColumn {
|
||
let factory = gtk4::SignalListItemFactory::new();
|
||
factory.connect_setup(|_, item| {
|
||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||
let label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||
label.add_css_class("dim-label");
|
||
item.set_child(Some(&label));
|
||
});
|
||
factory.connect_bind(move |_, item| {
|
||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||
let label = item.child().and_downcast::<gtk4::Label>().unwrap();
|
||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||
let entry = obj.borrow::<DirEntry>();
|
||
label.set_label(&render(&entry));
|
||
});
|
||
gtk4::ColumnViewColumn::new(Some(title), Some(factory))
|
||
}
|
||
|
||
/// Attach a secondary-button [`gtk4::GestureClick`] to a cell that pops a context
|
||
/// menu for whatever entry the owning `item` is currently bound to. Capturing the
|
||
/// [`gtk4::ListItem`] (rather than a snapshot of the entry) keeps the menu correct
|
||
/// as the view recycles cells while scrolling.
|
||
pub(crate) fn attach_context_menu(ui: &Rc<Ui>, item: >k4::ListItem, anchor: >k4::Box) {
|
||
let gesture = gtk4::GestureClick::new();
|
||
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
||
let ui = ui.clone();
|
||
let item = item.clone();
|
||
let target = anchor.clone();
|
||
gesture.connect_pressed(move |_, _, x, y| {
|
||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
|
||
let entry = obj.borrow::<DirEntry>().clone();
|
||
show_context_menu(&ui, &entry, &target, x, y);
|
||
}
|
||
});
|
||
anchor.add_controller(gesture);
|
||
}
|
||
|
||
/// Pop a context menu next to `anchor` at the click point, offering Open and a
|
||
/// Pin/Unpin toggle (files only). Built fresh per click because the items are
|
||
/// entry-specific; it unparents itself once dismissed.
|
||
pub(crate) fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: >k4::Box, x: f64, y: f64) {
|
||
let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||
let popover = gtk4::Popover::builder()
|
||
.has_arrow(false)
|
||
.position(gtk4::PositionType::Bottom)
|
||
.pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1))
|
||
.child(&menu)
|
||
.build();
|
||
popover.set_parent(anchor);
|
||
popover.connect_closed(|p| p.unparent());
|
||
|
||
// Media leads with Play (stream from the mount, no download); the plain Open
|
||
// below still downloads a local copy for anyone who wants one.
|
||
if is_streamable_media_entry(entry) {
|
||
let play = menu_item("Play (stream)", "media-playback-start-symbolic");
|
||
let ui_play = ui.clone();
|
||
let entry_play = entry.clone();
|
||
let pop = popover.clone();
|
||
play.connect_clicked(move |_| {
|
||
pop.popdown();
|
||
stream_entry(&ui_play, &entry_play);
|
||
});
|
||
menu.append(&play);
|
||
}
|
||
|
||
let open = menu_item("Open", "document-open-symbolic");
|
||
let ui_open = ui.clone();
|
||
let entry_open = entry.clone();
|
||
let pop = popover.clone();
|
||
open.connect_clicked(move |_| {
|
||
pop.popdown();
|
||
// 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_open) {
|
||
download_and_open(&ui_open, &entry_open);
|
||
} else {
|
||
activate_entry(&ui_open, &entry_open);
|
||
}
|
||
});
|
||
menu.append(&open);
|
||
|
||
if !entry.is_dir {
|
||
let (label, icon) = if entry.pinned {
|
||
("Unpin", "non-starred-symbolic")
|
||
} else {
|
||
("Keep offline", "starred-symbolic")
|
||
};
|
||
let pin = menu_item(label, icon);
|
||
let ui_pin = ui.clone();
|
||
let entry_pin = entry.clone();
|
||
let pop = popover.clone();
|
||
pin.connect_clicked(move |_| {
|
||
pop.popdown();
|
||
toggle_pin(&ui_pin, &entry_pin);
|
||
});
|
||
menu.append(&pin);
|
||
}
|
||
|
||
menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
|
||
|
||
let rename = menu_item("Rename…", "document-edit-symbolic");
|
||
let ui_rn = ui.clone();
|
||
let entry_rn = entry.clone();
|
||
let pop = popover.clone();
|
||
rename.connect_clicked(move |_| {
|
||
pop.popdown();
|
||
prompt_rename(&ui_rn, &entry_rn);
|
||
});
|
||
menu.append(&rename);
|
||
|
||
let move_it = menu_item("Move…", "folder-move-symbolic");
|
||
let ui_mv = ui.clone();
|
||
let entry_mv = entry.clone();
|
||
let pop = popover.clone();
|
||
move_it.connect_clicked(move |_| {
|
||
pop.popdown();
|
||
prompt_move(&ui_mv, &entry_mv);
|
||
});
|
||
menu.append(&move_it);
|
||
|
||
menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
|
||
|
||
let share = menu_item("Share…", "emblem-shared-symbolic");
|
||
let ui_sh = ui.clone();
|
||
let entry_sh = entry.clone();
|
||
let pop = popover.clone();
|
||
share.connect_clicked(move |_| {
|
||
pop.popdown();
|
||
open_share_dialog(&ui_sh, &entry_sh);
|
||
});
|
||
menu.append(&share);
|
||
|
||
let trash = menu_item("Move to Trash", "user-trash-symbolic");
|
||
let ui_tr = ui.clone();
|
||
let entry_tr = entry.clone();
|
||
let pop = popover.clone();
|
||
trash.connect_clicked(move |_| {
|
||
pop.popdown();
|
||
prompt_delete(&ui_tr, &entry_tr);
|
||
});
|
||
menu.append(&trash);
|
||
|
||
popover.popup();
|
||
}
|
||
|
||
/// A left-aligned, flat icon+label button for the context menu.
|
||
pub(crate) fn menu_item(label: &str, icon: &str) -> gtk4::Button {
|
||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||
row.append(>k4::Image::from_icon_name(icon));
|
||
row.append(
|
||
>k4::Label::builder()
|
||
.label(label)
|
||
.halign(gtk4::Align::Start)
|
||
.hexpand(true)
|
||
.build(),
|
||
);
|
||
let button = gtk4::Button::builder().child(&row).build();
|
||
button.add_css_class("flat");
|
||
button
|
||
}
|
||
|
||
/// 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,
|
||
"Couldn't play media",
|
||
"The file path isn't valid UTF-8.",
|
||
);
|
||
return;
|
||
};
|
||
toast(ui, &format!("Streaming “{}”…", 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("");
|
||
}
|
||
*ui.browser.path.borrow_mut() = rel;
|
||
load_browser(ui);
|
||
} 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);
|
||
}
|
||
}
|
||
|
||
/// Download a file's full content into the cache and hand it to the user's
|
||
/// default application. The download-and-open path behind both a plain
|
||
/// double-click and the context menu's "Open" (including for a video, when the
|
||
/// user explicitly wants a local copy rather than to stream it).
|
||
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
||
let rel = entry_rel(ui, entry);
|
||
// 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 rx = spawn_request(
|
||
ui.dirs.control_socket(),
|
||
Request::OpenFile { path: rel.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 {
|
||
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
||
Ok(Ok(Response::Error { message, kind })) => {
|
||
toast_failure(&ui, "Couldn't open file", &message, kind)
|
||
}
|
||
_ => toast_error(
|
||
&ui,
|
||
"Couldn't open file",
|
||
"The mount 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, "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 {
|
||
format!("“{name}” is no longer kept offline")
|
||
} else {
|
||
format!("“{name}” is now available offline")
|
||
},
|
||
);
|
||
}
|
||
_ => {
|
||
toast_error(
|
||
&ui,
|
||
"Couldn't change offline state",
|
||
"The mount 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, "Proton Drive", "", 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("");
|
||
*ui.browser.path.borrow_mut() = target.clone();
|
||
load_browser(&ui);
|
||
});
|
||
button.upcast()
|
||
}
|
||
|
||
/// Wire the browser header's New-folder, Upload-files and Upload-folder buttons.
|
||
pub(crate) fn wire_browser_actions(
|
||
ui: &Rc<Ui>,
|
||
new_folder: >k4::Button,
|
||
upload: >k4::Button,
|
||
upload_folder: >k4::Button,
|
||
) {
|
||
let ui_nf = ui.clone();
|
||
new_folder.connect_clicked(move |_| prompt_new_folder(&ui_nf));
|
||
let ui_up = ui.clone();
|
||
upload.connect_clicked(move |_| prompt_upload(&ui_up));
|
||
let ui_uf = ui.clone();
|
||
upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf));
|
||
}
|
||
|
||
/// 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").
|
||
pub(crate) fn run_mutation(ui: &Rc<Ui>, req: Request, done: String, failed: &'static str) {
|
||
if !*ui.mounted.borrow() {
|
||
toast_error(ui, failed, "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);
|
||
toast(&ui, &done);
|
||
}
|
||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||
_ => toast_error(&ui, failed, "The mount 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),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// 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("Rename")
|
||
.body(format!("Rename “{original}”."))
|
||
.build();
|
||
let group = adw::PreferencesGroup::new();
|
||
let row = adw::EntryRow::builder()
|
||
.title("New name")
|
||
.activates_default(true)
|
||
.build();
|
||
row.set_text(&original);
|
||
group.add(&row);
|
||
dialog.set_extra_child(Some(&group));
|
||
dialog.add_response("cancel", "Cancel");
|
||
dialog.add_response("confirm", "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 = format!("Renamed to “{new_name}”");
|
||
run_mutation(
|
||
&ui,
|
||
Request::Rename {
|
||
path: rel.clone(),
|
||
new_name,
|
||
},
|
||
done,
|
||
"Couldn't rename",
|
||
);
|
||
});
|
||
dialog.present(parent.as_ref());
|
||
}
|
||
|
||
/// Prompt for a destination folder (mountpoint-relative, empty = Drive root) and
|
||
/// move the entry there through the daemon.
|
||
pub(crate) fn prompt_move(ui: &Rc<Ui>, entry: &DirEntry) {
|
||
let parent = ui_window(ui);
|
||
let rel = entry_rel(ui, entry);
|
||
let name = entry.name.clone();
|
||
let dialog = adw::AlertDialog::builder()
|
||
.heading("Move")
|
||
.body(format!(
|
||
"Move “{}” into another folder. Enter its path from the Drive root \
|
||
(leave blank for the root).",
|
||
entry.name
|
||
))
|
||
.build();
|
||
let group = adw::PreferencesGroup::new();
|
||
let row = adw::EntryRow::builder()
|
||
.title("Destination folder")
|
||
.activates_default(true)
|
||
.build();
|
||
group.add(&row);
|
||
dialog.set_extra_child(Some(&group));
|
||
dialog.add_response("cancel", "Cancel");
|
||
dialog.add_response("confirm", "Move");
|
||
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_parent = row.text().trim().trim_matches('/').to_string();
|
||
let done = match new_parent.as_str() {
|
||
"" => format!("Moved “{name}” to Proton Drive"),
|
||
dest => format!("Moved “{name}” to “{dest}”"),
|
||
};
|
||
run_mutation(
|
||
&ui,
|
||
Request::Move {
|
||
path: rel.clone(),
|
||
new_parent,
|
||
},
|
||
done,
|
||
"Couldn't move",
|
||
);
|
||
});
|
||
dialog.present(parent.as_ref());
|
||
}
|
||
|
||
/// Confirm and move the entry to Trash through the daemon.
|
||
pub(crate) fn prompt_delete(ui: &Rc<Ui>, entry: &DirEntry) {
|
||
let win = ui_window(ui);
|
||
let rel = entry_rel(ui, entry);
|
||
let name = entry.name.clone();
|
||
let dialog = adw::AlertDialog::builder()
|
||
.heading("Move to Trash")
|
||
.body(format!("Move “{}” to Trash?", entry.name))
|
||
.build();
|
||
dialog.add_response("cancel", "Cancel");
|
||
dialog.add_response("trash", "Move to Trash");
|
||
dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive);
|
||
dialog.set_default_response(Some("cancel"));
|
||
dialog.set_close_response("cancel");
|
||
|
||
let ui = ui.clone();
|
||
dialog.connect_response(None, move |_, resp| {
|
||
if resp == "trash" {
|
||
run_mutation(
|
||
&ui,
|
||
Request::Delete { path: rel.clone() },
|
||
format!("Moved “{name}” to Trash"),
|
||
"Couldn't move to Trash",
|
||
);
|
||
}
|
||
});
|
||
dialog.present(win.as_ref());
|
||
}
|
||
|
||
/// 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("New folder")
|
||
.body("Create a folder in the current directory.")
|
||
.build();
|
||
let group = adw::PreferencesGroup::new();
|
||
let row = adw::EntryRow::builder()
|
||
.title("Folder name")
|
||
.activates_default(true)
|
||
.build();
|
||
group.add(&row);
|
||
dialog.set_extra_child(Some(&group));
|
||
dialog.add_response("cancel", "Cancel");
|
||
dialog.add_response("confirm", "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 = format!("Created “{name}”");
|
||
run_mutation(
|
||
&ui,
|
||
Request::CreateFolder {
|
||
parent: parent.clone(),
|
||
name,
|
||
},
|
||
done,
|
||
"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("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("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, "Couldn't upload", "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 {
|
||
"Uploading…".to_string()
|
||
} else {
|
||
format!("Uploading {n} items…")
|
||
};
|
||
toast(&ui, &what);
|
||
}
|
||
Ok(Ok(Response::Error { message, kind })) => {
|
||
toast_failure(&ui, "Couldn't upload", &message, kind)
|
||
}
|
||
_ => toast_error(&ui, "Couldn't upload", "The mount 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 {
|
||
("starred-symbolic", "badge-pinned")
|
||
} else if entry.cached {
|
||
("emblem-ok-symbolic", "badge-cached")
|
||
} else {
|
||
("weather-overcast-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 = format!("Moved into “{}”", dest.name);
|
||
run_mutation(
|
||
&ui,
|
||
Request::Move {
|
||
path: src,
|
||
new_parent: dest_path,
|
||
},
|
||
done,
|
||
"Couldn't move",
|
||
);
|
||
true
|
||
});
|
||
anchor.add_controller(target);
|
||
}
|
||
|
||
/// 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",
|
||
}
|
||
}
|
||
|
||
/// Format an epoch-seconds modification time as a short local date.
|
||
pub(crate) fn format_modified(secs: i64) -> String {
|
||
match glib::DateTime::from_unix_local(secs) {
|
||
Ok(dt) => dt
|
||
.format("%-d %b %Y")
|
||
.map(|s| s.to_string())
|
||
.unwrap_or_default(),
|
||
Err(_) => String::new(),
|
||
}
|
||
}
|
||
|
||
/// Request the current browser directory from the daemon and repaint both views.
|
||
pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
||
let path = ui.browser.path.borrow().clone();
|
||
repaint_crumb(ui, &path);
|
||
ui.browser.back.set_sensitive(!path.is_empty());
|
||
|
||
// Drop the previous folder's rows up front: a slow reply must not leave stale
|
||
// entries visible, where clicking one would open with a wrong relative path.
|
||
ui.browser.model.remove_all();
|
||
browser_status(
|
||
ui,
|
||
"folder-symbolic",
|
||
"Loading…",
|
||
"Reading this folder.",
|
||
false,
|
||
);
|
||
|
||
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 *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,
|
||
"Unexpected reply from the mount 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) {
|
||
ui.browser.model.remove_all();
|
||
browser_status(
|
||
ui,
|
||
"dialog-warning-symbolic",
|
||
error_headline(kind, "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>) {
|
||
if service::is_failed() || !service::is_active() {
|
||
browser_status(
|
||
ui,
|
||
"network-offline-symbolic",
|
||
"Not connected",
|
||
"The Proton Drive mount service isn't running.",
|
||
true,
|
||
);
|
||
return;
|
||
}
|
||
browser_status(
|
||
ui,
|
||
"folder-remote-symbolic",
|
||
"Connecting…",
|
||
"Waiting for the Proton Drive mount service to come up.",
|
||
false,
|
||
);
|
||
let ui = ui.clone();
|
||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||
// Only keep polling while the Files page is the one on screen.
|
||
if ui.stack.visible_child_name().as_deref() == Some("browser") {
|
||
load_browser(&ui);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 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]) {
|
||
ui.browser.model.remove_all();
|
||
if entries.is_empty() {
|
||
browser_status(
|
||
ui,
|
||
"folder-open-symbolic",
|
||
"This folder is empty",
|
||
"Upload a file or create a folder to get started.",
|
||
false,
|
||
);
|
||
return;
|
||
}
|
||
browser_views(ui);
|
||
|
||
let mut sorted = entries.to_vec();
|
||
sorted.sort_by(|a, b| {
|
||
b.is_dir
|
||
.cmp(&a.is_dir)
|
||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||
});
|
||
for entry in sorted {
|
||
ui.browser.model.append(&BoxedAnyObject::new(entry));
|
||
}
|
||
}
|
||
|
||
/// 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 |_| {
|
||
// 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);
|
||
});
|
||
}
|
||
|
||
/// 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) {
|
||
ui.browser.model.remove_all();
|
||
browser_status(
|
||
ui,
|
||
"system-search-symbolic",
|
||
"Searching…",
|
||
&format!("Looking for “{query}”."),
|
||
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,
|
||
},
|
||
);
|
||
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 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,
|
||
"Unexpected reply from the mount 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]) {
|
||
ui.browser.model.remove_all();
|
||
if hits.is_empty() {
|
||
browser_status(
|
||
ui,
|
||
"system-search-symbolic",
|
||
"No matches",
|
||
"No files or folders match that search.",
|
||
false,
|
||
);
|
||
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,
|
||
// Search hits don't carry cache state; the badge shows in listings.
|
||
cached: false,
|
||
uid: h.uid.clone(),
|
||
path: h.path.clone(),
|
||
})
|
||
.collect();
|
||
entries.sort_by(|a, b| {
|
||
b.is_dir
|
||
.cmp(&a.is_dir)
|
||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||
});
|
||
for entry in entries {
|
||
ui.browser.model.append(&BoxedAnyObject::new(entry));
|
||
}
|
||
}
|