Skip to content

Commit b5e1b3d

Browse files
fiskusclaude
andcommitted
Keep the remote-file selection across a package refresh
The selection was created inside InstalledPackageContent, which takes its data by value out of the resource's Suspend — so every re-resolution re-ran the component and every RwSignal in it was a brand-new signal. The picks died on any refresh, including refreshes with nothing to do with them: edit one local file while choosing remote files to download and the choices were gone. It was also stored as indices into an entries list that is sorted by filename, so one added or dropped entry shifts every later position. Preserving that would have re-pointed the selection at other files, so re-keying by path is a precondition, not a tidy-up. The page now owns it, as All { chosen } | Subset. All pins no names, so a file arriving in a later refresh is covered by it (today's behaviour); Subset pins names, so an arrival is never ticked into a download the user has not seen. Both fall out of one intersection against the entries the package currently offers, which is also what drops a name that has since been installed — no cleanup pass anywhere. The header checkbox and every row read that one derivation and store nothing, so they cannot disagree. All-ticked always collapses to All, whatever path reached it: a subset of every current name would draw identically yet diverge on the next refresh. `chosen` records that the user asked for everything rather than left the screen as it opened; nothing reads it yet — it is the state the always-download-new-files opt-in needs. The selection clears on a namespace change, since the router keeps this component mounted between packages. Suspense becomes Transition so the refreshes that remain update in place instead of blanking to a spinner. A partial selection now draws indeterminate rather than empty, which would claim nothing is selected — momentary before this change, standing after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2b8dcd8 commit b5e1b3d

5 files changed

Lines changed: 445 additions & 55 deletions

File tree

quilt-sync/ui/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ send_wrapper = "0.6.0"
2323

2424
[dev-dependencies]
2525
wasm-bindgen-test = "0.3.76"
26-
web-sys = { version = "0.3.103", features = ["DomTokenList", "Element", "HtmlButtonElement", "Node"] }
26+
web-sys = { version = "0.3.103", features = ["DomTokenList", "Element", "HtmlButtonElement", "HtmlInputElement", "Node"] }
2727

2828
[lints]
2929
workspace = true

quilt-sync/ui/src/pages/installed_package.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
mod content;
22
mod entries;
3+
mod selection;
34
mod status_banner;
45
mod toolbar;
56

67
use leptos::prelude::*;
78
use leptos_router::hooks::use_query_map;
89

910
use content::InstalledPackageContent;
11+
use selection::RemoteSelection;
1012
use toolbar::build_toolbar_actions;
1113

1214
use crate::commands::{
@@ -66,6 +68,28 @@ pub fn InstalledPackage() -> impl IntoView {
6668
async move { commands::get_installed_package_data(namespace, filter).await }
6769
});
6870

71+
// The remote-file selection is held *here*, not in the content component
72+
// that renders the checkboxes. `InstalledPackageContent` receives its data
73+
// by value out of the resource's `Suspend`, so every re-resolution re-runs
74+
// it and every signal created inside it is a brand-new signal — a selection
75+
// created down there is destroyed by any refresh. This is the same reason
76+
// `last_fingerprint` below sits up here.
77+
let selection = RwSignal::new(RemoteSelection::default());
78+
79+
// Moving between packages must not carry the previous package's picks over.
80+
// The router keeps this component **mounted** when only the `namespace`
81+
// query changes, so nothing unmounts the selection; and because it is keyed
82+
// by path, a carried-over set would tick same-named files in the package
83+
// just opened. Compares against the previous value rather than firing on
84+
// every read, so the first run (which has no previous namespace) is inert.
85+
Effect::new(move |previous: Option<Option<String>>| {
86+
let namespace = query.read().get("namespace");
87+
if previous.is_some_and(|prev| prev != namespace) {
88+
selection.set(RemoteSelection::default());
89+
}
90+
namespace
91+
});
92+
6993
// Autosync watcher → page refresh: when the backend reports a
7094
// status change for the currently-open namespace, refetch the
7195
// detail data so the entries list and toolbar reflect the new
@@ -158,8 +182,16 @@ pub fn InstalledPackage() -> impl IntoView {
158182
refetch.notify();
159183
});
160184

