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:
nvrl committed 2026-09-28 14:35:37 +02:00
1 parent 2339b17d97
commit 127b71efd0
28 files changed
+9501 -6673

No files matched your search

+15 -2
View File
@@ -905,7 +905,7 @@ pub struct ShareEntry {
}
/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct PublicLinkInfo {
/// Public-link id — the handle for [`Request::RemovePublicLink`].
pub id: String,
@@ -918,6 +918,9 @@ pub struct PublicLinkInfo {
pub expires: Option<i64>,
/// Whether a custom password additionally protects the link.
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`]).
@@ -950,7 +953,7 @@ pub struct BookmarkInfo {
/// 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
/// the node has one.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct SharedItem {
/// Node uid in `volume~link` form — the handle for opening the node's share
/// (via its mount path) or its details.
@@ -1138,6 +1141,16 @@ pub struct DirEntry {
/// warning worth showing.
#[serde(default)]
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
+27 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// 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 {
pub(super) fn migrate(&self) -> Result<()> {
@@ -316,6 +316,22 @@ impl Db {
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(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -1033,3 +1049,13 @@ UPDATE photos SET resolved_at = NULL;
const MIGRATION_V34: &str = "
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;
";
+11
View File
@@ -583,6 +583,7 @@ fn trash_replace_lists_folders_first() {
size: 10,
mtime: 1,
parent_uid: Some("t2".into()),
trashed_at: Some(100),
},
StoredTrash {
uid: "t2".into(),
@@ -591,6 +592,7 @@ fn trash_replace_lists_folders_first() {
size: 0,
mtime: 2,
parent_uid: None,
trashed_at: None,
},
])
.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.
db.trash_replace(&[]).unwrap();
assert!(db.trash_list().unwrap().is_empty());
+25 -4
View File
@@ -1,5 +1,7 @@
//! Trashed nodes, persisted so the trash view opens without a round-trip.
use std::collections::HashMap;
use rusqlite::params;
use super::Db;
@@ -15,6 +17,9 @@ pub struct StoredTrash {
/// row written before schema v29, and for a node whose parent the server
/// did not report.
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 {
@@ -24,8 +29,8 @@ impl Db {
tx.execute("DELETE FROM trash", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for item in items {
stmt.execute(params![
@@ -34,7 +39,8 @@ impl Db {
item.is_dir as i64,
item.size,
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>> {
let conn = self.read();
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",
)?;
let rows = stmt.query_map([], |r| {
@@ -58,6 +64,7 @@ impl Db {
size: r.get(3)?,
mtime: r.get(4)?,
parent_uid: r.get(5)?,
trashed_at: r.get(6)?,
})
})?;
let mut items = Vec::new();
@@ -81,5 +88,19 @@ impl Db {
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) -----------------------------------------
}