diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index bdb90d9..a7e0f68 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -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), diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs index ce87289..ec30569 100644 --- a/crates/pdfs-gui/src/app.rs +++ b/crates/pdfs-gui/src/app.rs @@ -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) { + // 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, code_tx: std::sync::mpsc::Sender) { 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, 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, 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) { .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.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, 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, 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) { .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) { 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.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) { 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, 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, 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, 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 |_| { diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index dc50143..7233ce9 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -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>, cursor: Cell>, + /// 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, filter: Cell, /// 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 => { - window_key.close(); + // 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.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) { @@ -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(); + } } - 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.render(); + } + Ok(Err(_)) | Err(_) => ui.go_offline(), + _ => { + ui.local_hits.borrow_mut().clear(); + ui.render(); } - _ => ui.local_hits.borrow_mut().clear(), } - ui.render(); }); } @@ -708,6 +755,13 @@ impl Ui { fn render(self: &Rc) { 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 = 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; }