185+
// A `Transition` (not `Suspense`) is deliberate: a plain `Suspense` shows its
186+
// fallback whenever the resource re-enters a pending state, so every refresh
187+
// that survives the fingerprint gate blanks the whole screen to a spinner and
188+
// back — for a change that may have nothing to do with what is on it.
189+
// `Transition` keeps the already-rendered children mounted while a later load
190+
// is pending and falls back only on the initial one, so a genuine change
191+
// updates the page in place. The commit screen made the same switch for a
192+
// related reason (see the note on its boundary).
161193
view! {
162-
<Suspense fallback=move || {
194+
<Transition fallback=move || {
163195
view! {
164196
<Layout breadcrumbs=vec![] notification=notification ui_locked=ui_locked>
165197
<Spinner />
@@ -200,6 +232,7 @@ pub fn InstalledPackage() -> impl IntoView {
200232
local_only=local_only
201233
show_set_remote_popup=show_set_remote_popup
202234
paused_event=paused_event
235+
selection=selection
203236
/>
204237
</Layout>
205238
}
@@ -211,7 +244,7 @@ pub fn InstalledPackage() -> impl IntoView {
211244
}
212245
})
213246
}}
214-
</Suspense>
247+
</Transition>
215248
}
216249
}
217250

quilt-sync/ui/src/pages/installed_package/content.rs

Lines changed: 47 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
use std::collections::BTreeSet;
2+
13
use leptos::prelude::*;
24

35
use super::entries::{EntriesToolbar, EntryRow};
6+
use super::selection::{RemoteSelection, all_selected, partially_selected, resolve, toggled_all};
47
use super::status_banner::StatusBanner;
58
use crate::commands::{self, InstalledPackageData, PausedEvent, PullCheck};
69
use crate::components::buttons;
@@ -34,6 +37,10 @@ pub(super) fn InstalledPackageContent(
3437
local_only: bool,
3538
show_set_remote_popup: RwSignal<bool>,
3639
paused_event: RwSignal<Option<PausedEvent>>,
40+
/// Which remote entries are ticked for download. Owned by the page component
41+
/// above the resource boundary so it survives a refresh — see
42+
/// [`super::selection`].
43+
selection: RwSignal<RemoteSelection>,
3744
) -> impl IntoView {
3845
let filter_unmodified = RwSignal::new(data.filter_unmodified);
3946
let filter_ignored = RwSignal::new(data.filter_ignored);
@@ -60,14 +67,26 @@ pub(super) fn InstalledPackageContent(
6067
.iter()
6168
.any(|e| matches!(e.status.as_str(), "added" | "modified" | "deleted"));
6269

63-
// Track which remote entries are checked (by index) — all selected by default
64-
let initial_checked: Vec<usize> = entries
65-
.iter()
66-
.enumerate()
67-
.filter(|(_, e)| e.status == "remote")
68-
.map(|(i, _)| i)
69-
.collect();
70-
let checked_indices = RwSignal::new(initial_checked);
70+
// The remote entries this package currently offers, by path. Every read of
71+
// the selection is resolved against this set, which is what lets a preserved
72+
// selection stay honest with no cleanup pass: a name that has since been
73+
// installed or dropped is simply not in here any more. Held in a
74+
// `StoredValue` so the rows share one copy rather than cloning it per row.
75+
let remote_paths = StoredValue::new(
76+
entries
77+
.iter()
78+
.filter(|e| e.status == "remote")
79+
.map(|e| e.filename.clone())
80+
.collect::<BTreeSet<String>>(),
81+
);
82+
83+
// The one derived selection. The header checkbox and every row read *this*
84+
// and store nothing of their own, so they cannot disagree with each other —
85+
// before, they were two independent derivations over one index vector, and
86+
// their agreement rested on both being rebuilt at once.
87+
let selected = Memo::new(move |_| {
88+
remote_paths.with_value(|remote| selection.with(|s| resolve(s, remote)))
89+
});
7190

7291
// Filtered entries
7392
let entries_for_view = entries.clone();
@@ -89,27 +108,22 @@ pub(super) fn InstalledPackageContent(
89108
});
90109

91110
// Count checked remote entries
92-
let checked_count = Memo::new(move |_| checked_indices.get().len());
111+
let checked_count = Memo::new(move |_| selected.with(BTreeSet::len));
93112

94113
let show_toolbar = has_remote_entries || ignored_count > 0 || unmodified_count > 0;
95114

96-
// Install selected paths
115+
// Install selected paths. The resolved selection is already remote-only and
116+
// already narrowed to what the package still offers, so it is the path list
117+
// verbatim — no index lookup left to mis-resolve.
97118
let uri_for_install = uri.clone();
98-
let entries_for_install = entries.clone();
99119
let on_install_paths = move |_| {
100120
let Some(uri) = uri_for_install
101121
.as_ref()
102122
.map(std::string::ToString::to_string)
103123
else {
104124
return;
105125
};
106-
let indices = checked_indices.get_untracked();
107-
let paths: Vec<String> = indices
108-
.iter()
109-
.filter_map(|&i| entries_for_install.get(i))
110-
.filter(|e| e.status == "remote")
111-
.map(|e| e.filename.clone())
112-
.collect();
126+
let paths: Vec<String> = selected.get_untracked().into_iter().collect();
113127
if paths.is_empty() {
114128
return;
115129
}
@@ -130,31 +144,20 @@ pub(super) fn InstalledPackageContent(
130144
});
131145
};
132146

