Skip to main content

datui_lib/home/
home_app.rs

1//! The home screen's work: listing, probing remote places, measuring and
2//! classifying rows, previews, search, catalogs and the cloud sources, and the
3//! answers its workers send back.
4
5use crate::app::background::{CacheWrites, OwedAnswer};
6use crate::app::feedback::Confirm;
7use crate::cache::CacheManager;
8use crate::cli::FileFormat;
9use crate::loading::open_options::OpenOptions;
10#[cfg(feature = "cloud")]
11use crate::wait_on_runtime;
12use crate::{
13    APP_NAME, App, AppEvent, InputMode, cloud::source, config, home, home::catalog, home::discover,
14    loading, widgets,
15};
16use color_eyre::Result;
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex};
20
21/// The home screen's work in flight and what it keeps for the session: probes, listings,
22/// search, previews and schemas.
23#[derive(Default)]
24pub struct HomeApp {
25    /// Network roots being listed off-thread, so a probe is not started twice. Never
26    /// removed for a root that does not answer: its thread is lost, and a retry would
27    /// lose another.
28    pub(crate) probes_inflight: Vec<PathBuf>,
29    /// When each listing out last sent news (began, or a page): one silent past
30    /// [`PROBE_PATIENCE`] is shown not answering rather than spun for all session.
31    pub(crate) probe_heard: HashMap<PathBuf, std::time::Instant>,
32    /// The wait before a silent listing is said not to answer, for tests.
33    #[cfg(test)]
34    pub(crate) probe_patience: Option<std::time::Duration>,
35    /// Held by a test, a lock every probe waits on before listing: a share that does
36    /// not answer.
37    #[cfg(test)]
38    pub(crate) probe_gate: Option<Arc<Mutex<()>>>,
39    /// Each cloud listing's stop flag, by place: leaving the place stops it before its
40    /// next page.
41    pub(crate) listing_cancels: HashMap<PathBuf, Arc<std::sync::atomic::AtomicBool>>,
42    /// The listing a filter asked of a cut-short cloud directory: where, the name
43    /// prefix, and its stop flag.
44    pub(crate) narrowing: Option<(PathBuf, String, Arc<std::sync::atomic::AtomicBool>)>,
45    /// Cloud discovery has started. It costs a request per provider, so it runs once
46    /// per session.
47    #[cfg(feature = "cloud")]
48    pub(crate) cloud_discovery_started: bool,
49    /// A recursive search below the working directory is out. One at a time: a second
50    /// would only compete for the disk.
51    pub(crate) search_inflight: bool,
52    /// The home generation the walk started in. Its batches stay its own across
53    /// refreshes; the root decides whether they still apply.
54    pub(crate) search_generation: u64,
55    /// Why the last open failed, shown at home when the error is dismissed with nothing
56    /// to fall back to.
57    pub(crate) last_load_error: Option<String>,
58    /// The screen's height when the last frame had room for the selected file's first
59    /// rows; `None` when it had none, and nothing is read for them.
60    pub preview_room: Option<u16>,
61    /// The schema reads out, by slot: one for local files (the empty path) and one for
62    /// each mount that can stall (its mount point), with the file each reads.
63    pub(crate) schema_reads: HashMap<PathBuf, PathBuf>,
64    /// Invalidates listings and measurements from a request the user has moved past.
65    pub(crate) generation: u64,
66    /// Places whose listing sent rows since the last frame; their sections are built
67    /// again from the rows before the next.
68    pub(crate) pages_owed: Vec<PathBuf>,
69    /// Directories the `~` prompt asked a worker to list, until each answers: typing
70    /// within one directory asks once.
71    pub(crate) path_listings_out: std::collections::HashSet<String>,
72    /// The session's start at home has been made, with no row picked: never again.
73    pub(crate) rested: bool,
74    /// The cache's dataset index has been read this session; later listings use it
75    /// as kept, without a scan of the cache.
76    pub(crate) facts_read: bool,
77    /// The dataset left for home, whose open may have recorded facts the index lacks.
78    pub(crate) left: Option<PathBuf>,
79    /// Index records already dated as used this session: each is dated once.
80    pub(crate) facts_dated: Arc<Mutex<std::collections::HashSet<PathBuf>>>,
81    /// Schema previews, memoized for the session only (persisted, they would go stale).
82    pub(crate) schema_cache: HashMap<PathBuf, Option<discover::SchemaPreview>>,
83    /// The home screen's `ROWS` previews, and the dataset the newest one built.
84    pub previews: crate::home::home_preview::Previews,
85    /// The pre-0.4.0 Ctrl+D directories in the cache have been moved to `catalog.toml`.
86    pub(crate) remembered_moved: bool,
87    /// Which home workers panic before starting, for tests. The jobs' own is
88    /// [`Jobs::worker_dies`].
89    #[cfg(test)]
90    pub(crate) worker_dies: Option<crate::HomeWorkerDies>,
91    /// Whether a browser opened here appears in front of the user: `o` on a doc link
92    /// is offered only then (`link_open::local_desktop`).
93    pub local_desktop: bool,
94    /// The reads of data started this session, by kind.
95    pub reads: crate::home::home_preview::ReadCounts,
96}
97
98/// Rows measured per background pass: small, so a slow filesystem shows progress.
99const MEASURE_BATCH: usize = 12;
100
101/// Rows a probe measures while it is already reading a remote directory.
102const PROBE_MEASURE_LIMIT: usize = 24;
103
104/// Rows one classification pass looks into: a cap on work in flight, the next pass
105/// chosen from the viewport when this one lands. Small like [`MEASURE_BATCH`], so a
106/// screen fills in rather than going silent.
107pub(crate) const CLASSIFY_BATCH: usize = 16;
108
109/// How long answers wait to share an event: a batch of local files lands as one, and a
110/// slow share still shows its rows a few times a second.
111const ANSWER_EVERY: std::time::Duration = std::time::Duration::from_millis(250);
112
113/// Look into `rows` on this thread, sending what was learned as `answer` events, the
114/// last one `done`. One event per batch where the batch is quick: each event is a pass
115/// of the event loop, and a frame.
116fn look_and_answer(
117    rows: Vec<discover::Entry>,
118    cache: &CacheManager,
119    known: &home::Known,
120    reads: home::Reads,
121    tx: &std::sync::mpsc::Sender<AppEvent>,
122    answer: impl Fn(Vec<(PathBuf, home::Measured)>, bool) -> AppEvent,
123) {
124    let mut held = Vec::new();
125    let mut since = std::time::Instant::now();
126    home::look_into_batch(rows, cache, known, reads, |path, measured| {
127        // A row's later answer (its count after its kind) replaces one not yet sent.
128        match held.iter().position(|(held, _)| *held == path) {
129            Some(at) => held[at].1 = measured,
130            None => held.push((path, measured)),
131        }
132        if since.elapsed() >= ANSWER_EVERY {
133            let _ = tx.send(answer(std::mem::take(&mut held), false));
134            since = std::time::Instant::now();
135        }
136    });
137    let _ = tx.send(answer(held, true));
138}
139
140/// How long a listing goes without a page or an answer before it is shown not
141/// answering: a dead share never answers, and its spinner would turn all session.
142const PROBE_PATIENCE: std::time::Duration = std::time::Duration::from_secs(30);
143
144/// Probes allowed at once: a probe of a gone share holds its thread until exit.
145pub(crate) const MAX_CONCURRENT_PROBES: usize = 4;
146
147/// A number per home search walk, so one walk's scorings are never taken for
148/// another's over the same place.
149fn next_search_epoch() -> u64 {
150    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
151    NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
152}
153
154/// A source as a home-screen row, with the last run's buckets when they still apply.
155#[cfg(feature = "cloud")]
156pub(crate) fn home_cloud_source(
157    source: &crate::cloud::cloud_sources::Source,
158    cached: Option<&crate::cache::CloudListing>,
159    listing: bool,
160) -> home::CloudSource {
161    let mut details: Vec<(String, String)> = vec![
162        ("source".to_string(), source.id.clone()),
163        ("api".to_string(), source.kind.name().to_string()),
164    ];
165    if let Some(endpoint) = &source.s3.endpoint {
166        details.push(("endpoint".to_string(), endpoint.clone()));
167    }
168    if let Some(region) = &source.s3.region {
169        details.push(("region".to_string(), region.clone()));
170    }
171    if let Some(project) = &source.project {
172        details.push(("project".to_string(), project.clone()));
173    }
174    if let Some(profile) = &source.profile {
175        details.push(("profile".to_string(), profile.clone()));
176    }
177    if let Some(configuration) = &source.gcloud {
178        details.push(("configuration".to_string(), configuration.clone()));
179    }
180    if source.s3.virtual_hosted.is_some() {
181        let style = if source.s3.virtual_hosted_style() {
182            "virtual-hosted"
183        } else {
184            "path-style"
185        };
186        details.push(("addressing".to_string(), style.to_string()));
187    }
188    details.push(("login".to_string(), source.origin.clone()));
189
190    let short = source.problem.as_deref().map(|problem| {
191        if problem.starts_with("not signed in") {
192            "not signed in"
193        } else if problem.starts_with("unsupported login") {
194            "unsupported login"
195        } else {
196            "not configured"
197        }
198    });
199    // A source that cannot list says what to do where the row has room; the count
200    // carries the short problem.
201    let note = match (&source.problem, short) {
202        (Some(problem), Some(short)) => problem
203            .strip_prefix(short)
204            .map(|rest| rest.trim_start_matches([':', ' ']))
205            .filter(|rest| !rest.is_empty())
206            .unwrap_or(problem)
207            .to_string(),
208        _ => [source.detail(), Some(source.origin.clone())]
209            .into_iter()
210            .flatten()
211            .filter(|n| !n.is_empty())
212            .collect::<Vec<_>>()
213            .join(&format!(" {} ", crate::glyphs::get().middot)),
214    };
215    let mut names: Vec<String> = cached.map(|c| c.buckets.clone()).unwrap_or_default();
216    for bucket in &source.buckets {
217        if !names.contains(bucket) {
218            names.push(bucket.clone());
219        }
220    }
221    let status = match (&source.problem, short) {
222        (Some(problem), Some(short)) => home::CloudStatus::Failed {
223            short: short.to_string(),
224            detail: problem.clone(),
225        },
226        _ if cached.is_some() => home::CloudStatus::Listed,
227        _ if listing => home::CloudStatus::Listing,
228        _ => home::CloudStatus::Unlisted,
229    };
230    home::CloudSource {
231        id: source.id.clone(),
232        label: source.label.clone(),
233        api: source.kind,
234        note,
235        buckets: names
236            .iter()
237            .map(|b| PathBuf::from(source.bucket_url(b)))
238            .collect(),
239        refreshing: listing && cached.is_some() && source.problem.is_none(),
240        // A source that failed before any request has nothing to ask.
241        asked: listing || source.problem.is_some(),
242        listed_at: cached
243            .map(|c| std::time::UNIX_EPOCH + std::time::Duration::from_secs(c.listed_at)),
244        status,
245        details,
246        place_details: Default::default(),
247    }
248}
249
250/// A listing error as a word for the row and the full message for the details pane.
251#[cfg(feature = "cloud")]
252fn summarize_cloud_failure(error: &str) -> (String, String) {
253    let lower = error.to_lowercase();
254    // A missing tool is already as short as it gets: `needs the AWS CLI`.
255    if let Some(start) = lower.find("needs ") {
256        return (error[start..].to_string(), error.to_string());
257    }
258    let short = if lower.contains("403")
259        || lower.contains("forbidden")
260        || lower.contains("accessdenied")
261        || lower.contains("access denied")
262    {
263        "403"
264    } else if lower.contains("401")
265        || lower.contains("unauthorized")
266        || lower.contains("credential")
267        || lower.contains("invalidaccesskeyid")
268        || lower.contains("expired")
269        || lower.contains("sso")
270        || lower.contains("az login")
271    {
272        "not logged in"
273    } else if lower.contains("unsupported login") {
274        "unsupported login"
275    } else if lower.contains("no gcp project") {
276        "no project"
277    } else if lower.contains("is not set") {
278        "not configured"
279    } else if lower.contains("timed out")
280        || lower.contains("timeout")
281        || lower.contains("connection")
282        || lower.contains("dns")
283        || lower.contains("resolve")
284    {
285        "unavailable"
286    } else {
287        "error"
288    };
289    (short.to_string(), error.to_string())
290}
291
292impl App {
293    /// Schema for a home entry, from Parquet metadata or the preview's read, memoized for
294    /// the session. `None` until read, or when not knowable without a scan, and the UI
295    /// says so. Asked for by [`Self::request_home_schema`], never by a frame.
296    pub fn home_schema(&self, entry: &discover::Entry) -> Option<discover::SchemaPreview> {
297        self.home_app
298            .schema_cache
299            .get(&entry.path)
300            .cloned()
301            .flatten()
302    }
303
304    /// What a home-screen worker owes in place of its answer if it panics.
305    fn owed_answer(&mut self, instead: AppEvent) -> OwedAnswer {
306        OwedAnswer {
307            tx: self.events.clone(),
308            #[cfg(test)]
309            dies: self
310                .home_app
311                .worker_dies
312                .as_mut()
313                .is_some_and(|dies| dies(&instead)),
314            instead: Some(instead),
315        }
316    }
317
318    /// List the directory the `~` prompt is typing, if not already listed: a URL from
319    /// what the screen knows, a local directory on a worker.
320    pub(crate) fn list_the_typed_directory(&mut self) {
321        if !self.home.path_input_active {
322            return;
323        }
324        let dir = home::typed_dir(&self.home.path_input).to_string();
325        if self
326            .home
327            .path_listing
328            .as_ref()
329            .is_some_and(|l| l.dir == dir)
330        {
331            return;
332        }
333        if home::typed_dir_is_url(&dir) {
334            self.home.path_listing = Some(home::names_under(&dir, self.home.known_urls()));
335            return;
336        }
337        if !self.home_app.path_listings_out.insert(dir.clone()) {
338            return;
339        }
340        // Off the UI thread: a typed path may name a dead mount.
341        let tx = self.events.clone();
342        let owed = self.owed_answer(AppEvent::HomePathListed {
343            listing: Box::new(home::PathListing {
344                dir: dir.clone(),
345                names: Vec::new(),
346                failed: true,
347                matched: Default::default(),
348            }),
349        });
350        std::thread::spawn(move || {
351            owed.run(|| {
352                let listing = home::list_typed_dir(&dir);
353                let _ = tx.send(AppEvent::HomePathListed {
354                    listing: Box::new(listing),
355                });
356            })
357        });
358    }
359
360    /// Complete the path being typed, on a worker.
361    pub(crate) fn request_path_completion(&mut self) {
362        let typed = self.home.path_input.clone();
363        if typed.is_empty() {
364            return;
365        }
366        let generation = self.home_app.generation;
367        let tx = self.events.clone();
368        std::thread::spawn(move || {
369            let (completed, candidates) = home::complete_path(&typed);
370            let _ = tx.send(AppEvent::HomePathCompleted {
371                generation,
372                typed,
373                completed,
374                candidates,
375            });
376        });
377    }
378
379    /// A home file's first rows for its `ROWS` preview, read on a worker as its open
380    /// reads them. `None` until they land or when not previewed. Asked for by
381    /// [`Self::request_selected_preview`], never by a frame.
382    pub fn home_preview_rows(
383        &self,
384        entry: &discover::Entry,
385    ) -> Option<Arc<crate::home::home_preview::PreviewRows>> {
386        let stamp = crate::home::home_preview::Stamp::of_entry(entry);
387        self.home_app.previews.rows(&entry.path, stamp).flatten()
388    }
389
390    /// Read the highlighted file's first rows when the last frame had room to show them
391    /// and nothing has: one read at a time, for the row the cursor is on when the last
392    /// one lands. `preview_room` is the screen's height, which sizes the page to the
393    /// table's.
394    pub(crate) fn request_selected_preview(&mut self) {
395        let Some(screen_height) = self.home_app.preview_room else {
396            return;
397        };
398        if self.home_app.previews.inflight.is_some() || self.home.path_input_active {
399            return;
400        }
401        let Some(entry) = self.home.selected_entry() else {
402            return;
403        };
404        let max = self.app_config.home.preview_max.bytes();
405        if !crate::home::home_preview::previewable(entry, max) {
406            return;
407        }
408        let stamp = crate::home::home_preview::Stamp::of_entry(entry);
409        if self.home_app.previews.rows(&entry.path, stamp).is_some() {
410            return;
411        }
412        let path = entry.path.clone();
413        self.request_home_preview(path, stamp, screen_height);
414    }
415
416    /// Whether `entry` is one the preview reads, before its rows are in.
417    pub fn home_preview_pending(&self, path: &Path) -> bool {
418        self.home_app.previews.reading(path)
419    }
420
421    /// Read a file's first page on a worker through the open's own scan and schema read,
422    /// so the open can install the result.
423    fn request_home_preview(
424        &mut self,
425        path: PathBuf,
426        stamp: crate::home::home_preview::Stamp,
427        screen_height: u16,
428    ) {
429        self.home_app.previews.inflight = Some(path.clone());
430        self.home_app.reads.previews += 1;
431        let tx = self.events.clone();
432        let cloud = self.app_config.cloud.clone();
433        let formats = self.formats.clone();
434        let runtime = self.runtime.clone();
435        let cache = self.cache.clone();
436        let writes = self.cache_writes.clone();
437        // The table's rows: the screen less the title, the header and the footer.
438        let visible = (screen_height as usize).saturating_sub(3).max(1);
439        let owed = self.owed_answer(AppEvent::HomePreviewReady {
440            path: path.clone(),
441            stamp,
442            read_at: None,
443            rows: None,
444            prepared: crate::home::home_preview::Handoff::default(),
445        });
446        self.runtime.spawn_blocking(move || {
447            owed.run(|| {
448                let began = std::time::Instant::now();
449                let read_at = crate::home::home_preview::Stamp::of_file(&path);
450                let read = Self::read_home_preview(
451                    &path, &cloud, &formats, &runtime, cache, writes, visible,
452                );
453                log::debug!(
454                    target: "datui",
455                    "home preview of {}: {:.1?}",
456                    path.display(),
457                    began.elapsed()
458                );
459                let (rows, prepared) = match read {
460                    Some((rows, prepared)) => (Some(Arc::new(rows)), Some(Box::new(prepared))),
461                    None => (None, None),
462                };
463                let _ = tx.send(AppEvent::HomePreviewReady {
464                    path,
465                    stamp,
466                    read_at,
467                    rows,
468                    prepared: Arc::new(Mutex::new(prepared)),
469                });
470            })
471        });
472    }
473
474    /// What opening `path` from home reads first: scan, schema and the page for
475    /// `visible` rows, built by the open's own steps and options so the dataset is the
476    /// one the open would build.
477    fn read_home_preview(
478        path: &Path,
479        cloud: &crate::config::CloudConfig,
480        formats: &crate::formats::Registry,
481        runtime: &tokio::runtime::Handle,
482        cache: CacheManager,
483        writes: CacheWrites,
484        visible: usize,
485    ) -> Option<(
486        crate::home::home_preview::PreviewRows,
487        crate::home::home_preview::Prepared,
488    )> {
489        let paths = [path.to_path_buf()];
490        let scanned = Self::scan_for_open(
491            cloud,
492            formats,
493            &paths,
494            OpenOptions::default(),
495            Some(path.to_path_buf()),
496        )
497        .ok()?;
498        let loading::LoadAnswer::Scanned { lf, path, options } = scanned else {
499            return None;
500        };
501        let progress = Arc::<crate::formats::schema_union::FooterProgress>::default();
502        let report = crate::loading::measurements::OpenReport {
503            progress: progress.clone(),
504            meter: Arc::new(crate::loading::measurements::Meter::default()),
505            remembered: Some(cache),
506            writes,
507        };
508        let read = Self::read_schema_for_open(
509            *lf,
510            path,
511            options,
512            cloud,
513            runtime,
514            &report,
515            loading::Made::default(),
516        )
517        .ok()?;
518        let loading::LoadAnswer::SchemaRead {
519            mut state,
520            options,
521            debug_label,
522            ..
523        } = read
524        else {
525            return None;
526        };
527        // Planned as the table plans its first page, so the page is the one it wants.
528        state.visible_rows = visible;
529        let began = std::time::Instant::now();
530        let request = state.prepare_async_collect(None)?;
531        let df =
532            crate::analysis::statistics::collect_lazy(request.lf, request.polars_streaming).ok()?;
533        let result = request.plan.fit(df);
534        let rows = crate::home::home_preview::PreviewRows::from_frame(result.rows());
535        state.measurements().read_page(began.elapsed(), Some(1));
536        state.apply_async_collect(result);
537        Some((
538            rows,
539            crate::home::home_preview::Prepared {
540                state,
541                options,
542                debug_label,
543                progress,
544            },
545        ))
546    }
547
548    /// Whether a schema read is currently out for this path.
549    pub fn home_schema_pending(&self, path: &Path) -> bool {
550        self.home_app.schema_reads.values().any(|p| p == path)
551    }
552
553    /// Read the highlighted dataset's schema on a worker, when nothing has and no read
554    /// is out: one at a time, for the row the cursor is on when the last one lands, so
555    /// a held arrow key reads where it stops rather than every row it passes. A file
556    /// the preview reads gets its columns from that read instead.
557    pub(crate) fn request_home_schema(&mut self) {
558        if self.home.path_input_active {
559            return;
560        }
561        let Some(entry) = self.home.selected_entry() else {
562            return;
563        };
564        // Nothing in an object store is read before it is opened.
565        // A file whose first rows are shown brings its columns with them.
566        let previewed = self.home_app.preview_room.is_some()
567            && crate::home::home_preview::previewable(
568                entry,
569                self.app_config.home.preview_max.bytes(),
570            );
571        if home::is_cloud_place(&entry.path)
572            || home::is_object_store_url(&entry.path)
573            || self.home_app.schema_cache.contains_key(&entry.path)
574            || previewed
575        {
576            return;
577        }
578        // One read out for local files, and one for each mount that can stall: a share that
579        // stops answering holds up its own reads and no others.
580        let network = (self.home.network_check)(&entry.path) || home::could_block_path(&entry.path);
581        let slot = if network {
582            crate::home::locality::Mounts::cached().mount_point_for(&entry.path)
583        } else {
584            PathBuf::new()
585        };
586        if self.home_app.schema_reads.contains_key(&slot) {
587            return;
588        }
589        let entry = entry.clone();
590        (self.home_app.schema_reads).insert(slot, entry.path.clone());
591        self.home_app.reads.schemas += 1;
592        let tx = self.events.clone();
593        // Remembered as none on failure, so it is not asked again.
594        let owed = self.owed_answer(AppEvent::HomeSchemaReady {
595            path: entry.path.clone(),
596            preview: None,
597        });
598        let read = move || {
599            owed.run(|| {
600                let preview = discover::schema_preview(&entry);
601                let _ = tx.send(AppEvent::HomeSchemaReady {
602                    path: entry.path,
603                    preview,
604                });
605            })
606        };
607        // A share that stops answering holds its thread: never one of the pool that loads
608        // data.
609        if network {
610            std::thread::spawn(read);
611        } else {
612            self.runtime.spawn_blocking(read);
613        }
614    }
615
616    /// Start listing network roots that have not answered yet. Never waits: a gone
617    /// share (a `hard` NFS mount) blocks its thread uninterruptibly, so the task is
618    /// abandoned, not joined.
619    pub(crate) fn spawn_home_probes(&mut self) {
620        self.stop_listings_left_behind();
621        for root in self.home.pending_probes() {
622            if self.home_app.probes_inflight.contains(&root) {
623                // Left and returned to before its next page: it goes on.
624                if let Some(cancelled) = self.home_app.listing_cancels.get(&root) {
625                    cancelled.store(false, std::sync::atomic::Ordering::Relaxed);
626                }
627                continue;
628            }
629            // Each probe of an unreachable share costs a thread forever, so they are capped.
630            // The browsed directory is exempt: it is the whole screen, and the user bounds how
631            // many they open.
632            let browsed = self.home.browsing.as_ref() == Some(&root);
633            if !browsed && self.home_app.probes_inflight.len() >= MAX_CONCURRENT_PROBES {
634                continue;
635            }
636            self.home_app.probes_inflight.push(root.clone());
637            (self.home_app.probe_heard).insert(root.clone(), std::time::Instant::now());
638            #[cfg(test)]
639            let gate = self.home_app.probe_gate.clone();
640            let tx = self.events.clone();
641            let cache = self.cache.clone();
642            let owed = self.owed_answer(AppEvent::HomeProbeFailed {
643                root: root.clone(),
644                message: "Could not read it; see the log".to_string(),
645            });
646            #[cfg(feature = "cloud")]
647            let cloud = self.app_config.cloud.clone();
648            #[cfg(feature = "cloud")]
649            let runtime = self.runtime.clone();
650            #[cfg(feature = "cloud")]
651            let cancelled = {
652                let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
653                self.home_app
654                    .listing_cancels
655                    .insert(root.clone(), flag.clone());
656                flag
657            };
658            // A detached thread, not the runtime's blocking pool: a thread wedged on a dead
659            // mount never returns, and must not eat the pool that loads data.
660            std::thread::spawn(move || {
661                owed.run(|| {
662                    #[cfg(test)]
663                    if let Some(gate) = gate {
664                        let _held = gate.lock();
665                    }
666                    // A bucket or prefix: listed with an object-store listing, not `read_dir` (which
667                    // fails on `gs://`). Metadata only: names, sizes and times for one level; no
668                    // footers, schemas or counts, which would cost a paid ranged read per row.
669                    #[cfg(feature = "cloud")]
670                    if let Some((id, account)) = home::cloud_account(&root) {
671                        let listed = wait_on_runtime(&runtime, async move {
672                            crate::cloud::cloud_browse::list_account(&id, &account, &cloud).await
673                        });
674                        match listed {
675                            Some(Ok(rows)) => {
676                                let _ = tx.send(AppEvent::HomeProbeReady {
677                                    root,
678                                    rows: Some(rows),
679                                    cut_short: false,
680                                });
681                            }
682                            Some(Err(message)) => {
683                                log::warn!(
684                                    target: "datui::cloud",
685                                    "listing {} failed: {message}",
686                                    root.display()
687                                );
688                                let _ = tx.send(AppEvent::HomeProbeFailed { root, message });
689                            }
690                            None => {
691                                let _ = tx.send(AppEvent::HomeProbeReady {
692                                    root,
693                                    rows: None,
694                                    cut_short: false,
695                                });
696                            }
697                        }
698                        return;
699                    }
700                    #[cfg(feature = "cloud")]
701                    if crate::cloud::cloud_browse::split_bucket_url(&root.to_string_lossy())
702                        .is_some()
703                        || source::azure_parts(&root.to_string_lossy()).is_some()
704                    {
705                        let url = root.to_string_lossy().into_owned();
706                        // Each page's rows are drawn as they come; leaving the place stops the listing.
707                        let watch = crate::cloud::cloud_browse::Watch {
708                            progress: Some(std::sync::Arc::new({
709                                let (tx, root) = (tx.clone(), root.clone());
710                                move |page: &[crate::home::discover::Entry]| {
711                                    let _ = tx.send(AppEvent::HomeProbeProgress {
712                                        root: root.clone(),
713                                        rows: page.to_vec(),
714                                    });
715                                }
716                            })),
717                            cancelled,
718                            names_from: None,
719                        };
720                        let listed = wait_on_runtime(&runtime, async move {
721                            crate::cloud::cloud_browse::list_objects_watched(&url, &cloud, &watch)
722                                .await
723                        });
724                        // A refused listing says why, rather than reading as a place that stopped
725                        // answering.
726                        match listed {
727                            Some(Err(message)) => {
728                                log::warn!(
729                                    target: "datui::cloud",
730                                    "listing {} failed: {message}",
731                                    root.display()
732                                );
733                                let _ = tx.send(AppEvent::HomeProbeFailed { root, message });
734                            }
735                            Some(Ok(level)) if level.cancelled => {
736                                let _ = tx.send(AppEvent::HomeProbeCancelled { root });
737                            }
738                            other => {
739                                let (rows, cut_short) = match other {
740                                    Some(Ok(level)) => (Some(level.rows), level.truncated),
741                                    _ => (None, false),
742                                };
743                                let _ = tx.send(AppEvent::HomeProbeReady {
744                                    root,
745                                    rows,
746                                    cut_short,
747                                });
748                            }
749                        }
750                        return;
751                    }
752                    // No cloud feature: `read_dir` on a bucket URL would only say unavailable.
753                    #[cfg(not(feature = "cloud"))]
754                    if source::is_remote_url(&root) {
755                        let message = "cloud support not in this build".to_string();
756                        let _ = tx.send(AppEvent::HomeProbeFailed { root, message });
757                        return;
758                    }
759                    let mut cut_short = false;
760                    let rows = if std::fs::read_dir(&root).is_ok() {
761                        // What has been read shows while the rest is read (seconds for thousands of files).
762                        let scan = crate::home::discover::scan_dir_progressive(&root, |read| {
763                            let _ = tx.send(AppEvent::HomeProbeProgress {
764                                root: root.clone(),
765                                rows: read.to_vec(),
766                            });
767                        });
768                        cut_short = scan.truncated;
769                        let mut rows = scan.entries;
770                        // Measured here too: this thread is already the one allowed to block on the share.
771                        for row in rows.iter_mut().take(PROBE_MEASURE_LIMIT) {
772                            crate::home::discover::enrich(row);
773                        }
774                        // Remote datasets are measured nowhere else, so remember them here; otherwise a
775                        // remote row is blank on every run.
776                        let mounts = crate::home::locality::Mounts::current();
777                        for row in rows.iter_mut() {
778                            row.cost.source = Some(mounts.describe(&row.path).fstype);
779                        }
780                        let facts: Vec<_> = rows.iter().filter_map(home::facts_for).collect();
781                        cache.record_dataset_facts(&facts);
782                        Some(rows)
783                    } else {
784                        None
785                    };
786                    let _ = tx.send(AppEvent::HomeProbeReady {
787                        root,
788                        rows,
789                        cut_short,
790                    });
791                })
792            });
793        }
794    }
795
796    /// Stop cloud listings of places no longer on screen (the browsed one, or home's
797    /// roots). A place left and revisited is listed again.
798    fn stop_listings_left_behind(&mut self) {
799        let home = &self.home;
800        for (root, cancelled) in &self.home_app.listing_cancels {
801            let wanted = match &home.browsing {
802                Some(dir) => dir == root,
803                None => home
804                    .sections
805                    .iter()
806                    .any(|s| s.remote_root.as_ref() == Some(root)),
807            };
808            if !wanted {
809                cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
810            }
811        }
812        if let Some((dir, _, cancelled)) = &self.home_app.narrowing
813            && home.browsing.as_ref() != Some(dir)
814        {
815            cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
816            self.home_app.narrowing = None;
817        }
818        if self
819            .home
820            .narrowed
821            .as_ref()
822            .is_some_and(|n| self.home.browsing.as_ref() != Some(&n.dir))
823        {
824            self.home.narrowed = None;
825        }
826    }
827
828    /// In a cloud directory cut short at the cap, ask the server for names starting with
829    /// the filter, so names past the cap can be found. Nothing when the filter is empty
830    /// or what is held answers it.
831    #[cfg(feature = "cloud")]
832    pub(crate) fn narrow_cloud_listing(&mut self) {
833        let dir = self.home.browsing.clone();
834        let prefix = dir.as_ref().and_then(|dir| {
835            if !self.home.probes.cut_short(dir) {
836                return None;
837            }
838            let rows = self.home.probes.listed(dir)?;
839            let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
840            crate::cloud::cloud_browse::narrowing_prefix(&self.home.filter, &names)
841        });
842        let (Some(dir), Some(prefix)) = (dir, prefix) else {
843            if let Some((_, _, cancelled)) = self.home_app.narrowing.take() {
844                cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
845            }
846            if self.home.narrowed.take().is_some() {
847                self.home_refresh();
848            }
849            return;
850        };
851        // Everything under a shorter prefix is everything under this one too.
852        if self.home.narrowed.as_ref().is_some_and(|n| {
853            n.dir == dir && (n.prefix == prefix || (!n.truncated && prefix.starts_with(&n.prefix)))
854        }) {
855            return;
856        }
857        if let Some((d, p, _)) = &self.home_app.narrowing
858            && *d == dir
859            && *p == prefix
860        {
861            return;
862        }
863        if let Some((_, _, cancelled)) = self.home_app.narrowing.take() {
864            cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
865        }
866        let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
867        self.home_app.narrowing = Some((dir.clone(), prefix.clone(), cancelled.clone()));
868        let tx = self.events.clone();
869        let owed = self.owed_answer(AppEvent::HomeNarrowed {
870            dir: dir.clone(),
871            prefix: prefix.clone(),
872            listed: None,
873        });
874        let cloud = self.app_config.cloud.clone();
875        let runtime = self.runtime.clone();
876        std::thread::spawn(move || {
877            owed.run(|| {
878                let url = dir.to_string_lossy().into_owned();
879                let watch = crate::cloud::cloud_browse::Watch {
880                    progress: None,
881                    cancelled,
882                    names_from: Some(prefix.clone()),
883                };
884                let listed = wait_on_runtime(&runtime, async move {
885                    crate::cloud::cloud_browse::list_objects_watched(&url, &cloud, &watch).await
886                });
887                let listed = match listed {
888                    Some(Ok(level)) if !level.cancelled => Some((level.rows, level.truncated)),
889                    _ => None,
890                };
891                let _ = tx.send(AppEvent::HomeNarrowed {
892                    dir,
893                    prefix,
894                    listed,
895                });
896            })
897        });
898    }
899
900    /// Find the cloud sources this machine and the config describe, and list their
901    /// buckets when `[cloud] list_on_start` asks. Once per session; Ctrl+R asks again.
902    #[cfg(feature = "cloud")]
903    fn spawn_cloud_discovery(&mut self) {
904        if self.home_app.cloud_discovery_started {
905            return;
906        }
907        self.home_app.cloud_discovery_started = true;
908        let list = self.app_config.cloud.list_on_start;
909        self.list_cloud_sources(None, list);
910    }
911
912    /// List the browsed source if not asked this session; entering a source asks.
913    #[cfg(feature = "cloud")]
914    fn list_browsed_cloud_source(&mut self) {
915        let Some(id) = self
916            .home
917            .browsing
918            .as_deref()
919            .and_then(home::cloud_source_id)
920        else {
921            return;
922        };
923        let Some(source) = self.home.cloud.iter_mut().find(|s| s.id == id) else {
924            return;
925        };
926        if source.asked {
927            return;
928        }
929        source.begin_listing();
930        self.list_cloud_sources(Some(id), true);
931    }
932
933    /// Send every source's rows, or list the named one's buckets. Rows go first, filled
934    /// from the last run's listing when still valid. With `list`, sources are listed a
935    /// few at a time and each result sent on arrival; without, nothing leaves the
936    /// machine. On the runtime, not a detached thread: every call has a global timeout.
937    #[cfg(feature = "cloud")]
938    fn list_cloud_sources(&mut self, only: Option<String>, list: bool) {
939        let tx = self.events.clone();
940        let cloud = self.app_config.cloud.clone();
941        let cache = self.cache.clone();
942        self.runtime.spawn(async move {
943            let mut hidden = cache.load_hidden_cloud_sources();
944            hidden.extend(cloud.hide.iter().cloned());
945            let cached_for = |source: &crate::cloud::cloud_sources::Source| {
946                cache.cloud_listing(&source.id, &source.fingerprint())
947            };
948            // Looked for again, and kept for the opens and listings that follow.
949            let found = crate::cloud::cloud_sources::rediscover(&cloud).to_vec();
950            // A bucket under Recent opens with the login that listed it, shown or not, unless
951            // its source is hidden (perhaps for a dead login; the default opens it then).
952            // Only with the rows, so an old listing never overrides a newer one.
953            if only.is_none() {
954                for source in found.iter().filter(|s| !hidden.contains(&s.id)) {
955                    if let Some(cached) = cached_for(source) {
956                        crate::cloud::cloud_sources::remember_listed(source, &cached.buckets);
957                    }
958                }
959            }
960            let sources: Vec<crate::cloud::cloud_sources::Source> =
961                crate::cloud::cloud_sources::on_home(found, &cloud)
962                    .into_iter()
963                    .filter(|s| !hidden.contains(&s.id))
964                    .collect();
965
966            match &only {
967                None => {
968                    let rows = sources
969                        .iter()
970                        .map(|source| home_cloud_source(source, cached_for(source).as_ref(), list))
971                        .collect();
972                    let _ = tx.send(AppEvent::HomeCloudSources { sources: rows });
973                }
974                // Gone since its row was drawn (a profile removed, a source hidden): said, so the
975                // row does not wait forever.
976                Some(id) if !sources.iter().any(|s| &s.id == id) => {
977                    let _ = tx.send(AppEvent::HomeCloudListed {
978                        id: id.clone(),
979                        buckets: Vec::new(),
980                        details: Vec::new(),
981                        failure: Some((
982                            "not found".to_string(),
983                            format!("{id} is gone or hidden. Ctrl+R at the top looks again."),
984                        )),
985                        listed_at: std::time::SystemTime::now(),
986                    });
987                    return;
988                }
989                Some(_) => {}
990            }
991            if !list {
992                return;
993            }
994
995            // One slow endpoint does not delay the rest, and many sources do not storm.
996            const LISTING_AT_ONCE: usize = 4;
997            let permits = std::sync::Arc::new(tokio::sync::Semaphore::new(LISTING_AT_ONCE));
998            let mut listings = tokio::task::JoinSet::new();
999            for source in sources
1000                .into_iter()
1001                .filter(|s| only.as_ref().is_none_or(|id| &s.id == id))
1002            {
1003                let permits = permits.clone();
1004                listings.spawn(async move {
1005                    let _permit = permits.acquire_owned().await;
1006                    let result = crate::cloud::cloud_browse::list_first_level(&source).await;
1007                    (source, result)
1008                });
1009            }
1010            while let Some(joined) = listings.join_next().await {
1011                let Ok((source, result)) = joined else {
1012                    continue;
1013                };
1014                let listed_at = std::time::SystemTime::now();
1015                // Buckets named in the config show whether or not the login can list them.
1016                let mut names = source.buckets.clone();
1017                let mut details = Vec::new();
1018                let failure = match result {
1019                    Ok(listed) => {
1020                        for item in listed {
1021                            crate::cloud::cloud_sources::remember_bucket(&source, &item.name);
1022                            if !item.details.is_empty() {
1023                                details.push((item.place.clone(), item.details));
1024                            }
1025                            let name = item.name;
1026                            if !names.contains(&name) {
1027                                names.push(name);
1028                            }
1029                        }
1030                        cache.save_cloud_listing(
1031                            &source.id,
1032                            crate::cache::CloudListing {
1033                                fingerprint: source.fingerprint(),
1034                                buckets: names.clone(),
1035                                listed_at: listed_at
1036                                    .duration_since(std::time::UNIX_EPOCH)
1037                                    .map(|d| d.as_secs())
1038                                    .unwrap_or(0),
1039                            },
1040                        );
1041                        None
1042                    }
1043                    Err(e) => {
1044                        log::warn!(target: "datui::cloud", "listing {} failed: {e}", source.id);
1045                        Some(summarize_cloud_failure(&e))
1046                    }
1047                };
1048                let _ = tx.send(AppEvent::HomeCloudListed {
1049                    id: source.id.clone(),
1050                    buckets: names
1051                        .iter()
1052                        .map(|b| PathBuf::from(source.bucket_url(b)))
1053                        .collect(),
1054                    details,
1055                    failure,
1056                    listed_at,
1057                });
1058            }
1059        });
1060    }
1061
1062    /// Ask again for what is on screen, bypassing the cache: the browsed source's
1063    /// buckets, the browsed bucket or directory, or every source's buckets.
1064    pub(crate) fn home_reload(&mut self) {
1065        #[cfg(feature = "cloud")]
1066        {
1067            let browsing = self.home.browsing.clone();
1068            match browsing.as_deref().and_then(home::cloud_source_id) {
1069                Some(id) => {
1070                    if let Some(source) = self.home.cloud.iter_mut().find(|s| s.id == id) {
1071                        source.begin_listing();
1072                    }
1073                    self.list_cloud_sources(Some(id), true);
1074                }
1075                None if browsing.is_none() && !self.home.cloud.is_empty() => {
1076                    for source in &mut self.home.cloud {
1077                        source.begin_listing();
1078                    }
1079                    self.list_cloud_sources(None, true);
1080                }
1081                None => {}
1082            }
1083        }
1084        // A listing that went silent is waited on again: its thread is still out, and a
1085        // second on the same share would stick as it has.
1086        for place in self.home.probes.silent_places() {
1087            self.home.probes.forget(&place);
1088            (self.home_app.probe_heard).insert(place, std::time::Instant::now());
1089        }
1090        if let Some(dir) = self.home.browsing.clone() {
1091            // Its answer, not a listing still coming in.
1092            if self.home.probes.settled(&dir) {
1093                self.home.probes.forget(&dir);
1094            }
1095        }
1096        // The rows shown are stat'ed again, and a file rewritten since is measured again.
1097        self.home.stat_epoch += 1;
1098        // Ctrl+R retries failed peeks and missing web files too.
1099        self.home.peek_failed.clear();
1100        for path in std::mem::take(&mut self.home.web_gone).into_keys() {
1101            self.home.sized.remove(&path);
1102        }
1103        self.home.status = None;
1104        self.home_refresh();
1105    }
1106
1107    /// Start the recursive search below the working directory, if wanted and not
1108    /// running. Triggered by typing, not by opening home, so opening a recent costs no
1109    /// walk.
1110    pub(crate) fn spawn_home_search(&mut self) {
1111        if self.home_app.search_inflight || self.home.search.done {
1112            return;
1113        }
1114        let config = self.app_config.home.search.clone();
1115        if !config.enabled {
1116            return;
1117        }
1118        let Some(root) =
1119            crate::home::search::search_root(self.home.browsing.as_ref(), self.home.network_check)
1120        else {
1121            return;
1122        };
1123
1124        self.home.search.reset();
1125        self.home.search.root = Some(root.clone());
1126        self.home.search.running = true;
1127        self.home.search.epoch = next_search_epoch();
1128        self.home.search_limit = config.max_results;
1129        self.home_app.search_inflight = true;
1130
1131        let generation = self.home_app.generation;
1132        self.home_app.search_generation = generation;
1133        let tx = self.events.clone();
1134        let formats = self.formats.clone();
1135        let known = self.home.known.clone();
1136        // Ended, with what the batches already found kept.
1137        let owed = self.owed_answer(AppEvent::HomeSearchDone {
1138            generation,
1139            root: root.clone(),
1140            scanned: 0,
1141            limited: Some(crate::glyphs::dotted("partial · failed")),
1142        });
1143        // A detached thread, as for probes: a filesystem stall must not stop drawing.
1144        std::thread::spawn(move || {
1145            owed.run(|| {
1146                let walk_root = root.clone();
1147                let batch_tx = tx.clone();
1148                let batch_gen = generation;
1149                let batch_root = root.clone();
1150                let outcome = crate::home::search::walk_recalling(
1151                    &walk_root,
1152                    &config,
1153                    &formats,
1154                    &known,
1155                    move |found, outcome| {
1156                        // Sent even when empty: it carries progress, and a failed send tells the walk
1157                        // nobody listens.
1158                        batch_tx
1159                            .send(AppEvent::HomeSearchBatch {
1160                                generation: batch_gen,
1161                                root: batch_root.clone(),
1162                                found,
1163                                scanned: outcome.scanned,
1164                            })
1165                            // A closed channel means the app is gone; stop walking.
1166                            .is_ok()
1167                    },
1168                );
1169                let _ = tx.send(AppEvent::HomeSearchDone {
1170                    generation,
1171                    root,
1172                    scanned: outcome.scanned,
1173                    limited: outcome.note().map(crate::glyphs::dotted),
1174                });
1175            })
1176        });
1177    }
1178
1179    /// Score the filter against the search's files on a worker, when owed. Asked after
1180    /// every event; over tens of thousands of files scoring held back keystroke echo.
1181    /// One at a time; each answer asks for the next if the filter moved.
1182    pub(crate) fn home_score_search(&mut self) {
1183        if self.input_mode != InputMode::Home {
1184            return;
1185        }
1186        let Some(job) = self.home.score_job() else {
1187            return;
1188        };
1189        let epoch = job.epoch;
1190        let tx = self.events.clone();
1191        // A worker that dies answers with nothing.
1192        let owed = self.owed_answer(AppEvent::HomeSearchScored {
1193            epoch,
1194            matches: None,
1195        });
1196        self.runtime.spawn_blocking(move || {
1197            owed.run(move || {
1198                let matches = crate::home::search::score(
1199                    &job.results,
1200                    &job.query,
1201                    job.base.as_ref(),
1202                    job.limit,
1203                );
1204                let _ = tx.send(AppEvent::HomeSearchScored {
1205                    epoch,
1206                    matches: Some(Box::new(matches)),
1207                });
1208            })
1209        });
1210    }
1211
1212    /// Rebuild the home listing from the filesystem.
1213    pub(crate) fn home_refresh(&mut self) {
1214        // Every way into a source comes through here: Enter, Backspace up from a bucket,
1215        // a jump, and rows arriving while it is open.
1216        #[cfg(feature = "cloud")]
1217        self.list_browsed_cloud_source();
1218        // Listings of where the user was are pages for nobody.
1219        self.stop_listings_left_behind();
1220        self.home_app.generation = self.home_app.generation.wrapping_add(1);
1221        let generation = self.home_app.generation;
1222
1223        self.move_remembered_places();
1224        let mut catalogs = home::catalogs(&self.app_config);
1225        // Hidden with Delete on its heading: only the bundled catalog, never a user's
1226        // `examples.toml`.
1227        if self.cache.examples_hidden() {
1228            catalogs.retain(|c| c.origin != crate::home::catalog::Origin::Bundled);
1229        }
1230        self.home.set_catalogs(catalogs);
1231        let mut request = home::ListingRequest {
1232            // Filled on the worker from the cache and the desktop's recents, so the first frame
1233            // waits on no file.
1234            recents: Vec::new(),
1235            desktop_dirs: Vec::new(),
1236            browsing: self.home.browsing.clone(),
1237            probes: self.home.probes.clone(),
1238            narrowed: self.home.narrowed.clone(),
1239            network_check: self.home.network_check,
1240            cloud: self.home.cloud.clone(),
1241            catalogs: self.home.catalogs.clone(),
1242            known: self.home.known.clone(),
1243            formats: self.formats.clone(),
1244        };
1245        let read_folds = std::mem::take(&mut self.home.folds_owed);
1246        let desktop = self.app_config.home.desktop_recents;
1247        let cache = self.cache.clone();
1248        let writes = self.cache_writes.clone();
1249        // The index is read until a listing brings it; then kept, with this session's
1250        // measurements beside it, and only the dataset just left read again.
1251        let read_facts = !self.home_app.facts_read;
1252        let left = self.home_app.left.take().filter(|_| !read_facts);
1253        let dated = self.home_app.facts_dated.clone();
1254
1255        self.home.listing_in_flight = true;
1256        let tx = self.events.clone();
1257        let owed = self.owed_answer(AppEvent::HomeListingFailed);
1258        self.runtime.spawn_blocking(move || {
1259            owed.run(move || {
1260                // After the dataset just left is in the recents with its shape.
1261                writes.settle();
1262                // Ranked by frecency; the cursor lands on the newest, one Enter from the last file.
1263                let (recents, visits) = cache.load_recents_with_visits();
1264                let newest = recents.first().cloned();
1265                request.recents = crate::cache::by_frecency(recents, &visits);
1266                if read_facts {
1267                    request.known = Arc::new(cache.load_dataset_facts());
1268                }
1269                // Read, which dates them: they are shown.
1270                let learned: Vec<(PathBuf, crate::cache::DatasetFacts)> = (left.iter())
1271                    .flat_map(|path| [path.clone(), home::index_key(path)])
1272                    .filter_map(|key| Some((key.clone(), cache.dataset_facts(&key)?)))
1273                    .collect();
1274                if desktop {
1275                    request.desktop_dirs = home::desktop_recent_dirs();
1276                }
1277                let mut listing = home::build_listing(&request);
1278                listing.learn(&learned, request.network_check);
1279                let mut visits = visits;
1280                listing.alias_visits(&mut visits);
1281                // A record shown is a record used: the ones eviction keeps. Each is dated once
1282                // a session, not at every listing.
1283                let shown: Vec<PathBuf> = {
1284                    let mut dated = dated.lock().unwrap_or_else(|e| e.into_inner());
1285                    (listing.sections.iter())
1286                        .flat_map(|s| s.rows.iter().chain(&s.door))
1287                        .flat_map(|row| [row.path.clone(), home::index_key(&row.path)])
1288                        .filter(|key| request.known.contains_key(key) && dated.insert(key.clone()))
1289                        .collect()
1290                };
1291                cache.touch_dataset_facts(shown.iter().map(PathBuf::as_path));
1292                let known = read_facts.then(|| request.known.clone());
1293                // Not held past the answer: the screen adds to the index in place.
1294                drop(request);
1295                let _ = tx.send(AppEvent::HomeListingReady {
1296                    generation,
1297                    listing: Box::new(listing),
1298                    known,
1299                    learned,
1300                    visits,
1301                    newest,
1302                    folds: read_folds.then(|| cache.load_folds()),
1303                });
1304            })
1305        });
1306    }
1307
1308    /// Ask the worker to measure on-screen rows not yet known. Opening a footer can
1309    /// block (a FIFO, a device, a wedged mount, a failing disk), so never on the draw
1310    /// thread.
1311    pub(crate) fn request_home_measurements(&mut self) {
1312        if self.home.measure_in_flight {
1313            return;
1314        }
1315        // The rows on screen first; then, sorted by size or time, the stat of the rest.
1316        let (wanted, reads) = match self.home.unmeasured_visible(MEASURE_BATCH) {
1317            shown if !shown.is_empty() => (shown, home::Reads::Files),
1318            _ => (
1319                self.home.unstated_for_sort(MEASURE_BATCH),
1320                home::Reads::StatOnly,
1321            ),
1322        };
1323        if wanted.is_empty() {
1324            return;
1325        }
1326
1327        self.home.measure_in_flight = true;
1328        let tx = self.events.clone();
1329        let cache = self.cache.clone();
1330        let known = self.home.known.clone();
1331        // A batch whose worker dies is answered as looked at, so it is not asked again.
1332        let stat_only = reads == home::Reads::StatOnly;
1333        let owed = self.owed_answer(AppEvent::HomeMeasured {
1334            measured: (wanted.iter())
1335                .map(|entry| {
1336                    let failed = home::Measured {
1337                        stat_only,
1338                        ..Default::default()
1339                    };
1340                    (entry.path.clone(), failed)
1341                })
1342                .collect(),
1343            done: true,
1344        });
1345        self.runtime.spawn_blocking(move || {
1346            owed.run(|| {
1347                look_and_answer(wanted, &cache, &known, reads, &tx, |measured, done| {
1348                    AppEvent::HomeMeasured { measured, done }
1349                })
1350            })
1351        });
1352    }
1353
1354    /// Ask an HTTP(S) server the size of the file under the cursor, once a session,
1355    /// when unmeasured. The row shows the catalog's `~33 MB` until then; the answer is
1356    /// kept for the next listing.
1357    #[cfg(feature = "http")]
1358    pub(crate) fn size_selected_web_file(&mut self) {
1359        if !self.info.head_web_rows {
1360            return;
1361        }
1362        let Some(entry) = self.home.selected_entry().cloned() else {
1363            return;
1364        };
1365        if entry.size.is_some()
1366            || !matches!(
1367                source::input_source(&entry.path),
1368                source::InputSource::Http(_)
1369            )
1370            || !self.home.sized.insert(entry.path.clone())
1371        {
1372            return;
1373        }
1374        let tx = self.events.clone();
1375        let cache = self.cache.clone();
1376        self.runtime.spawn_blocking(move || {
1377            let size = match Self::fetch_remote_size_http(&entry.path.to_string_lossy()) {
1378                Ok(Some(size)) => size,
1379                Ok(None) => return,
1380                Err(gone) => {
1381                    let _ = tx.send(AppEvent::HomeWebGone {
1382                        path: entry.path,
1383                        gone,
1384                    });
1385                    return;
1386                }
1387            };
1388            let key = home::index_key(&entry.path);
1389            let mut facts = cache.dataset_facts(&key).unwrap_or_default();
1390            facts.size = size;
1391            cache.record_dataset_facts(&[(key, facts)]);
1392            let measured = home::Measured {
1393                rows: entry.rows,
1394                cols: entry.cols,
1395                cols_sampled: entry.cols_sampled,
1396                size: Some(size),
1397                modified: None,
1398                stat_only: false,
1399                columns: entry.columns.clone(),
1400                cost: entry.cost.clone(),
1401                kind: None,
1402                holds: entry.holds.clone(),
1403            };
1404            let _ = tx.send(AppEvent::HomeSized {
1405                path: entry.path,
1406                measured,
1407            });
1408        });
1409    }
1410
1411    /// Ask a worker what the rows on screen are. Classifying reads the named directory
1412    /// (a round trip on a share, forever on a wedged mount), so only on-screen rows,
1413    /// on a detached thread, one pass at a time. It also measures remote rows, which
1414    /// [`crate::home::HomeState::unmeasured_visible`] leaves alone since probes return them
1415    /// `Unknown`.
1416    pub(crate) fn request_home_classifications(&mut self) {
1417        // One pass per filesystem: a pass stuck on a share that stopped answering holds
1418        // up that share's rows, which would stick too, and nothing else.
1419        let mounts = crate::home::locality::Mounts::cached();
1420        let place = |entry: &discover::Entry| mounts.mount_point_for(&entry.path);
1421        loop {
1422            let wanted = self
1423                .home
1424                .unclassified_visible_where(CLASSIFY_BATCH, |entry| {
1425                    !self.home.classifying.contains(&place(entry))
1426                });
1427            let Some(first) = wanted.first() else {
1428                return;
1429            };
1430            let pass = place(first);
1431            let wanted: Vec<discover::Entry> = (wanted.into_iter())
1432                .filter(|entry| place(entry) == pass)
1433                .collect();
1434            self.home.classifying.insert(pass.clone());
1435            let tx = self.events.clone();
1436            let cache = self.cache.clone();
1437            let known = self.home.known.clone();
1438            // A pass whose worker dies is answered as looked at, so it is not asked again.
1439            let owed = self.owed_answer(AppEvent::HomeClassified {
1440                pass: pass.clone(),
1441                measured: (wanted.iter())
1442                    .map(|entry| (entry.path.clone(), home::Measured::default()))
1443                    .collect(),
1444                done: true,
1445            });
1446            std::thread::spawn(move || {
1447                owed.run(|| {
1448                    look_and_answer(
1449                        wanted,
1450                        &cache,
1451                        &known,
1452                        home::Reads::Files,
1453                        &tx,
1454                        |measured, done| AppEvent::HomeClassified {
1455                            pass: pass.clone(),
1456                            measured,
1457                            done,
1458                        },
1459                    )
1460                })
1461            });
1462        }
1463    }
1464
1465    pub fn enter_home(&mut self) {
1466        self.counting.pause_indexing();
1467        if self.return_from_quality_evidence(false) {
1468            self.analysis_modal.close();
1469        }
1470        self.close_overlays();
1471        self.stop_find();
1472        // A count of the dataset being left is read for nobody.
1473        self.stop_value_count();
1474        self.export_modal.forget_counts();
1475        self.abandon_load();
1476        // Nobody is watching the file any more.
1477        if let Some(state) = self.data_table_state.as_mut() {
1478            state.stop_following();
1479        }
1480        self.home.status = None;
1481        // The search that found the dataset comes back selected: the next character starts
1482        // a new one, and `~` opens the path prompt.
1483        self.home.filter_selected = !self.home.filter.is_empty();
1484        self.home.folds_owed = true;
1485        // Coming back from a dataset, the cursor lands on it, a row the user chose.
1486        if self.path.is_some() {
1487            self.home.resting = false;
1488        }
1489        self.home_app.left = self.path.clone();
1490        self.home_refresh();
1491        if let Some(open_path) = self.path.clone() {
1492            // Rows are compared as listed (from canonical roots and canonical recents), so
1493            // only the open path is resolved, once, and not on a share that may not answer.
1494            let resolved = (!home::is_remote_path(&open_path))
1495                .then(|| crate::canonical::canonicalize(&open_path).ok())
1496                .flatten();
1497            if let Some(idx) = self.home.position(|row| match row {
1498                home::Row::Entry { entry, .. } => {
1499                    entry.path == open_path || resolved.as_ref() == Some(&entry.path)
1500                }
1501                // Not the door: its path is the directory's, and would take the cursor from the
1502                // file to the whole-directory row.
1503                home::Row::Header { .. }
1504                | home::Row::Place { .. }
1505                | home::Row::More { .. }
1506                | home::Row::Hidden { .. }
1507                | home::Row::Up { .. }
1508                | home::Row::Door { .. } => false,
1509            }) {
1510                self.home.selected = idx;
1511            }
1512        }
1513        self.input_mode = InputMode::Home;
1514    }
1515
1516    /// The session starts at home with no row picked, reading nothing until one is.
1517    /// Once a session: a later visit home keeps whatever was chosen.
1518    pub fn rest_at_start(&mut self) {
1519        if !std::mem::replace(&mut self.home_app.rested, true) {
1520            self.home.resting = true;
1521        }
1522    }
1523
1524    /// Esc backs out one layer: the filter, the directory descended into, then back to
1525    /// the open data. Nothing at the top level; Ctrl+C quits.
1526    pub(crate) fn home_escape(&mut self) -> Option<AppEvent> {
1527        if !self.home.filter.is_empty() {
1528            self.home.filter.clear();
1529            self.home.sync_search_section();
1530            // On a dataset, as at launch, not on the first section's header.
1531            self.home.select_first_entry();
1532            return None;
1533        }
1534        if self.home.browsing.is_some() {
1535            if self.home.below_browse_start() {
1536                self.home_ascend();
1537            } else {
1538                // Not past where the browse began: back to the listing it started from.
1539                self.home_leave_browsing(None);
1540            }
1541            return None;
1542        }
1543        if self.data_table_state.is_some() {
1544            self.show_table();
1545            // Said on arrival: one Esc too many lands here, and the next keys act on the table.
1546            let name = self
1547                .path
1548                .as_deref()
1549                .and_then(|p| p.file_name())
1550                .map(|n| n.to_string_lossy().into_owned());
1551            if let Some(name) = name {
1552                self.flash_note(format!("Back to {name}"));
1553            }
1554        }
1555        None
1556    }
1557
1558    /// Drop the highlighted dataset from the recents list. Only from Recent: elsewhere
1559    /// a row is a file on disk, and forgetting it would imply a deletion.
1560    pub(crate) fn home_forget_selected(&mut self) {
1561        // A catalog's heading: Delete hides the bundled one until the cache is cleared; a
1562        // user's catalog is hidden by id in the config.
1563        if let Some(catalog) = self.home.selected_catalog() {
1564            if catalog.origin == crate::home::catalog::Origin::Bundled {
1565                let message = format!(
1566                    "Hide {}? It comes back after datui cache clear.",
1567                    catalog.label
1568                );
1569                self.confirmation_modal
1570                    .show_destructive(message, "Hide", Confirm::HideExamples);
1571            } else {
1572                self.home.status = Some(format!(
1573                    "[home] hide = [\"{}\"] in config.toml hides it",
1574                    catalog.id
1575                ));
1576            }
1577            return;
1578        }
1579        // A place row stands for every recent under it, so forgetting them asks first, as
1580        // Shift+Delete does.
1581        if let Some(home::Row::Place { path, held, .. }) = self.home.selected_row() {
1582            let message = format!(
1583                "Forget {held} recently opened {} under {}?",
1584                if held == 1 { "dataset" } else { "datasets" },
1585                home::display_path(&path)
1586            );
1587            self.confirmation_modal
1588                .show(message, Confirm::ForgetPlace(path.clone()));
1589            return;
1590        }
1591        // A row in catalog.toml's own section is removed from the file, as Ctrl+D does; the
1592        // same place under Recent is only forgotten as a recent.
1593        let in_mine = self
1594            .home
1595            .selected_section()
1596            .and_then(|i| self.home.sections.get(i))
1597            .is_some_and(|s| s.origin == Some("catalog.toml"));
1598        if in_mine
1599            && let Some((location, _)) = self.home_row_for_catalog()
1600            && let Some((id, name)) = self.mine_entry_at(&location)
1601        {
1602            self.home_forget_from_catalog(&id, &name);
1603            return;
1604        }
1605        let section_title = self
1606            .home
1607            .selected_section()
1608            .and_then(|i| self.home.sections.get(i))
1609            .map(|s| s.title.clone())
1610            .unwrap_or_default();
1611        if self.home.browsing.is_none()
1612            && section_title == home::HomeState::CLOUD_SECTION
1613            && let Some(id) = self
1614                .home
1615                .selected_entry()
1616                .and_then(|e| home::cloud_source_id(&e.path))
1617        {
1618            self.cache.hide_cloud_source(&id);
1619            self.home.cloud.retain(|s| s.id != id);
1620            self.home_refresh();
1621            return;
1622        }
1623        let in_recents = section_title == "Recent";
1624        if !in_recents {
1625            self.home.status = Some("Only recents and catalog.toml rows can be forgotten".into());
1626            return;
1627        }
1628        let Some(entry) = self.home.selected_entry() else {
1629            return;
1630        };
1631        self.cache.forget_recent(&entry.path);
1632        // Nothing to say: the row going is the answer.
1633        self.home.status = None;
1634        self.home_refresh();
1635    }
1636
1637    /// The location and shown name Ctrl+D adds for the highlighted row; a heading stands
1638    /// for its section's directory. Tables inside files, cloud sources and non-place
1639    /// rows have none.
1640    fn home_row_for_catalog(&self) -> Option<(PathBuf, String)> {
1641        match self.home.selected_row()? {
1642            home::Row::Place { path, .. } => {
1643                let name = home::display_path(&path);
1644                Some((path, name))
1645            }
1646            home::Row::Door { entry, .. } => {
1647                // A local door's trailing slash goes; a URL keeps its `//`, which `components`
1648                // would fold.
1649                let path: PathBuf = if matches!(
1650                    source::input_source(&entry.path),
1651                    source::InputSource::Local(_)
1652                ) {
1653                    entry.path.components().collect()
1654                } else {
1655                    entry.path.clone()
1656                };
1657                Some((path.clone(), home::display_path(&path)))
1658            }
1659            // A table inside a file is listed with its `table`: no stat asks it again, as
1660            // this is asked every frame for the footer.
1661            home::Row::Entry { entry, .. } => (entry.table.is_none()
1662                && !home::is_cloud_place(&entry.path))
1663            .then(|| (entry.path.clone(), entry.name.clone())),
1664            home::Row::Header { section, .. } => {
1665                let root = self.home.sections.get(section)?.root.clone()?;
1666                let name = home::display_path(&root);
1667                Some((root, name))
1668            }
1669            home::Row::More { .. } | home::Row::Hidden { .. } | home::Row::Up { .. } => None,
1670        }
1671    }
1672
1673    /// `catalog.toml`'s path: beside the config file read, else in the config directory.
1674    fn mine_catalog_file(&self) -> Option<PathBuf> {
1675        let dir = match &self.app_config.catalog_dir {
1676            Some(dir) => dir.clone(),
1677            None => config::ConfigManager::new(APP_NAME)
1678                .ok()?
1679                .config_dir()
1680                .to_path_buf(),
1681        };
1682        Some(dir.join(catalog::MINE_FILE))
1683    }
1684
1685    /// The id and name of the `catalog.toml` entry at `location`, when there is one.
1686    fn mine_entry_at(&self, location: &Path) -> Option<(String, String)> {
1687        self.app_config
1688            .read_catalogs
1689            .iter()
1690            .find(|c| c.origin == catalog::Origin::Mine)?
1691            .dataset_at(location)
1692            .map(|d| (d.id.clone(), d.name.clone()))
1693    }
1694
1695    /// Read the catalogs again after `catalog.toml` changed, and list again.
1696    fn reload_catalogs(&mut self) -> Result<(), String> {
1697        let dir = self
1698            .mine_catalog_file()
1699            .and_then(|f| f.parent().map(Path::to_path_buf));
1700        self.app_config
1701            .read_catalog_files(dir.as_deref())
1702            .map_err(|e| e.to_string())?;
1703        // The open dataset's notes and Documentation tab follow the file.
1704        self.info
1705            .follow_catalogs(&self.app_config, self.path.as_deref());
1706        self.open_info_documentation();
1707        self.home_refresh();
1708        Ok(())
1709    }
1710
1711    /// What Ctrl+D writes for `location` named `name`: a copy of another catalog's
1712    /// entry, or the place as is.
1713    fn new_catalog_dataset(&self, location: &Path, name: &str) -> catalog::NewDataset {
1714        if let Some((_, shown)) = self.home.catalog_dataset(location) {
1715            let entry = &shown.entry;
1716            return catalog::NewDataset {
1717                name: entry.name.clone(),
1718                path: entry.local_path().map(|p| home::display_path(&p)),
1719                url: entry.url.clone(),
1720                auth: entry.auth.clone(),
1721                connection: entry.connection.clone(),
1722                description: entry.description.clone(),
1723                size: entry.size,
1724            };
1725        }
1726        let mut new = catalog::NewDataset {
1727            name: name.to_string(),
1728            ..Default::default()
1729        };
1730        if matches!(
1731            source::input_source(location),
1732            source::InputSource::Local(_)
1733        ) {
1734            let absolute = if location.is_relative() {
1735                std::env::current_dir()
1736                    .map(|cwd| cwd.join(location))
1737                    .unwrap_or_else(|_| location.to_path_buf())
1738            } else {
1739                location.to_path_buf()
1740            };
1741            new.path = Some(home::display_path(&absolute));
1742            return new;
1743        }
1744        // A store reached through a configured source: the source becomes the connection
1745        // and leaves the URL.
1746        let text = location.to_string_lossy();
1747        let (id, plain) = source::split_source_id(&text);
1748        new.url = Some(plain.into_owned());
1749        if let Some(id) = id {
1750            new.connection = Some(id.to_string());
1751        }
1752        new
1753    }
1754
1755    /// Ctrl+D: add the row under the cursor to `catalog.toml`, or forget it from there.
1756    pub(crate) fn home_toggle_catalog(&mut self) {
1757        let Some((location, name)) = self.home_row_for_catalog() else {
1758            self.home.status = Some("Move to a dataset or directory to add it".into());
1759            return;
1760        };
1761        if let Some((id, name)) = self.mine_entry_at(&location) {
1762            self.home_forget_from_catalog(&id, &name);
1763            return;
1764        }
1765        let Some(file) = self.mine_catalog_file() else {
1766            self.home.status = Some("No config directory to keep catalog.toml in".into());
1767            return;
1768        };
1769        let new = self.new_catalog_dataset(&location, &name);
1770        // A source found on the machine but not configured cannot be named in a catalog:
1771        // the URL would be read elsewhere.
1772        if let Some(connection) = &new.connection
1773            && !self
1774                .app_config
1775                .cloud
1776                .connections
1777                .iter()
1778                .any(|c| c.name == *connection)
1779        {
1780            self.home.status = Some(format!(
1781                "Not added: {connection} is not a [[cloud.connections]] entry in the config"
1782            ));
1783            return;
1784        }
1785        if let Err(why) = new.check() {
1786            self.home.status = Some(format!("Not added: {why}"));
1787            return;
1788        }
1789        let label = self
1790            .app_config
1791            .read_catalogs
1792            .iter()
1793            .find(|c| c.origin == catalog::Origin::Mine)
1794            .map(|c| c.label.clone())
1795            .unwrap_or_else(|| catalog::MINE_LABEL.to_string());
1796        match catalog::add(&file, &new) {
1797            Ok(_) => match self.reload_catalogs() {
1798                Ok(()) => self.flash_note(format!("Added {} to {label}", new.name)),
1799                Err(e) => self.error_modal.show(e),
1800            },
1801            Err(e) => self.error_modal.show(e.to_string()),
1802        }
1803    }
1804
1805    /// Remove the entry `id` from `catalog.toml`.
1806    fn home_forget_from_catalog(&mut self, id: &str, name: &str) {
1807        let Some(file) = self.mine_catalog_file() else {
1808            return;
1809        };
1810        match catalog::forget(&file, id) {
1811            Ok(()) => match self.reload_catalogs() {
1812                Ok(()) => self.flash_note(format!("Forgot {name}")),
1813                Err(e) => self.error_modal.show(e),
1814            },
1815            Err(e) => self.error_modal.show(e.to_string()),
1816        }
1817    }
1818
1819    /// Move pre-0.4.0 Ctrl+D directories from the cache into `catalog.toml`, once.
1820    fn move_remembered_places(&mut self) {
1821        if std::mem::replace(&mut self.home_app.remembered_moved, true) {
1822            return;
1823        }
1824        let places = self.cache.load_remembered_places();
1825        if places.is_empty() {
1826            return;
1827        }
1828        let Some(file) = self.mine_catalog_file() else {
1829            return;
1830        };
1831        // The cache's list goes only once all are in catalog.toml; a failure retries next
1832        // run.
1833        match catalog::move_places(&file, &places) {
1834            Ok(_) => self.cache.clear_remembered_places(),
1835            Err(e) => {
1836                log::warn!(target: "datui", "moving remembered places into catalog.toml: {e:#}")
1837            }
1838        }
1839        let dir = file.parent().map(Path::to_path_buf);
1840        if let Err(e) = self.app_config.read_catalog_files(dir.as_deref()) {
1841            log::warn!(target: "datui", "reading catalog.toml: {e:#}");
1842        }
1843    }
1844
1845    /// Ctrl+E: the Documentation view of the row under the cursor.
1846    pub(crate) fn home_open_documentation(&mut self) {
1847        let Some((path, doc)) = self.home_documented_row() else {
1848            self.home.status =
1849                Some("Ctrl+E shows what a catalog or a format spec says of a row".into());
1850            return;
1851        };
1852        let measured = self
1853            .home
1854            .selected_entry()
1855            .filter(|e| {
1856                e.path == path
1857                    && doc
1858                        .catalog
1859                        .as_ref()
1860                        .is_some_and(|(_, entry)| entry.location() == path)
1861            })
1862            .and_then(|e| e.size);
1863        self.info.documentation.open(doc, measured);
1864        self.info.documentation.links_open = self.home_app.local_desktop;
1865    }
1866
1867    /// What Ctrl+E documents for the row under the cursor, with its path: the catalog
1868    /// dataset it is or is inside, and its format spec's docs, if any.
1869    pub(crate) fn home_documented_row(
1870        &self,
1871    ) -> Option<(PathBuf, widgets::documentation::Documented)> {
1872        let row = self.home.selected_row();
1873        let (path, file) = match &row {
1874            Some(home::Row::Entry { entry, .. }) | Some(home::Row::Door { entry, .. }) => {
1875                (Some(entry.path.clone()), Some(*entry))
1876            }
1877            Some(home::Row::Place { path, .. }) => (Some(path.clone()), None),
1878            Some(home::Row::Header { section, .. }) => (
1879                self.home
1880                    .sections
1881                    .get(*section)
1882                    .and_then(|s| s.root.clone()),
1883                None,
1884            ),
1885            _ => (None, None),
1886        };
1887        let path = path?;
1888        let catalog = home::catalog_entry_for(&self.home.catalogs, &path);
1889        let spec = file
1890            .filter(|e| e.kind == discover::EntryKind::File)
1891            .and_then(|e| e.format_spec.as_deref())
1892            .and_then(|name| self.home.formats.get(name))
1893            .and_then(|spec| spec.docs())
1894            .map(std::sync::Arc::new);
1895        // A record type's row (`day.ord/add`) documents its file.
1896        let name = file
1897            .map(|e| {
1898                let text = e.path.to_string_lossy();
1899                text.strip_suffix(e.name.as_str())
1900                    .filter(|_| e.table.is_some())
1901                    .map(|file| file.trim_end_matches(std::path::is_separator))
1902                    .and_then(|file| Path::new(file).file_name())
1903                    .map_or_else(|| e.name.clone(), |n| n.to_string_lossy().into_owned())
1904            })
1905            .unwrap_or_default();
1906        let doc = widgets::documentation::Documented::new(catalog, spec, name)?;
1907        Some((path, doc))
1908    }
1909
1910    /// Whether Delete on the selected row hides a catalog: the bundled one's heading.
1911    pub(crate) fn home_hides_catalog(&self) -> bool {
1912        self.home
1913            .selected_catalog()
1914            .is_some_and(|c| c.origin == crate::home::catalog::Origin::Bundled)
1915    }
1916
1917    /// What Ctrl+D does on the row under the cursor, as the footer names it: add to
1918    /// `catalog.toml` or forget from it; `None` where it cannot add.
1919    pub(crate) fn home_catalog_action(&self) -> Option<&'static str> {
1920        let (location, _) = self.home_row_for_catalog()?;
1921        Some(if self.mine_entry_at(&location).is_some() {
1922            "Forget"
1923        } else {
1924            "Add"
1925        })
1926    }
1927
1928    /// Fold or unfold the section under the cursor; folding moves the cursor to its
1929    /// header.
1930    pub(crate) fn home_toggle_fold(&mut self) {
1931        if let Some(section) = self.home.selected_section() {
1932            self.home.toggle_collapsed(section);
1933            self.home.clamp_selection();
1934            self.cache.save_folds(&self.home.folds);
1935        }
1936    }
1937
1938    pub(crate) fn home_collapse(&mut self, collapse: bool) {
1939        // The browsed listing is the whole screen: it never folds, and no fold is
1940        // remembered for its path (see `set_collapsed`).
1941        if self.home.browsing.is_some() {
1942            return;
1943        }
1944        let Some(section) = self.home.selected_section() else {
1945            return;
1946        };
1947        // → on a section's more row shows it whole; ← on a row its cut would hide cuts it
1948        // back. Elsewhere they fold.
1949        if collapse && self.home.cut_again(section) {
1950            return;
1951        }
1952        if !collapse && matches!(self.home.selected_row(), Some(home::Row::More { .. })) {
1953            self.home.show_all(section);
1954            return;
1955        }
1956        if collapse && !self.home.is_collapsed(section) {
1957            self.home.set_collapsed(section, true);
1958            if let Some(idx) = self
1959                .home
1960                .visible()
1961                .iter()
1962                .position(|row| row.section() == section)
1963            {
1964                self.home.selected = idx;
1965            }
1966        } else if !collapse {
1967            self.home.set_collapsed(section, false);
1968        }
1969        self.home.clamp_selection();
1970        self.cache.save_folds(&self.home.folds);
1971    }
1972
1973    /// Step out of a directory that was descended into.
1974    pub(crate) fn home_ascend(&mut self) {
1975        let Some(current) = self.home.browsing.clone() else {
1976            return;
1977        };
1978        let parent = self.home.parent_of(&current);
1979        self.home_leave_browsing(parent);
1980    }
1981
1982    /// Move the browse up to `to`, or back to the root listing when `None`.
1983    fn home_leave_browsing(&mut self, to: Option<PathBuf>) {
1984        // What the last place said of itself no longer applies.
1985        self.home.status = None;
1986        let from = std::mem::replace(&mut self.home.browsing, to);
1987        // Backspace can climb above the browse start; the start follows so Esc has a place
1988        // to stop.
1989        if !self.home.below_browse_start() {
1990            self.home.browse_start = self.home.browsing.clone();
1991        }
1992        // The filter, search and row left here come back; the cursor returns to that row
1993        // when the listing lands. A search of the old place no longer applies.
1994        self.home.come_back(from);
1995        self.home.sync_search_section();
1996        self.home.selected = 0;
1997        self.home_refresh();
1998        if !self.home.filter.is_empty() {
1999            self.spawn_home_search();
2000        }
2001    }
2002
2003    /// Whether a peek's answer changes what a row draws: its kind, or anything in
2004    /// `holds` (`truncated` turns `dir` into `dir+`). Kept answers rebuild the listing
2005    /// on the UI thread, so the rest become "a directory, nothing to say", still sent
2006    /// to clear `peeking`.
2007    #[cfg(feature = "cloud")]
2008    pub(crate) fn peek_tells_a_row_something(
2009        answer: &(discover::EntryKind, discover::Holds),
2010    ) -> bool {
2011        answer.0 != discover::EntryKind::Directory || !answer.1.is_empty()
2012    }
2013
2014    /// Peek inside cloud directories at or near the cursor so datasets show `hive` or
2015    /// `multi` and open as one: one small listing per directory, once a session, plus
2016    /// up to three few-KiB footer reads for a `multi` candidate. On-screen rows a batch
2017    /// at a time, the highlighted first.
2018    #[cfg(feature = "cloud")]
2019    pub(crate) fn peek_cloud_directories(&mut self) {
2020        const PEEKS_AT_ONCE: usize = 4;
2021        let directories = self.home.cloud_directories_to_peek(PEEKS_AT_ONCE);
2022        if directories.is_empty() {
2023            return;
2024        }
2025        // Marked out, not answered: a second pass must not ask again, and writing an
2026        // answer now would claim one before the request is made.
2027        for directory in &directories {
2028            self.home.peeking.insert(directory.clone());
2029        }
2030        let tx = self.events.clone();
2031        let cloud = self.app_config.cloud.clone();
2032        self.runtime.spawn(async move {
2033            let permits = Arc::new(tokio::sync::Semaphore::new(PEEKS_AT_ONCE));
2034            let mut peeks = tokio::task::JoinSet::new();
2035            // By task, so a panicking peek is sent back as failed rather than spinning
2036            // forever.
2037            let mut asked = std::collections::HashMap::new();
2038            for directory in directories {
2039                let (permits, cloud) = (permits.clone(), cloud.clone());
2040                let task_directory = directory.clone();
2041                let task = peeks.spawn(async move {
2042                    let directory = task_directory;
2043                    let _permit = permits.acquire_owned().await;
2044                    let kind =
2045                        crate::cloud::cloud_browse::peek_kind(&directory.to_string_lossy(), &cloud)
2046                            .await;
2047                    (directory, kind)
2048                });
2049                asked.insert(task.id(), directory);
2050            }
2051            // Sent a few at a time so labels fill in without a rebuild per directory. Every
2052            // directory asked is sent back, undecided and failed ones too: that clears
2053            // `peeking` and keeps one request per directory.
2054            let mut found = Vec::new();
2055            let mut failed = Vec::new();
2056            while let Some(joined) = peeks.join_next_with_id().await {
2057                match joined {
2058                    Ok((_, (directory, Ok(answer)))) => {
2059                        let answer = Some(answer)
2060                            .filter(Self::peek_tells_a_row_something)
2061                            .unwrap_or((discover::EntryKind::Directory, Default::default()));
2062                        found.push((directory, answer));
2063                    }
2064                    Ok((_, (directory, Err(_)))) => failed.push(directory),
2065                    Err(error) => failed.extend(asked.remove(&error.id())),
2066                }
2067                if found.len() + failed.len() >= PEEKS_AT_ONCE {
2068                    let _ = tx.send(AppEvent::HomeCloudKinds {
2069                        kinds: std::mem::take(&mut found),
2070                        failed: std::mem::take(&mut failed),
2071                    });
2072                }
2073            }
2074            if !found.is_empty() || !failed.is_empty() {
2075                let _ = tx.send(AppEvent::HomeCloudKinds {
2076                    kinds: found,
2077                    failed,
2078                });
2079            }
2080        });
2081    }
2082
2083    /// Browse into a directory or bucket, local or remote.
2084    pub(crate) fn home_browse_into(&mut self, path: PathBuf) {
2085        self.home.leave_mark();
2086        if self.home.browsing.is_none() {
2087            self.home.browse_start = Some(path.clone());
2088        } else if self.home.browse_start.is_none() {
2089            self.home.browse_start = self.home.browsing.clone();
2090        }
2091        self.home.browsing = Some(path);
2092        // What the last walk found and the status line describe a different place; a fresh
2093        // walk starts on the next keystroke. A caller with news of the new place says it
2094        // after this returns.
2095        self.home.status = None;
2096        self.home.search.reset();
2097        self.home.filter.clear();
2098        self.home.sync_search_section();
2099        self.home.selected = 0;
2100        self.home_refresh();
2101    }
2102
2103    /// Whether the highlighted row is the `(all files)` door, which opens the browsed
2104    /// directory and so is already inside it.
2105    fn selection_opens_the_whole_directory(&self) -> bool {
2106        self.home.selection_is_the_door()
2107    }
2108
2109    /// The highlighted row when → goes inside it: any directory, local or remote,
2110    /// whatever its label. Files, headers and the door are left out.
2111    pub(crate) fn selected_directory_to_enter(&self) -> Option<PathBuf> {
2112        // A place under `RECENT` has no entry, so it is answered first.
2113        if let Some(home::Row::Place { path, .. }) = self.home.selected_row() {
2114            return home::place_is_browsable(&path).then_some(path);
2115        }
2116        let entry = self.home.selected_entry()?;
2117        if self.selection_opens_the_whole_directory() || self.home.missing.contains(&entry.path) {
2118            return None;
2119        }
2120        // A SQLite database lists its tables, however many it has.
2121        if entry.cost.tables.is_some() {
2122            return Some(entry.path.clone());
2123        }
2124        (!matches!(
2125            entry.kind,
2126            discover::EntryKind::File | discover::EntryKind::Other
2127        ))
2128        .then_some(entry.path.clone())
2129    }
2130
2131    /// Why Enter on a bucket directory's `(all files)` row reads nothing, by Enter's
2132    /// own rule: a hive root or one-table directory reads through its files, and one
2133    /// with a reader for its contents reads with that.
2134    #[cfg(feature = "cloud")]
2135    pub(crate) fn why_a_door_reads_nothing(entry: &discover::Entry) -> Option<String> {
2136        if !home::is_object_store_url(&entry.path)
2137            || matches!(
2138                entry.kind,
2139                discover::EntryKind::Hive | discover::EntryKind::MultiFile
2140            )
2141            || Self::cloud_prefix_format(&entry.holds).is_some()
2142        {
2143            return None;
2144        }
2145        Self::why_a_cloud_prefix_cannot_be_read(&entry.holds)
2146    }
2147
2148    /// Why an object-store prefix cannot be read as one table, from its listed
2149    /// contents (no request). `None` when it may yet be Parquet: it holds Parquet,
2150    /// or no data files and perhaps data a level down.
2151    #[cfg(feature = "cloud")]
2152    pub(crate) fn why_a_cloud_prefix_cannot_be_read(holds: &discover::Holds) -> Option<String> {
2153        let reads_parquet =
2154            |name: &str| crate::FileFormat::from_name(name) == Some(crate::FileFormat::Parquet);
2155        if holds.formats.iter().any(|(name, _)| reads_parquet(name)) {
2156            return None;
2157        }
2158        match holds.formats.as_slice() {
2159            // Data files, none Parquet. `label()` says `mixed` for several formats, so the
2160            // line spells them out.
2161            [] => {
2162                // Nothing readable. A refusal only if nothing is below: sub-prefixes may hold
2163                // Parquet a level down.
2164                (holds.not_read > 0 && holds.directories == 0).then(|| {
2165                    "this prefix holds nothing datui can read — datui reads a directory in \
2166                     an object store as Parquet only."
2167                        .to_string()
2168                })
2169            }
2170            formats => {
2171                let held = formats
2172                    .iter()
2173                    .map(|(name, count)| format!("{count} {name}"))
2174                    .collect::<Vec<_>>()
2175                    .join(", ");
2176                Some(format!(
2177                    "this prefix holds {held} — datui reads a directory in an object store \
2178                     as Parquet only. Open one of the files below instead."
2179                ))
2180            }
2181        }
2182    }
2183
2184    /// The reader an object-store prefix calls for, from its listing's counts: the
2185    /// commonest format, ranked by `rank_formats` as on disk, Parquet included. `None`
2186    /// when nothing there has a multi-file reader, where the refusal belongs.
2187    #[cfg(feature = "cloud")]
2188    pub(crate) fn cloud_prefix_format(
2189        holds: &discover::Holds,
2190    ) -> Option<(FileFormat, Vec<(FileFormat, usize)>)> {
2191        // A saved DatasetDict: its splits are Arrow, read one at a time.
2192        if holds.dataset_dict {
2193            return Some((FileFormat::Arrow, Vec::new()));
2194        }
2195        // Model weights beside config and tokenizer JSON: the prefix is the model, and the
2196        // JSON is not data passed over.
2197        if let Some((name, _)) = holds.model_weights() {
2198            return FileFormat::from_name(name).map(|format| (format, Vec::new()));
2199        }
2200        let (name, _) = holds.formats.first()?;
2201        // A GPS log is read whole from disk; a bucket's logs open one at a time, as its
2202        // text files do.
2203        let format = FileFormat::from_name(name)
2204            .filter(|f| f.reads_many_files() && !f.reads_into() && !f.is_lines())?;
2205        // What taking the commonest passes over. Polars lists the prefix and never sees
2206        // other formats, so the note comes from the listing on screen.
2207        let left_out = holds
2208            .formats
2209            .iter()
2210            .skip(1)
2211            .filter_map(|(name, n)| FileFormat::from_name(name).map(|f| (f, *n)))
2212            .collect();
2213        Some((format, left_out))
2214    }
2215
2216    /// Open the highlighted entry: toggle a section, descend, or load a dataset.
2217    pub(crate) fn home_open_selected(&mut self) -> Option<AppEvent> {
2218        match self.home.selected_row() {
2219            // Into the directory or prefix the recents under it live in.
2220            Some(home::Row::Place { path, .. }) => {
2221                if home::place_is_browsable(&path) {
2222                    self.home_browse_into(path);
2223                } else {
2224                    self.home.status = Some(
2225                        "An HTTP server has no listing to browse. Open a file under it".into(),
2226                    );
2227                }
2228                return None;
2229            }
2230            // The rest of `RECENT`, or of a directory, for the session.
2231            Some(home::Row::More { section, .. }) => {
2232                self.home.show_all(section);
2233                return None;
2234            }
2235            // Up a level: as Backspace while browsing, and above a root at the listing.
2236            Some(home::Row::Up { section }) => {
2237                if self.home.browsing.is_some() {
2238                    self.home_ascend();
2239                } else if let Some(parent) = self
2240                    .home
2241                    .sections
2242                    .get(section)
2243                    .and_then(|s| s.root.as_deref())
2244                    .and_then(|root| self.home.parent_of(root))
2245                {
2246                    self.home_browse_into(parent);
2247                }
2248                return None;
2249            }
2250            // What Ctrl+A shows; the cursor goes to the first, where the hidden row stood.
2251            Some(home::Row::Hidden { .. }) => {
2252                self.home.hide_unreadable = false;
2253                if let Some(idx) = self.home.position(|row| {
2254                    matches!(row, home::Row::Entry { entry, .. }
2255                        if entry.hidden_by_default())
2256                }) {
2257                    self.home.selected = idx;
2258                }
2259                return None;
2260            }
2261            _ => {}
2262        }
2263        if self.home.selection_is_header() {
2264            self.home_toggle_fold();
2265            return None;
2266        }
2267        let entry = self.home.selected_entry()?.clone();
2268        // A collection's local dataset that is not there: said here, where it was named.
2269        if self.home.missing.contains(&entry.path) {
2270            self.home.status = Some(format!(
2271                "{} does not exist",
2272                home::display_path(&entry.path)
2273            ));
2274            return None;
2275        }
2276        // A place a collection suggests opens as one table on Enter; → still goes in.
2277        if entry.kind != discover::EntryKind::File && self.home.bookmark(&entry.path).is_some() {
2278            #[cfg(feature = "cloud")]
2279            let reader = if home::is_object_store_url(&entry.path)
2280                && !matches!(
2281                    entry.kind,
2282                    discover::EntryKind::Hive | discover::EntryKind::MultiFile
2283                ) {
2284                Self::cloud_prefix_format(&entry.holds)
2285            } else {
2286                None
2287            };
2288            #[cfg(not(feature = "cloud"))]
2289            let reader = None;
2290            let directory = home::directory_dataset_url(&entry.path);
2291            return Some(self.home_open_directory_as(directory, true, None, reader));
2292        }
2293        // The `(all files)` row opens its directory whatever the label: no label can lock
2294        // the user out. Sent straight to the open, since `open_what_it_is` would read the
2295        // label and step inside again.
2296        if self.selection_opens_the_whole_directory() {
2297            // A lake table read as Parquet counts tombstoned rows and every rewritten version,
2298            // so the read is labeled, not refused: a note in the panel and a footer chip say
2299            // so, and Enter one level up explains datui does not read the table itself yet.
2300            let lake = entry.kind.lake_name();
2301            // Pick an object-store prefix's reader from its listed contents (no request), so a
2302            // CSV prefix is not scanned as Parquet and blamed on credentials. A prefix the
2303            // listing already calls a dataset is left alone: a hive root reads through its
2304            // partitions despite a stray `manifest.csv`.
2305            #[cfg(feature = "cloud")]
2306            let reader = if home::is_object_store_url(&entry.path)
2307                && !matches!(
2308                    entry.kind,
2309                    discover::EntryKind::Hive | discover::EntryKind::MultiFile
2310                ) {
2311                let reader = Self::cloud_prefix_format(&entry.holds);
2312                // Nothing readable: the refusal names what is there rather than blaming the
2313                // connection.
2314                if reader.is_none()
2315                    && let Some(what) = Self::why_a_cloud_prefix_cannot_be_read(&entry.holds)
2316                {
2317                    self.home.status = Some(what);
2318                    return None;
2319                }
2320                reader
2321            } else {
2322                None
2323            };
2324            #[cfg(not(feature = "cloud"))]
2325            let reader = None;
2326            // `hive: true` reads it as one and carries partition columns through the hive
2327            // route. The cloud route returns before the dispatch.
2328            let directory = home::directory_dataset_url(&entry.path);
2329            return Some(self.home_open_directory_as(directory, true, lake, reader));
2330        }
2331        // A row nothing has looked at is looked at first: `Unknown` is offered as openable,
2332        // and a lake root would otherwise read as one table. On a worker: the directory is
2333        // read, and any mount can stall.
2334        if entry.kind == discover::EntryKind::Unknown
2335            && !home::is_cloud_place(&entry.path)
2336            && matches!(
2337                source::input_source(&entry.path),
2338                source::InputSource::Local(_)
2339            )
2340        {
2341            return Some(AppEvent::ClassifyThenOpen {
2342                path: entry.path,
2343                typed: None,
2344            });
2345        }
2346        // A database of several tables lists them; one not yet measured opens and lands on
2347        // its tables the same way.
2348        if entry.enter_lists_tables() {
2349            self.home_browse_into(entry.path);
2350            return None;
2351        }
2352        self.open_what_it_is(entry.path, entry.kind, false)
2353    }
2354
2355    /// Browse into `path` as a jump: the browse starts here, so Esc returns to the
2356    /// listing rather than up through the path's parents.
2357    pub(crate) fn home_jump_into(&mut self, path: PathBuf) {
2358        // Only the listing's mark is still a way back.
2359        self.home.trail.retain(|mark| mark.place.is_none());
2360        if self.home.browsing.is_none() {
2361            self.home.leave_mark();
2362        }
2363        self.home.browse_start = Some(path.clone());
2364        self.home.browsing = Some(path);
2365        self.home.status = None;
2366        self.home.search.reset();
2367        self.home.filter.clear();
2368        self.home.sync_search_section();
2369        self.home.selected = 0;
2370        self.home_refresh();
2371    }
2372
2373    /// Load a path from the home screen. The `Open` handler records the recent.
2374    pub(crate) fn home_open_path(&mut self, path: PathBuf, hive: bool) -> AppEvent {
2375        self.home_open_directory(path, hive, None)
2376    }
2377
2378    /// As [`Self::home_open_path`], noting whether the directory is a lake table whose
2379    /// plain files are read, so the dataset can say so.
2380    fn home_open_directory(
2381        &mut self,
2382        path: PathBuf,
2383        hive: bool,
2384        lake: Option<&'static str>,
2385    ) -> AppEvent {
2386        self.home_open_directory_as(path, hive, lake, None)
2387    }
2388
2389    /// As [`Self::home_open_directory`], naming the reader. For an object-store prefix
2390    /// the cloud scan runs before the format dispatch, so the listing's format travels
2391    /// with the open or the scan falls back to Parquet.
2392    fn home_open_directory_as(
2393        &mut self,
2394        path: PathBuf,
2395        hive: bool,
2396        lake: Option<&'static str>,
2397        reader: Option<(FileFormat, Vec<(FileFormat, usize)>)>,
2398    ) -> AppEvent {
2399        let (format, left_out) = match reader {
2400            Some((format, left_out)) => (Some(format), left_out),
2401            None => (None, Vec::new()),
2402        };
2403        // Told, not stat'ed: the caller knows, and a stat on a gone share would freeze the
2404        // key thread (also why `Open` fills in the size).
2405        let options = OpenOptions {
2406            hive,
2407            read_as_plain_files_of: lake,
2408            format,
2409            left_out,
2410            ..self.open_defaults()
2411        };
2412        self.show_table();
2413        // Chosen here, so a failure is reported here.
2414        self.announce_open(true, "Scanning input".to_string(), 10);
2415        // The frame drawn before `Open` runs says which file; `Open` fills in the size a
2416        // frame later, off this thread.
2417        self.name_what_is_loading(path.clone());
2418        AppEvent::Open(vec![path], options)
2419    }
2420
2421    /// Before a frame: build again the sections whose listings sent rows, and say which
2422    /// listings out have gone silent past the wait.
2423    pub(crate) fn take_listing_news(&mut self) {
2424        for root in std::mem::take(&mut self.home_app.pages_owed) {
2425            self.home.relist_remote(&root);
2426        }
2427        #[cfg(test)]
2428        let patience = self.home_app.probe_patience.unwrap_or(PROBE_PATIENCE);
2429        #[cfg(not(test))]
2430        let patience = PROBE_PATIENCE;
2431        let now = std::time::Instant::now();
2432        let silent: Vec<PathBuf> = (self.home_app.probe_heard.iter())
2433            .filter(|(root, heard)| {
2434                now.duration_since(**heard) >= patience
2435                    && self.home_app.probes_inflight.contains(root)
2436                    && !self.home.probes.settled(root)
2437            })
2438            .map(|(root, _)| root.clone())
2439            .collect();
2440        for root in silent {
2441            self.home.probes.go_silent(&root);
2442            self.home.relist_remote(&root);
2443        }
2444    }
2445
2446    /// The home screen's worker answers.
2447    pub(crate) fn home_event(&mut self, event: AppEvent) -> Option<AppEvent> {
2448        match event {
2449            AppEvent::HomeListingReady {
2450                generation,
2451                listing,
2452                known,
2453                learned,
2454                visits,
2455                newest,
2456                folds,
2457            } => {
2458                // Only the current listing's answer clears the flag; a stale one landing first
2459                // would say nothing is in flight.
2460                if generation == self.home_app.generation {
2461                    self.home.listing_in_flight = false;
2462                }
2463                // Read fresh from the cache, so true whichever listing carried them; only the
2464                // first listing after entering home carries the folds.
2465                if let Some(known) = known {
2466                    self.home.known = known;
2467                    self.home_app.facts_read = true;
2468                }
2469                if !learned.is_empty() {
2470                    std::sync::Arc::make_mut(&mut self.home.known).extend(learned);
2471                }
2472                self.home.set_visits(visits);
2473                self.home.newest_recent = newest;
2474                if let Some(folds) = folds {
2475                    self.home.folds = folds;
2476                }
2477                // A superseded listing describes somewhere the user has left.
2478                if generation != self.home_app.generation {
2479                    return None;
2480                }
2481                self.home.apply_listing(*listing);
2482                // Probes are chosen from the sections, so only once they exist.
2483                self.spawn_home_probes();
2484                #[cfg(feature = "cloud")]
2485                self.spawn_cloud_discovery();
2486                self.request_home_measurements();
2487                self.request_home_classifications();
2488                None
2489            }
2490            AppEvent::HomeListingFailed => {
2491                // The rows already listed stay. The panic is flashed as a raw worker's.
2492                self.home.listing_in_flight = false;
2493                None
2494            }
2495            AppEvent::HomeMeasured { measured, done } => {
2496                for (path, m) in measured {
2497                    self.home.record_measurement(path, m);
2498                }
2499                // A batch answers one file per event; they are folded in at the next frame
2500                // (`begin_frame`), not one list per file.
2501                if done {
2502                    self.home.measure_in_flight = false;
2503                    self.request_home_measurements();
2504                }
2505                None
2506            }
2507            AppEvent::HomeSized { path, measured } => {
2508                self.home.record_size(path, measured);
2509                self.home.apply_new_measurements();
2510                None
2511            }
2512            AppEvent::HomeWebGone { path, gone } => {
2513                self.home.web_gone.insert(path, gone);
2514                None
2515            }
2516            AppEvent::HomeClassified {
2517                pass,
2518                measured,
2519                done,
2520            } => {
2521                // Kept even if the listing was rebuilt since: probes and peeks rebuild it often,
2522                // and dropping answers would leave rows unlabeled.
2523                for (path, m) in measured {
2524                    self.home.record_measurement(path, m);
2525                }
2526                // Nothing re-sorts: kinds are written into rows in place, so the listing never
2527                // reshuffles under the cursor. Folded in at the next frame, as measurements.
2528                // The next batch comes from the viewport as it is now, not the rows scrolled past.
2529                if done {
2530                    self.home.classifying.remove(&pass);
2531                    self.request_home_classifications();
2532                }
2533                None
2534            }
2535            AppEvent::HomePathListed { listing } => {
2536                self.home_app.path_listings_out.remove(&listing.dir);
2537                // Kept only for the directory still being typed.
2538                if self.home.path_input_active
2539                    && home::typed_dir(&self.home.path_input) == listing.dir
2540                {
2541                    self.home.path_listing = Some(*listing);
2542                    if self.home.path_pick.is_none() {
2543                        self.home.pick_first_path();
2544                    }
2545                }
2546                None
2547            }
2548            AppEvent::HomePathCompleted {
2549                generation,
2550                typed,
2551                completed,
2552                candidates,
2553            } => {
2554                // Discard if the user typed since asking: completing would scramble their input.
2555                if generation != self.home_app.generation || self.home.path_input != typed {
2556                    return None;
2557                }
2558                if candidates == 0 {
2559                    self.home.status = Some("No such path".to_string());
2560                } else {
2561                    self.home.status = None;
2562                    if candidates > 1 {
2563                        self.flash_note(format!("{candidates} matches"));
2564                    }
2565                    self.home.path_input = completed;
2566                    self.home.pick_first_path();
2567                }
2568                None
2569            }
2570            AppEvent::HomePreviewReady {
2571                path,
2572                stamp,
2573                read_at,
2574                rows,
2575                prepared,
2576            } => {
2577                let prepared = prepared.lock().ok().and_then(|mut p| p.take());
2578                // The columns came with the rows: the pane lists them, for a CSV too.
2579                if let Some(prepared) = &prepared {
2580                    let schema = prepared
2581                        .state
2582                        .schema()
2583                        .iter()
2584                        .map(|(name, dtype)| (name.to_string(), dtype.clone()))
2585                        .collect();
2586                    self.home_app
2587                        .schema_cache
2588                        .insert(path.clone(), Some(schema));
2589                }
2590                let prepared = prepared.filter(|_| read_at.is_some());
2591                self.home_app.previews.landed(
2592                    path,
2593                    stamp,
2594                    read_at.unwrap_or(stamp),
2595                    rows,
2596                    prepared,
2597                );
2598                None
2599            }
2600            AppEvent::HomeSchemaReady { path, preview } => {
2601                self.home_app.schema_reads.retain(|_, read| *read != path);
2602                // Kept whatever was listed since: a schema is the file's, and a finished read is
2603                // never thrown away. A preview's columns are not taken back by a metadata read
2604                // that had none.
2605                let known = self
2606                    .home_app
2607                    .schema_cache
2608                    .get(&path)
2609                    .is_some_and(Option::is_some);
2610                if preview.is_some() || !known {
2611                    self.home_app.schema_cache.insert(path, preview);
2612                }
2613                None
2614            }
2615            AppEvent::HomeSearchBatch {
2616                generation,
2617                root,
2618                found,
2619                scanned,
2620            } => {
2621                // Batches from a walk a later navigation superseded describe a place left behind;
2622                // the walk is abandoned, not cancelled, so late batches are expected. A refresh of
2623                // the same place supersedes nothing.
2624                if generation == self.home_app.search_generation {
2625                    self.home.search_batch(&root, found, scanned);
2626                }
2627                None
2628            }
2629            AppEvent::HomeSearchScored { epoch, matches } => {
2630                // A scoring that died is not retried (it would die again); listed matches stand.
2631                if let Some(matches) = matches {
2632                    self.home.search_scored(epoch, *matches);
2633                }
2634                None
2635            }
2636            AppEvent::HomeSearchDone {
2637                generation,
2638                root,
2639                scanned,
2640                limited,
2641            } => {
2642                if generation == self.home_app.search_generation {
2643                    self.home.search_finished(&root, scanned, limited);
2644                }
2645                self.home_app.search_inflight = false;
2646                // A walk abandoned by a browse held up the one the filter now asks for.
2647                if !self.home.filter.is_empty() && self.home.search.root.is_none() {
2648                    self.spawn_home_search();
2649                }
2650                None
2651            }
2652            #[cfg(feature = "cloud")]
2653            AppEvent::HomeCloudSources { sources } => {
2654                // Only the sources' rows change: nothing else is listed again.
2655                self.home.set_cloud(sources);
2656                None
2657            }
2658            #[cfg(feature = "cloud")]
2659            AppEvent::HomeCloudListed {
2660                id,
2661                buckets,
2662                details,
2663                failure,
2664                listed_at,
2665            } => {
2666                if let Some(source) = self.home.cloud.iter_mut().find(|s| s.id == id) {
2667                    source.refreshing = false;
2668                    for (place, lines) in details {
2669                        source.place_details.insert(place, lines);
2670                    }
2671                    match failure {
2672                        // A failed refresh keeps the last buckets, and the row says it failed.
2673                        Some((short, detail)) => {
2674                            for bucket in buckets {
2675                                if !source.buckets.contains(&bucket) {
2676                                    source.buckets.push(bucket);
2677                                }
2678                            }
2679                            source.status = home::CloudStatus::Failed { short, detail };
2680                        }
2681                        None => {
2682                            source.buckets = buckets;
2683                            source.status = home::CloudStatus::Listed;
2684                            source.listed_at = Some(listed_at);
2685                        }
2686                    }
2687                }
2688                let cloud = std::mem::take(&mut self.home.cloud);
2689                self.home.set_cloud(cloud);
2690                None
2691            }
2692            AppEvent::HomeNarrowed {
2693                dir,
2694                prefix,
2695                listed,
2696            } => {
2697                // Only the current request: a replaced one may answer late with a shorter prefix.
2698                let asked = self
2699                    .home_app
2700                    .narrowing
2701                    .as_ref()
2702                    .is_some_and(|(d, p, _)| *d == dir && *p == prefix);
2703                if asked {
2704                    self.home_app.narrowing = None;
2705                }
2706                // Only while it is still where the user is and what the filter asks.
2707                let wanted = asked
2708                    && self.home.browsing.as_ref() == Some(&dir)
2709                    && !self.home.filter.is_empty();
2710                if let (Some((rows, truncated)), true) = (listed, wanted) {
2711                    self.home.narrowed = Some(home::Narrowed {
2712                        dir: dir.clone(),
2713                        prefix,
2714                        rows,
2715                        truncated,
2716                    });
2717                    self.home.relist_remote(&dir);
2718                }
2719                None
2720            }
2721            AppEvent::HomeProbeCancelled { root } => {
2722                self.home_app.probes_inflight.retain(|p| p != &root);
2723                self.home_app.probe_heard.remove(&root);
2724                self.home_app.listing_cancels.remove(&root);
2725                self.home.probes.stopped(&root);
2726                // Come back to after it had stopped: listed afresh.
2727                if self.home.browsing.as_ref() == Some(&root) {
2728                    self.home_refresh();
2729                }
2730                None
2731            }
2732            AppEvent::HomeProbeFailed { root, message } => {
2733                self.home_app.probes_inflight.retain(|p| p != &root);
2734                self.home_app.probe_heard.remove(&root);
2735                self.home_app.listing_cancels.remove(&root);
2736                self.home.probe_failed(root, Some(message));
2737                self.home_refresh();
2738                None
2739            }
2740            AppEvent::HomeProbeProgress { root, rows } => {
2741                // Only while that listing is out: a late batch must not paint over the answer.
2742                if self.home_app.probes_inflight.contains(&root)
2743                    && self.home.probes.takes_pages(&root)
2744                {
2745                    self.home.probes.read(&root, &rows);
2746                    (self.home_app.probe_heard).insert(root.clone(), std::time::Instant::now());
2747                    // Listed once a frame, however many batches came in it.
2748                    if !self.home_app.pages_owed.contains(&root) {
2749                        self.home_app.pages_owed.push(root);
2750                    }
2751                }
2752                None
2753            }
2754            AppEvent::HomeProbeReady {
2755                root,
2756                rows,
2757                cut_short,
2758            } => {
2759                // Free the slot. The cap bounds threads wedged on dead mounts, which never send
2760                // this; without freeing, probing stops after MAX_CONCURRENT_PROBES roots.
2761                self.home_app.probes_inflight.retain(|p| p != &root);
2762                self.home_app.probe_heard.remove(&root);
2763                self.home_app.listing_cancels.remove(&root);
2764                let landed = rows.is_some();
2765                match rows {
2766                    Some(rows) => self.home.probe_ready(root.clone(), rows, cut_short),
2767                    None => self.home.probe_failed(root.clone(), None),
2768                }
2769                // A filter typed while it was listing asks the server too.
2770                #[cfg(feature = "cloud")]
2771                if cut_short && !self.home.filter.is_empty() {
2772                    self.narrow_cloud_listing();
2773                }
2774                // An account read with its keys (the sign-in has no data role) says so.
2775                #[cfg(feature = "cloud")]
2776                if let Some((account, _, _)) = source::azure_parts(&root.to_string_lossy())
2777                    && crate::cloud::azure::remembered_key(&account).is_some()
2778                {
2779                    for source in &mut self.home.cloud {
2780                        let place = source
2781                            .buckets
2782                            .iter()
2783                            .find(|b| home::cloud_account(b).is_some_and(|(_, a)| a == account))
2784                            .cloned();
2785                        if let Some(place) = place {
2786                            let lines = source.place_details.entry(place).or_default();
2787                            if !lines.iter().any(|(k, _)| k == "access") {
2788                                lines.push(("access".to_string(), "access key".to_string()));
2789                            }
2790                        }
2791                    }
2792                }
2793                // Rebuild so the listing picks up the result.
2794                self.home_refresh();
2795                // Then peek at its rows: after the rebuild, which writes the `visible()` the picker
2796                // reads. Here, because a listing landing under a still cursor may draw no frame to
2797                // notice.
2798                #[cfg(feature = "cloud")]
2799                if landed {
2800                    self.peek_cloud_directories();
2801                }
2802                #[cfg(not(feature = "cloud"))]
2803                let _ = landed;
2804                None
2805            }
2806            AppEvent::HomeCloudKinds { kinds, failed } => {
2807                for directory in failed {
2808                    self.home.peeking.remove(&directory);
2809                    self.home.peek_failed.insert(directory);
2810                }
2811                for (directory, kind) in kinds {
2812                    // Answered: out of `peeking` into the set rows are labeled from; every directory
2813                    // comes back, so none is asked twice.
2814                    self.home.peeking.remove(&directory);
2815                    self.home.cloud_kinds.insert(directory, kind);
2816                }
2817                // Into the rows as listed: nothing is read again for a label.
2818                self.home.take_cloud_kinds();
2819                None
2820            }
2821            _ => unreachable!("not a home event"),
2822        }
2823    }
2824}