mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,fuse,gui): rebuild Trash and shared pages on one file list
The list and grid view from My files moves into widgets/file_list.rs as FileList, so Trash, Shared with me and Shared by me share its columns, selection, context menus, keyboard handling and list/grid switch instead of each drawing its own rows. Trash shows where each item came from and when it was deleted. Proton does not expose the trash time through the SDK yet, so the daemon records when it first sees a node in the trash (migration V35, trash_seen) and reports that; the date becomes exact once proton-sdk-rs exposes Node::trash_time. Restoring offers a Show action, and opening a trashed item explains that it needs to be restored first. Shared with me splits files and saved links into two views. Files show who shared them, with a warning pill for unverified senders, the access they give and when they were shared. Pending invitations sit in a banner that opens a dialog to accept or decline each one. Several items can be left at once. Saved links get their own list with open, copy and remove actions. Shared by me gains a filter (all, links, people, expired links) and columns for location, people and link state, with a copy button for live links. Several items can stop being shared at once, the page refreshes after the share dialog closes, and a reload rebinds only the rows that changed so the selection survives. All catalogs are updated and complete.
This commit is contained in:
1 parent
2339b17d97
commit
127b71efd0
28 files changed
+9501
-6673
No files matched your search
@@ -3364,6 +3364,8 @@ mod login_wait_tests {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
};
|
};
|
||||||
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
||||||
entry.shared_by = "alice@proton.me".into();
|
entry.shared_by = "alice@proton.me".into();
|
||||||
|
|||||||
@@ -905,7 +905,7 @@ pub struct ShareEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]).
|
/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
pub struct PublicLinkInfo {
|
pub struct PublicLinkInfo {
|
||||||
/// Public-link id — the handle for [`Request::RemovePublicLink`].
|
/// Public-link id — the handle for [`Request::RemovePublicLink`].
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -918,6 +918,9 @@ pub struct PublicLinkInfo {
|
|||||||
pub expires: Option<i64>,
|
pub expires: Option<i64>,
|
||||||
/// Whether a custom password additionally protects the link.
|
/// Whether a custom password additionally protects the link.
|
||||||
pub has_password: bool,
|
pub has_password: bool,
|
||||||
|
/// When the link was made, epoch seconds; 0 when unknown.
|
||||||
|
#[serde(default)]
|
||||||
|
pub created: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One invitation addressed to me (in [`Response::Invitations`]).
|
/// One invitation addressed to me (in [`Response::Invitations`]).
|
||||||
@@ -950,7 +953,7 @@ pub struct BookmarkInfo {
|
|||||||
/// the share's state so the "Shared" view renders in one pass: how many people
|
/// the share's state so the "Shared" view renders in one pass: how many people
|
||||||
/// have access, how many invitations are still pending, and the public link if
|
/// have access, how many invitations are still pending, and the public link if
|
||||||
/// the node has one.
|
/// the node has one.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
pub struct SharedItem {
|
pub struct SharedItem {
|
||||||
/// Node uid in `volume~link` form — the handle for opening the node's share
|
/// Node uid in `volume~link` form — the handle for opening the node's share
|
||||||
/// (via its mount path) or its details.
|
/// (via its mount path) or its details.
|
||||||
@@ -1138,6 +1141,16 @@ pub struct DirEntry {
|
|||||||
/// warning worth showing.
|
/// warning worth showing.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub shared_by_unverified: bool,
|
pub shared_by_unverified: bool,
|
||||||
|
/// When a [`Request::ListTrash`] entry went to the trash, epoch seconds; 0
|
||||||
|
/// when unknown, and for anything not in the trash. The API does not report
|
||||||
|
/// it to this client, so the daemon notes when it first saw the node there.
|
||||||
|
#[serde(default)]
|
||||||
|
pub trashed_at: i64,
|
||||||
|
/// The folder a [`Request::ListTrash`] entry goes back to on restore, as a
|
||||||
|
/// mountpoint-relative path (`""` for the root). `None` when that folder is
|
||||||
|
/// not known, and for anything not in the trash.
|
||||||
|
#[serde(default)]
|
||||||
|
pub trashed_from: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 34;
|
pub(super) const SCHEMA_VERSION: i64 = 35;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -316,6 +316,22 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V34)?;
|
tx.execute_batch(MIGRATION_V34)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 35 {
|
||||||
|
// Same guards as V26-V34.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'trashed_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_trash: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_trash && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V35)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -1033,3 +1049,13 @@ UPDATE photos SET resolved_at = NULL;
|
|||||||
const MIGRATION_V34: &str = "
|
const MIGRATION_V34: &str = "
|
||||||
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// Schema v35: when each trashed node went to the trash.
|
||||||
|
///
|
||||||
|
/// The API does not tell this client, so `trashed_at` is when the daemon first
|
||||||
|
/// saw the node in the trash, in epoch seconds. `NULL` when it cannot say: rows
|
||||||
|
/// from before this version, and nodes found by a refresh that did not follow a
|
||||||
|
/// complete one, which may have been in the trash for a long time already.
|
||||||
|
const MIGRATION_V35: &str = "
|
||||||
|
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||||
|
";
|
||||||
@@ -583,6 +583,7 @@ fn trash_replace_lists_folders_first() {
|
|||||||
size: 10,
|
size: 10,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
parent_uid: Some("t2".into()),
|
parent_uid: Some("t2".into()),
|
||||||
|
trashed_at: Some(100),
|
||||||
},
|
},
|
||||||
StoredTrash {
|
StoredTrash {
|
||||||
uid: "t2".into(),
|
uid: "t2".into(),
|
||||||
@@ -591,6 +592,7 @@ fn trash_replace_lists_folders_first() {
|
|||||||
size: 0,
|
size: 0,
|
||||||
mtime: 2,
|
mtime: 2,
|
||||||
parent_uid: None,
|
parent_uid: None,
|
||||||
|
trashed_at: None,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -613,6 +615,15 @@ fn trash_replace_lists_folders_first() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The first-seen times round-trip, unknown ones included.
|
||||||
|
let mut seen: Vec<_> = db.trash_seen().unwrap().into_iter().collect();
|
||||||
|
seen.sort();
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
[("t1".to_string(), Some(100)), ("t2".to_string(), None)]
|
||||||
|
);
|
||||||
|
assert_eq!(items[1].trashed_at, Some(100));
|
||||||
|
|
||||||
// A replace is a replace: emptying the trash on the server empties it here.
|
// A replace is a replace: emptying the trash on the server empties it here.
|
||||||
db.trash_replace(&[]).unwrap();
|
db.trash_replace(&[]).unwrap();
|
||||||
assert!(db.trash_list().unwrap().is_empty());
|
assert!(db.trash_list().unwrap().is_empty());
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
@@ -15,6 +17,9 @@ pub struct StoredTrash {
|
|||||||
/// row written before schema v29, and for a node whose parent the server
|
/// row written before schema v29, and for a node whose parent the server
|
||||||
/// did not report.
|
/// did not report.
|
||||||
pub parent_uid: Option<String>,
|
pub parent_uid: Option<String>,
|
||||||
|
/// When the daemon first saw the node in the trash, epoch seconds. `None`
|
||||||
|
/// when it cannot say (see schema v35).
|
||||||
|
pub trashed_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
@@ -24,8 +29,8 @@ impl Db {
|
|||||||
tx.execute("DELETE FROM trash", [])?;
|
tx.execute("DELETE FROM trash", [])?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid) \
|
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
)?;
|
)?;
|
||||||
for item in items {
|
for item in items {
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
@@ -34,7 +39,8 @@ impl Db {
|
|||||||
item.is_dir as i64,
|
item.is_dir as i64,
|
||||||
item.size,
|
item.size,
|
||||||
item.mtime,
|
item.mtime,
|
||||||
item.parent_uid
|
item.parent_uid,
|
||||||
|
item.trashed_at
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +53,7 @@ impl Db {
|
|||||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, is_dir, size, mtime, parent_uid FROM trash
|
"SELECT uid, name, is_dir, size, mtime, parent_uid, trashed_at FROM trash
|
||||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
@@ -58,6 +64,7 @@ impl Db {
|
|||||||
size: r.get(3)?,
|
size: r.get(3)?,
|
||||||
mtime: r.get(4)?,
|
mtime: r.get(4)?,
|
||||||
parent_uid: r.get(5)?,
|
parent_uid: r.get(5)?,
|
||||||
|
trashed_at: r.get(6)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
@@ -81,5 +88,19 @@ impl Db {
|
|||||||
Ok(pairs)
|
Ok(pairs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When each node now in the trash was first seen there, keyed by uid, so
|
||||||
|
/// a refresh can carry the times over into the listing it writes.
|
||||||
|
pub fn trash_seen(&self) -> Result<HashMap<String, Option<i64>>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare("SELECT uid, trashed_at FROM trash")?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
let mut seen = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
let (uid, at) = row?;
|
||||||
|
seen.insert(uid, at);
|
||||||
|
}
|
||||||
|
Ok(seen)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- device sync (devices.md) -----------------------------------------
|
// ---- device sync (devices.md) -----------------------------------------
|
||||||
}
|
}
|
||||||
@@ -271,6 +271,12 @@ const PHOTOS_AVAILABLE: &str = "photos_available";
|
|||||||
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
|
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
|
||||||
const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:";
|
const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:";
|
||||||
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
||||||
|
/// Set when a trash refresh finishes and cleared when one starts, so it is only
|
||||||
|
/// present while the stored listing is the whole trash as of some moment. A node
|
||||||
|
/// that a refresh finds and that listing lacks went to the trash since then; one
|
||||||
|
/// found after an interrupted refresh may have been there all along. Unlike
|
||||||
|
/// [`TRASH_SYNCED_MS`], our own mutations leave it alone.
|
||||||
|
const TRASH_COMPLETE_MS: &str = "trash_complete_ms";
|
||||||
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
|
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
|
||||||
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
|
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
|
||||||
const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:";
|
const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:";
|
||||||
@@ -3815,6 +3821,8 @@ impl Core {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -4406,12 +4414,21 @@ impl Core {
|
|||||||
self.await_trash_refresh(TRASH_FIRST_WAIT);
|
self.await_trash_refresh(TRASH_FIRST_WAIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Most of a trash comes from a handful of folders.
|
||||||
|
let mut origins: HashMap<String, Option<String>> = HashMap::new();
|
||||||
Ok(self
|
Ok(self
|
||||||
.db
|
.db
|
||||||
.trash_list()
|
.trash_list()
|
||||||
.map_err(CoreError::from)?
|
.map_err(CoreError::from)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| DirEntry {
|
.map(|item| DirEntry {
|
||||||
|
trashed_from: item.parent_uid.as_ref().and_then(|parent| {
|
||||||
|
origins
|
||||||
|
.entry(parent.clone())
|
||||||
|
.or_insert_with(|| self.db.node_path(parent).ok().flatten())
|
||||||
|
.clone()
|
||||||
|
}),
|
||||||
|
trashed_at: item.trashed_at.unwrap_or(0),
|
||||||
name: item.name,
|
name: item.name,
|
||||||
is_dir: item.is_dir,
|
is_dir: item.is_dir,
|
||||||
size: item.size.max(0) as u64,
|
size: item.size.max(0) as u64,
|
||||||
@@ -4450,6 +4467,18 @@ impl Core {
|
|||||||
"trash refresh: enumerated"
|
"trash refresh: enumerated"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// What the listing held before, and whether it was the whole trash: the
|
||||||
|
// chunks below overwrite it, so both are read up front.
|
||||||
|
let seen = self.db.trash_seen().unwrap_or_default();
|
||||||
|
let complete = self
|
||||||
|
.db
|
||||||
|
.state_i64(TRASH_COMPLETE_MS)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let _ = self.db.clear_state(TRASH_COMPLETE_MS);
|
||||||
|
let now = now_secs();
|
||||||
|
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
self.db.trash_replace(&[]).map_err(CoreError::from)?;
|
self.db.trash_replace(&[]).map_err(CoreError::from)?;
|
||||||
}
|
}
|
||||||
@@ -4470,6 +4499,7 @@ impl Core {
|
|||||||
// The folder the node will go back to, which is also what tells
|
// The folder the node will go back to, which is also what tells
|
||||||
// a restore whether that folder is itself in the trash.
|
// a restore whether that folder is itself in the trash.
|
||||||
parent_uid: node.parent_uid.as_ref().map(|uid| uid.to_string()),
|
parent_uid: node.parent_uid.as_ref().map(|uid| uid.to_string()),
|
||||||
|
trashed_at: first_seen_in_trash(&seen, &node.uid.to_string(), complete, now),
|
||||||
}));
|
}));
|
||||||
// Cumulative, so the table is always a prefix of the real trash
|
// Cumulative, so the table is always a prefix of the real trash
|
||||||
// rather than a mix of this refresh and the last one.
|
// rather than a mix of this refresh and the last one.
|
||||||
@@ -4484,6 +4514,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
|
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
|
||||||
|
let _ = self.db.set_state_i64(TRASH_COMPLETE_MS, now_ms());
|
||||||
info!(
|
info!(
|
||||||
count = items.len(),
|
count = items.len(),
|
||||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
@@ -4997,6 +5028,7 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo {
|
|||||||
role: role_to_str(link.role).to_string(),
|
role: role_to_str(link.role).to_string(),
|
||||||
expires: link.expiration_time,
|
expires: link.expiration_time,
|
||||||
has_password: link.has_custom_password,
|
has_password: link.has_custom_password,
|
||||||
|
created: link.creation_time,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5429,6 +5461,25 @@ fn node_size(node: &Node) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When a node a trash refresh found went to the trash, as far as the daemon
|
||||||
|
/// can tell. A node the stored listing already had keeps the time it had. A new
|
||||||
|
/// one went there since the last refresh, so now is close, but only when that
|
||||||
|
/// refresh saw the whole trash (`complete`): after a first or interrupted one,
|
||||||
|
/// a node missing from the listing may be years old, and no date beats a wrong
|
||||||
|
/// one.
|
||||||
|
fn first_seen_in_trash(
|
||||||
|
seen: &HashMap<String, Option<i64>>,
|
||||||
|
uid: &str,
|
||||||
|
complete: bool,
|
||||||
|
now: i64,
|
||||||
|
) -> Option<i64> {
|
||||||
|
match seen.get(uid) {
|
||||||
|
Some(at) => *at,
|
||||||
|
None if complete => Some(now),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Expand a restore request across the trashed tree, and order it so a parent is
|
/// Expand a restore request across the trashed tree, and order it so a parent is
|
||||||
/// always restored before its children.
|
/// always restored before its children.
|
||||||
///
|
///
|
||||||
@@ -6220,8 +6271,8 @@ mod tests {
|
|||||||
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
||||||
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
||||||
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
||||||
fuse_name, is_stale_mount, node_visible, note_self_change, orphan_past_retention,
|
first_seen_in_trash, fuse_name, is_stale_mount, node_visible, note_self_change,
|
||||||
parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
orphan_past_retention, parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
||||||
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
||||||
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
||||||
rename_needs_queue, require_node_parent_access, require_rename_access,
|
rename_needs_queue, require_node_parent_access, require_rename_access,
|
||||||
@@ -6364,6 +6415,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A node keeps the time it was first seen in the trash; a new one is dated
|
||||||
|
/// now only when the previous refresh saw the whole trash.
|
||||||
|
#[test]
|
||||||
|
fn trash_dates_are_kept_and_only_guessed_after_a_complete_refresh() {
|
||||||
|
let seen = HashMap::from([("old".to_string(), Some(5)), ("unknown".to_string(), None)]);
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "old", true, 9), Some(5));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "old", false, 9), Some(5));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "unknown", true, 9), None);
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "new", true, 9), Some(9));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "new", false, 9), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// A parent chain that loops back on itself must terminate.
|
/// A parent chain that loops back on itself must terminate.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_cyclic_listing_terminates() {
|
fn a_cyclic_listing_terminates() {
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
shared_by,
|
shared_by,
|
||||||
shared_at,
|
shared_at,
|
||||||
shared_by_unverified,
|
shared_by_unverified,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
|||||||
|
|
||||||
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
||||||
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
||||||
|
.role-pill.warning-pill { color: var(--warning-color); background-color: color-mix(in srgb, var(--warning-bg-color) 20%, transparent); }
|
||||||
|
|
||||||
/* Activity: the feed sits on the window background as one card per row, under
|
/* Activity: the feed sits on the window background as one card per row, under
|
||||||
* day headings, like a boxed list with its rows set apart. */
|
* day headings, like a boxed list with its rows set apart. */
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use pages::trash::*;
|
|||||||
use pages::verify::*;
|
use pages::verify::*;
|
||||||
use reload::{LoadTicket, Loader, replace_items};
|
use reload::{LoadTicket, Loader, replace_items};
|
||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
|
use widgets::file_list::*;
|
||||||
use widgets::menu::*;
|
use widgets::menu::*;
|
||||||
use widgets::share_dialog::*;
|
use widgets::share_dialog::*;
|
||||||
use widgets::thumbnails::*;
|
use widgets::thumbnails::*;
|
||||||
@@ -563,7 +564,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
transfer_batch: RefCell::new(None),
|
transfer_batch: RefCell::new(None),
|
||||||
},
|
},
|
||||||
browser: BrowserState {
|
browser: BrowserState {
|
||||||
model: browser_widgets.model.clone(),
|
files: browser_widgets.files.clone(),
|
||||||
history: RefCell::new(Vec::new()),
|
history: RefCell::new(Vec::new()),
|
||||||
future: RefCell::new(Vec::new()),
|
future: RefCell::new(Vec::new()),
|
||||||
crumb: browser_widgets.crumb.clone(),
|
crumb: browser_widgets.crumb.clone(),
|
||||||
@@ -588,8 +589,6 @@ fn build_window(app: &adw::Application) {
|
|||||||
search_source: RefCell::new(None),
|
search_source: RefCell::new(None),
|
||||||
loader: Loader::new(&browser_widgets.content),
|
loader: Loader::new(&browser_widgets.content),
|
||||||
listed: RefCell::new(None),
|
listed: RefCell::new(None),
|
||||||
views: browser_widgets.views.clone(),
|
|
||||||
column_view: browser_widgets.column_view.clone(),
|
|
||||||
bulk: browser_widgets.bulk.clone(),
|
bulk: browser_widgets.bulk.clone(),
|
||||||
bulk_label: browser_widgets.bulk_label.clone(),
|
bulk_label: browser_widgets.bulk_label.clone(),
|
||||||
bulk_trash: browser_widgets.bulk_trash.clone(),
|
bulk_trash: browser_widgets.bulk_trash.clone(),
|
||||||
@@ -599,23 +598,20 @@ fn build_window(app: &adw::Application) {
|
|||||||
empty_actions: browser_widgets.empty_actions.clone(),
|
empty_actions: browser_widgets.empty_actions.clone(),
|
||||||
summary: browser_widgets.summary.clone(),
|
summary: browser_widgets.summary.clone(),
|
||||||
zoom: browser_widgets.zoom.clone(),
|
zoom: browser_widgets.zoom.clone(),
|
||||||
grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT),
|
|
||||||
grid_tiles: RefCell::new(Vec::new()),
|
|
||||||
},
|
},
|
||||||
details: DetailsState {
|
details: DetailsState {
|
||||||
details: browser_widgets.details,
|
details: browser_widgets.details,
|
||||||
details_entry: RefCell::new(None),
|
details_entry: RefCell::new(None),
|
||||||
details_suppress: Cell::new(false),
|
details_suppress: Cell::new(false),
|
||||||
selection: browser_widgets.selection.clone(),
|
selection: browser_widgets.files.selection.clone(),
|
||||||
},
|
},
|
||||||
trash: TrashState {
|
trash: TrashState {
|
||||||
model: trash_widgets.model.clone(),
|
files: trash_widgets.files.clone(),
|
||||||
content: trash_widgets.content.clone(),
|
content: trash_widgets.content.clone(),
|
||||||
status: trash_widgets.status.clone(),
|
status: trash_widgets.status.clone(),
|
||||||
retry: trash_widgets.retry.clone(),
|
retry: trash_widgets.retry.clone(),
|
||||||
empty: trash_widgets.empty.clone(),
|
empty: trash_widgets.empty.clone(),
|
||||||
subtitle: trash_widgets.subtitle.clone(),
|
subtitle: trash_widgets.subtitle.clone(),
|
||||||
selection: trash_widgets.selection.clone(),
|
|
||||||
selection_bar: trash_widgets.selection_bar.clone(),
|
selection_bar: trash_widgets.selection_bar.clone(),
|
||||||
selection_label: trash_widgets.selection_label.clone(),
|
selection_label: trash_widgets.selection_label.clone(),
|
||||||
loader: Loader::new(&trash_widgets.content),
|
loader: Loader::new(&trash_widgets.content),
|
||||||
@@ -708,31 +704,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
select_trash: gallery_widgets.select_trash.clone(),
|
select_trash: gallery_widgets.select_trash.clone(),
|
||||||
select_album: gallery_widgets.select_album.clone(),
|
select_album: gallery_widgets.select_album.clone(),
|
||||||
},
|
},
|
||||||
shared: SharedState {
|
shared: SharedState::new(&shared_widgets),
|
||||||
content: shared_widgets.content.clone(),
|
shared_by_me: SharedByMeState::new(&shared_by_me_widgets),
|
||||||
status: shared_widgets.status.clone(),
|
|
||||||
retry: shared_widgets.retry.clone(),
|
|
||||||
with_me_group: shared_widgets.shared_with_me.clone(),
|
|
||||||
invitations_group: shared_widgets.invitations.clone(),
|
|
||||||
bookmarks_group: shared_widgets.bookmarks.clone(),
|
|
||||||
title: shared_widgets.title.clone(),
|
|
||||||
back: shared_widgets.back.clone(),
|
|
||||||
add_bookmark: shared_widgets.add_bookmark.clone(),
|
|
||||||
nav: RefCell::new(Vec::new()),
|
|
||||||
rows: RefCell::new(Vec::new()),
|
|
||||||
loader: Loader::new(&shared_widgets.content),
|
|
||||||
listed: RefCell::new(None),
|
|
||||||
loaded_at: Cell::new(None),
|
|
||||||
},
|
|
||||||
shared_by_me: SharedByMeState {
|
|
||||||
content: shared_by_me_widgets.content.clone(),
|
|
||||||
status: shared_by_me_widgets.status.clone(),
|
|
||||||
retry: shared_by_me_widgets.retry.clone(),
|
|
||||||
group: shared_by_me_widgets.group.clone(),
|
|
||||||
rows: RefCell::new(Vec::new()),
|
|
||||||
loader: Loader::new(&shared_by_me_widgets.content),
|
|
||||||
loaded_at: Cell::new(None),
|
|
||||||
},
|
|
||||||
devices: DevicesState {
|
devices: DevicesState {
|
||||||
content: devices_widgets.content.clone(),
|
content: devices_widgets.content.clone(),
|
||||||
status: devices_widgets.status.clone(),
|
status: devices_widgets.status.clone(),
|
||||||
@@ -820,7 +793,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
&main_widgets.mountpoint_button,
|
&main_widgets.mountpoint_button,
|
||||||
);
|
);
|
||||||
wire_sidebar(&ui);
|
wire_sidebar(&ui);
|
||||||
wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view);
|
wire_browser(&ui);
|
||||||
wire_bulk(
|
wire_bulk(
|
||||||
&ui,
|
&ui,
|
||||||
&browser_widgets.bulk_clear,
|
&browser_widgets.bulk_clear,
|
||||||
@@ -844,8 +817,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
wire_albums(&ui);
|
wire_albums(&ui);
|
||||||
wire_places(&ui);
|
wire_places(&ui);
|
||||||
wire_trash(&ui, &trash_widgets);
|
wire_trash(&ui, &trash_widgets);
|
||||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
wire_shared(&ui, &shared_widgets);
|
||||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
wire_shared_by_me(&ui, &shared_by_me_widgets);
|
||||||
wire_devices(&ui, &devices_widgets.retry);
|
wire_devices(&ui, &devices_widgets.retry);
|
||||||
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
||||||
wire_activity(&ui, &activity_widgets);
|
wire_activity(&ui, &activity_widgets);
|
||||||
@@ -1094,11 +1067,21 @@ fn page_frame(
|
|||||||
content: &impl IsA<gtk4::Widget>,
|
content: &impl IsA<gtk4::Widget>,
|
||||||
) -> (adw::ToolbarView, adw::HeaderBar, adw::WindowTitle) {
|
) -> (adw::ToolbarView, adw::HeaderBar, adw::WindowTitle) {
|
||||||
let window_title = adw::WindowTitle::new(title, "");
|
let window_title = adw::WindowTitle::new(title, "");
|
||||||
|
let (toolbar, header) = page_frame_with(&window_title, content);
|
||||||
|
(toolbar, header, window_title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`page_frame`] with `title` in the header's title slot instead of a plain
|
||||||
|
/// title, such as a view switcher.
|
||||||
|
fn page_frame_with(
|
||||||
|
title: &impl IsA<gtk4::Widget>,
|
||||||
|
content: &impl IsA<gtk4::Widget>,
|
||||||
|
) -> (adw::ToolbarView, adw::HeaderBar) {
|
||||||
let spinner = gtk4::Spinner::new();
|
let spinner = gtk4::Spinner::new();
|
||||||
spinner.set_visible(false);
|
spinner.set_visible(false);
|
||||||
BUSY_SPINNERS.with(|spinners| spinners.borrow_mut().push(spinner.clone()));
|
BUSY_SPINNERS.with(|spinners| spinners.borrow_mut().push(spinner.clone()));
|
||||||
let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||||
title_box.append(&window_title);
|
title_box.append(title);
|
||||||
title_box.append(&spinner);
|
title_box.append(&spinner);
|
||||||
|
|
||||||
let header = adw::HeaderBar::new();
|
let header = adw::HeaderBar::new();
|
||||||
@@ -1106,7 +1089,7 @@ fn page_frame(
|
|||||||
let toolbar = adw::ToolbarView::new();
|
let toolbar = adw::ToolbarView::new();
|
||||||
toolbar.add_top_bar(&header);
|
toolbar.add_top_bar(&header);
|
||||||
toolbar.set_content(Some(content));
|
toolbar.set_content(Some(content));
|
||||||
(toolbar, header, window_title)
|
(toolbar, header)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sidebar destination a page belongs under. Most pages are their own
|
/// The sidebar destination a page belongs under. Most pages are their own
|
||||||
@@ -1693,6 +1676,7 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
||||||
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
|
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
|
||||||
|
let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash");
|
||||||
match key {
|
match key {
|
||||||
// Refresh works on every page, so it is matched before the
|
// Refresh works on every page, so it is matched before the
|
||||||
// browser-only bindings.
|
// browser-only bindings.
|
||||||
@@ -1708,20 +1692,18 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
ui.browser.search.grab_focus();
|
ui.browser.search.grab_focus();
|
||||||
}
|
}
|
||||||
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
|
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
|
||||||
gtk4::gdk::Key::_1 if ctrl && on_browser => set_files_view(
|
gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view(
|
||||||
&ui,
|
&ui,
|
||||||
FilesView {
|
FilesView {
|
||||||
list: false,
|
list: key == gtk4::gdk::Key::_2,
|
||||||
..ui.browser.view.get()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view(
|
|
||||||
&ui,
|
|
||||||
FilesView {
|
|
||||||
list: true,
|
|
||||||
..ui.browser.view.get()
|
..ui.browser.view.get()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && visible_file_list(&ui).is_some() => {
|
||||||
|
if let Some(list) = visible_file_list(&ui) {
|
||||||
|
list.show_list(key == gtk4::gdk::Key::_2);
|
||||||
|
}
|
||||||
|
}
|
||||||
gtk4::gdk::Key::Left if alt && on_browser => {
|
gtk4::gdk::Key::Left if alt && on_browser => {
|
||||||
ui.browser.actions.activate_action("back", None)
|
ui.browser.actions.activate_action("back", None)
|
||||||
}
|
}
|
||||||
@@ -1737,14 +1719,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
ui.browser.actions.activate_action("back", None)
|
ui.browser.actions.activate_action("back", None)
|
||||||
}
|
}
|
||||||
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
||||||
gtk4::gdk::Key::Menu if on_browser => popup_keyboard_context_menu(&ui),
|
gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => {
|
||||||
|
if let Some(list) = visible_file_list(&ui) {
|
||||||
|
list.popup_keyboard_menu(&ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter if alt && on_browser => {
|
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter if alt && on_browser => {
|
||||||
toggle_details(&ui)
|
toggle_details(&ui)
|
||||||
}
|
}
|
||||||
gtk4::gdk::Key::F10
|
gtk4::gdk::Key::F10
|
||||||
if on_browser && state.contains(gtk4::gdk::ModifierType::SHIFT_MASK) =>
|
if visible_file_list(&ui).is_some()
|
||||||
|
&& state.contains(gtk4::gdk::ModifierType::SHIFT_MASK) =>
|
||||||
{
|
{
|
||||||
popup_keyboard_context_menu(&ui)
|
if let Some(list) = visible_file_list(&ui) {
|
||||||
|
list.popup_keyboard_menu(&ui);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
gtk4::gdk::Key::F2 if on_browser => {
|
gtk4::gdk::Key::F2 if on_browser => {
|
||||||
// Renaming is one name at a time; saying so beats a key that
|
// Renaming is one name at a time; saying so beats a key that
|
||||||
@@ -1764,6 +1753,12 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
trash_entries(&ui, entries);
|
trash_entries(&ui, entries);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
gtk4::gdk::Key::Delete if on_trash => {
|
||||||
|
let entries = ui.trash.files.selected();
|
||||||
|
if !entries.is_empty() {
|
||||||
|
prompt_delete_forever(&ui, &entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
gtk4::gdk::Key::Escape
|
gtk4::gdk::Key::Escape
|
||||||
if on_browser
|
if on_browser
|
||||||
&& (ui.browser.split.shows_sidebar() || ui.browser.bulk.reveals_child()) =>
|
&& (ui.browser.split.shows_sidebar() || ui.browser.bulk.reveals_child()) =>
|
||||||
@@ -1773,6 +1768,15 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
clear_details(&ui);
|
clear_details(&ui);
|
||||||
ui.details.details.toggle.set_active(false);
|
ui.details.details.toggle.set_active(false);
|
||||||
}
|
}
|
||||||
|
gtk4::gdk::Key::Escape
|
||||||
|
if !on_browser
|
||||||
|
&& visible_file_list(&ui)
|
||||||
|
.is_some_and(|list| list.selection.selection().size() > 0) =>
|
||||||
|
{
|
||||||
|
if let Some(list) = visible_file_list(&ui) {
|
||||||
|
list.selection.unselect_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => return glib::Propagation::Proceed,
|
_ => return glib::Propagation::Proceed,
|
||||||
}
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use crate::*;
|
|||||||
|
|
||||||
pub(crate) struct BrowserState {
|
pub(crate) struct BrowserState {
|
||||||
// Files (browser) page.
|
// Files (browser) page.
|
||||||
/// Shared model behind the grid and column views; repopulated per directory.
|
/// The grid and list; the model is repopulated per directory.
|
||||||
pub(crate) model: gio::ListStore,
|
pub(crate) files: FileList,
|
||||||
/// Folders left by navigating, newest last, and folders left by going back:
|
/// 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
|
/// the `files.back` and `files.forward` history. Up is Alt+Up or a
|
||||||
/// breadcrumb.
|
/// breadcrumb.
|
||||||
@@ -60,10 +60,6 @@ pub(crate) struct BrowserState {
|
|||||||
/// hits or nothing. Reloading this folder keeps the rows up and usable;
|
/// hits or nothing. Reloading this folder keeps the rows up and usable;
|
||||||
/// loading any other greys them out until the new ones arrive.
|
/// loading any other greys them out until the new ones arrive.
|
||||||
pub(crate) listed: RefCell<Option<String>>,
|
pub(crate) listed: RefCell<Option<String>>,
|
||||||
/// The grid/list view stack, read to find out which view is on screen.
|
|
||||||
pub(crate) views: gtk4::Stack,
|
|
||||||
/// The list view, whose Location column shows only for search hits.
|
|
||||||
pub(crate) column_view: gtk4::ColumnView,
|
|
||||||
/// The bulk-action bar, revealed once more than one entry is selected.
|
/// The bulk-action bar, revealed once more than one entry is selected.
|
||||||
pub(crate) bulk: gtk4::Revealer,
|
pub(crate) bulk: gtk4::Revealer,
|
||||||
pub(crate) bulk_label: gtk4::Label,
|
pub(crate) bulk_label: gtk4::Label,
|
||||||
@@ -81,10 +77,6 @@ pub(crate) struct BrowserState {
|
|||||||
/// zoom value, and Proton account storage usage.
|
/// zoom value, and Proton account storage usage.
|
||||||
pub(crate) summary: gtk4::Label,
|
pub(crate) summary: gtk4::Label,
|
||||||
pub(crate) zoom: gtk4::Scale,
|
pub(crate) zoom: gtk4::Scale,
|
||||||
pub(crate) grid_thumbnail_size: Cell<i32>,
|
|
||||||
/// Weak references to realised grid cells. Zoom resizes only these visible,
|
|
||||||
/// recycled surfaces instead of invalidating the whole list model.
|
|
||||||
pub(crate) grid_tiles: RefCell<Vec<(glib::WeakRef<gtk4::Overlay>, glib::WeakRef<gtk4::Label>)>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Idle pause after the last keystroke before a search query is sent, so typing
|
/// Idle pause after the last keystroke before a search query is sent, so typing
|
||||||
@@ -102,11 +94,8 @@ pub(crate) const GRID_THUMB_DEFAULT: i32 = 72;
|
|||||||
pub(crate) const GRID_THUMB_STEP: i32 = 8;
|
pub(crate) const GRID_THUMB_STEP: i32 = 8;
|
||||||
|
|
||||||
/// The Files page: a Nautilus-style file manager. A back/breadcrumb header with
|
/// 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
|
/// a grid/list view toggle sits over a [`FileList`] with Name / Size / Modified
|
||||||
/// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`]
|
/// columns, so a directory load repopulates one model and both views update.
|
||||||
/// 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
|
/// 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
|
/// for activation and the right-click menu, so they're installed later in
|
||||||
@@ -117,10 +106,8 @@ pub(crate) const GRID_THUMB_STEP: i32 = 8;
|
|||||||
/// so "this folder is empty" and "the mount is down" read as first-class states
|
/// so "this folder is empty" and "the mount is down" read as first-class states
|
||||||
/// rather than a stray line above a blank grid.
|
/// rather than a stray line above a blank grid.
|
||||||
pub(crate) struct BrowserWidgets {
|
pub(crate) struct BrowserWidgets {
|
||||||
pub(crate) model: gio::ListStore,
|
pub(crate) files: FileList,
|
||||||
pub(crate) crumb: gtk4::Box,
|
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.
|
/// Swaps the content area between the grid/list views and the status page.
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
/// The empty/loading/error surface shown in place of the views.
|
/// The empty/loading/error surface shown in place of the views.
|
||||||
@@ -141,11 +128,6 @@ pub(crate) struct BrowserWidgets {
|
|||||||
/// Wraps the views + the details pane; the pane shows the selection while its header toggle is on.
|
/// Wraps the views + the details pane; the pane shows the selection while its header toggle is on.
|
||||||
pub(crate) split: adw::OverlaySplitView,
|
pub(crate) split: adw::OverlaySplitView,
|
||||||
pub(crate) details: DetailsWidgets,
|
pub(crate) details: DetailsWidgets,
|
||||||
/// The selection shared by both views, so a selection change can drive the
|
|
||||||
/// details pane and an action can re-read the entries the user highlighted.
|
|
||||||
pub(crate) selection: gtk4::MultiSelection,
|
|
||||||
/// The grid/list stack, so the page can tell which view is on screen.
|
|
||||||
pub(crate) views: gtk4::Stack,
|
|
||||||
pub(crate) bulk: gtk4::Revealer,
|
pub(crate) bulk: gtk4::Revealer,
|
||||||
pub(crate) bulk_label: gtk4::Label,
|
pub(crate) bulk_label: gtk4::Label,
|
||||||
pub(crate) bulk_trash: gtk4::Button,
|
pub(crate) bulk_trash: gtk4::Button,
|
||||||
@@ -159,8 +141,6 @@ pub(crate) struct BrowserWidgets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
|
||||||
|
|
||||||
let back = gtk4::Button::builder()
|
let back = gtk4::Button::builder()
|
||||||
.icon_name("go-previous-symbolic")
|
.icon_name("go-previous-symbolic")
|
||||||
.tooltip_text(gettext("Back (Alt+Left)"))
|
.tooltip_text(gettext("Back (Alt+Left)"))
|
||||||
@@ -333,40 +313,8 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
status.add_css_class("compact");
|
status.add_css_class("compact");
|
||||||
|
|
||||||
// Icon grid. Multi-select: acting on a batch is the common case for trashing
|
let files = FileList::new();
|
||||||
// and for taking a folder's worth of files offline, and doing it one
|
let view_stack = files.views.clone();
|
||||||
// confirmation dialog at a time is not a workflow.
|
|
||||||
// One selection for both views: switching layout keeps what is selected.
|
|
||||||
// Rubberband drags a selection box from empty space, as a file manager does.
|
|
||||||
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
|
||||||
let grid = gtk4::GridView::builder()
|
|
||||||
.model(&selection)
|
|
||||||
.min_columns(2)
|
|
||||||
.max_columns(32)
|
|
||||||
.enable_rubberband(true)
|
|
||||||
.build();
|
|
||||||
grid.add_css_class("file-grid");
|
|
||||||
let grid_scroll = gtk4::ScrolledWindow::builder()
|
|
||||||
.vexpand(true)
|
|
||||||
.child(&grid)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Column list, over the same selection.
|
|
||||||
let column_view = gtk4::ColumnView::builder()
|
|
||||||
.model(&selection)
|
|
||||||
.enable_rubberband(true)
|
|
||||||
.build();
|
|
||||||
column_view.add_css_class("data-table");
|
|
||||||
let column_scroll = gtk4::ScrolledWindow::builder()
|
|
||||||
.vexpand(true)
|
|
||||||
.child(&column_view)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Stack swapped by the `files.view` action.
|
|
||||||
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"));
|
|
||||||
|
|
||||||
// Outer stack: the views, or the status page when there's nothing to show.
|
// Outer stack: the views, or the status page when there's nothing to show.
|
||||||
let content = gtk4::Stack::new();
|
let content = gtk4::Stack::new();
|
||||||
@@ -520,10 +468,8 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
|||||||
(
|
(
|
||||||
frame.upcast(),
|
frame.upcast(),
|
||||||
BrowserWidgets {
|
BrowserWidgets {
|
||||||
model,
|
files,
|
||||||
crumb,
|
crumb,
|
||||||
grid,
|
|
||||||
column_view,
|
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
retry,
|
retry,
|
||||||
@@ -540,8 +486,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
|||||||
refresh,
|
refresh,
|
||||||
split,
|
split,
|
||||||
details,
|
details,
|
||||||
selection,
|
|
||||||
views: view_stack,
|
|
||||||
bulk,
|
bulk,
|
||||||
bulk_label,
|
bulk_label,
|
||||||
bulk_trash,
|
bulk_trash,
|
||||||
@@ -570,28 +514,14 @@ pub(crate) fn browser_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
|
|||||||
clear_details(ui);
|
clear_details(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selection model shared by the grid and the list.
|
/// Every entry highlighted in My files, in model order.
|
||||||
pub(crate) fn active_selection(ui: &Rc<Ui>) -> gtk4::MultiSelection {
|
|
||||||
ui.details.selection.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every entry highlighted in the view on screen, in model order.
|
|
||||||
///
|
|
||||||
/// Walks the model rather than the selection bitset: a listing is at most a few
|
|
||||||
/// thousand rows, and asking each position whether it is selected keeps this
|
|
||||||
/// free of bitset-iterator lifetimes for no measurable cost.
|
|
||||||
pub(crate) fn selected_entries(ui: &Rc<Ui>) -> Vec<DirEntry> {
|
pub(crate) fn selected_entries(ui: &Rc<Ui>) -> Vec<DirEntry> {
|
||||||
let selection = active_selection(ui);
|
ui.browser.files.selected()
|
||||||
let count = selection.n_items();
|
|
||||||
(0..count)
|
|
||||||
.filter(|i| selection.is_selected(*i))
|
|
||||||
.filter_map(|i| entry_at(Some(&selection), i))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the selection, which also retracts the bulk bar.
|
/// Drop the selection, which also retracts the bulk bar.
|
||||||
pub(crate) fn clear_selection(ui: &Rc<Ui>) {
|
pub(crate) fn clear_selection(ui: &Rc<Ui>) {
|
||||||
ui.details.selection.unselect_all();
|
ui.browser.files.selection.unselect_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reflect the current selection in the bulk bar: how many are selected, and
|
/// Reflect the current selection in the bulk bar: how many are selected, and
|
||||||
@@ -822,13 +752,6 @@ pub(crate) fn wire_bulk(
|
|||||||
sync_bulk_bar(&ui_clear);
|
sync_bulk_bar(&ui_clear);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Switching views switches which selection is live, so the bar has to be
|
|
||||||
// re-derived rather than left showing the other view's count.
|
|
||||||
let ui_view = ui.clone();
|
|
||||||
ui.browser
|
|
||||||
.views
|
|
||||||
.connect_visible_child_name_notify(move |_| sync_bulk_bar(&ui_view));
|
|
||||||
|
|
||||||
let ui_upload = ui.clone();
|
let ui_upload = ui.clone();
|
||||||
empty_upload.connect_clicked(move |_| prompt_upload(&ui_upload));
|
empty_upload.connect_clicked(move |_| prompt_upload(&ui_upload));
|
||||||
let ui_new = ui.clone();
|
let ui_new = ui.clone();
|
||||||
@@ -840,21 +763,27 @@ pub(crate) fn browser_views(ui: &Rc<Ui>) {
|
|||||||
ui.browser.content.set_visible_child_name("views");
|
ui.browser.content.set_visible_child_name("views");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the entry factories, columns, activation handlers and the back
|
/// Install the entry factories, columns, activation handlers and the zoom
|
||||||
/// button. Split out from [`build_browser_page`] because every renderer needs
|
/// slider. Split out from [`build_browser_page`] because every renderer needs
|
||||||
/// the [`Ui`] handle to open entries and raise the context menu.
|
/// 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) {
|
pub(crate) fn wire_browser(ui: &Rc<Ui>) {
|
||||||
attach_background_menu(ui, grid);
|
let files = &ui.browser.files;
|
||||||
attach_background_menu(ui, column_view);
|
files.wire(
|
||||||
attach_background_deselect(ui, grid);
|
ui,
|
||||||
attach_background_deselect(ui, column_view);
|
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
|
// Resize only realised grid cells. Rebuilding the whole model for every
|
||||||
// slider step would repeatedly tear down selection state while the pointer
|
// slider step would repeatedly tear down selection state while the pointer
|
||||||
// is still moving.
|
// is still moving.
|
||||||
let zoom = ui.browser.zoom.clone();
|
|
||||||
let ui_zoom = ui.clone();
|
let ui_zoom = ui.clone();
|
||||||
let grid_zoom = grid.clone();
|
ui.browser.zoom.connect_value_changed(move |scale| {
|
||||||
zoom.connect_value_changed(move |scale| {
|
|
||||||
let size = (scale.value().round() as i32).clamp(GRID_THUMB_MIN, GRID_THUMB_MAX);
|
let size = (scale.value().round() as i32).clamp(GRID_THUMB_MIN, GRID_THUMB_MAX);
|
||||||
scale.set_tooltip_text(Some(&ngettext_f(
|
scale.set_tooltip_text(Some(&ngettext_f(
|
||||||
"Size: {n} pixel",
|
"Size: {n} pixel",
|
||||||
@@ -862,199 +791,27 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: >k4::GridView, column_view: >k
|
|||||||
size as u64,
|
size as u64,
|
||||||
&[],
|
&[],
|
||||||
)));
|
)));
|
||||||
if ui_zoom.browser.grid_thumbnail_size.replace(size) == size {
|
ui_zoom.browser.files.set_thumbnail_size(size);
|
||||||
return;
|
|
||||||
}
|
|
||||||
ui_zoom
|
|
||||||
.browser
|
|
||||||
.grid_tiles
|
|
||||||
.borrow_mut()
|
|
||||||
.retain(|(thumbnail_ref, label_ref)| {
|
|
||||||
let (Some(thumbnail), Some(label)) = (thumbnail_ref.upgrade(), label_ref.upgrade())
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
resize_grid_tile(&thumbnail, &label, size);
|
|
||||||
true
|
|
||||||
});
|
|
||||||
grid_zoom.queue_resize();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Grid tiles: a thumbnail over an ellipsized name, with a right-click menu.
|
files
|
||||||
let factory = gtk4::SignalListItemFactory::new();
|
.column_view
|
||||||
factory.connect_setup({
|
.append_column(&text_column(&pgettext("column", "Size"), |e| {
|
||||||
let ui = ui.clone();
|
if e.is_dir {
|
||||||
move |_, item| {
|
"—".to_string()
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
} else {
|
||||||
let size = ui.browser.grid_thumbnail_size.get();
|
human_bytes(e.size)
|
||||||
let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size));
|
}
|
||||||
// Keep the sync-state badge inside the thumbnail surface.
|
}));
|
||||||
let badge = gtk4::Image::builder()
|
files
|
||||||
.pixel_size(18)
|
.column_view
|
||||||
.halign(gtk4::Align::End)
|
.append_column(&text_column(&pgettext("column", "Modified"), |e| {
|
||||||
.valign(gtk4::Align::Start)
|
dates::short_date(e.modified)
|
||||||
.margin_top(2)
|
}));
|
||||||
.margin_end(2)
|
|
||||||
.build();
|
|
||||||
badge.add_css_class("file-badge");
|
|
||||||
thumbnail.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();
|
|
||||||
ui.browser
|
|
||||||
.grid_tiles
|
|
||||||
.borrow_mut()
|
|
||||||
.push((thumbnail.downgrade(), label.downgrade()));
|
|
||||||
let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
|
||||||
tile.add_css_class("file-tile");
|
|
||||||
tile.append(&thumbnail);
|
|
||||||
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({
|
|
||||||
let ui = ui.clone();
|
|
||||||
move |_, item| {
|
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
||||||
let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
|
|
||||||
let thumbnail = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
|
||||||
let badge = thumbnail
|
|
||||||
.last_child()
|
|
||||||
.and_downcast::<gtk4::Image>()
|
|
||||||
.unwrap();
|
|
||||||
let label = thumbnail
|
|
||||||
.next_sibling()
|
|
||||||
.and_downcast::<gtk4::Label>()
|
|
||||||
.unwrap();
|
|
||||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
|
||||||
let entry = obj.borrow::<DirEntry>();
|
|
||||||
let size = ui.browser.grid_thumbnail_size.get();
|
|
||||||
resize_grid_tile(&thumbnail, &label, size);
|
|
||||||
bind_file_thumbnail(&ui, &thumbnail, &entry, false);
|
|
||||||
label.set_label(&entry.name);
|
|
||||||
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(&pgettext("column", "Size"), |e| {
|
|
||||||
if e.is_dir {
|
|
||||||
"—".to_string()
|
|
||||||
} else {
|
|
||||||
human_bytes(e.size)
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
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.
|
// Search hits come from anywhere in the Drive; this says where.
|
||||||
let location = text_column(&gettext(LOCATION_COLUMN), |e| hit_location(&e.path));
|
let location = text_column(&gettext(LOCATION_COLUMN), |e| hit_location(&e.path));
|
||||||
location.set_visible(false);
|
location.set_visible(false);
|
||||||
column_view.append_column(&location);
|
files.column_view.append_column(&location);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) {
|
|
||||||
resize_file_thumbnail(thumbnail, size, grid_fallback_size(size));
|
|
||||||
let name_width = (size / 6 + 1).clamp(8, 24);
|
|
||||||
label.set_width_chars(name_width);
|
|
||||||
label.set_max_width_chars(name_width);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn grid_fallback_size(thumbnail_size: i32) -> i32 {
|
|
||||||
(thumbnail_size * 8 / 9).clamp(24, thumbnail_size)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the Name column: a small thumbnail with its local-state badge overlaid,
|
|
||||||
/// followed by the name and 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 thumbnail = file_thumbnail_widget(28, 16);
|
|
||||||
let badge = gtk4::Image::builder()
|
|
||||||
.pixel_size(14)
|
|
||||||
.halign(gtk4::Align::End)
|
|
||||||
.valign(gtk4::Align::Start)
|
|
||||||
.build();
|
|
||||||
badge.add_css_class("file-badge");
|
|
||||||
thumbnail.add_overlay(&badge);
|
|
||||||
// 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 cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
||||||
cell.append(&thumbnail);
|
|
||||||
cell.append(&label);
|
|
||||||
attach_context_menu(&ui, item, &cell);
|
|
||||||
attach_drag(&ui, item, &cell);
|
|
||||||
attach_drop(&ui, item, &cell);
|
|
||||||
item.set_child(Some(&cell));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
factory.connect_bind({
|
|
||||||
let ui = ui.clone();
|
|
||||||
move |_, item| {
|
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
||||||
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
|
||||||
let thumbnail = cell.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
|
||||||
let badge = thumbnail
|
|
||||||
.last_child()
|
|
||||||
.and_downcast::<gtk4::Image>()
|
|
||||||
.unwrap();
|
|
||||||
let label = thumbnail
|
|
||||||
.next_sibling()
|
|
||||||
.and_downcast::<gtk4::Label>()
|
|
||||||
.unwrap();
|
|
||||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
|
||||||
let entry = obj.borrow::<DirEntry>();
|
|
||||||
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
|
||||||
label.set_label(&entry.name);
|
|
||||||
label.set_tooltip_text(Some(&entry.name));
|
|
||||||
apply_badge(&badge, &entry);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Name")), Some(factory));
|
|
||||||
column.set_expand(true);
|
|
||||||
column
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Title of the search-hit folder column, also how the column is found again.
|
/// Title of the search-hit folder column, also how the column is found again.
|
||||||
@@ -1070,7 +827,7 @@ fn hit_location(path: &str) -> String {
|
|||||||
|
|
||||||
/// Show the Location column only while the list holds search hits.
|
/// Show the Location column only while the list holds search hits.
|
||||||
fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
||||||
let columns = ui.browser.column_view.columns();
|
let columns = ui.browser.files.column_view.columns();
|
||||||
for column in (0..columns.n_items())
|
for column in (0..columns.n_items())
|
||||||
.filter_map(|i| columns.item(i).and_downcast::<gtk4::ColumnViewColumn>())
|
.filter_map(|i| columns.item(i).and_downcast::<gtk4::ColumnViewColumn>())
|
||||||
{
|
{
|
||||||
@@ -1080,141 +837,6 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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. The click is claimed, so the
|
|
||||||
/// view's own background menu does not open on top of it.
|
|
||||||
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 |gesture, _, x, y| {
|
|
||||||
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
|
||||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
|
|
||||||
let entry = obj.borrow::<DirEntry>().clone();
|
|
||||||
// Right-clicking a row that is part of a multi-selection acts on the
|
|
||||||
// batch; right-clicking outside one acts on the row, as before.
|
|
||||||
let selected = selected_entries(&ui);
|
|
||||||
if selected.len() > 1 && selected.iter().any(|e| e.uid == entry.uid) {
|
|
||||||
bulk_context_menu(&ui, selected).popup_at(&target, x, y);
|
|
||||||
} else {
|
|
||||||
entry_context_menu(&ui, &entry).popup_at(&target, x, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
anchor.add_controller(gesture);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Right-clicking the empty space of a view offers the folder's own actions.
|
|
||||||
pub(crate) fn attach_background_menu(ui: &Rc<Ui>, view: &impl IsA<gtk4::Widget>) {
|
|
||||||
let gesture = gtk4::GestureClick::new();
|
|
||||||
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
|
||||||
let ui = ui.clone();
|
|
||||||
let target = view.clone().upcast::<gtk4::Widget>();
|
|
||||||
gesture.connect_pressed(move |_, _, x, y| {
|
|
||||||
background_context_menu(&ui).popup_at(&target, x, y);
|
|
||||||
});
|
|
||||||
view.add_controller(gesture);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clicking the empty space of a view drops the selection, as in a file
|
|
||||||
/// manager. GTK's list views leave it alone, so a stray highlight otherwise
|
|
||||||
/// sticks around until another item is clicked.
|
|
||||||
pub(crate) fn attach_background_deselect(ui: &Rc<Ui>, view: &impl IsA<gtk4::Widget>) {
|
|
||||||
let gesture = gtk4::GestureClick::new();
|
|
||||||
gesture.set_button(gtk4::gdk::BUTTON_PRIMARY);
|
|
||||||
// Capture sees the press before an item's own gesture claims it; this only
|
|
||||||
// looks, so the item still gets its click.
|
|
||||||
gesture.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
|
||||||
let ui = ui.clone();
|
|
||||||
let target = view.clone().upcast::<gtk4::Widget>();
|
|
||||||
gesture.connect_pressed(move |gesture, _, x, y| {
|
|
||||||
let modifiers = gesture.current_event_state();
|
|
||||||
if modifiers
|
|
||||||
.intersects(gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::SHIFT_MASK)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if !on_item(&target, x, y) {
|
|
||||||
clear_selection(&ui);
|
|
||||||
sync_bulk_bar(&ui);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
view.add_controller(gesture);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `(x, y)` in `view` lands on an item: a grid tile ("child"), a list
|
|
||||||
/// row ("row"), or a column header ("header"), which has clicks of its own.
|
|
||||||
fn on_item(view: >k4::Widget, x: f64, y: f64) -> bool {
|
|
||||||
let mut widget = view.pick(x, y, gtk4::PickFlags::DEFAULT);
|
|
||||||
while let Some(w) = widget {
|
|
||||||
if &w == view {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if matches!(w.css_name().as_str(), "child" | "row" | "header") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
widget = w.parent();
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Menu key or Shift+F10: the menu for the selection, or for the folder
|
|
||||||
/// when nothing is selected, opened at the focused item.
|
|
||||||
pub(crate) fn popup_keyboard_context_menu(ui: &Rc<Ui>) {
|
|
||||||
let view = ui
|
|
||||||
.browser
|
|
||||||
.views
|
|
||||||
.visible_child()
|
|
||||||
.unwrap_or_else(|| ui.browser.views.clone().upcast());
|
|
||||||
// The focused cell is where the eye is; without one, the middle of the view.
|
|
||||||
let (x, y) = view
|
|
||||||
.root()
|
|
||||||
.and_then(|root| root.focus())
|
|
||||||
.filter(|focus| focus.is_ancestor(&view))
|
|
||||||
.and_then(|focus| focus.compute_bounds(&view))
|
|
||||||
.map(|b| {
|
|
||||||
(
|
|
||||||
(b.x() + b.width() / 2.0) as f64,
|
|
||||||
(b.y() + b.height() / 2.0) as f64,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or((view.width() as f64 / 2.0, view.height() as f64 / 2.0));
|
|
||||||
let selected = selected_entries(ui);
|
|
||||||
let menu = match selected.len() {
|
|
||||||
0 => background_context_menu(ui),
|
|
||||||
1 => entry_context_menu(ui, &selected[0]),
|
|
||||||
_ => bulk_context_menu(ui, selected),
|
|
||||||
};
|
|
||||||
menu.popup_at(&view, x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The menu for one entry, in the order of `docs/UI_UX_PLAN.md` §6: open,
|
/// 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.
|
/// offline, sharing, organising, and the destructive item last on its own.
|
||||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
@@ -1334,7 +956,7 @@ pub(crate) fn background_context_menu(ui: &Rc<Ui>) -> ActionMenu {
|
|||||||
}
|
}
|
||||||
let ui_c = ui.clone();
|
let ui_c = ui.clone();
|
||||||
menu.item(&gettext("Select All"), move || {
|
menu.item(&gettext("Select All"), move || {
|
||||||
active_selection(&ui_c).select_all();
|
ui_c.browser.files.selection.select_all();
|
||||||
});
|
});
|
||||||
let ui_c = ui.clone();
|
let ui_c = ui.clone();
|
||||||
menu.item(&gettext("Refresh"), move || reload_listing(&ui_c));
|
menu.item(&gettext("Refresh"), move || reload_listing(&ui_c));
|
||||||
@@ -1795,7 +1417,7 @@ pub(crate) fn set_files_view(ui: &Rc<Ui>, view: FilesView) {
|
|||||||
/// Bring the view stack, the view button and the actions' check marks in line
|
/// Bring the view stack, the view button and the actions' check marks in line
|
||||||
/// with `view`.
|
/// with `view`.
|
||||||
fn apply_files_view(ui: &Rc<Ui>, view: FilesView) {
|
fn apply_files_view(ui: &Rc<Ui>, view: FilesView) {
|
||||||
ui.browser.views.set_visible_child_name(layout_name(view));
|
ui.browser.files.show_list(view.list);
|
||||||
// The button offers the other layout, the way a toggle reads.
|
// The button offers the other layout, the way a toggle reads.
|
||||||
ui.browser.view_button.set_icon_name(if view.list {
|
ui.browser.view_button.set_icon_name(if view.list {
|
||||||
"view-grid-symbolic"
|
"view-grid-symbolic"
|
||||||
@@ -1852,6 +1474,13 @@ pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) {
|
|||||||
load_browser(ui);
|
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.
|
/// Back: out of a search first, then to the previous folder.
|
||||||
fn browse_back(ui: &Rc<Ui>) {
|
fn browse_back(ui: &Rc<Ui>) {
|
||||||
if !ui.browser.search.text().is_empty() {
|
if !ui.browser.search.text().is_empty() {
|
||||||
@@ -2186,6 +1815,17 @@ fn thumbnail_build_failed(ui: &Rc<Ui>) {
|
|||||||
/// `failed` is an untranslated msgid: callers mark it with [`gettext_noop`] and
|
/// `failed` is an untranslated msgid: callers mark it with [`gettext_noop`] and
|
||||||
/// it is translated here.
|
/// it is translated here.
|
||||||
pub(crate) fn run_mutation(ui: &Rc<Ui>, req: Request, done: String, failed: &'static str) {
|
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);
|
let failed = gettext(failed);
|
||||||
if !*ui.mounted.borrow() {
|
if !*ui.mounted.borrow() {
|
||||||
toast_error(ui, &failed, &gettext("Proton Drive isn't connected."));
|
toast_error(ui, &failed, &gettext("Proton Drive isn't connected."));
|
||||||
@@ -2202,7 +1842,7 @@ pub(crate) fn run_mutation(ui: &Rc<Ui>, req: Request, done: String, failed: &'st
|
|||||||
// The listing the mutation changed is stale now; reload it, then
|
// The listing the mutation changed is stale now; reload it, then
|
||||||
// confirm, so the toast lands over the updated view.
|
// confirm, so the toast lands over the updated view.
|
||||||
reload_listing(&ui);
|
reload_listing(&ui);
|
||||||
toast(&ui, &done);
|
done(&ui);
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, &failed, &message, kind)
|
toast_failure(&ui, &failed, &message, kind)
|
||||||
@@ -2830,7 +2470,7 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
|||||||
// wrong file.
|
// wrong file.
|
||||||
let ui_p = ui.clone();
|
let ui_p = ui.clone();
|
||||||
let placeholder = move || {
|
let placeholder = move || {
|
||||||
ui_p.browser.model.remove_all();
|
ui_p.browser.files.model.remove_all();
|
||||||
ui_p.browser.summary.set_label(&gettext("Loading…"));
|
ui_p.browser.summary.set_label(&gettext("Loading…"));
|
||||||
browser_status(
|
browser_status(
|
||||||
&ui_p,
|
&ui_p,
|
||||||
@@ -2880,7 +2520,7 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
|||||||
/// (which restarts the service) wouldn't help and isn't offered.
|
/// (which restarts the service) wouldn't help and isn't offered.
|
||||||
pub(crate) fn browser_failed(ui: &Rc<Ui>, message: &str, kind: ErrorKind) {
|
pub(crate) fn browser_failed(ui: &Rc<Ui>, message: &str, kind: ErrorKind) {
|
||||||
forget_listing(ui);
|
forget_listing(ui);
|
||||||
ui.browser.model.remove_all();
|
ui.browser.files.model.remove_all();
|
||||||
ui.browser.summary.set_label(&gettext("Folder unavailable"));
|
ui.browser.summary.set_label(&gettext("Folder unavailable"));
|
||||||
browser_status(
|
browser_status(
|
||||||
ui,
|
ui,
|
||||||
@@ -2931,7 +2571,7 @@ pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
&gettext("Drop files here, or upload a file or create a folder to get started."),
|
&gettext("Drop files here, or upload a file or create a folder to get started."),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
ui.browser.model.remove_all();
|
ui.browser.files.model.remove_all();
|
||||||
ui.browser.empty_actions.set_visible(true);
|
ui.browser.empty_actions.set_visible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2941,14 +2581,14 @@ pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
sort_entries(&mut sorted, ui.browser.view.get());
|
sort_entries(&mut sorted, ui.browser.view.get());
|
||||||
// Only the rows that changed are swapped, so a refresh keeps the
|
// Only the rows that changed are swapped, so a refresh keeps the
|
||||||
// selection, the scroll position and the thumbnails already drawn.
|
// selection, the scroll position and the thumbnails already drawn.
|
||||||
replace_items(&ui.browser.model, &sorted);
|
replace_items(&ui.browser.files.model, &sorted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget which folder the rows belong to and drop the selection, before the
|
/// Forget which folder the rows belong to and drop the selection, before the
|
||||||
/// rows stop matching the folder on screen.
|
/// rows stop matching the folder on screen.
|
||||||
fn forget_listing(ui: &Rc<Ui>) {
|
fn forget_listing(ui: &Rc<Ui>) {
|
||||||
ui.browser.listed.borrow_mut().take();
|
ui.browser.listed.borrow_mut().take();
|
||||||
active_selection(ui).unselect_all();
|
clear_selection(ui);
|
||||||
clear_details(ui);
|
clear_details(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3013,7 +2653,7 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
|
|||||||
let ui_p = ui.clone();
|
let ui_p = ui.clone();
|
||||||
let looking_for = query.to_string();
|
let looking_for = query.to_string();
|
||||||
let ticket = ui.browser.loader.replace(move || {
|
let ticket = ui.browser.loader.replace(move || {
|
||||||
ui_p.browser.model.remove_all();
|
ui_p.browser.files.model.remove_all();
|
||||||
ui_p.browser.summary.set_label(&gettext("Searching…"));
|
ui_p.browser.summary.set_label(&gettext("Searching…"));
|
||||||
browser_status(
|
browser_status(
|
||||||
&ui_p,
|
&ui_p,
|
||||||
@@ -3092,7 +2732,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
&gettext("No files or folders match that search."),
|
&gettext("No files or folders match that search."),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
ui.browser.model.remove_all();
|
ui.browser.files.model.remove_all();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
browser_views(ui);
|
browser_views(ui);
|
||||||
@@ -3112,10 +2752,12 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
sort_entries(&mut entries, ui.browser.view.get());
|
sort_entries(&mut entries, ui.browser.view.get());
|
||||||
replace_items(&ui.browser.model, &entries);
|
replace_items(&ui.browser.files.model, &entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
||||||
@@ -3169,6 +2811,8 @@ mod tests {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,39 +1,100 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
pub(crate) struct SharedByMeState {
|
pub(crate) struct SharedByMeState {
|
||||||
// Shared (by me) page: one section listing the items I've shared, each with a
|
// Shared by me page: the items I have shared, as a file list whose columns
|
||||||
// copy-link / manage affordance. Rebuilt wholesale on each load.
|
// say where each lives, who has it and what its public link is doing.
|
||||||
|
pub(crate) files: FileList,
|
||||||
|
/// "list", or "status" while the page loads, is empty or can't be read.
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
/// "12 items" under the page title.
|
||||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
|
/// Which items to show, indexed like [`SHARE_FILTERS`].
|
||||||
|
pub(crate) filter: gtk4::DropDown,
|
||||||
|
/// The last listing, unfiltered. The list holds [`DirEntry`]s; the columns
|
||||||
|
/// and menus look a row's people and link up here by uid.
|
||||||
|
pub(crate) items: Rc<RefCell<Vec<SharedItem>>>,
|
||||||
/// Runs the loads; the rows stay up while one runs.
|
/// Runs the loads; the rows stay up while one runs.
|
||||||
pub(crate) loader: Rc<Loader>,
|
pub(crate) loader: Rc<Loader>,
|
||||||
|
/// When the page last painted good data. `None` = never / invalidated,
|
||||||
|
/// forcing a fetch on next visit. See [`PAGE_TTL`].
|
||||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SharedByMeState {
|
||||||
|
pub(crate) fn new(widgets: &SharedByMeWidgets) -> Self {
|
||||||
|
SharedByMeState {
|
||||||
|
files: widgets.files.clone(),
|
||||||
|
content: widgets.content.clone(),
|
||||||
|
status: widgets.status.clone(),
|
||||||
|
retry: widgets.retry.clone(),
|
||||||
|
subtitle: widgets.subtitle.clone(),
|
||||||
|
filter: widgets.filter.clone(),
|
||||||
|
items: Rc::new(RefCell::new(Vec::new())),
|
||||||
|
loader: Loader::new(&widgets.content),
|
||||||
|
loaded_at: Cell::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Widgets the Shared (by-me) page's load/repaint touch.
|
/// Widgets the Shared (by-me) page's load/repaint touch.
|
||||||
pub(crate) struct SharedByMeWidgets {
|
pub(crate) struct SharedByMeWidgets {
|
||||||
|
pub(crate) files: FileList,
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) refresh: gtk4::Button,
|
||||||
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
|
pub(crate) filter: gtk4::DropDown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Shared page: one section listing the items I have shared with others —
|
/// Which shared items the page shows.
|
||||||
/// each row summarizing who has access, opening the item when activated, with
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
/// Copy link and a menu for the link and the per-node Share dialog.
|
pub(crate) enum ShareFilter {
|
||||||
|
All,
|
||||||
|
/// Items with a public link.
|
||||||
|
Links,
|
||||||
|
/// Items shared with people, invited or already in.
|
||||||
|
People,
|
||||||
|
/// Items whose public link has expired.
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filters in the order the header's drop-down lists them.
|
||||||
|
const SHARE_FILTERS: [ShareFilter; 4] = [
|
||||||
|
ShareFilter::All,
|
||||||
|
ShareFilter::Links,
|
||||||
|
ShareFilter::People,
|
||||||
|
ShareFilter::Expired,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn share_filter_label(filter: ShareFilter) -> String {
|
||||||
|
match filter {
|
||||||
|
ShareFilter::All => gettext("All Shared Items"),
|
||||||
|
ShareFilter::Links => gettext("Links Only"),
|
||||||
|
ShareFilter::People => gettext("People Only"),
|
||||||
|
ShareFilter::Expired => gettext("Expired Links"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Shared by me page: every item I have shared with people or by link.
|
||||||
|
/// Activating one opens its Share dialog, since managing access is what this
|
||||||
|
/// page is for; opening the item itself is in the menu.
|
||||||
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||||
let refresh = refresh_button();
|
let refresh = refresh_button();
|
||||||
|
let files = FileList::new();
|
||||||
let group = adw::PreferencesGroup::new();
|
files.show_list(true);
|
||||||
let clamp = adw::Clamp::builder().child(&group).build();
|
let layout = layout_button(&files);
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let labels: Vec<String> = SHARE_FILTERS
|
||||||
.vexpand(true)
|
.iter()
|
||||||
.child(&clamp)
|
.copied()
|
||||||
.build();
|
.map(share_filter_label)
|
||||||
|
.collect();
|
||||||
|
let filter =
|
||||||
|
gtk4::DropDown::from_strings(&labels.iter().map(String::as_str).collect::<Vec<_>>());
|
||||||
|
filter.set_valign(gtk4::Align::Center);
|
||||||
|
filter.set_tooltip_text(Some(&gettext("Show only some shared items")));
|
||||||
|
|
||||||
let retry = gtk4::Button::builder()
|
let retry = gtk4::Button::builder()
|
||||||
.label(gettext("Retry"))
|
.label(gettext("Retry"))
|
||||||
@@ -52,34 +113,218 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
|||||||
let content = gtk4::Stack::new();
|
let content = gtk4::Stack::new();
|
||||||
content.set_vexpand(true);
|
content.set_vexpand(true);
|
||||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||||
content.add_named(&scroll, Some("list"));
|
content.add_named(&files.views, Some("list"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(18);
|
inner.set_margin_top(12);
|
||||||
inner.set_margin_bottom(18);
|
inner.set_margin_bottom(12);
|
||||||
inner.set_margin_start(18);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(18);
|
inner.set_margin_end(12);
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
let (frame, header, _) = page_frame(&gettext("Shared by me"), &inner);
|
|
||||||
|
let (frame, header, subtitle) = page_frame(&gettext("Shared by me"), &inner);
|
||||||
|
header.pack_start(&filter);
|
||||||
header.pack_end(&refresh);
|
header.pack_end(&refresh);
|
||||||
|
header.pack_end(&layout);
|
||||||
|
|
||||||
(
|
(
|
||||||
frame.upcast(),
|
frame.upcast(),
|
||||||
SharedByMeWidgets {
|
SharedByMeWidgets {
|
||||||
|
files,
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
group,
|
|
||||||
retry,
|
retry,
|
||||||
refresh,
|
refresh,
|
||||||
|
subtitle,
|
||||||
|
filter,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the Shared (by-me) page's retry button.
|
/// Install the file list, its columns, the filter and the retry button.
|
||||||
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: >k4::Button) {
|
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, widgets: &SharedByMeWidgets) {
|
||||||
|
let files = &ui.shared_by_me.files;
|
||||||
|
files.wire(
|
||||||
|
ui,
|
||||||
|
FileListBehavior {
|
||||||
|
activate: open_share_dialog,
|
||||||
|
entry_menu: shared_by_me_menu,
|
||||||
|
bulk_menu: shared_by_me_bulk_menu,
|
||||||
|
background_menu: shared_by_me_background_menu,
|
||||||
|
badges: false,
|
||||||
|
drag_and_drop: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let items = &ui.shared_by_me.items;
|
||||||
|
files
|
||||||
|
.column_view
|
||||||
|
.append_column(&text_column(&gettext("Location"), |e| {
|
||||||
|
location_label(&e.path)
|
||||||
|
}));
|
||||||
|
let people = items.clone();
|
||||||
|
files
|
||||||
|
.column_view
|
||||||
|
.append_column(&text_column(&pgettext("column", "People"), move |e| {
|
||||||
|
find_item(&people, &e.uid)
|
||||||
|
.map(|item| people_label(&item))
|
||||||
|
.unwrap_or_else(|| "—".to_string())
|
||||||
|
}));
|
||||||
|
files.column_view.append_column(&link_column(ui));
|
||||||
|
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared_by_me));
|
widgets
|
||||||
|
.retry
|
||||||
|
.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared_by_me));
|
||||||
|
let ui_filter = ui.clone();
|
||||||
|
widgets.filter.connect_selected_notify(move |_| {
|
||||||
|
let items = ui_filter.shared_by_me.items.borrow().clone();
|
||||||
|
repaint_shared_by_me(&ui_filter, &items);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared item a row stands for.
|
||||||
|
fn find_item(items: &RefCell<Vec<SharedItem>>, uid: &str) -> Option<SharedItem> {
|
||||||
|
items.borrow().iter().find(|item| item.uid == uid).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Link column: what the public link is doing, and a button that copies
|
||||||
|
/// it while it works.
|
||||||
|
fn link_column(ui: &Rc<Ui>) -> gtk4::ColumnViewColumn {
|
||||||
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
|
let ui_setup = ui.clone();
|
||||||
|
factory.connect_setup(move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let label = gtk4::Label::builder()
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.hexpand(true)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.build();
|
||||||
|
label.add_css_class("dim-label");
|
||||||
|
let copy = gtk4::Button::builder()
|
||||||
|
.icon_name("pdfs-link-symbolic")
|
||||||
|
.tooltip_text(gettext("Copy link"))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
copy.add_css_class("flat");
|
||||||
|
// Read at click time: the view recycles this cell for other rows.
|
||||||
|
let (ui, item_c) = (ui_setup.clone(), item.clone());
|
||||||
|
copy.connect_clicked(move |btn| {
|
||||||
|
let Some(obj) = item_c.item().and_downcast::<BoxedAnyObject>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let uid = obj.borrow::<DirEntry>().uid.clone();
|
||||||
|
if let Some(url) = find_item(&ui.shared_by_me.items, &uid)
|
||||||
|
.and_then(|item| item.link)
|
||||||
|
.and_then(|link| link.url)
|
||||||
|
{
|
||||||
|
btn.clipboard().set_text(&url);
|
||||||
|
flash_copied(btn, "pdfs-link-symbolic");
|
||||||
|
toast(&ui, &gettext("Link copied"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||||
|
cell.append(&label);
|
||||||
|
cell.append(©);
|
||||||
|
item.set_child(Some(&cell));
|
||||||
|
});
|
||||||
|
let items = ui.shared_by_me.items.clone();
|
||||||
|
factory.connect_bind(move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||||
|
let label = cell.first_child().and_downcast::<gtk4::Label>().unwrap();
|
||||||
|
let copy = cell.last_child().unwrap();
|
||||||
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||||
|
let uid = obj.borrow::<DirEntry>().uid.clone();
|
||||||
|
let link = find_item(&items, &uid).and_then(|item| item.link);
|
||||||
|
let now = glib::real_time() / 1_000_000;
|
||||||
|
let text = link_label(link.as_ref(), now);
|
||||||
|
label.set_label(&text);
|
||||||
|
label.set_tooltip_text(Some(&text));
|
||||||
|
copy.set_visible(
|
||||||
|
link.as_ref()
|
||||||
|
.is_some_and(|l| l.url.is_some() && !link_expired(l, now)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Link")), Some(factory));
|
||||||
|
column.set_resizable(true);
|
||||||
|
column
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a shared item lives in My files: its folder, "My files" at the top,
|
||||||
|
/// or a dash when the daemon can't place it.
|
||||||
|
pub(crate) fn location_label(path: &str) -> String {
|
||||||
|
match path.rsplit_once('/') {
|
||||||
|
Some((parent, _)) => parent.to_string(),
|
||||||
|
None if path.is_empty() => "—".to_string(),
|
||||||
|
None => gettext("My files"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who can reach a shared item through an invitation.
|
||||||
|
pub(crate) fn people_label(item: &SharedItem) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if item.member_count > 0 {
|
||||||
|
// Translators: how many people have access to a shared item.
|
||||||
|
parts.push(ngettext_f(
|
||||||
|
"{n} person",
|
||||||
|
"{n} people",
|
||||||
|
item.member_count as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if item.invite_count > 0 {
|
||||||
|
// Translators: how many invitations to a shared item are not accepted yet.
|
||||||
|
parts.push(ngettext_f(
|
||||||
|
"{n} pending",
|
||||||
|
"{n} pending",
|
||||||
|
item.invite_count as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
"—".to_string()
|
||||||
|
} else {
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_expired(link: &PublicLinkInfo, now: i64) -> bool {
|
||||||
|
link.expires.is_some_and(|at| at <= now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a public link is doing at `now` (epoch seconds): working, working
|
||||||
|
/// until a date, or expired, and whether a password guards it.
|
||||||
|
pub(crate) fn link_label(link: Option<&PublicLinkInfo>, now: i64) -> String {
|
||||||
|
let Some(link) = link else {
|
||||||
|
return "—".to_string();
|
||||||
|
};
|
||||||
|
let state = match link.expires {
|
||||||
|
Some(at) if at <= now => pgettext("link", "Expired"),
|
||||||
|
// Translators: {date} is when a public link stops working.
|
||||||
|
Some(at) => gettext_f("Expires {date}", &[("date", &dates::short_date(at))]),
|
||||||
|
None if link.created > 0 => gettext_f(
|
||||||
|
// Translators: {date} is when the public link was created.
|
||||||
|
"Active since {date}",
|
||||||
|
&[("date", &dates::short_date(link.created))],
|
||||||
|
),
|
||||||
|
None => pgettext("link", "Active"),
|
||||||
|
};
|
||||||
|
if link.has_password {
|
||||||
|
[state, gettext("Password protected")].join(" · ")
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `item` passes `filter` at `now` (epoch seconds).
|
||||||
|
pub(crate) fn matches_filter(item: &SharedItem, filter: ShareFilter, now: i64) -> bool {
|
||||||
|
match filter {
|
||||||
|
ShareFilter::All => true,
|
||||||
|
ShareFilter::Links => item.link.is_some(),
|
||||||
|
ShareFilter::People => item.member_count + item.invite_count > 0,
|
||||||
|
ShareFilter::Expired => item.link.as_ref().is_some_and(|l| link_expired(l, now)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a status page in place of the Shared (by-me) list.
|
/// Show a status page in place of the Shared (by-me) list.
|
||||||
@@ -95,6 +340,7 @@ pub(crate) fn shared_by_me_status(
|
|||||||
ui.shared_by_me.status.set_description(Some(description));
|
ui.shared_by_me.status.set_description(Some(description));
|
||||||
ui.shared_by_me.retry.set_visible(retry);
|
ui.shared_by_me.retry.set_visible(retry);
|
||||||
ui.shared_by_me.content.set_visible_child_name("status");
|
ui.shared_by_me.content.set_visible_child_name("status");
|
||||||
|
ui.shared_by_me.subtitle.set_subtitle("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the shared-by-me listing and repaint the page.
|
/// Fetch the shared-by-me listing and repaint the page.
|
||||||
@@ -102,6 +348,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
|||||||
cancel_file_thumbnails(ui);
|
cancel_file_thumbnails(ui);
|
||||||
let ui_p = ui.clone();
|
let ui_p = ui.clone();
|
||||||
let ticket = ui.shared_by_me.loader.refresh(move || {
|
let ticket = ui.shared_by_me.loader.refresh(move || {
|
||||||
|
ui_p.shared_by_me.files.model.remove_all();
|
||||||
shared_by_me_status(
|
shared_by_me_status(
|
||||||
&ui_p,
|
&ui_p,
|
||||||
"pdfs-share-symbolic",
|
"pdfs-share-symbolic",
|
||||||
@@ -119,9 +366,15 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
|||||||
if !ticket.is_current() {
|
if !ticket.is_current() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if !matches!(result, Ok(Ok(Response::SharedByMe { .. }))) {
|
||||||
|
ui.shared_by_me.files.model.remove_all();
|
||||||
|
}
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::SharedByMe { items })) => {
|
Ok(Ok(Response::SharedByMe { items })) => {
|
||||||
|
let changed = changed_items(&ui.shared_by_me.items.borrow(), &items);
|
||||||
|
*ui.shared_by_me.items.borrow_mut() = items.clone();
|
||||||
repaint_shared_by_me(&ui, &items);
|
repaint_shared_by_me(&ui, &items);
|
||||||
|
rebind_rows(&ui, &changed);
|
||||||
ui.shared_by_me.loaded_at.set(Some(Instant::now()));
|
ui.shared_by_me.loaded_at.set(Some(Instant::now()));
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
|
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
|
||||||
@@ -151,11 +404,8 @@ pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
|||||||
service_unreachable(ui, "sharedbyme", shared_by_me_status, load_shared_by_me);
|
service_unreachable(ui, "sharedbyme", shared_by_me_status, load_shared_by_me);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the Shared (by-me) section from a fresh listing.
|
/// Show the items that pass the header's filter.
|
||||||
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||||
for row in ui.shared_by_me.rows.borrow_mut().drain(..) {
|
|
||||||
ui.shared_by_me.group.remove(&row);
|
|
||||||
}
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
shared_by_me_status(
|
shared_by_me_status(
|
||||||
ui,
|
ui,
|
||||||
@@ -164,96 +414,147 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
&gettext("Items you share with people or by link show up here."),
|
&gettext("Items you share with people or by link show up here."),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
ui.shared_by_me.files.model.remove_all();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let filter = SHARE_FILTERS
|
||||||
|
.get(ui.shared_by_me.filter.selected() as usize)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(ShareFilter::All);
|
||||||
|
let now = glib::real_time() / 1_000_000;
|
||||||
|
let shown: Vec<DirEntry> = items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| matches_filter(item, filter, now))
|
||||||
|
.map(shared_item_as_entry)
|
||||||
|
.collect();
|
||||||
|
if shown.is_empty() {
|
||||||
|
shared_by_me_status(
|
||||||
|
ui,
|
||||||
|
"pdfs-share-symbolic",
|
||||||
|
&gettext("No Matching Items"),
|
||||||
|
&gettext("None of your shared items match this filter."),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ui.shared_by_me.files.model.remove_all();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.shared_by_me.content.set_visible_child_name("list");
|
ui.shared_by_me.content.set_visible_child_name("list");
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
ui.shared_by_me.subtitle.set_subtitle(&ngettext_f(
|
||||||
for item in items {
|
"{n} item",
|
||||||
let entry = shared_item_as_entry(item);
|
"{n} items",
|
||||||
let row = adw::ActionRow::builder()
|
shown.len() as u64,
|
||||||
.title(&item.name)
|
&[],
|
||||||
.subtitle(shared_item_summary(item))
|
));
|
||||||
.build();
|
// Only the rows that changed are swapped, so a refresh keeps the selection
|
||||||
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
|
// and the scroll position.
|
||||||
|
replace_items(&ui.shared_by_me.files.model, &shown);
|
||||||
// A node the daemon can place in my tree opens like it would in My Files.
|
|
||||||
if !item.path.is_empty() {
|
|
||||||
row.set_activatable(true);
|
|
||||||
let ui_open = ui.clone();
|
|
||||||
let entry_open = entry.clone();
|
|
||||||
row.connect_activated(move |_| open_shared_by_me(&ui_open, &entry_open));
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = item.link.as_ref().and_then(|l| l.url.clone());
|
|
||||||
// The one action people come here for most gets its own button.
|
|
||||||
if let Some(url) = url.clone() {
|
|
||||||
let copy = gtk4::Button::builder()
|
|
||||||
.icon_name("pdfs-link-symbolic")
|
|
||||||
.tooltip_text(gettext("Copy link"))
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
copy.add_css_class("flat");
|
|
||||||
let ui_copy = ui.clone();
|
|
||||||
copy.connect_clicked(move |btn| {
|
|
||||||
btn.clipboard().set_text(&url);
|
|
||||||
flash_copied(btn, "pdfs-link-symbolic");
|
|
||||||
toast(&ui_copy, &gettext("Link copied"));
|
|
||||||
});
|
|
||||||
row.add_suffix(©);
|
|
||||||
}
|
|
||||||
row.add_suffix(&shared_by_me_menu(ui, &entry, url));
|
|
||||||
|
|
||||||
ui.shared_by_me.group.add(&row);
|
|
||||||
rows.push(row.upcast());
|
|
||||||
}
|
|
||||||
*ui.shared_by_me.rows.borrow_mut() = rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ⋮ menu on a shared item: open it, the link, the Share dialog, and
|
/// The uids in `new` whose people or link differ from `old`. Their rows show
|
||||||
/// Stop Sharing.
|
/// the same [`DirEntry`] as before, so the list would not repaint them.
|
||||||
///
|
fn changed_items(old: &[SharedItem], new: &[SharedItem]) -> HashSet<String> {
|
||||||
/// Manage access opens the per-node Share dialog, which addresses a pathless
|
new.iter()
|
||||||
|
.filter(|item| old.iter().find(|o| o.uid == item.uid) != Some(*item))
|
||||||
|
.map(|item| item.uid.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Have the list bind the rows of `uids` again, to repaint their columns.
|
||||||
|
fn rebind_rows(ui: &Rc<Ui>, uids: &HashSet<String>) {
|
||||||
|
let model = &ui.shared_by_me.files.model;
|
||||||
|
for pos in 0..model.n_items() {
|
||||||
|
if entry_at(Some(model), pos).is_some_and(|e| uids.contains(&e.uid)) {
|
||||||
|
model.items_changed(pos, 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shared item's right-click menu: manage it, open it, its link, and Stop
|
||||||
|
/// Sharing. Manage Access opens the Share dialog, which addresses a pathless
|
||||||
/// node by uid, so every shared item can be managed from here.
|
/// node by uid, so every shared item can be managed from here.
|
||||||
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4::MenuButton {
|
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
let mut items: Vec<(String, MenuAction)> = Vec::new();
|
let mut menu = ActionMenu::new();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Manage Access…"), move || {
|
||||||
|
open_share_dialog(&ui_c, &entry_c)
|
||||||
|
});
|
||||||
|
// A node the daemon can place in my tree opens like it would in My Files.
|
||||||
if !entry.path.is_empty() {
|
if !entry.path.is_empty() {
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
items.push((
|
menu.item(&pgettext("verb", "Open"), move || {
|
||||||
pgettext("verb", "Open"),
|
open_shared_by_me(&ui_c, &entry_c)
|
||||||
Box::new(move || open_shared_by_me(&ui_c, &entry_c)),
|
});
|
||||||
));
|
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
items.push((
|
menu.item(&gettext("Show in My Files"), move || {
|
||||||
gettext("Show in My Files"),
|
show_in_my_files(&ui_c, &entry_c)
|
||||||
Box::new(move || show_in_my_files(&ui_c, &entry_c)),
|
});
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
let now = glib::real_time() / 1_000_000;
|
||||||
|
let url = find_item(&ui.shared_by_me.items, &entry.uid)
|
||||||
|
.and_then(|item| item.link)
|
||||||
|
.filter(|link| !link_expired(link, now))
|
||||||
|
.and_then(|link| link.url);
|
||||||
if let Some(url) = url {
|
if let Some(url) = url {
|
||||||
items.push((gettext("Open Link"), Box::new(move || open_uri(&url))));
|
menu.section();
|
||||||
|
let (ui_c, url_c) = (ui.clone(), url.clone());
|
||||||
|
menu.item(&gettext("Copy Link"), move || {
|
||||||
|
ui_c.stack.clipboard().set_text(&url_c);
|
||||||
|
toast(&ui_c, &gettext("Link copied"));
|
||||||
|
});
|
||||||
|
menu.item(&gettext("Open Link"), move || open_uri(&url));
|
||||||
}
|
}
|
||||||
|
menu.section();
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
items.push((
|
menu.item(&gettext("Stop Sharing…"), move || {
|
||||||
gettext("Manage Access…"),
|
prompt_stop_sharing(&ui_c, std::slice::from_ref(&entry_c))
|
||||||
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_by_me_bulk_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
menu.labelled_section(&ngettext_f(
|
||||||
|
"{n} selected",
|
||||||
|
"{n} selected",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
));
|
));
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let ui_c = ui.clone();
|
||||||
items.push((
|
menu.item(&gettext("Stop Sharing…"), move || {
|
||||||
gettext("Stop Sharing…"),
|
prompt_stop_sharing(&ui_c, &entries)
|
||||||
Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)),
|
});
|
||||||
));
|
menu
|
||||||
let (labels, actions): (Vec<String>, Vec<MenuAction>) = items.into_iter().unzip();
|
}
|
||||||
more_menu_button(labels.iter().map(String::as_str).zip(actions).collect())
|
|
||||||
|
fn shared_by_me_background_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Select All"), move || {
|
||||||
|
ui_c.shared_by_me.files.selection.select_all();
|
||||||
|
});
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
|
||||||
|
menu
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm, then remove every member, invitation and the public link.
|
/// Confirm, then remove every member, invitation and the public link.
|
||||||
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
|
let body = match entries {
|
||||||
|
// Translators: {name} is the shared item's name.
|
||||||
|
[one] => gettext_f(
|
||||||
|
"Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
|
||||||
|
&[("name", &one.name)],
|
||||||
|
),
|
||||||
|
_ => ngettext_f(
|
||||||
|
"Everyone you invited loses access to {n} item, pending invitations are withdrawn and its public link stops working.",
|
||||||
|
"Everyone you invited loses access to {n} items, pending invitations are withdrawn and their public links stop working.",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Stop Sharing?"))
|
.heading(gettext("Stop Sharing?"))
|
||||||
// Translators: {name} is the shared item's name.
|
.body(body)
|
||||||
.body(gettext_f(
|
|
||||||
"Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
|
|
||||||
&[("name", &entry.name)],
|
|
||||||
))
|
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", &gettext("Cancel"));
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
dialog.add_response("stop", &gettext("Stop Sharing"));
|
dialog.add_response("stop", &gettext("Stop Sharing"));
|
||||||
@@ -261,15 +562,30 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
dialog.set_default_response(Some("cancel"));
|
dialog.set_default_response(Some("cancel"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
let ui_c = ui.clone();
|
let ui_c = ui.clone();
|
||||||
let uid = entry.uid.clone();
|
let done = match entries {
|
||||||
let name = entry.name.clone();
|
// Translators: {name} is the item's name.
|
||||||
|
[one] => gettext_f("Stopped sharing {name}", &[("name", &one.name)]),
|
||||||
|
_ => ngettext_f(
|
||||||
|
"Stopped sharing {n} item",
|
||||||
|
"Stopped sharing {n} items",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||||
dialog.connect_response(Some("stop"), move |_, _| {
|
dialog.connect_response(Some("stop"), move |_, _| {
|
||||||
run_mutation(
|
let done = done.clone();
|
||||||
|
run_requests(
|
||||||
&ui_c,
|
&ui_c,
|
||||||
Request::StopSharingByUid { uid: uid.clone() },
|
uids.iter()
|
||||||
// Translators: {name} is the item's name.
|
.map(|uid| Request::StopSharingByUid { uid: uid.clone() })
|
||||||
gettext_f("Stopped sharing {name}", &[("name", &name)]),
|
.collect(),
|
||||||
gettext_noop("Couldn't stop sharing"),
|
gettext_noop("Couldn't stop sharing"),
|
||||||
|
move |ui| {
|
||||||
|
ui.shared_by_me.loaded_at.set(None);
|
||||||
|
reload_listing(ui);
|
||||||
|
toast(ui, &done);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
dialog.present(ui_window(ui).as_ref());
|
dialog.present(ui_window(ui).as_ref());
|
||||||
@@ -278,9 +594,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
||||||
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
ui.stack.set_visible_child_name("browser");
|
open_in_my_files(ui, entry.path.clone());
|
||||||
ui.browser.search.set_text("");
|
|
||||||
browse_to(ui, entry.path.clone());
|
|
||||||
} else {
|
} else {
|
||||||
activate_entry(ui, entry);
|
activate_entry(ui, entry);
|
||||||
}
|
}
|
||||||
@@ -293,45 +607,11 @@ pub(crate) fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
.rsplit_once('/')
|
.rsplit_once('/')
|
||||||
.map(|(parent, _)| parent.to_string())
|
.map(|(parent, _)| parent.to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
ui.stack.set_visible_child_name("browser");
|
open_in_my_files(ui, parent);
|
||||||
ui.browser.search.set_text("");
|
|
||||||
browse_to(ui, parent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A one-line summary of who can reach a shared item, for its row subtitle.
|
/// Build a [`DirEntry`] from a [`SharedItem`], for the file list and the
|
||||||
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
/// Share dialog, which addresses a pathless node by uid.
|
||||||
let mut parts = Vec::new();
|
|
||||||
if item.member_count > 0 {
|
|
||||||
// Translators: how many people have access to a shared item.
|
|
||||||
parts.push(ngettext_f(
|
|
||||||
"{n} person",
|
|
||||||
"{n} people",
|
|
||||||
item.member_count as u64,
|
|
||||||
&[],
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if item.invite_count > 0 {
|
|
||||||
// Translators: how many invitations to a shared item are not accepted yet.
|
|
||||||
parts.push(ngettext_f(
|
|
||||||
"{n} pending",
|
|
||||||
"{n} pending",
|
|
||||||
item.invite_count as u64,
|
|
||||||
&[],
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if item.link.is_some() {
|
|
||||||
parts.push(gettext("Public link"));
|
|
||||||
}
|
|
||||||
if parts.is_empty() {
|
|
||||||
// Translators: a shared item's summary when no people or link can be counted.
|
|
||||||
pgettext("state", "Shared")
|
|
||||||
} else {
|
|
||||||
parts.join(" · ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can
|
|
||||||
/// open on it. Only used when the item's path is known.
|
|
||||||
pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||||
DirEntry {
|
DirEntry {
|
||||||
name: item.name.clone(),
|
name: item.name.clone(),
|
||||||
@@ -347,5 +627,80 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn item(members: usize, invites: usize, link: Option<PublicLinkInfo>) -> SharedItem {
|
||||||
|
SharedItem {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
name: "Budget.ods".into(),
|
||||||
|
is_dir: false,
|
||||||
|
modified: 0,
|
||||||
|
path: "Work/Budget.ods".into(),
|
||||||
|
member_count: members,
|
||||||
|
invite_count: invites,
|
||||||
|
link,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link(expires: Option<i64>, has_password: bool) -> PublicLinkInfo {
|
||||||
|
PublicLinkInfo {
|
||||||
|
id: "l".into(),
|
||||||
|
url: Some("https://drive.proton.me/urls/X#y".into()),
|
||||||
|
role: "viewer".into(),
|
||||||
|
expires,
|
||||||
|
has_password,
|
||||||
|
created: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_says_whether_it_still_works() {
|
||||||
|
assert_eq!(link_label(None, 100), "—");
|
||||||
|
assert_eq!(link_label(Some(&link(None, false)), 100), "Active");
|
||||||
|
assert_eq!(link_label(Some(&link(Some(100), false)), 100), "Expired");
|
||||||
|
assert!(link_label(Some(&link(Some(200), false)), 100).starts_with("Expires "));
|
||||||
|
assert_eq!(
|
||||||
|
link_label(Some(&link(None, true)), 100),
|
||||||
|
"Active · Password protected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_filters_pick_links_people_and_expired_links() {
|
||||||
|
let people = item(2, 0, None);
|
||||||
|
let invited = item(0, 1, None);
|
||||||
|
let live = item(0, 0, Some(link(None, false)));
|
||||||
|
let expired = item(0, 0, Some(link(Some(50), false)));
|
||||||
|
let pick = |filter| {
|
||||||
|
[&people, &invited, &live, &expired]
|
||||||
|
.iter()
|
||||||
|
.map(|i| matches_filter(i, filter, 100))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(pick(ShareFilter::All), [true, true, true, true]);
|
||||||
|
assert_eq!(pick(ShareFilter::Links), [false, false, true, true]);
|
||||||
|
assert_eq!(pick(ShareFilter::People), [true, true, false, false]);
|
||||||
|
assert_eq!(pick(ShareFilter::Expired), [false, false, false, true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_location_is_the_folder_in_my_files() {
|
||||||
|
assert_eq!(location_label("Work/Budget.ods"), "Work");
|
||||||
|
assert_eq!(location_label("Budget.ods"), "My files");
|
||||||
|
assert_eq!(location_label(""), "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn people_counts_members_and_pending_invitations() {
|
||||||
|
assert_eq!(people_label(&item(0, 0, None)), "—");
|
||||||
|
assert_eq!(people_label(&item(1, 0, None)), "1 person");
|
||||||
|
assert_eq!(people_label(&item(2, 3, None)), "2 people · 3 pending");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -642,8 +642,10 @@ pub(crate) fn wire_settings(
|
|||||||
let on = row.is_active();
|
let on = row.is_active();
|
||||||
set_proton_theme(on);
|
set_proton_theme(on);
|
||||||
// Folders swap their icon with the theme; have every row bound again.
|
// Folders swap their icon with the theme; have every row bound again.
|
||||||
let rows = ui_theme.browser.model.n_items();
|
for list in file_lists(&ui_theme) {
|
||||||
ui_theme.browser.model.items_changed(0, rows, rows);
|
let rows = list.model.n_items();
|
||||||
|
list.model.items_changed(0, rows, rows);
|
||||||
|
}
|
||||||
let mut config = ui_theme.dirs.load_config();
|
let mut config = ui_theme.dirs.load_config();
|
||||||
config.proton_theme = Some(on);
|
config.proton_theme = Some(on);
|
||||||
if let Err(e) = ui_theme.dirs.save_config(&config) {
|
if let Err(e) = ui_theme.dirs.save_config(&config) {
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ use crate::*;
|
|||||||
pub(crate) struct TrashState {
|
pub(crate) struct TrashState {
|
||||||
// Trash page. Trashed nodes are addressed by uid, not by path, so this page
|
// Trash page. Trashed nodes are addressed by uid, not by path, so this page
|
||||||
// keeps no current-directory state — it re-lists from the daemon on show.
|
// keeps no current-directory state — it re-lists from the daemon on show.
|
||||||
pub(crate) model: gio::ListStore,
|
pub(crate) files: FileList,
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
/// Empties the trash; insensitive while it is empty (or unread).
|
/// Empties the trash; hidden while it is empty (or unread).
|
||||||
pub(crate) empty: gtk4::Button,
|
pub(crate) empty: gtk4::Button,
|
||||||
/// "12 items" under the page title.
|
/// "12 items" under the page title.
|
||||||
pub(crate) subtitle: adw::WindowTitle,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
pub(crate) selection: gtk4::MultiSelection,
|
|
||||||
/// The bottom bar acting on the selection; revealed while anything is selected.
|
/// The bottom bar acting on the selection; revealed while anything is selected.
|
||||||
pub(crate) selection_bar: gtk4::Revealer,
|
pub(crate) selection_bar: gtk4::Revealer,
|
||||||
pub(crate) selection_label: gtk4::Label,
|
pub(crate) selection_label: gtk4::Label,
|
||||||
@@ -21,33 +20,30 @@ pub(crate) struct TrashState {
|
|||||||
|
|
||||||
/// The widgets of the Trash page that a load repaints.
|
/// The widgets of the Trash page that a load repaints.
|
||||||
pub(crate) struct TrashWidgets {
|
pub(crate) struct TrashWidgets {
|
||||||
pub(crate) model: gio::ListStore,
|
pub(crate) files: FileList,
|
||||||
pub(crate) list: gtk4::ListView,
|
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) empty: gtk4::Button,
|
pub(crate) empty: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) refresh: gtk4::Button,
|
||||||
pub(crate) subtitle: adw::WindowTitle,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
pub(crate) selection: gtk4::MultiSelection,
|
|
||||||
pub(crate) selection_bar: gtk4::Revealer,
|
pub(crate) selection_bar: gtk4::Revealer,
|
||||||
pub(crate) selection_label: gtk4::Label,
|
pub(crate) selection_label: gtk4::Label,
|
||||||
pub(crate) restore_selected: gtk4::Button,
|
pub(crate) restore_selected: gtk4::Button,
|
||||||
pub(crate) delete_selected: gtk4::Button,
|
pub(crate) delete_selected: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Trash page: a flat list of everything Drive is holding in the trash, each
|
/// The Trash page: everything Drive is holding in the trash, as a list with
|
||||||
/// row offering Restore and Delete Forever, with Empty Trash in the header. A
|
/// the folder each item came from and when it was deleted, or as a grid, where
|
||||||
/// selection (click, Shift/Ctrl+click, Ctrl+A) gets the same two actions in a
|
/// photos are easier to recognise. Restore and Delete Permanently sit in the
|
||||||
/// bottom bar.
|
/// right-click menu and, for a selection, in a bottom bar; Empty Trash is in
|
||||||
|
/// the header while there is something to empty.
|
||||||
///
|
///
|
||||||
/// A trashed node has no path inside the mount — the daemon forgets it when it is
|
/// A trashed node has no path inside the mount — the daemon forgets it when it
|
||||||
/// trashed — so unlike the Files page this one addresses entries by uid and always
|
/// is trashed — so unlike the Files page this one addresses entries by uid. The
|
||||||
/// re-lists from the server rather than from a cached listing. Row rendering needs
|
/// list's factories need the [`Ui`] handle, so they are installed in
|
||||||
/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`].
|
/// [`wire_trash`].
|
||||||
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
|
||||||
|
|
||||||
let empty = gtk4::Button::builder()
|
let empty = gtk4::Button::builder()
|
||||||
.label(gettext("Empty Trash…"))
|
.label(gettext("Empty Trash…"))
|
||||||
.tooltip_text(gettext("Permanently delete everything in the Trash"))
|
.tooltip_text(gettext("Permanently delete everything in the Trash"))
|
||||||
@@ -69,28 +65,27 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
status.add_css_class("compact");
|
status.add_css_class("compact");
|
||||||
|
|
||||||
// Bulk Delete Forever always confirms with the count, so a stale selection
|
// The list first: where an item came from and when it went are what tell
|
||||||
// can't delete anything silently.
|
// two copies of a file apart here.
|
||||||
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
let files = FileList::new();
|
||||||
let list = gtk4::ListView::builder().model(&selection).build();
|
files.show_list(true);
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let layout = layout_button(&files);
|
||||||
.vexpand(true)
|
|
||||||
.child(&list)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let content = gtk4::Stack::new();
|
let content = gtk4::Stack::new();
|
||||||
content.set_vexpand(true);
|
content.set_vexpand(true);
|
||||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||||
content.add_named(&scroll, Some("list"));
|
content.add_named(&files.views, Some("list"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(18);
|
inner.set_margin_top(12);
|
||||||
inner.set_margin_bottom(18);
|
inner.set_margin_bottom(12);
|
||||||
inner.set_margin_start(18);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(18);
|
inner.set_margin_end(12);
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
|
||||||
|
// Bulk Delete Permanently always confirms with the count, so a stale
|
||||||
|
// selection can't delete anything silently.
|
||||||
let selection_label = gtk4::Label::new(None);
|
let selection_label = gtk4::Label::new(None);
|
||||||
let restore_selected = gtk4::Button::with_label(&pgettext("verb", "Restore"));
|
let restore_selected = gtk4::Button::with_label(&pgettext("verb", "Restore"));
|
||||||
let delete_selected = gtk4::Button::with_label(&gettext("Delete Permanently…"));
|
let delete_selected = gtk4::Button::with_label(&gettext("Delete Permanently…"));
|
||||||
@@ -108,19 +103,18 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner);
|
let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner);
|
||||||
header.pack_start(&empty);
|
header.pack_start(&empty);
|
||||||
header.pack_end(&refresh);
|
header.pack_end(&refresh);
|
||||||
|
header.pack_end(&layout);
|
||||||
|
|
||||||
(
|
(
|
||||||
frame.upcast(),
|
frame.upcast(),
|
||||||
TrashWidgets {
|
TrashWidgets {
|
||||||
model,
|
files,
|
||||||
list,
|
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
retry,
|
retry,
|
||||||
empty,
|
empty,
|
||||||
refresh,
|
refresh,
|
||||||
subtitle,
|
subtitle,
|
||||||
selection,
|
|
||||||
selection_bar,
|
selection_bar,
|
||||||
selection_label,
|
selection_label,
|
||||||
restore_selected,
|
restore_selected,
|
||||||
@@ -129,142 +123,144 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the row factory and the Empty Trash button. The row's two buttons read
|
/// Install the list's factories and columns, the Empty Trash button and the
|
||||||
/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a
|
/// selection bar.
|
||||||
/// captured copy, so a recycled row always acts on the item it currently shows.
|
|
||||||
pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
|
pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
|
||||||
let list = &widgets.list;
|
let files = &ui.trash.files;
|
||||||
let empty = &widgets.empty;
|
files.wire(
|
||||||
let factory = gtk4::SignalListItemFactory::new();
|
ui,
|
||||||
let ui_setup = ui.clone();
|
FileListBehavior {
|
||||||
factory.connect_setup(move |_, item| {
|
activate: activate_trashed,
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
entry_menu: trash_entry_menu,
|
||||||
|
bulk_menu: trash_bulk_menu,
|
||||||
let thumbnail = file_thumbnail_widget(40, 32);
|
background_menu: trash_background_menu,
|
||||||
let name = gtk4::Label::builder()
|
badges: false,
|
||||||
.halign(gtk4::Align::Start)
|
drag_and_drop: false,
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
},
|
||||||
.build();
|
);
|
||||||
let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
files
|
||||||
meta.add_css_class("dim-label");
|
.column_view
|
||||||
meta.add_css_class("caption");
|
.append_column(&text_column(&gettext("Original Location"), |e| {
|
||||||
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
trashed_from_label(e)
|
||||||
text.set_hexpand(true);
|
}));
|
||||||
text.set_valign(gtk4::Align::Center);
|
files
|
||||||
text.append(&name);
|
.column_view
|
||||||
text.append(&meta);
|
.append_column(&text_column(&pgettext("column", "Deleted"), |e| {
|
||||||
|
if e.trashed_at > 0 {
|
||||||
let restore = gtk4::Button::builder()
|
dates::relative(e.trashed_at)
|
||||||
.icon_name("edit-undo-symbolic")
|
} else {
|
||||||
.tooltip_text(gettext("Restore to its original folder"))
|
"—".to_string()
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
restore.add_css_class("flat");
|
|
||||||
let purge = gtk4::Button::builder()
|
|
||||||
.icon_name("edit-delete-symbolic")
|
|
||||||
.tooltip_text(gettext("Delete permanently"))
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
purge.add_css_class("flat");
|
|
||||||
|
|
||||||
let ui_restore = ui_setup.clone();
|
|
||||||
let item_restore = item.clone();
|
|
||||||
restore.connect_clicked(move |_| {
|
|
||||||
if let Some(entry) = bound_entry(&item_restore) {
|
|
||||||
restore_entry(&ui_restore, &entry);
|
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
let ui_purge = ui_setup.clone();
|
files
|
||||||
let item_purge = item.clone();
|
.column_view
|
||||||
purge.connect_clicked(move |_| {
|
.append_column(&text_column(&pgettext("column", "Size"), |e| {
|
||||||
if let Some(entry) = bound_entry(&item_purge) {
|
if e.is_dir {
|
||||||
prompt_delete_forever(&ui_purge, std::slice::from_ref(&entry));
|
"—".to_string()
|
||||||
|
} else {
|
||||||
|
human_bytes(e.size)
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
|
||||||
row.set_margin_top(6);
|
|
||||||
row.set_margin_bottom(6);
|
|
||||||
row.set_margin_start(6);
|
|
||||||
row.set_margin_end(6);
|
|
||||||
row.append(&thumbnail);
|
|
||||||
row.append(&text);
|
|
||||||
row.append(&restore);
|
|
||||||
row.append(&purge);
|
|
||||||
item.set_child(Some(&row));
|
|
||||||
});
|
|
||||||
factory.connect_bind({
|
|
||||||
let ui = ui.clone();
|
|
||||||
move |_, item| {
|
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
||||||
let Some(entry) = bound_entry(item) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
|
||||||
let Some(thumbnail) = row.first_child().and_downcast::<gtk4::Overlay>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
|
||||||
let Some(text) = thumbnail.next_sibling().and_downcast::<gtk4::Box>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
|
||||||
name.set_label(&entry.name);
|
|
||||||
}
|
|
||||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
|
||||||
let kind = if entry.is_dir {
|
|
||||||
gettext("Folder")
|
|
||||||
} else {
|
|
||||||
human_bytes(entry.size)
|
|
||||||
};
|
|
||||||
let modified = dates::short_date(entry.modified);
|
|
||||||
// Translators: {kind} is "Folder" or a file size; {modified} is a date.
|
|
||||||
let text = gettext_f(
|
|
||||||
"{kind} · {modified}",
|
|
||||||
&[("kind", &kind), ("modified", &modified)],
|
|
||||||
);
|
|
||||||
meta.set_label(&text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
list.set_factory(Some(&factory));
|
|
||||||
|
|
||||||
let ui_empty = ui.clone();
|
let ui_empty = ui.clone();
|
||||||
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
widgets
|
||||||
|
.empty
|
||||||
|
.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
||||||
|
|
||||||
let ui_sel = ui.clone();
|
let ui_sel = ui.clone();
|
||||||
widgets
|
files
|
||||||
.selection
|
.selection
|
||||||
.connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel));
|
.connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel));
|
||||||
let ui_restore = ui.clone();
|
let ui_restore = ui.clone();
|
||||||
widgets.restore_selected.connect_clicked(move |_| {
|
widgets.restore_selected.connect_clicked(move |_| {
|
||||||
let entries = selected_trash(&ui_restore);
|
let entries = ui_restore.trash.files.selected();
|
||||||
if !entries.is_empty() {
|
if !entries.is_empty() {
|
||||||
restore_entries(&ui_restore, &entries);
|
restore_entries(&ui_restore, &entries);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let ui_delete = ui.clone();
|
let ui_delete = ui.clone();
|
||||||
widgets.delete_selected.connect_clicked(move |_| {
|
widgets.delete_selected.connect_clicked(move |_| {
|
||||||
let entries = selected_trash(&ui_delete);
|
let entries = ui_delete.trash.files.selected();
|
||||||
if !entries.is_empty() {
|
if !entries.is_empty() {
|
||||||
prompt_delete_forever(&ui_delete, &entries);
|
prompt_delete_forever(&ui_delete, &entries);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trashed entries currently selected, in list order.
|
/// Where a trashed entry goes back to on restore, for the Original Location
|
||||||
fn selected_trash(ui: &Rc<Ui>) -> Vec<DirEntry> {
|
/// column.
|
||||||
let selected = ui.trash.selection.selection();
|
fn trashed_from_label(entry: &DirEntry) -> String {
|
||||||
(0..selected.size())
|
match entry.trashed_from.as_deref() {
|
||||||
.filter_map(|i| ui.trash.model.item(selected.nth(i as u32)))
|
None => "—".to_string(),
|
||||||
.filter_map(|obj| obj.downcast::<BoxedAnyObject>().ok())
|
Some("") => gettext("My files"),
|
||||||
.map(|obj| obj.borrow::<DirEntry>().clone())
|
Some(path) => path.to_string(),
|
||||||
.collect()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trashed file can't be opened; say what would let it be.
|
||||||
|
fn activate_trashed(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
toast(
|
||||||
|
ui,
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
&gettext_f("Restore “{name}” to open it", &[("name", &entry.name)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_entry_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", "Restore"), move || {
|
||||||
|
restore_entries(&ui_c, std::slice::from_ref(&entry_c))
|
||||||
|
});
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Delete Permanently…"), move || {
|
||||||
|
prompt_delete_forever(&ui_c, std::slice::from_ref(&entry_c))
|
||||||
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_bulk_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
menu.labelled_section(&ngettext_f(
|
||||||
|
"{n} selected",
|
||||||
|
"{n} selected",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||||
|
menu.item(&pgettext("verb", "Restore"), move || {
|
||||||
|
restore_entries(&ui_c, &batch)
|
||||||
|
});
|
||||||
|
menu.section();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Delete Permanently…"), move || {
|
||||||
|
prompt_delete_forever(&ui_c, &entries)
|
||||||
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_background_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Select All"), move || {
|
||||||
|
ui_c.trash.files.selection.select_all();
|
||||||
|
});
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
|
||||||
|
if ui.trash.files.model.n_items() > 0 {
|
||||||
|
menu.section();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Empty Trash…"), move || {
|
||||||
|
prompt_empty_trash(&ui_c)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menu
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reveal the selection bar while anything is selected and count what is.
|
/// Reveal the selection bar while anything is selected and count what is.
|
||||||
fn sync_trash_selection(ui: &Rc<Ui>) {
|
fn sync_trash_selection(ui: &Rc<Ui>) {
|
||||||
let count = ui.trash.selection.selection().size() as usize;
|
let count = ui.trash.files.selection.selection().size() as usize;
|
||||||
ui.trash.selection_bar.set_reveal_child(count > 0);
|
ui.trash.selection_bar.set_reveal_child(count > 0);
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
ui.trash.selection_label.set_label(&ngettext_f(
|
ui.trash.selection_label.set_label(&ngettext_f(
|
||||||
@@ -276,14 +272,6 @@ fn sync_trash_selection(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
|
|
||||||
/// (recycled) row.
|
|
||||||
pub(crate) fn bound_entry(item: >k4::ListItem) -> Option<DirEntry> {
|
|
||||||
let obj = item.item().and_downcast::<BoxedAnyObject>()?;
|
|
||||||
let entry = obj.borrow::<DirEntry>().clone();
|
|
||||||
Some(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch the trash listing and repaint the page.
|
/// Fetch the trash listing and repaint the page.
|
||||||
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||||
cancel_file_thumbnails(ui);
|
cancel_file_thumbnails(ui);
|
||||||
@@ -352,8 +340,8 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
|
|||||||
ui.trash.subtitle.set_subtitle("");
|
ui.trash.subtitle.set_subtitle("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repopulate the trash list, most recently modified first — the order in which a
|
/// Repopulate the trash, most recently deleted first: the order in which a user
|
||||||
/// user looks for what they just deleted.
|
/// looks for what they just deleted.
|
||||||
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
// An empty Trash has nothing to empty, so the button goes away rather than
|
// An empty Trash has nothing to empty, so the button goes away rather than
|
||||||
// sitting there greyed out.
|
// sitting there greyed out.
|
||||||
@@ -379,45 +367,69 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let mut sorted = entries.to_vec();
|
let mut sorted = entries.to_vec();
|
||||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
sort_trash(&mut sorted);
|
||||||
// Only the rows that changed are swapped, so a refresh keeps the
|
// Only the rows that changed are swapped, so a refresh keeps the
|
||||||
// selection and the scroll position.
|
// selection and the scroll position.
|
||||||
replace_items(&ui.trash.model, &sorted);
|
replace_items(&ui.trash.files.model, &sorted);
|
||||||
sync_trash_selection(ui);
|
sync_trash_selection(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Newest deletion first. Entries with no known deletion time go last, newest
|
||||||
|
/// modification first among themselves.
|
||||||
|
fn sort_trash(entries: &mut [DirEntry]) {
|
||||||
|
entries.sort_by_key(|e| {
|
||||||
|
(
|
||||||
|
e.trashed_at == 0,
|
||||||
|
std::cmp::Reverse(e.trashed_at),
|
||||||
|
std::cmp::Reverse(e.modified),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop every row, and with them the selection and its bar.
|
/// Drop every row, and with them the selection and its bar.
|
||||||
fn clear_trash(ui: &Rc<Ui>) {
|
fn clear_trash(ui: &Rc<Ui>) {
|
||||||
ui.trash.model.remove_all();
|
ui.trash.files.model.remove_all();
|
||||||
sync_trash_selection(ui);
|
sync_trash_selection(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore one trashed entry to the folder it was trashed from.
|
/// Restore trashed entries to the folders they were trashed from. When they
|
||||||
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
/// all came from one known folder, the toast offers to show it.
|
||||||
restore_entries(ui, std::slice::from_ref(entry));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restore trashed entries to the folders they were trashed from.
|
|
||||||
pub(crate) fn restore_entries(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn restore_entries(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
run_mutation(
|
let message = match entries {
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
[one] => gettext_f("Restored “{name}”", &[("name", &one.name)]),
|
||||||
|
_ => ngettext_f(
|
||||||
|
"Restored {n} item",
|
||||||
|
"Restored {n} items",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let folder = common_origin(entries);
|
||||||
|
run_mutation_then(
|
||||||
ui,
|
ui,
|
||||||
Request::Restore {
|
Request::Restore {
|
||||||
uids: entries.iter().map(|e| e.uid.clone()).collect(),
|
uids: entries.iter().map(|e| e.uid.clone()).collect(),
|
||||||
},
|
},
|
||||||
match entries {
|
|
||||||
// Translators: {name} is a file or folder name.
|
|
||||||
[one] => gettext_f("Restored “{name}”", &[("name", &one.name)]),
|
|
||||||
_ => ngettext_f(
|
|
||||||
"Restored {n} item",
|
|
||||||
"Restored {n} items",
|
|
||||||
entries.len() as u64,
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
gettext_noop("Couldn't restore"),
|
gettext_noop("Couldn't restore"),
|
||||||
|
move |ui| match folder {
|
||||||
|
Some(folder) => toast_action(ui, &message, &gettext("Show"), move |ui| {
|
||||||
|
open_in_my_files(ui, folder.clone())
|
||||||
|
}),
|
||||||
|
None => toast(ui, &message),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The folder every entry goes back to, when that is one known folder.
|
||||||
|
fn common_origin(entries: &[DirEntry]) -> Option<String> {
|
||||||
|
let first = entries.first()?.trashed_from.clone()?;
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.all(|e| e.trashed_from.as_deref() == Some(first.as_str()))
|
||||||
|
.then_some(first)
|
||||||
|
}
|
||||||
|
|
||||||
/// Confirm, then permanently delete trashed entries. Irreversible, so it asks.
|
/// Confirm, then permanently delete trashed entries. Irreversible, so it asks.
|
||||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
@@ -475,7 +487,7 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
/// Confirm, then permanently delete everything in the trash.
|
/// Confirm, then permanently delete everything in the trash.
|
||||||
pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let count = ui.trash.model.n_items();
|
let count = ui.trash.files.model.n_items();
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(gettext("Empty Trash?"))
|
.heading(gettext("Empty Trash?"))
|
||||||
.body(ngettext_f(
|
.body(ngettext_f(
|
||||||
@@ -504,3 +516,59 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
dialog.present(win.as_ref());
|
dialog.present(win.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn trashed(name: &str, trashed_at: i64, modified: i64, from: Option<&str>) -> DirEntry {
|
||||||
|
DirEntry {
|
||||||
|
name: name.into(),
|
||||||
|
is_dir: false,
|
||||||
|
size: 0,
|
||||||
|
modified,
|
||||||
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
|
uid: name.into(),
|
||||||
|
path: String::new(),
|
||||||
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
|
trashed_at,
|
||||||
|
trashed_from: from.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trash_lists_newest_deletion_first_and_undated_last() {
|
||||||
|
let mut entries = vec![
|
||||||
|
trashed("undated-old", 0, 1, None),
|
||||||
|
trashed("older", 10, 50, None),
|
||||||
|
trashed("undated-new", 0, 9, None),
|
||||||
|
trashed("newest", 20, 5, None),
|
||||||
|
];
|
||||||
|
sort_trash(&mut entries);
|
||||||
|
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["newest", "older", "undated-new", "undated-old"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_offers_a_folder_only_when_all_share_one() {
|
||||||
|
let docs = trashed("a", 1, 1, Some("Documents"));
|
||||||
|
let docs2 = trashed("b", 1, 1, Some("Documents"));
|
||||||
|
let root = trashed("c", 1, 1, Some(""));
|
||||||
|
let unknown = trashed("d", 1, 1, None);
|
||||||
|
assert_eq!(
|
||||||
|
common_origin(&[docs.clone(), docs2]).as_deref(),
|
||||||
|
Some("Documents")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
common_origin(std::slice::from_ref(&root)).as_deref(),
|
||||||
|
Some("")
|
||||||
|
);
|
||||||
|
assert_eq!(common_origin(&[docs.clone(), root]), None);
|
||||||
|
assert_eq!(common_origin(&[docs, unknown]), None);
|
||||||
|
assert_eq!(common_origin(&[]), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
//! The file list every file page shows: an icon grid and a column list over one
|
||||||
|
//! model and one selection, with thumbnails, context menus, rubberband selection
|
||||||
|
//! and click-to-deselect. My files, Trash, Shared with me and Shared by me build
|
||||||
|
//! one each and differ only in their columns, menus and what activating an entry
|
||||||
|
//! does (see [`FileListBehavior`]).
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// Where a page's entries go when the user acts on them. Plain function pointers:
|
||||||
|
/// each page has one of these, fixed for the life of the window.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct FileListBehavior {
|
||||||
|
/// Double-click or Enter on an entry.
|
||||||
|
pub(crate) activate: fn(&Rc<Ui>, &DirEntry),
|
||||||
|
/// The right-click menu for one entry.
|
||||||
|
pub(crate) entry_menu: fn(&Rc<Ui>, &DirEntry) -> ActionMenu,
|
||||||
|
/// The right-click menu for a multi-selection the clicked entry is part of.
|
||||||
|
pub(crate) bulk_menu: fn(&Rc<Ui>, Vec<DirEntry>) -> ActionMenu,
|
||||||
|
/// The right-click menu on empty space.
|
||||||
|
pub(crate) background_menu: fn(&Rc<Ui>) -> ActionMenu,
|
||||||
|
/// Paint the offline/cached/online-only badge. Only entries in the mounted
|
||||||
|
/// tree have a local state worth showing.
|
||||||
|
pub(crate) badges: bool,
|
||||||
|
/// Entries can be dragged onto folders to move them.
|
||||||
|
pub(crate) drag_and_drop: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A realised grid cell's thumbnail and name label.
|
||||||
|
type GridTile = (glib::WeakRef<gtk4::Overlay>, glib::WeakRef<gtk4::Label>);
|
||||||
|
|
||||||
|
/// One page's grid and list. Cheap to clone: every field is a GObject or an
|
||||||
|
/// `Rc`, so a clone is the same list.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct FileList {
|
||||||
|
/// The entries, as [`BoxedAnyObject`]-wrapped [`DirEntry`]s.
|
||||||
|
pub(crate) model: gio::ListStore,
|
||||||
|
/// One selection for both views: switching layout keeps what is selected.
|
||||||
|
pub(crate) selection: gtk4::MultiSelection,
|
||||||
|
pub(crate) grid: gtk4::GridView,
|
||||||
|
pub(crate) column_view: gtk4::ColumnView,
|
||||||
|
/// The two views, named "grid" and "list".
|
||||||
|
pub(crate) views: gtk4::Stack,
|
||||||
|
thumbnail_size: Rc<Cell<i32>>,
|
||||||
|
/// Weak references to realised grid cells. A new thumbnail size resizes only
|
||||||
|
/// these visible, recycled surfaces instead of invalidating the whole model.
|
||||||
|
tiles: Rc<RefCell<Vec<GridTile>>>,
|
||||||
|
behavior: Rc<Cell<Option<FileListBehavior>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileList {
|
||||||
|
/// Assemble the empty views. The factories need the [`Ui`] handle, so they
|
||||||
|
/// are installed later by [`FileList::wire`].
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||||
|
// Multi-select: acting on a batch is the common case, and doing it one
|
||||||
|
// confirmation dialog at a time is not a workflow. Rubberband drags a
|
||||||
|
// selection box from empty space, as a file manager does.
|
||||||
|
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
||||||
|
let grid = gtk4::GridView::builder()
|
||||||
|
.model(&selection)
|
||||||
|
.min_columns(2)
|
||||||
|
.max_columns(32)
|
||||||
|
.enable_rubberband(true)
|
||||||
|
.build();
|
||||||
|
grid.add_css_class("file-grid");
|
||||||
|
let grid_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&grid)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let column_view = gtk4::ColumnView::builder()
|
||||||
|
.model(&selection)
|
||||||
|
.enable_rubberband(true)
|
||||||
|
.build();
|
||||||
|
column_view.add_css_class("data-table");
|
||||||
|
let column_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&column_view)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let views = gtk4::Stack::new();
|
||||||
|
views.set_vexpand(true);
|
||||||
|
views.add_named(&grid_scroll, Some("grid"));
|
||||||
|
views.add_named(&column_scroll, Some("list"));
|
||||||
|
|
||||||
|
FileList {
|
||||||
|
model,
|
||||||
|
selection,
|
||||||
|
grid,
|
||||||
|
column_view,
|
||||||
|
views,
|
||||||
|
thumbnail_size: Rc::new(Cell::new(GRID_THUMB_DEFAULT)),
|
||||||
|
tiles: Rc::new(RefCell::new(Vec::new())),
|
||||||
|
behavior: Rc::new(Cell::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the grid tiles, the Name column, activation, the context menus
|
||||||
|
/// and click-to-deselect. The page appends its other columns afterwards.
|
||||||
|
pub(crate) fn wire(&self, ui: &Rc<Ui>, behavior: FileListBehavior) {
|
||||||
|
self.behavior.set(Some(behavior));
|
||||||
|
for view in [
|
||||||
|
self.grid.clone().upcast::<gtk4::Widget>(),
|
||||||
|
self.column_view.clone().upcast(),
|
||||||
|
] {
|
||||||
|
self.attach_background_menu(ui, &view, behavior);
|
||||||
|
self.attach_background_deselect(&view);
|
||||||
|
}
|
||||||
|
|
||||||
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
|
factory.connect_setup({
|
||||||
|
let (ui, list) = (ui.clone(), self.clone());
|
||||||
|
move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let size = list.thumbnail_size.get();
|
||||||
|
let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size));
|
||||||
|
// Keep the sync-state badge inside the thumbnail surface.
|
||||||
|
let badge = gtk4::Image::builder()
|
||||||
|
.pixel_size(18)
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.valign(gtk4::Align::Start)
|
||||||
|
.margin_top(2)
|
||||||
|
.margin_end(2)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
badge.add_css_class("file-badge");
|
||||||
|
thumbnail.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();
|
||||||
|
list.tiles
|
||||||
|
.borrow_mut()
|
||||||
|
.push((thumbnail.downgrade(), label.downgrade()));
|
||||||
|
let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||||
|
tile.add_css_class("file-tile");
|
||||||
|
tile.append(&thumbnail);
|
||||||
|
tile.append(&label);
|
||||||
|
list.attach_context_menu(&ui, item, &tile, behavior);
|
||||||
|
if behavior.drag_and_drop {
|
||||||
|
attach_drag(&ui, item, &tile);
|
||||||
|
attach_drop(&ui, item, &tile);
|
||||||
|
}
|
||||||
|
item.set_child(Some(&tile));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
factory.connect_bind({
|
||||||
|
let (ui, list) = (ui.clone(), self.clone());
|
||||||
|
move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||||
|
let thumbnail = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
||||||
|
let badge = thumbnail
|
||||||
|
.last_child()
|
||||||
|
.and_downcast::<gtk4::Image>()
|
||||||
|
.unwrap();
|
||||||
|
let label = thumbnail
|
||||||
|
.next_sibling()
|
||||||
|
.and_downcast::<gtk4::Label>()
|
||||||
|
.unwrap();
|
||||||
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||||
|
let entry = obj.borrow::<DirEntry>();
|
||||||
|
resize_grid_tile(&thumbnail, &label, list.thumbnail_size.get());
|
||||||
|
bind_file_thumbnail(&ui, &thumbnail, &entry, false);
|
||||||
|
label.set_label(&entry.name);
|
||||||
|
label.set_tooltip_text(Some(&entry.name));
|
||||||
|
if behavior.badges {
|
||||||
|
apply_badge(&badge, &entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.grid.set_factory(Some(&factory));
|
||||||
|
|
||||||
|
let ui_grid = ui.clone();
|
||||||
|
self.grid.connect_activate(move |grid, pos| {
|
||||||
|
if let Some(entry) = entry_at(grid.model().as_ref(), pos) {
|
||||||
|
(behavior.activate)(&ui_grid, &entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.column_view
|
||||||
|
.append_column(&self.name_column(ui, behavior));
|
||||||
|
let ui_col = ui.clone();
|
||||||
|
self.column_view.connect_activate(move |view, pos| {
|
||||||
|
if let Some(entry) = entry_at(view.model().as_ref(), pos) {
|
||||||
|
(behavior.activate)(&ui_col, &entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every highlighted entry, in model order.
|
||||||
|
///
|
||||||
|
/// Walks the model rather than the selection bitset: a listing is at most a
|
||||||
|
/// few thousand rows, and asking each position whether it is selected keeps
|
||||||
|
/// this free of bitset-iterator lifetimes for no measurable cost.
|
||||||
|
pub(crate) fn selected(&self) -> Vec<DirEntry> {
|
||||||
|
(0..self.selection.n_items())
|
||||||
|
.filter(|i| self.selection.is_selected(*i))
|
||||||
|
.filter_map(|i| entry_at(Some(&self.selection), i))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the grid, or the list.
|
||||||
|
pub(crate) fn show_list(&self, list: bool) {
|
||||||
|
self.views
|
||||||
|
.set_visible_child_name(if list { "list" } else { "grid" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the list, rather than the grid, is on screen.
|
||||||
|
pub(crate) fn shows_list(&self) -> bool {
|
||||||
|
self.views.visible_child_name().as_deref() == Some("list")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the grid's thumbnails. Only realised cells are touched; the rest
|
||||||
|
/// pick the size up when they are bound.
|
||||||
|
pub(crate) fn set_thumbnail_size(&self, size: i32) {
|
||||||
|
if self.thumbnail_size.replace(size) == size {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.tiles
|
||||||
|
.borrow_mut()
|
||||||
|
.retain(|(thumbnail_ref, label_ref)| {
|
||||||
|
let (Some(thumbnail), Some(label)) = (thumbnail_ref.upgrade(), label_ref.upgrade())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
resize_grid_tile(&thumbnail, &label, size);
|
||||||
|
true
|
||||||
|
});
|
||||||
|
self.grid.queue_resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Menu key or Shift+F10: the menu for the selection, or the background
|
||||||
|
/// menu when nothing is selected, opened at the focused item.
|
||||||
|
pub(crate) fn popup_keyboard_menu(&self, ui: &Rc<Ui>) {
|
||||||
|
let Some(behavior) = self.behavior.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let view = self
|
||||||
|
.views
|
||||||
|
.visible_child()
|
||||||
|
.unwrap_or_else(|| self.views.clone().upcast());
|
||||||
|
// The focused cell is where the eye is; without one, the middle of the view.
|
||||||
|
let (x, y) = view
|
||||||
|
.root()
|
||||||
|
.and_then(|root| root.focus())
|
||||||
|
.filter(|focus| focus.is_ancestor(&view))
|
||||||
|
.and_then(|focus| focus.compute_bounds(&view))
|
||||||
|
.map(|b| {
|
||||||
|
(
|
||||||
|
(b.x() + b.width() / 2.0) as f64,
|
||||||
|
(b.y() + b.height() / 2.0) as f64,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((view.width() as f64 / 2.0, view.height() as f64 / 2.0));
|
||||||
|
let selected = self.selected();
|
||||||
|
let menu = match selected.len() {
|
||||||
|
0 => (behavior.background_menu)(ui),
|
||||||
|
1 => (behavior.entry_menu)(ui, &selected[0]),
|
||||||
|
_ => (behavior.bulk_menu)(ui, selected),
|
||||||
|
};
|
||||||
|
menu.popup_at(&view, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Name column: a small thumbnail with its local-state badge overlaid,
|
||||||
|
/// followed by the name and the same right-click menu the grid tiles carry.
|
||||||
|
fn name_column(&self, ui: &Rc<Ui>, behavior: FileListBehavior) -> gtk4::ColumnViewColumn {
|
||||||
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
|
factory.connect_setup({
|
||||||
|
let (ui, list) = (ui.clone(), self.clone());
|
||||||
|
move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let thumbnail = file_thumbnail_widget(28, 16);
|
||||||
|
let badge = gtk4::Image::builder()
|
||||||
|
.pixel_size(14)
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.valign(gtk4::Align::Start)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
badge.add_css_class("file-badge");
|
||||||
|
thumbnail.add_overlay(&badge);
|
||||||
|
// 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 the other columns 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 cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
cell.append(&thumbnail);
|
||||||
|
cell.append(&label);
|
||||||
|
list.attach_context_menu(&ui, item, &cell, behavior);
|
||||||
|
if behavior.drag_and_drop {
|
||||||
|
attach_drag(&ui, item, &cell);
|
||||||
|
attach_drop(&ui, item, &cell);
|
||||||
|
}
|
||||||
|
item.set_child(Some(&cell));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
factory.connect_bind({
|
||||||
|
let ui = ui.clone();
|
||||||
|
move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||||
|
let thumbnail = cell.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
||||||
|
let badge = thumbnail
|
||||||
|
.last_child()
|
||||||
|
.and_downcast::<gtk4::Image>()
|
||||||
|
.unwrap();
|
||||||
|
let label = thumbnail
|
||||||
|
.next_sibling()
|
||||||
|
.and_downcast::<gtk4::Label>()
|
||||||
|
.unwrap();
|
||||||
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||||
|
let entry = obj.borrow::<DirEntry>();
|
||||||
|
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
||||||
|
label.set_label(&entry.name);
|
||||||
|
label.set_tooltip_text(Some(&entry.name));
|
||||||
|
if behavior.badges {
|
||||||
|
apply_badge(&badge, &entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Name")), Some(factory));
|
||||||
|
column.set_expand(true);
|
||||||
|
column
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the menu for whatever entry `item` is bound to when a cell is
|
||||||
|
/// right-clicked. Capturing the [`gtk4::ListItem`] rather than a snapshot of
|
||||||
|
/// the entry keeps the menu right as the view recycles cells while
|
||||||
|
/// scrolling. The click is claimed, so the background menu does not open on
|
||||||
|
/// top of it.
|
||||||
|
fn attach_context_menu(
|
||||||
|
&self,
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
item: >k4::ListItem,
|
||||||
|
anchor: >k4::Box,
|
||||||
|
behavior: FileListBehavior,
|
||||||
|
) {
|
||||||
|
let gesture = gtk4::GestureClick::new();
|
||||||
|
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
||||||
|
let (ui, list, item, target) = (ui.clone(), self.clone(), item.clone(), anchor.clone());
|
||||||
|
gesture.connect_pressed(move |gesture, _, x, y| {
|
||||||
|
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||||
|
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
|
||||||
|
let entry = obj.borrow::<DirEntry>().clone();
|
||||||
|
// Right-clicking a row that is part of a multi-selection acts on
|
||||||
|
// the batch; right-clicking outside one acts on the row.
|
||||||
|
let selected = list.selected();
|
||||||
|
if selected.len() > 1 && selected.iter().any(|e| e.uid == entry.uid) {
|
||||||
|
(behavior.bulk_menu)(&ui, selected).popup_at(&target, x, y);
|
||||||
|
} else {
|
||||||
|
(behavior.entry_menu)(&ui, &entry).popup_at(&target, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
anchor.add_controller(gesture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Right-clicking the empty space of a view offers the page's own actions.
|
||||||
|
fn attach_background_menu(&self, ui: &Rc<Ui>, view: >k4::Widget, behavior: FileListBehavior) {
|
||||||
|
let gesture = gtk4::GestureClick::new();
|
||||||
|
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
||||||
|
let (ui, target) = (ui.clone(), view.clone());
|
||||||
|
gesture.connect_pressed(move |_, _, x, y| {
|
||||||
|
(behavior.background_menu)(&ui).popup_at(&target, x, y);
|
||||||
|
});
|
||||||
|
view.add_controller(gesture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicking the empty space of a view drops the selection, as in a file
|
||||||
|
/// manager. GTK's list views leave it alone, so a stray highlight otherwise
|
||||||
|
/// sticks around until another item is clicked.
|
||||||
|
fn attach_background_deselect(&self, view: >k4::Widget) {
|
||||||
|
let gesture = gtk4::GestureClick::new();
|
||||||
|
gesture.set_button(gtk4::gdk::BUTTON_PRIMARY);
|
||||||
|
// Capture sees the press before an item's own gesture claims it; this
|
||||||
|
// only looks, so the item still gets its click.
|
||||||
|
gesture.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||||
|
let (selection, target) = (self.selection.clone(), view.clone());
|
||||||
|
gesture.connect_pressed(move |gesture, _, x, y| {
|
||||||
|
let modifiers = gesture.current_event_state();
|
||||||
|
if modifiers.intersects(
|
||||||
|
gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::SHIFT_MASK,
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !on_item(&target, x, y) {
|
||||||
|
selection.unselect_all();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
view.add_controller(gesture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A header button that switches `list` between grid and list. It offers the
|
||||||
|
/// other layout, the way a toggle reads.
|
||||||
|
pub(crate) fn layout_button(list: &FileList) -> gtk4::Button {
|
||||||
|
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
|
||||||
|
let sync = |button: >k4::Button, list: bool| {
|
||||||
|
button.set_icon_name(if list {
|
||||||
|
"view-grid-symbolic"
|
||||||
|
} else {
|
||||||
|
"view-list-symbolic"
|
||||||
|
});
|
||||||
|
button.set_tooltip_text(Some(&if list {
|
||||||
|
gettext("Show as grid (Ctrl+1)")
|
||||||
|
} else {
|
||||||
|
gettext("Show as list (Ctrl+2)")
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
sync(&button, list.shows_list());
|
||||||
|
let weak = button.downgrade();
|
||||||
|
list.views.connect_visible_child_name_notify(move |views| {
|
||||||
|
if let Some(button) = weak.upgrade() {
|
||||||
|
sync(
|
||||||
|
&button,
|
||||||
|
views.visible_child_name().as_deref() == Some("list"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let views = list.views.clone();
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
let to_list = views.visible_child_name().as_deref() != Some("list");
|
||||||
|
views.set_visible_child_name(if to_list { "list" } else { "grid" });
|
||||||
|
});
|
||||||
|
button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.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>();
|
||||||
|
let text = render(&entry);
|
||||||
|
label.set_tooltip_text(Some(&text));
|
||||||
|
label.set_label(&text);
|
||||||
|
});
|
||||||
|
gtk4::ColumnViewColumn::new(Some(title), Some(factory))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) {
|
||||||
|
resize_file_thumbnail(thumbnail, size, grid_fallback_size(size));
|
||||||
|
let name_width = (size / 6 + 1).clamp(8, 24);
|
||||||
|
label.set_width_chars(name_width);
|
||||||
|
label.set_max_width_chars(name_width);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grid_fallback_size(thumbnail_size: i32) -> i32 {
|
||||||
|
(thumbnail_size * 8 / 9).clamp(24, thumbnail_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `(x, y)` in `view` lands on an item: a grid tile ("child"), a list
|
||||||
|
/// row ("row"), or a column header ("header"), which has clicks of its own.
|
||||||
|
fn on_item(view: >k4::Widget, x: f64, y: f64) -> bool {
|
||||||
|
let mut widget = view.pick(x, y, gtk4::PickFlags::DEFAULT);
|
||||||
|
while let Some(w) = widget {
|
||||||
|
if &w == view {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if matches!(w.css_name().as_str(), "child" | "row" | "header") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
widget = w.parent();
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every page's file list.
|
||||||
|
pub(crate) fn file_lists(ui: &Ui) -> [&FileList; 4] {
|
||||||
|
[
|
||||||
|
&ui.browser.files,
|
||||||
|
&ui.trash.files,
|
||||||
|
&ui.shared.files,
|
||||||
|
&ui.shared_by_me.files,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file list of the page on screen, if it has one.
|
||||||
|
pub(crate) fn visible_file_list(ui: &Ui) -> Option<&FileList> {
|
||||||
|
match ui.stack.visible_child_name()?.as_str() {
|
||||||
|
"browser" => Some(&ui.browser.files),
|
||||||
|
"trash" => Some(&ui.trash.files),
|
||||||
|
"sharedbyme" => Some(&ui.shared_by_me.files),
|
||||||
|
"shared" if ui.shared.views.visible_child_name().as_deref() == Some("files") => {
|
||||||
|
Some(&ui.shared.files)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
|
pub(crate) mod file_list;
|
||||||
pub(crate) mod menu;
|
pub(crate) mod menu;
|
||||||
pub(crate) mod share_dialog;
|
pub(crate) mod share_dialog;
|
||||||
pub(crate) mod thumbnails;
|
pub(crate) mod thumbnails;
|
||||||
|
|||||||
@@ -407,6 +407,15 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Shared by me lists what this dialog changes: reload it when it is the
|
||||||
|
// page underneath, and have the next visit fetch otherwise.
|
||||||
|
let ui_closed = ui.clone();
|
||||||
|
dialog.connect_closed(move |_| {
|
||||||
|
ui_closed.shared_by_me.loaded_at.set(None);
|
||||||
|
if ui_closed.stack.visible_child_name().as_deref() == Some("sharedbyme") {
|
||||||
|
load_shared_by_me(&ui_closed);
|
||||||
|
}
|
||||||
|
});
|
||||||
share_dialog_reload(&state);
|
share_dialog_reload(&state);
|
||||||
dialog.present(ui_window(ui).as_ref());
|
dialog.present(ui_window(ui).as_ref());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,19 +214,6 @@ pub(crate) fn bind_file_thumbnail(
|
|||||||
schedule_file_thumbs(ui);
|
schedule_file_thumbs(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create and immediately bind a thumbnail for a non-recycled row.
|
|
||||||
pub(crate) fn file_thumbnail(
|
|
||||||
ui: &Rc<Ui>,
|
|
||||||
entry: &DirEntry,
|
|
||||||
size: i32,
|
|
||||||
fallback_size: i32,
|
|
||||||
symbolic_fallback: bool,
|
|
||||||
) -> gtk4::Overlay {
|
|
||||||
let widget = file_thumbnail_widget(size, fallback_size);
|
|
||||||
bind_file_thumbnail(ui, &widget, entry, symbolic_fallback);
|
|
||||||
widget
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the file name denotes an image format for which Proton or the local
|
/// Whether the file name denotes an image format for which Proton or the local
|
||||||
/// decoder can reasonably provide a thumbnail.
|
/// decoder can reasonably provide a thumbnail.
|
||||||
pub(crate) fn is_image_name(name: &str) -> bool {
|
pub(crate) fn is_image_name(name: &str) -> bool {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ crates/pdfs-gui/src/app/pages/takeout.rs
|
|||||||
crates/pdfs-gui/src/app/pages/trash.rs
|
crates/pdfs-gui/src/app/pages/trash.rs
|
||||||
crates/pdfs-gui/src/app/pages/verify.rs
|
crates/pdfs-gui/src/app/pages/verify.rs
|
||||||
crates/pdfs-gui/src/app/widgets/details.rs
|
crates/pdfs-gui/src/app/widgets/details.rs
|
||||||
|
crates/pdfs-gui/src/app/widgets/file_list.rs
|
||||||
crates/pdfs-gui/src/app/widgets/menu.rs
|
crates/pdfs-gui/src/app/widgets/menu.rs
|
||||||
crates/pdfs-gui/src/app/widgets/share_dialog.rs
|
crates/pdfs-gui/src/app/widgets/share_dialog.rs
|
||||||
crates/pdfs-gui/src/app/widgets/thumbnails.rs
|
crates/pdfs-gui/src/app/widgets/thumbnails.rs
|
||||||
|
|||||||
+824
-611
File diff suppressed because it is too large.
Load diff
+734
-585
File diff suppressed because it is too large.
Load diff
+817
-600
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user