133-
// Select all
134-
let entries_for_select = entries.clone();
147+
// Select all — clears a full selection, otherwise takes everything.
135148
let on_select_all = move |_: leptos::ev::Event| {
136-
let current = checked_indices.get_untracked();
137-
let remote_indices: Vec<usize> = entries_for_select
138-
.iter()
139-
.enumerate()
140-
.filter(|(_, e)| e.status == "remote")
141-
.map(|(i, _)| i)
142-
.collect();
143-
if current.len() == remote_indices.len() {
144-
checked_indices.set(Vec::new());
145-
} else {
146-
checked_indices.set(remote_indices);
147-
}
149+
let current = selection.get_untracked();
150+
selection.set(remote_paths.with_value(|remote| toggled_all(&current, remote)));
148151
};
149152

150-
let entries_for_all_check = entries.clone();
151153
let all_remote_selected = Memo::new(move |_| {
152-
let checked = checked_indices.get();
153-
let remote_count = entries_for_all_check
154-
.iter()
155-
.filter(|e| e.status == "remote")
156-
.count();
157-
remote_count > 0 && checked.len() == remote_count
154+
remote_paths.with_value(|remote| selected.with(|s| all_selected(s, remote)))
155+
});
156+
// Drives the header checkbox's indeterminate state: a partial selection now
157+
// outlives a refresh, so drawing it as an empty box would be a standing claim
158+
// that nothing is selected.
159+
let some_remote_selected = Memo::new(move |_| {
160+
remote_paths.with_value(|remote| selected.with(|s| partially_selected(s, remote)))
158161
});
159162

