improved ui + prompt

This commit is contained in:
nvrl committed 2026-07-17 23:34:46 +02:00
1 parent 81e7133003
commit c555315998
3 files changed
+155 -29

No files matched your search

+5 -1
View File
@@ -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
View File
@@ -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 |_| {
+81 -8
View File
@@ -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;
}