mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
improved ui + prompt
This commit is contained in:
1 parent
81e7133003
commit
c555315998
3 files changed
+155
-29
No files matched your search
@@ -1501,7 +1501,11 @@ impl Core {
|
||||
);
|
||||
let uid = self
|
||||
.client
|
||||
.upload_file_from(
|
||||
// Mirror push: the local file is authoritative. If a prior attempt was
|
||||
// interrupted mid-upload it left an unsealed draft of this name; recover
|
||||
// it even across a daemon restart (which rotates our client uid), so the
|
||||
// folder can reach idle instead of `AlreadyExists`-looping forever.
|
||||
.upload_file_replacing_draft_from(
|
||||
parent,
|
||||
name,
|
||||
crate::media_type_for(name),
|
||||
|
||||
+69
-20
@@ -1386,6 +1386,15 @@ fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker
|
||||
/// thread, and report the outcome back on the main loop.
|
||||
fn wire_login(ui: &Rc<Ui>) {
|
||||
// Pressing Enter in either field submits the form, so signing in never needs
|
||||
// a reach for the mouse. Both fields route to the same button.
|
||||
let btn = ui.login_button.clone();
|
||||
ui.email
|
||||
.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
let btn = ui.login_button.clone();
|
||||
ui.password
|
||||
.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
|
||||
let ui = ui.clone();
|
||||
let button = ui.login_button.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
@@ -1493,6 +1502,7 @@ fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let entry = adw::EntryRow::builder()
|
||||
.title("Authentication code")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
group.add(&entry);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
@@ -3100,7 +3110,10 @@ fn prompt_rename(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
.body(format!("Rename “{original}”."))
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder().title("New name").build();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title("New name")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
row.set_text(&original);
|
||||
group.add(&row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
@@ -3148,7 +3161,10 @@ fn prompt_move(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
))
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder().title("Destination folder").build();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title("Destination folder")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
group.add(&row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
@@ -3218,7 +3234,10 @@ fn prompt_new_folder(ui: &Rc<Ui>) {
|
||||
.body("Create a folder in the current directory.")
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder().title("Folder name").build();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title("Folder name")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
group.add(&row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
@@ -3468,11 +3487,20 @@ fn load_browser(ui: &Rc<Ui>) {
|
||||
);
|
||||
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListDir { path });
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::ListDir { path: path.clone() },
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
// The user may have navigated on while this folder was loading. A stale
|
||||
// out-of-order reply must not repaint rows for a folder we've left, or
|
||||
// the breadcrumb and the grid would disagree.
|
||||
if *ui.browser_path.borrow() != path {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries),
|
||||
Ok(Ok(Response::Error { message })) => browser_failed(&ui, &message),
|
||||
@@ -3590,10 +3618,11 @@ fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
);
|
||||
|
||||
ui.busy_begin();
|
||||
let query = query.to_string();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::Search {
|
||||
query: query.to_string(),
|
||||
query: query.clone(),
|
||||
limit: SEARCH_LIMIT,
|
||||
},
|
||||
);
|
||||
@@ -3601,9 +3630,10 @@ fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
// The box may have been cleared (or changed) while the reply was in
|
||||
// flight; if so a fresh load/search already owns the model — drop this.
|
||||
if ui.browser_search.text().trim().is_empty() {
|
||||
// The box may have been cleared or typed past while the reply was in
|
||||
// flight; if the query no longer matches, a fresher load/search already
|
||||
// owns the model — drop this stale, possibly out-of-order result.
|
||||
if ui.browser_search.text().trim() != query {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
@@ -4564,9 +4594,13 @@ fn prompt_add_bookmark(ui: &Rc<Ui>) {
|
||||
.body("Paste a Proton Drive public link to save it here.")
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let url_row = adw::EntryRow::builder().title("Public link URL").build();
|
||||
let url_row = adw::EntryRow::builder()
|
||||
.title("Public link URL")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title("Password (if the link has one)")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
group.add(&url_row);
|
||||
group.add(&pw_row);
|
||||
@@ -4656,10 +4690,15 @@ fn load_devices(ui: &Rc<Ui>) {
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
// The two lists are independent and the daemon serves requests concurrently,
|
||||
// so fire both up front and collect them, rather than paying two round trips
|
||||
// back to back.
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
|
||||
let rx2 = spawn_request(ui.dirs.control_socket(), Request::ListDevices);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let sync = rx.recv().await;
|
||||
let devices_reply = rx2.recv().await;
|
||||
let folders = match sync {
|
||||
Ok(Ok(Response::SyncFolders { items })) => items,
|
||||
// The daemon answered but not as expected — treat as empty and carry
|
||||
@@ -4677,8 +4716,7 @@ fn load_devices(ui: &Rc<Ui>) {
|
||||
ui.devices_content.set_visible_child_name("list");
|
||||
repaint_sync_folders(&ui, &folders);
|
||||
|
||||
let rx2 = spawn_request(ui.dirs.control_socket(), Request::ListDevices);
|
||||
let devices = match rx2.recv().await {
|
||||
let devices = match devices_reply {
|
||||
Ok(Ok(Response::Devices { items })) => items,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
@@ -5007,15 +5045,13 @@ fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
// The daemon acks before the row exists — registering the
|
||||
// device and creating the remote folder is off-socket network
|
||||
// work. A fixed delay here either fires too early (empty list)
|
||||
// or too late (row flashes in). Instead let the periodic
|
||||
// `refresh_sync_folders` tick pick the row up whenever it
|
||||
// actually lands, which is what keeps a running pass live too.
|
||||
toast(&ui, "Syncing folder…");
|
||||
// The upload runs off-socket; reload shortly so the new row
|
||||
// appears once the daemon has registered it.
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(std::time::Duration::from_secs(2), move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("devices") {
|
||||
load_devices(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Ok(Response::Error { message })) => {
|
||||
toast_error(&ui, "Couldn't add folder", &message)
|
||||
@@ -5128,7 +5164,10 @@ fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||
.body(format!("Rename “{current}”."))
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder().title("New name").build();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title("New name")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
row.set_text(current);
|
||||
group.add(&row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
@@ -5919,6 +5958,12 @@ fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
link_rows: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
// Enter in either free-text field sends the invitations, no mouse needed.
|
||||
let btn = invite_btn.clone();
|
||||
email_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
let btn = invite_btn.clone();
|
||||
message_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
|
||||
// Invite button.
|
||||
let state_inv = state.clone();
|
||||
invite_btn.connect_clicked(move |_| {
|
||||
@@ -6165,6 +6210,10 @@ fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
|
||||
.child(&create)
|
||||
.build();
|
||||
|
||||
// Enter in the password field creates the link.
|
||||
let btn = create.clone();
|
||||
pw_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
|
||||
let state_c = state.clone();
|
||||
let pw_for = pw_row.clone();
|
||||
create.connect_clicked(move |_| {
|
||||
|
||||
@@ -173,6 +173,15 @@ impl Hit {
|
||||
fn pinned(&self) -> bool {
|
||||
matches!(self, Hit::Drive(h) if h.pinned)
|
||||
}
|
||||
|
||||
/// Stable identity used to preserve the keyboard cursor across re-renders.
|
||||
/// The bool keeps a Drive and a local hit at the same path distinct.
|
||||
fn key(&self) -> (bool, String) {
|
||||
match self {
|
||||
Hit::Drive(h) => (false, h.path.clone()),
|
||||
Hit::Local(h) => (true, h.path.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the window needs to render and act, in one place so the many
|
||||
@@ -199,6 +208,10 @@ struct Ui {
|
||||
/// then local ones, matching the visual order.
|
||||
visible: RefCell<Vec<Hit>>,
|
||||
cursor: Cell<Option<usize>>,
|
||||
/// The trimmed query the currently-rendered rows belong to. Enter compares
|
||||
/// against it so a keystroke that lands just before a fresher render can't
|
||||
/// open a file from a result set the user has already typed past.
|
||||
rendered_query: RefCell<String>,
|
||||
|
||||
filter: Cell<Filter>,
|
||||
/// Monotonic query id. A reply whose id is stale (the user typed again while
|
||||
@@ -419,6 +432,7 @@ fn build_window(app: &adw::Application) {
|
||||
local_hits: RefCell::new(Vec::new()),
|
||||
visible: RefCell::new(Vec::new()),
|
||||
cursor: Cell::new(None),
|
||||
rendered_query: RefCell::new(String::new()),
|
||||
filter: Cell::new(Filter::All),
|
||||
query_id: Cell::new(0),
|
||||
pending: Cell::new(0),
|
||||
@@ -469,7 +483,13 @@ fn build_window(app: &adw::Application) {
|
||||
}
|
||||
match key {
|
||||
gtk4::gdk::Key::Escape => {
|
||||
// Spotlight-style: first Escape clears a non-empty query, a
|
||||
// second (or an already-empty box) dismisses the launcher.
|
||||
if ui_key.entry.text().is_empty() {
|
||||
window_key.close();
|
||||
} else {
|
||||
ui_key.entry.set_text("");
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
gtk4::gdk::Key::Down => {
|
||||
@@ -556,6 +576,17 @@ impl Ui {
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon stopped answering after the window was already open. Flip to
|
||||
/// the offline page and lock the entry; the retry button re-runs `connect`.
|
||||
fn go_offline(self: &Rc<Self>) {
|
||||
self.pending.set(0);
|
||||
self.spinner.stop();
|
||||
self.spinner.set_visible(false);
|
||||
self.entry.set_sensitive(false);
|
||||
self.hint.set_label("Daemon offline");
|
||||
self.stack.set_visible_child_name("offline");
|
||||
}
|
||||
|
||||
/// The empty-query view: pinned files, which are the ones the user chose to
|
||||
/// keep on this machine — the closest thing the daemon has to "recent".
|
||||
fn load_suggestions(self: &Rc<Self>) {
|
||||
@@ -567,6 +598,10 @@ impl Ui {
|
||||
if !ui.finish_query(id) {
|
||||
return;
|
||||
}
|
||||
if matches!(reply, Ok(Err(_)) | Err(_)) {
|
||||
ui.go_offline();
|
||||
return;
|
||||
}
|
||||
let hits = match reply {
|
||||
Ok(Ok(Response::Pins { pins })) => pins
|
||||
.into_iter()
|
||||
@@ -612,10 +647,18 @@ impl Ui {
|
||||
return;
|
||||
}
|
||||
match reply {
|
||||
Ok(Ok(Response::SearchResults { hits })) => *ui.drive_hits.borrow_mut() = hits,
|
||||
_ => ui.drive_hits.borrow_mut().clear(),
|
||||
}
|
||||
Ok(Ok(Response::SearchResults { hits })) => {
|
||||
*ui.drive_hits.borrow_mut() = hits;
|
||||
ui.render();
|
||||
}
|
||||
// Transport failure: the daemon is gone. Don't paint "No
|
||||
// results" over a crash — say so.
|
||||
Ok(Err(_)) | Err(_) => ui.go_offline(),
|
||||
_ => {
|
||||
ui.drive_hits.borrow_mut().clear();
|
||||
ui.render();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let local = spawn_request(
|
||||
@@ -635,10 +678,14 @@ impl Ui {
|
||||
Ok(Ok(Response::LocalResults { hits, indexing })) => {
|
||||
ui.indexing.set(indexing);
|
||||
*ui.local_hits.borrow_mut() = hits;
|
||||
}
|
||||
_ => ui.local_hits.borrow_mut().clear(),
|
||||
}
|
||||
ui.render();
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => ui.go_offline(),
|
||||
_ => {
|
||||
ui.local_hits.borrow_mut().clear();
|
||||
ui.render();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -708,6 +755,13 @@ impl Ui {
|
||||
fn render(self: &Rc<Self>) {
|
||||
let filter = self.filter.get();
|
||||
|
||||
// Identity of the row under the cursor before we rebuild the list, so a
|
||||
// late-arriving second search can't yank the selection back to the top.
|
||||
let selected_key = self
|
||||
.cursor
|
||||
.get()
|
||||
.and_then(|i| self.visible.borrow().get(i).map(Hit::key));
|
||||
|
||||
let drive: Vec<Hit> = self
|
||||
.drive_hits
|
||||
.borrow()
|
||||
@@ -725,7 +779,8 @@ impl Ui {
|
||||
.map(Hit::Local)
|
||||
.collect();
|
||||
|
||||
let searching = !self.entry.text().trim().is_empty();
|
||||
let query = self.entry.text().trim().to_string();
|
||||
let searching = !query.is_empty();
|
||||
let drive_title = if searching {
|
||||
"Proton Drive"
|
||||
} else {
|
||||
@@ -737,7 +792,12 @@ impl Ui {
|
||||
let total = drive.len() + local.len();
|
||||
let mut visible = drive;
|
||||
visible.extend(local);
|
||||
|
||||
// Find where the previously-selected row landed in the rebuilt list.
|
||||
let restored = selected_key.and_then(|key| visible.iter().position(|hit| hit.key() == key));
|
||||
|
||||
*self.visible.borrow_mut() = visible;
|
||||
*self.rendered_query.borrow_mut() = query;
|
||||
|
||||
if total == 0 {
|
||||
self.placeholder.set_title(if searching {
|
||||
@@ -757,7 +817,7 @@ impl Ui {
|
||||
self.cursor.set(None);
|
||||
} else {
|
||||
self.stack.set_visible_child_name("results");
|
||||
self.select(0);
|
||||
self.select(restored.unwrap_or(0));
|
||||
}
|
||||
|
||||
self.hint.set_label(&self.status_text(total, searching));
|
||||
@@ -845,6 +905,12 @@ impl Ui {
|
||||
if self.opening.get() {
|
||||
return;
|
||||
}
|
||||
// The rows on screen may belong to an older query whose reply is still
|
||||
// settling. Refuse to act until the visible list matches what's typed,
|
||||
// so Enter can never launch a file the user has already typed past.
|
||||
if self.entry.text().trim() != *self.rendered_query.borrow() {
|
||||
return;
|
||||
}
|
||||
let Some(hit) = self.visible.borrow().get(index).cloned() else {
|
||||
return;
|
||||
};
|
||||
@@ -1034,6 +1100,13 @@ fn format_age(epoch_secs: i64) -> String {
|
||||
/// launcher follows the system light/dark theme instead of pinning its own.
|
||||
fn load_css() {
|
||||
let css = "
|
||||
/* Pin the accent to Proton Purple so the HUD matches the main app,
|
||||
which its own process/theme would otherwise leave at the system
|
||||
default (often blue or orange). */
|
||||
@define-color accent_bg_color #6d4aff;
|
||||
@define-color accent_color #6d4aff;
|
||||
@define-color accent_fg_color #ffffff;
|
||||
|
||||
window.launcher-window {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user