160163
// Commit button: primary when no remote entries are checked
@@ -318,8 +321,9 @@ pub(super) fn InstalledPackageContent(
318321
<Show when=move || show_toolbar>
319322
<EntriesToolbar
320323
has_remote_entries=has_remote_entries
321-
on_select_all=on_select_all.clone()
324+
on_select_all=on_select_all
322325
all_selected=all_remote_selected
326+
partially_selected=some_remote_selected
323327
checked_count=checked_count
324328
on_install_paths=on_install_paths.clone()
325329
filter_unmodified=filter_unmodified
@@ -339,10 +343,11 @@ pub(super) fn InstalledPackageContent(
339343
let:item
340344
>
341345
<EntryRow
342-
index=item.0
343346
entry=item.1
344347
pkg_uri=uri.clone()
345-
checked_indices=checked_indices
348+
selection=selection
349+
selected=selected
350+
remote_paths=remote_paths
346351
notification=notification
347352
show_ignore_popup=show_ignore_popup
348353
show_unignore_popup=show_unignore_popup

quilt-sync/ui/src/pages/installed_package/entries.rs

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
use std::collections::BTreeSet;
2+
13
use leptos::prelude::*;
24

35
use quilt_uri::S3PackageUri;
46

7+
use super::selection::{RemoteSelection, toggled_path};
58
use crate::commands::{self, EntryData};
69
use crate::components::buttons;
710
use crate::components::{IgnorePopupData, Notification, UnignorePopupData};
@@ -15,6 +18,9 @@ pub(super) fn EntriesToolbar(
1518
has_remote_entries: bool,
1619
on_select_all: impl Fn(leptos::ev::Event) + 'static,
1720
all_selected: Memo<bool>,
21+
/// Some remote entries are ticked but not all — draws the box indeterminate
22+
/// rather than empty, which would read as "nothing is selected".
23+
partially_selected: Memo<bool>,
1824
checked_count: Memo<usize>,
1925
on_install_paths: impl Fn(leptos::ev::MouseEvent) + 'static,
2026
filter_unmodified: RwSignal<bool>,
@@ -46,6 +52,7 @@ pub(super) fn EntriesToolbar(
4652
<input
4753
type="checkbox"
4854
prop:checked=move || all_selected.get()
55+
prop:indeterminate=move || partially_selected.get()
4956
on:change=on_select_all
5057
/>
5158
"Select all"
@@ -134,10 +141,15 @@ fn EntriesFilter(
134141
reason = "declarative Leptos view; length is markup, not logic complexity"
135142
)]
136143
pub(super) fn EntryRow(
137-
index: usize,
138144
entry: EntryData,
139145
pkg_uri: Option<S3PackageUri>,
140-
checked_indices: RwSignal<Vec<usize>>,
146+
/// The held selection, written on a checkbox click.
147+
selection: RwSignal<RemoteSelection>,
148+
/// What is ticked right now — the one derivation the header shares, read
149+
/// rather than mirrored, so a row cannot disagree with it.
150+
selected: Memo<BTreeSet<String>>,
151+
/// The remote entries the package currently offers, for resolving a toggle.
152+
remote_paths: StoredValue<BTreeSet<String>>,
141153
notification: RwSignal<Option<Notification>>,
142154
show_ignore_popup: RwSignal<Option<IgnorePopupData>>,
143155
show_unignore_popup: RwSignal<Option<UnignorePopupData>>,
@@ -183,24 +195,23 @@ pub(super) fn EntryRow(
183195
let filename_title = filename.clone();
184196

185197
// Checkbox state for remote entries
198+
let name_for_check = filename.clone();
186199
let is_checked = Memo::new(move |_| {
187200
if !is_remote {
188201
return true; // non-remote always show as checked (disabled)
189202
}
190-
checked_indices.get().contains(&index)
203+
selected.with(|s| s.contains(&name_for_check))
191204
});
192205

206+
let name_for_toggle = filename.clone();
193207
let on_checkbox_change = move |_| {
194208
if !is_remote {
195209
return;
196210
}
197-
let mut indices = checked_indices.get_untracked();
198-
if let Some(pos) = indices.iter().position(|&i| i == index) {
199-
indices.remove(pos);
200-
} else {
201-
indices.push(index);
202-
}
203-
checked_indices.set(indices);
211+
let current = selection.get_untracked();
212+
selection.set(
213+
remote_paths.with_value(|remote| toggled_path(&current, remote, &name_for_toggle)),
214+
);
204215
};
205216

206217
// Action buttons
@@ -343,3 +354,72 @@ pub(super) fn EntryRow(
343354
</div>
344355
}
345356
}
357+
358+
#[cfg(test)]
359+
mod tests {
360+
use super::EntriesToolbar;
361+
use leptos::prelude::*;
362+
use wasm_bindgen::JsCast;
363+
use wasm_bindgen_test::wasm_bindgen_test;
364+
365+
fn mount<N: IntoView + 'static>(f: impl FnOnce() -> N + 'static) -> web_sys::Element {
366+
let doc = web_sys::window().unwrap().document().unwrap();
367+
let container: web_sys::HtmlElement =
368+
doc.create_element("div").unwrap().dyn_into().unwrap();
369+
doc.body().unwrap().append_child(&container).unwrap();
370+
leptos::mount::mount_to(container.clone(), f).forget();
371+
container.into()
372+
}
373+
374+
/// The toolbar's header checkbox in one selection state. `indeterminate` is a
375+
/// DOM *property* with no attribute form, so it can only be checked against a
376+
/// real element — which is the whole reason these two tests are here rather
377+
/// than beside the pure rules in `super::super::selection`.
378+
fn header_checkbox(all: bool, partial: bool) -> web_sys::HtmlInputElement {
379+
let el = mount(move || {
380+
view! {
381+
<EntriesToolbar
382+
has_remote_entries=true
383+
on_select_all=|_| {}
384+
all_selected=Memo::new(move |_| all)
385+
partially_selected=Memo::new(move |_| partial)
386+
checked_count=Memo::new(move |_| usize::from(all || partial))
387+
on_install_paths=|_| {}
388+
filter_unmodified=RwSignal::new(true)
389+
filter_ignored=RwSignal::new(true)
390+
ignored_count=0
391+
unmodified_count=0
392+
with_status=false
393+
/>
394+
}
395+
});
396+
el.query_selector(".select-all input")
397+
.unwrap()
398+
.expect("the toolbar still renders a select-all checkbox")
399+
.dyn_into()
400+
.unwrap()
401+
}
402+
403+
/// A partial selection draws **indeterminate**, not empty. An empty box says
404+
/// "nothing is selected", which was a momentary lie while the selection died
405+
/// on every refresh and is a standing one now that it survives.
406+
#[wasm_bindgen_test]
407+
fn a_partial_selection_draws_indeterminate() {
408+
let header = header_checkbox(false, true);
409+
assert!(header.indeterminate());
410+
assert!(!header.checked());
411+
}
412+
413+
/// The contrast, so the test above cannot pass by everything being drawn
414+
/// indeterminate: full is plainly checked, empty is plainly empty.
415+
#[wasm_bindgen_test]
416+
fn full_and_empty_selections_draw_plainly() {
417+
let full = header_checkbox(true, false);
418+
assert!(full.checked());
419+
assert!(!full.indeterminate());
420+
421+
let empty = header_checkbox(false, false);
422+
assert!(!empty.checked());
423+
assert!(!empty.indeterminate());
424+
}
425+
}

0 commit comments

Comments
 (0)