Fix thumbnail pop-in: hold-queue for paint-cap overflow + append-only scan streaming + window-liveness watchdog

The slow pop-in (warm and cold) had two root causes, both in the
drain tick / scan pipeline:

1. Paint-cap drop bug: finished decodes beyond MAX_PAINT_CELLS_PER_TICK
   were silently dropped (out of inflight, not in painted) so FILL
   re-submitted them — the same cell re-decoded 3-6x per viewport and
   a new viewport took 500-850ms to fill after a flick. Now excess
   results hold in app.ready (bounded READY_CAP=48) and paint on a
   later tick at zero re-decode cost; FILL skips ready_paths. The cap
   is adaptive: 2 during grid motion (frame budget), 4 when settled
   (fast pop-in).

2. Cold-start scan storm: every 512-entry scan batch re-sorted the
   WHOLE list and set_list did a full model reset + generation bump,
   orphaning every queued decode (arrived marked stale, dropped,
   re-submitted) — a 17x re-decode storm (max observed dups = 18 =
   batch count) plus full row churn per batch. Streaming now appends
   (each 512 group mtime-sorted, groups concatenated = stable prefix),
   and set_list detects appends (prefix match) to extend the model
   in place (extend_from_slice) with NO reset/generation-bump. The
   final global sort is ONE reset at scan end; viewport re-fills from
   the disk cache (~2ms/cell). Measured: scan 7637ms -> ~4.3-4.9s,
   decode dups 18 -> 2-3.

Window black after pause (regression found while testing): the OS can
invalidate our surface (system dialog, app switch) with NO window
event and no pending model change -> the window returned black and
stayed black (nothing ever set pending_redraw). Fixes:
 - request_redraw() unconditionally in RenderingSetup
 - drain-tick watchdog: window visible but no frame completed for
   >2.5s -> one forced redraw (LAST_PRESENT_US, updated in
   AfterRendering; now_us/NOW_T0 no longer perf-gated)

Also: debuggable=false — the system 16KB-compat dialog (debuggable
apps only) is its own activity, it paused us on every cold launch,
and its inconsistent lifecycle (TermWin or not) was the main black-
window trigger in testing.

Phone-verified (cold, 8580 files, fresh DB): clean launch, grid
streams in with thumbnails, flicks render 120Hz (median 8.3-8.5ms,
0-2 late frames), viewport fully painted after flick, grid intact
after an app switch (no black window).
This commit is contained in:
Greg Pomerantz 2026-10-30 09:04:01 -04:00
parent 2f4768f354
commit b9317e7d23
2 changed files with 185 additions and 38 deletions

View File

@ -57,9 +57,11 @@ target_sdk_version = 35
[package.metadata.android.application] [package.metadata.android.application]
label = "Gallery" label = "Gallery"
# JANK-HUNT ONLY: debuggable so `adb shell simpleperf`/run-as can attach to # Not debuggable: a debuggable app shows the system 16 KB compatibility
# the process (off-CPU profiling of the UI thread). REMOVE before shipping. # dialog on EVERY cold launch — that dialog is its own activity, it pauses
debuggable = true # us, and its lifecycle was a source of lost surfaces / black windows.
# (Jank-hunt is over; simpleperf no longer needed.)
debuggable = false
[[package.metadata.android.application.activity]] [[package.metadata.android.application.activity]]
name = "android.app.NativeActivity" name = "android.app.NativeActivity"

View File

@ -209,6 +209,9 @@ const MAX_PAINTED: usize = 225;
/// model mutation (one cell property change; texture upload only when the /// model mutation (one cell property change; texture upload only when the
/// pixels actually change) — cheap enough for 4+4 per tick. /// pixels actually change) — cheap enough for 4+4 per tick.
const MAX_PAINT_CELLS_PER_TICK: usize = 2; const MAX_PAINT_CELLS_PER_TICK: usize = 2;
/// Hold-queue cap for finished results that missed the per-tick paint cap
/// (~two viewports); overflow drops back to the disk cache.
const READY_CAP: usize = 48;
const MAX_EVICT_CELLS_PER_TICK: usize = 4; const MAX_EVICT_CELLS_PER_TICK: usize = 4;
/// Reconcile tick period. 8 ms ≈ one 120 Hz frame; Slint's event loop is /// Reconcile tick period. 8 ms ≈ one 120 Hz frame; Slint's event loop is
/// VSync-paced on Android, so the actual cadence quantizes to the display /// VSync-paced on Android, so the actual cadence quantizes to the display
@ -307,6 +310,15 @@ struct AppState {
/// set: a full decode can fail where the 400 px thumb succeeds, e.g. /// set: a full decode can fail where the 400 px thumb succeeds, e.g.
/// the GPU texture cap). UI thread only. /// the GPU texture cap). UI thread only.
full_failed: Mutex<HashSet<String>>, full_failed: Mutex<HashSet<String>>,
/// Jank fix (pop-in): finished decode results that missed the
/// per-tick paint cap. Previously they were DROPPED (out of inflight,
/// not in painted) and FILL re-submitted them → the same cell
/// re-decoded 3–6× and a new viewport took 500–850 ms to fill after a
/// flick. Held results paint on a later tick at zero re-decode cost;
/// `ready_paths` keeps FILL from re-submitting them. Bounded by
/// READY_CAP. UI thread only.
ready: Mutex<Vec<JobResult>>,
ready_paths: Mutex<HashSet<String>>,
/// Grid motion: (last list-y seen, when it last CHANGED by more than /// Grid motion: (last list-y seen, when it last CHANGED by more than
/// half a px). The tick polls list-y every ~8 ms, so a fling updates /// half a px). The tick polls list-y every ~8 ms, so a fling updates
/// the time every frame until it settles (even below one row of /// the time every frame until it settles (even below one row of
@ -314,7 +326,7 @@ struct AppState {
/// A cell press younger than 150 ms is a touch DURING A FLING: the /// A cell press younger than 150 ms is a touch DURING A FLING: the
/// Flickable's own press handling stops the fling, so the release /// Flickable's own press handling stops the fling, so the release
/// must not open the viewer (`tap_suppressed`). UI thread only. /// must not open the viewer (`tap_suppressed`). UI thread only.
grid_motion: Mutex<(f32, Instant)>, grid_motion: Mutex<(f32, Instant)>,
/// Set by `on_cell_pressing` when the grid was still moving; consumed /// Set by `on_cell_pressing` when the grid was still moving; consumed
/// (swap) by `on_open_image`, which then does not open the viewer. /// (swap) by `on_open_image`, which then does not open the viewer.
tap_suppressed: AtomicBool, tap_suppressed: AtomicBool,
@ -435,17 +447,22 @@ static REAPER_TX: std::sync::LazyLock<std::sync::mpsc::Sender<Vec<u8>>> =
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>(); let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
std::thread::Builder::new() std::thread::Builder::new()
.name("full-reaper".into()) .name("full-reaper".into())
.spawn(move || loop { .spawn(move || {
// 500 ms batching: coalesce drops, never touch the UI path. // Garbage: never compete with the decode workers (nice +2)
match rx.recv_timeout(std::time::Duration::from_millis(500)) { // or the UI thread (nice -10) for CPU.
Ok(first) => { prio::set_self_nice(5);
drop(first); loop {
// Drain whatever else is waiting (no extra sleep). // 500 ms batching: coalesce drops, never touch the UI path.
while let Ok(b) = rx.try_recv() { match rx.recv_timeout(std::time::Duration::from_millis(500)) {
drop(b); Ok(first) => {
drop(first);
// Drain whatever else is waiting (no extra sleep).
while let Ok(b) = rx.try_recv() {
drop(b);
}
} }
Err(_) => {}
} }
Err(_) => {}
} }
}) })
.ok(); .ok();
@ -484,6 +501,8 @@ fn state() -> &'static AppState {
full_inflight: Mutex::new(HashMap::new()), full_inflight: Mutex::new(HashMap::new()),
full_evicted_recent: Mutex::new(HashMap::new()), full_evicted_recent: Mutex::new(HashMap::new()),
full_failed: Mutex::new(HashSet::new()), full_failed: Mutex::new(HashSet::new()),
ready: Mutex::new(Vec::new()),
ready_paths: Mutex::new(HashSet::new()),
grid_motion: Mutex::new((0.0, Instant::now())), grid_motion: Mutex::new((0.0, Instant::now())),
tap_suppressed: AtomicBool::new(false), tap_suppressed: AtomicBool::new(false),
thumb_dims: Mutex::new(HashMap::new()), thumb_dims: Mutex::new(HashMap::new()),
@ -540,20 +559,52 @@ fn set_list(weak: &slint::Weak<Main>, entries: Vec<Entry>, status: String) {
let _ = slint::invoke_from_event_loop(move || { let _ = slint::invoke_from_event_loop(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
let Some(app) = STATE.get() else { return }; let Some(app) = STATE.get() else { return };
app.engine.lock().unwrap().bump_generation();
app.inflight.lock().unwrap().clear(); // STREAMING APPEND vs REPLACE: during the initial scan the list grows
app.painted.lock().unwrap().clear(); // in 512-entry batches. A reset (set_vec + generation bump + state
app.failed.lock().unwrap().clear(); // clears) on every batch orphaned every queued/in-flight decode
app.full_cache.lock().unwrap().clear(); // (results arrived marked stale → dropped → FILL re-submitted) — a
app.full_inflight.lock().unwrap().clear(); // 17× decode storm on cold start, plus full row churn. Appends must
app.full_evicted_recent.lock().unwrap().clear(); // be silent to the pipeline: extend the model, keep all state.
app.full_failed.lock().unwrap().clear(); let is_append = {
app.thumb_dims.lock().unwrap().clear(); let cur = app.entries.lock().unwrap();
!entries.is_empty()
&& entries.len() > cur.len()
&& cur.len() > 0
&& entries[..cur.len()].iter().zip(cur.iter()).all(|(a, b)| a == b)
};
if !is_append {
app.engine.lock().unwrap().bump_generation();
app.inflight.lock().unwrap().clear();
app.painted.lock().unwrap().clear();
app.failed.lock().unwrap().clear();
app.full_cache.lock().unwrap().clear();
app.full_inflight.lock().unwrap().clear();
app.full_evicted_recent.lock().unwrap().clear();
app.full_failed.lock().unwrap().clear();
app.thumb_dims.lock().unwrap().clear();
app.ready.lock().unwrap().clear();
app.ready_paths.lock().unwrap().clear();
}
*app.entries.lock().unwrap() = entries.clone(); *app.entries.lock().unwrap() = entries.clone();
let n = entries.len(); let n = entries.len();
*app.viewport.lock().unwrap() = 0..INITIAL_VIEWPORT_CELLS.min(n); *app.viewport.lock().unwrap() = 0..INITIAL_VIEWPORT_CELLS.min(n);
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<Item>>() { if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<Item>>() {
m.set_vec(chunk(&entries)); if is_append {
let old_rows = m.row_count();
let new_rows = n.div_ceil(GRID_COLS);
if new_rows > old_rows {
// Re-chunk only the appended suffix; existing rows keep
// their textures untouched.
let mut rows = chunk(&entries);
rows.truncate(new_rows);
rows.drain(..old_rows.min(rows.len()));
m.extend_from_slice(&rows);
}
} else {
m.set_vec(chunk(&entries));
}
} }
w.set_status(status.into()); w.set_status(status.into());
}); });
@ -579,20 +630,20 @@ fn set_status(weak: &slint::Weak<Main>, status: String) {
/// was the scroll hitch + content pulse.) Slint's ListView for-loop keeps /// was the scroll hitch + content pulse.) Slint's ListView for-loop keeps
/// every row alive, so the live row's model is always reachable. /// every row alive, so the live row's model is always reachable.
/// Returns the number of paint cells applied. Paints are capped at /// Returns the number of paint cells applied. Paints are capped at
/// `MAX_PAINT_CELLS_PER_TICK`; unapplied paints are dropped on purpose — /// `MAX_PAINT_CELLS_PER_TICK` (cap); excess finished results are held in
/// their cells are no longer painted and no longer in flight, so the fill /// `app.ready` (pop-in fix) and paint on a later tick without re-decoding.
/// step re-submits them (disk-cache hit) and they land next tick.
fn apply_cell_updates( fn apply_cell_updates(
app: &AppState, app: &AppState,
m: &VecModel<Item>, m: &VecModel<Item>,
entries: &[Entry], entries: &[Entry],
paints: &[JobResult], paints: &[JobResult],
clears: &[usize], clears: &[usize],
paint_cap: usize,
) -> usize { ) -> usize {
let mut painted_now = 0usize; let mut painted_now = 0usize;
for r in paints { for r in paints {
if painted_now >= MAX_PAINT_CELLS_PER_TICK { if painted_now >= paint_cap {
break; // the rest is re-covered by the fill step break; // the rest is held / re-covered by the fill step
} }
if !r.ok || r.id >= entries.len() || entries[r.id].path != r.path { if !r.ok || r.id >= entries.len() || entries[r.id].path != r.path {
continue; // list moved on; drop continue; // list moved on; drop
@ -1225,6 +1276,8 @@ fn reset_grid_in_place(w: &Main, app: &AppState) {
app.full_evicted_recent.lock().unwrap().clear(); app.full_evicted_recent.lock().unwrap().clear();
app.full_failed.lock().unwrap().clear(); app.full_failed.lock().unwrap().clear();
app.thumb_dims.lock().unwrap().clear(); app.thumb_dims.lock().unwrap().clear();
app.ready.lock().unwrap().clear();
app.ready_paths.lock().unwrap().clear();
// The viewer's image (and its load) also belonged to the dying device: // The viewer's image (and its load) also belonged to the dying device:
// close it; the tick/grid re-fill on the new one. // close it; the tick/grid re-fill on the new one.
close_viewer(w, app); close_viewer(w, app);
@ -1296,6 +1349,14 @@ fn install_render_notifier(window: &Main, wid: u32) {
} else if gputex::ready() { } else if gputex::ready() {
log::info!("gputex: [W{wid}] wgpu device captured"); log::info!("gputex: [W{wid}] wgpu device captured");
} }
// Always repaint on (re)setup: the surface may have been
// invalidated while the window was away (a system dialog,
// an app switch, screen lock) with NO model change pending
// — without this the window comes back black and stays
// black until the next content mutation.
if let Some(w) = weak.upgrade() {
w.window().request_redraw();
}
} }
// Jank-hunt instrumentation (`perf` feature; see // Jank-hunt instrumentation (`perf` feature; see
// docs/BUGS/scroll-jank-120hz.md). FR = app-side render cost // docs/BUGS/scroll-jank-120hz.md). FR = app-side render cost
@ -1307,6 +1368,7 @@ fn install_render_notifier(window: &Main, wid: u32) {
FR_BEFORE_US.store(now_us(), std::sync::atomic::Ordering::Relaxed); FR_BEFORE_US.store(now_us(), std::sync::atomic::Ordering::Relaxed);
} }
slint::RenderingState::AfterRendering => { slint::RenderingState::AfterRendering => {
LAST_PRESENT_US.store(now_us(), std::sync::atomic::Ordering::Relaxed);
#[cfg(feature = "perf")] #[cfg(feature = "perf")]
{ {
let now = now_us(); let now = now_us();
@ -1346,8 +1408,7 @@ static FR_BEFORE_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64
#[cfg(feature = "perf")] #[cfg(feature = "perf")]
static FR_LAST_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static FR_LAST_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Monotonic ms since process start (wall clock could jump on NTP). /// Monotonic µs since process start (wall clock could jump on NTP).
#[cfg(feature = "perf")]
static NOW_T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new(); static NOW_T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
#[cfg(feature = "perf")] #[cfg(feature = "perf")]
#[inline] #[inline]
@ -1356,7 +1417,8 @@ fn now_ms() -> f64 {
t0.elapsed().as_secs_f64() * 1000.0 t0.elapsed().as_secs_f64() * 1000.0
} }
#[cfg(feature = "perf")] // Unconditional (not `perf`-gated): the window-liveness watchdog needs it
// in every build.
fn now_us() -> u64 { fn now_us() -> u64 {
NOW_T0.get_or_init(std::time::Instant::now).elapsed().as_micros() as u64 NOW_T0.get_or_init(std::time::Instant::now).elapsed().as_micros() as u64
} }
@ -1542,6 +1604,13 @@ fn on_scroll(app: &AppState, y: f32, height: f32, width: f32) {
/// lines are only visible at log level Debug). /// lines are only visible at log level Debug).
static DBG_TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static DBG_TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// App clock (µs) of the last completed frame (AfterRendering). Feeds the
/// window-liveness watchdog in the drain tick. Updated unconditionally
/// (not behind the `perf` feature) — it is the only signal we have that
/// the surface is still presenting.
static LAST_PRESENT_US: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
fn start_drain_timer(weak: &slint::Weak<Main>, wid: u32) { fn start_drain_timer(weak: &slint::Weak<Main>, wid: u32) {
let weak = weak.clone(); let weak = weak.clone();
let timer = Timer::default(); let timer = Timer::default();
@ -1557,6 +1626,22 @@ fn start_drain_timer(weak: &slint::Weak<Main>, wid: u32) {
let Some(app) = STATE.get() else { return }; let Some(app) = STATE.get() else { return };
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
// WINDOW LIVENESS WATCHDOG: the OS can invalidate our surface
// (a system dialog activity, an app switch, screen lock) with
// NO window event at all — the last presented buffer is lost
// and, at rest, nothing ever sets pending_redraw again, so the
// window sits black forever. If the window is visible but no
// frame has completed in a while, force one redraw (one 8 ms
// frame, once per >2.5 s, negligible).
if w.window().is_visible()
&& now_us()
.saturating_sub(LAST_PRESENT_US.load(std::sync::atomic::Ordering::Relaxed))
> 2_500_000
{
log::info!("gallery: [W{wid}] no frame for >2.5s while visible — forcing redraw");
w.window().request_redraw();
}
// Pause thumbnail painting and model mutations during active drag/fling // Pause thumbnail painting and model mutations during active drag/fling
// (motion within the last 150ms) to ensure zero layout thrashing during scroll. // (motion within the last 150ms) to ensure zero layout thrashing during scroll.
// Thumbnails paint instantly the moment scrolling pauses/settles. // Thumbnails paint instantly the moment scrolling pauses/settles.
@ -1652,9 +1737,44 @@ fn start_drain_timer(weak: &slint::Weak<Main>, wid: u32) {
// done). Paint viewport results (capped); the disk cache owns // done). Paint viewport results (capped); the disk cache owns
// the rest — a dropped result costs a ~10 ms cache hit on // the rest — a dropped result costs a ~10 ms cache hit on
// re-entry, never a re-decode. // re-entry, never a re-decode.
// Paint cap: 2 during grid motion (protect the 120 Hz frame
// budget — each paint uploads a texture), 4 while the grid is
// settled (fast pop-in right after a flick).
let paint_cap = {
let gm = app.grid_motion.lock().unwrap();
if gm.1.elapsed().as_millis() < 150 {
MAX_PAINT_CELLS_PER_TICK
} else {
MAX_PAINT_CELLS_PER_TICK * 2
}
};
let mut paints: Vec<JobResult> = Vec::new(); let mut paints: Vec<JobResult> = Vec::new();
// 1a. HOLD QUEUE: results that missed the paint cap on a
// previous tick paint first this tick (already decoded —
// zero re-decode cost). Stale ones (scrolled away) drop
// back to the disk cache.
{
let mut rd = app.ready.lock().unwrap();
if !rd.is_empty() {
let mut kept: Vec<JobResult> = Vec::new();
let mut rp = app.ready_paths.lock().unwrap();
for r in rd.drain(..) {
if paints.len() < paint_cap && viewport.contains(&r.id) {
rp.remove(&r.path);
paints.push(r);
} else if kept.len() < READY_CAP {
kept.push(r);
} else {
rp.remove(&r.path); // overflow: disk cache owns it
}
}
rd.extend(kept);
}
}
{ {
let mut inflight = app.inflight.lock().unwrap(); let mut inflight = app.inflight.lock().unwrap();
let mut rd = app.ready.lock().unwrap();
let mut rp = app.ready_paths.lock().unwrap();
for r in results { for r in results {
inflight.remove(&r.path); inflight.remove(&r.path);
if r.skipped { if r.skipped {
@ -1674,11 +1794,18 @@ fn start_drain_timer(weak: &slint::Weak<Main>, wid: u32) {
} }
continue; continue;
} }
if viewport.contains(&r.id) if viewport.contains(&r.id) && paints.len() < paint_cap {
&& paints.len() < MAX_PAINT_CELLS_PER_TICK
{
paints.push(r); paints.push(r);
} else if viewport.contains(&r.id) && rd.len() < READY_CAP {
// Cap full: HOLD for the next tick instead of
// dropping (drop ⇒ FILL re-submits ⇒ re-decode
// storm + slow pop-in). Mark the path so FILL
// skips it while held.
rp.insert(r.path.clone());
rd.push(r);
} }
// Out-of-viewport results: drop (the disk cache owns
// them; a re-entry is a ~2 ms hit, never a re-decode).
} }
} }
@ -1716,7 +1843,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>, wid: u32) {
// 3. APPLY: in-place nested cell updates (≤4 paints + ≤4 // 3. APPLY: in-place nested cell updates (≤4 paints + ≤4
// evictions; each = one cell property change). // evictions; each = one cell property change).
let painted_now = apply_cell_updates(app, m, &entries, &paints, &to_clear); let painted_now = apply_cell_updates(app, m, &entries, &paints, &to_clear, paint_cap);
// 4. FILL: every viewport cell without pixels and without an // 4. FILL: every viewport cell without pixels and without an
// in-flight job gets submitted now, center-first so the eye is // in-flight job gets submitted now, center-first so the eye is
@ -1732,6 +1859,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>, wid: u32) {
{ {
let painted = app.painted.lock().unwrap(); let painted = app.painted.lock().unwrap();
let failed = app.failed.lock().unwrap(); let failed = app.failed.lock().unwrap();
let ready_paths = app.ready_paths.lock().unwrap();
let mut inflight = app.inflight.lock().unwrap(); let mut inflight = app.inflight.lock().unwrap();
let center = (viewport.start + viewport.end) / 2; let center = (viewport.start + viewport.end) / 2;
let mut missing: Vec<usize> = viewport let mut missing: Vec<usize> = viewport
@ -1741,6 +1869,9 @@ fn start_drain_timer(weak: &slint::Weak<Main>, wid: u32) {
let e = &entries[*id]; let e = &entries[*id];
!painted.contains(id) !painted.contains(id)
&& !inflight.contains_key(&e.path) && !inflight.contains_key(&e.path)
// A held (already-decoded) result paints on a
// later tick — do not re-submit it.
&& !ready_paths.contains(&e.path)
&& !failed.contains(&(e.path.clone(), e.mtime, e.size)) && !failed.contains(&(e.path.clone(), e.mtime, e.size))
}) })
.collect(); .collect();
@ -1989,14 +2120,28 @@ fn scan_thread(weak: &slint::Weak<Main>) {
// Warm start: keep the cached list, only update the status line. // Warm start: keep the cached list, only update the status line.
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
let roots = scan::roots(); let roots = scan::roots();
// STREAMING ORDER: append-only (each 512-file group mtime-sorted,
// groups concatenated). A stable prefix is what lets set_list take its
// APPEND path (no model reset, no generation bump, no orphaned decode
// jobs) — the old code re-sorted the WHOLE accumulated list per batch,
// so every batch's "sorted prefix" differed and every batch was a full
// reset: a 17× decode storm on cold start. The final global sort lands
// as ONE reset at scan end (viewport re-fills from disk cache, ~2 ms
// per cell).
let acc = std::cell::RefCell::new(Vec::<Entry>::new());
let last = std::cell::Cell::new(0usize);
let entries = scan::walk_stream(&roots, &|batch| { let entries = scan::walk_stream(&roots, &|batch| {
let n = batch.len(); let n = batch.len();
if db_warm { if db_warm {
set_status(weak, format!("Updating… {n}")); set_status(weak, format!("Updating… {n}"));
last.set(n);
} else { } else {
let mut sorted = batch.to_vec(); let from = last.get();
sorted.sort_by(|a, b| b.mtime.cmp(&a.mtime).then_with(|| a.path.cmp(&b.path))); last.set(n);
set_list(weak, sorted, format!("Scanning… {n}")); let mut group: Vec<Entry> = batch[from..].to_vec();
group.sort_by(|a, b| b.mtime.cmp(&a.mtime).then_with(|| a.path.cmp(&b.path)));
acc.borrow_mut().extend(group);
set_list(weak, acc.borrow().clone(), format!("Scanning… {n}"));
} }
}); });