Skip to main content

datui_lib/home/
mod.rs

1//! The home screen: pick a dataset without first knowing where it is.
2//!
3//! It lists *roots* (places to look) and *catalogs* (named datasets and
4//! directories):
5//!
6//! 1. **The working directory.**
7//! 2. **Catalogs**: `catalog.toml` (Ctrl+D adds to it), the files `catalogs` lists, and
8//!    the bundled `public` catalog. A directory in one is a row to step into.
9//!
10//! `RECENT` lists every dataset opened, grouped under the directory or prefix it
11//! lives in; `Enter` on such a place row browses it. It is derived state, cheap to
12//! rebuild or discard.
13
14pub mod catalog;
15pub mod codebook;
16pub mod discover;
17pub mod fuzzy;
18pub(crate) mod home_app;
19pub(crate) mod home_keys;
20pub mod home_preview;
21pub mod locality;
22pub mod search;
23
24use crate::home::discover::{Entry, EntryKind};
25use std::path::{Path, PathBuf};
26
27/// Where a root came from, shown subtly in the UI.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum RootOrigin {
30    Cwd,
31    /// Derived from the desktop's own recently-used list.
32    Desktop,
33}
34
35impl RootOrigin {
36    pub fn note(self) -> &'static str {
37        match self {
38            RootOrigin::Cwd => "current directory",
39            RootOrigin::Desktop => "opened elsewhere",
40        }
41    }
42}
43
44/// Directories holding data files the desktop recorded you opening
45/// (`recently-used.xbel`), so a fresh install has places to suggest. Only the
46/// directories are used, never the files: the list holds whatever was opened
47/// anywhere, which may be private.
48pub fn desktop_recent_dirs() -> Vec<PathBuf> {
49    let Some(data_dir) = dirs::data_dir() else {
50        return Vec::new();
51    };
52    let path = data_dir.join("recently-used.xbel");
53    let Ok(contents) = std::fs::read_to_string(&path) else {
54        return Vec::new();
55    };
56    dirs_from_xbel(&contents)
57}
58
59/// Extract directories of data files from XBEL content. Scans for
60/// `href="file://…"` rather than parsing XML, to avoid an XML dependency.
61pub fn dirs_from_xbel(contents: &str) -> Vec<PathBuf> {
62    const PREFIX: &str = "href=\"file://";
63    let mut dirs: Vec<PathBuf> = Vec::new();
64
65    for chunk in contents.split(PREFIX).skip(1) {
66        let Some(end) = chunk.find('"') else { continue };
67        let decoded = percent_decode(&chunk[..end]);
68        let file = PathBuf::from(decoded);
69        // Only files datui can open that still exist.
70        if !crate::home::discover::is_data_file(&file) || !file.is_file() {
71            continue;
72        }
73        let Some(parent) = file.parent() else {
74            continue;
75        };
76        if parent.as_os_str().is_empty() {
77            continue;
78        }
79        let parent = parent.to_path_buf();
80        if !dirs.contains(&parent) {
81            dirs.push(parent);
82        }
83    }
84
85    dirs
86}
87
88/// Decode `%20`-style escapes in a file URI.
89fn percent_decode(raw: &str) -> String {
90    let bytes = raw.as_bytes();
91    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
92    let mut i = 0;
93    while i < bytes.len() {
94        if bytes[i] == b'%' && i + 2 < bytes.len() {
95            let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
96            if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
97                out.push(byte);
98                i += 3;
99                continue;
100            }
101        }
102        out.push(bytes[i]);
103        i += 1;
104    }
105    String::from_utf8_lossy(&out).into_owned()
106}
107
108/// The service's word for the top of an object-store place: a source, account,
109/// bucket or container. `None` otherwise, including directories inside a bucket,
110/// which are labeled by their contents like local ones.
111pub fn object_place_label(path: &Path) -> Option<&'static str> {
112    if cloud_source_id(path).is_some() {
113        return Some("source");
114    }
115    if cloud_account(path).is_some() {
116        return Some("account");
117    }
118    let text = path.to_string_lossy();
119    if let Some((_, _, key)) = crate::cloud::source::azure_parts(&text) {
120        return key.trim_matches('/').is_empty().then_some("container");
121    }
122    let (scheme, rest) = text.split_once("://")?;
123    if !matches!(scheme, "s3" | "s3a" | "gs" | "gcs") {
124        return None;
125    }
126    let rest = rest.trim_end_matches('/');
127    if rest.is_empty() {
128        return None;
129    }
130    (!rest.contains('/')).then_some("bucket")
131}
132
133/// A directory in an object store that has not said what it holds yet.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum CloudLook {
136    /// Not asked about: `…`.
137    Waiting,
138    /// Its peek is out, or its answer has not reached the row: a spinner.
139    Looking,
140    /// Its peek failed: `?` until Ctrl+R.
141    Failed,
142}
143
144/// What a home row is called, decided once for the list and the pane beside it.
145#[derive(Debug, Default, PartialEq)]
146pub struct RowLabel {
147    /// The list's word beside the name: a count, a kind, the curated word, a source id.
148    pub short: String,
149    /// The pane's `kind` line, which has the room to say it in words.
150    pub words: String,
151    /// `short` is the word a source or catalog gives the place.
152    pub curated: bool,
153    /// `short` is the source id the path names, or that the source is gone.
154    pub source: bool,
155    /// The source the path names has left the config.
156    pub missing_source: bool,
157}
158
159/// What `entry` is called. `look` is a bucket directory's look-up state, drawn at
160/// spinner `frame`; `known_sources` are the sources a URL can name, `None` where
161/// the trail already names it.
162pub fn describe(
163    entry: &Entry,
164    place_kind: Option<&'static str>,
165    look: Option<CloudLook>,
166    frame: usize,
167    known_sources: Option<&[crate::config::CloudConnectionConfig]>,
168) -> RowLabel {
169    // The door is an action: labels, curated words and source ids describe the
170    // directory, not the door.
171    if entry.opens_whole_directory {
172        return RowLabel::default();
173    }
174    let g = crate::glyphs::get();
175    // A source's word for a place it names (`dataset`, `project`), or `missing` for an
176    // absent catalog dataset, ahead of any count so the row stays marked curated.
177    let curated =
178        place_kind.filter(|_| matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown));
179    let look_glyph = look.map(|look| match look {
180        CloudLook::Waiting => g.ellipsis,
181        CloudLook::Looking => g.spinner[frame % g.spinner.len()],
182        CloudLook::Failed => "?",
183    });
184    let short = match curated {
185        Some(word) => word.to_string(),
186        // An uncounted directory: a bucket's own word, else that it is being looked into.
187        None if entry.kind == EntryKind::Directory && entry.holds.formats.is_empty() => {
188            object_place_label(&entry.path)
189                .or(look_glyph)
190                .map(str::to_string)
191                .unwrap_or_else(|| entry.label().into_owned())
192        }
193        None => entry.label().into_owned(),
194    };
195    // Two stores can hold the same bucket and key, so a row named by source says
196    // which, or that the source has left the config.
197    let path_text = entry.path.to_string_lossy();
198    let named = crate::cloud::source::split_source_id(&path_text).0;
199    let missing_source = named
200        .is_some_and(|id| known_sources.is_some_and(|known| !known.iter().any(|k| k.name == id)));
201    let (short, source) = match (named, known_sources) {
202        (Some(id), Some(_)) if short.is_empty() && missing_source => {
203            (format!("source not found: {id}"), true)
204        }
205        (Some(id), Some(_)) if short.is_empty() => (id.to_string(), true),
206        // Nothing known yet: an ellipsis claims nothing.
207        _ if short.is_empty() && entry.kind == EntryKind::Unknown => {
208            (g.ellipsis.to_string(), false)
209        }
210        _ => (short, false),
211    };
212    let words = match (&entry.table, entry.kind) {
213        (Some(table), _) => {
214            let of = match (&entry.format_spec, table.format) {
215                (Some(spec), _) => spec.clone(),
216                (None, Some(format)) => format.name().to_string(),
217                (None, None) => String::new(),
218            };
219            format!("{of} {}", table.kind).trim_start().to_string()
220        }
221        (None, _) if curated.is_some() => curated.unwrap_or_default().to_string(),
222        (None, EntryKind::File) => match (
223            &entry.format_spec,
224            crate::FileFormat::from_path(&entry.path),
225        ) {
226            (Some(spec), _) => format!("{spec} file"),
227            (None, Some(format)) => format!("{} file", format.name()),
228            // Named nothing, and found by its bytes to be data.
229            (None, None) => "data file".to_string(),
230        },
231        (None, EntryKind::Hive) => "hive table".to_string(),
232        (None, EntryKind::MultiFile) => "multi-file table".to_string(),
233        (None, kind) if kind.is_lake_table() => {
234            format!(
235                "{} table",
236                kind.lake_name().unwrap_or_default().to_lowercase()
237            )
238        }
239        (None, EntryKind::Directory) => match look {
240            Some(CloudLook::Failed) => format!("? {} listing failed, Ctrl+R retries", g.middot),
241            // Nothing to say yet; the row's spinner shows it is being looked into.
242            Some(_) => String::new(),
243            None => object_place_label(&entry.path)
244                .unwrap_or("directory")
245                .to_string(),
246        },
247        _ => String::new(),
248    };
249    RowLabel {
250        short,
251        words,
252        curated: curated.is_some(),
253        source,
254        missing_source,
255    }
256}
257
258/// How a cloud source is addressed on home: `cloud://<id>`, naming the level above
259/// its buckets, which no real URL can.
260pub const CLOUD_PLACE: &str = "cloud://";
261
262/// Lines kept between the cursor and the edge of the list while it can scroll.
263const SCROLL_MARGIN: usize = 2;
264
265/// The first line of a `height`-line list that keeps `selected` on screen, given it
266/// started at `top`. Scrolls only enough to keep the cursor [`SCROLL_MARGIN`] lines
267/// from an edge, and never leaves empty space below the last line.
268pub(crate) fn settle_top(top: usize, selected: usize, height: usize, total: usize) -> usize {
269    if height == 0 {
270        return top.min(selected);
271    }
272    // A short list keeps the cursor reachable at every line.
273    let margin = SCROLL_MARGIN.min((height - 1) / 2);
274    let top = if selected < top + margin {
275        selected.saturating_sub(margin)
276    } else if selected + margin >= top + height {
277        selected + margin + 1 - height
278    } else {
279        top
280    };
281    top.min(total.saturating_sub(height))
282}
283
284/// The place for one cloud source.
285pub fn cloud_place(id: &str) -> PathBuf {
286    PathBuf::from(format!("{CLOUD_PLACE}{id}"))
287}
288
289/// The source ID of a `cloud://<id>` place.
290pub fn cloud_source_id(path: &Path) -> Option<String> {
291    let text = path.to_string_lossy();
292    let id = text.strip_prefix(CLOUD_PLACE)?.trim_end_matches('/');
293    (!id.is_empty() && !id.contains('/')).then(|| id.to_string())
294}
295
296/// The source ID and account of a `cloud://<id>/<account>` place: an Azure storage
297/// account, which has no URL of its own.
298pub fn cloud_account(path: &Path) -> Option<(String, String)> {
299    let text = path.to_string_lossy();
300    let rest = text.strip_prefix(CLOUD_PLACE)?.trim_end_matches('/');
301    let (id, account) = rest.split_once('/')?;
302    (!id.is_empty() && !account.is_empty() && !account.contains('/'))
303        .then(|| (id.to_string(), account.to_string()))
304}
305
306/// Whether `url` is `root` or inside it, for URLs of any provider.
307fn within(url: &str, root: &str) -> bool {
308    #[cfg(feature = "cloud")]
309    {
310        crate::cloud::cloud_sources::is_within(url, root)
311    }
312    #[cfg(not(feature = "cloud"))]
313    {
314        let (url, root) = (url.trim_end_matches('/'), root.trim_end_matches('/'));
315        url == root || url.strip_prefix(root).is_some_and(|r| r.starts_with('/'))
316    }
317}
318
319/// Whether two locations are one place, however a trailing slash, an Azure URL or a
320/// local path's separators and drive letter are spelled.
321fn same_place(a: &Path, b: &Path) -> bool {
322    place_key(a) == place_key(b)
323}
324
325/// A location as [`same_place`] compares it.
326fn place_key(path: &Path) -> String {
327    let text = path.to_string_lossy();
328    // A catalog may write `C:/data/x.csv` where a listing or a recent has
329    // `C:\data\x.csv`; compare local paths by their components.
330    if !text.contains("://") {
331        return crate::config::path_place(path).display().to_string();
332    }
333    #[cfg(feature = "cloud")]
334    {
335        crate::cloud::source::canonical_cloud_place(&text)
336    }
337    #[cfg(not(feature = "cloud"))]
338    {
339        text.trim_end_matches('/').to_string()
340    }
341}
342
343/// The catalogs' datasets and bookmarks indexed by place, for per-row lookups.
344#[derive(Debug, Default)]
345pub struct CatalogPlaces {
346    /// Catalog and dataset, by [`place_key`]: the first listed of two at one place.
347    datasets: std::collections::HashMap<String, (usize, usize)>,
348    /// Catalog, dataset and bookmark, by [`place_key`].
349    bookmarks: std::collections::HashMap<String, (usize, usize, usize)>,
350    /// How many datasets and bookmarks the catalogs held; catalogs set other than by
351    /// [`HomeState::set_catalogs`] are scanned instead.
352    counted: (usize, usize),
353}
354
355impl CatalogPlaces {
356    fn of(catalogs: &[ShownCatalog]) -> Self {
357        let mut places = CatalogPlaces {
358            counted: Self::count(catalogs),
359            ..Default::default()
360        };
361        for (c, catalog) in catalogs.iter().enumerate() {
362            for (d, dataset) in catalog.datasets.iter().enumerate() {
363                places
364                    .datasets
365                    .entry(place_key(&dataset.location))
366                    .or_insert((c, d));
367                for (b, (_, place)) in dataset.bookmarks.iter().enumerate() {
368                    places
369                        .bookmarks
370                        .entry(place_key(place))
371                        .or_insert((c, d, b));
372                }
373            }
374        }
375        places
376    }
377
378    fn count(catalogs: &[ShownCatalog]) -> (usize, usize) {
379        let datasets = catalogs.iter().flat_map(|c| c.datasets.iter());
380        (
381            datasets.clone().count(),
382            datasets.map(|d| d.bookmarks.len()).sum(),
383        )
384    }
385
386    /// Whether the index answers for `catalogs`: a lookup it misses is a miss.
387    fn indexes(&self, catalogs: &[ShownCatalog]) -> bool {
388        self.counted == Self::count(catalogs)
389    }
390}
391
392/// What is left of `url` below `root`, which it is [`within`].
393fn within_rest(url: &str, root: &str) -> String {
394    let canonical = |u: &str| match crate::cloud::source::azure_parts(u) {
395        Some((account, container, path)) => {
396            crate::cloud::source::azure_url(&account, &container, &path)
397        }
398        None => u.to_string(),
399    };
400    let (url, root) = (canonical(url), canonical(root));
401    url.strip_prefix(root.trim_end_matches('/'))
402        .unwrap_or("")
403        .to_string()
404}
405
406/// Whether `path` is a place in an object store: `s3://`, `gs://`, or Azure.
407pub fn is_object_store_url(path: &Path) -> bool {
408    let text = path.to_string_lossy();
409    let scheme = text
410        .split_once("://")
411        .map(|(s, _)| s.to_ascii_lowercase())
412        .unwrap_or_default();
413    matches!(scheme.as_str(), "s3" | "s3a" | "gs" | "gcs")
414        || crate::cloud::source::azure_parts(&text).is_some()
415}
416
417/// The URL that opens a cloud directory as one dataset: with a trailing slash, so
418/// it is scanned as a prefix rather than fetched as an object.
419pub fn directory_dataset_url(path: &Path) -> PathBuf {
420    let text = path.to_string_lossy();
421    if text.ends_with('/') {
422        path.to_path_buf()
423    } else {
424        PathBuf::from(format!("{text}/"))
425    }
426}
427
428/// The open-all row: opens the browsed directory as one table whatever its
429/// label, so a wrong label costs at most a keystroke. Offered in every directory,
430/// local or remote, and built from the listing on screen.
431fn whole_directory_row(dir: &Path, rows: &[Entry], remote: bool) -> Option<Entry> {
432    // A `cloud://<id>/<account>` place is an Azure account: its children are
433    // containers, with no URL to open.
434    if cloud_account(dir).is_some() {
435        return None;
436    }
437    let directories: Vec<String> = rows
438        .iter()
439        .filter(|r| !matches!(r.kind, EntryKind::File | EntryKind::Other))
440        .map(|r| format!("{}/", r.name))
441        .collect();
442    let objects: Vec<(String, u64)> = rows
443        .iter()
444        .filter(|r| matches!(r.kind, EntryKind::File | EntryKind::Other))
445        .map(|r| (r.path.to_string_lossy().into_owned(), r.size.unwrap_or(1)))
446        .collect();
447    // Each route classifies in its own terms: the listing drops dotted names (so a
448    // local `.hoodie` would be missed by the cloud classifier), and the cloud Iceberg
449    // rule is looser by design. Nothing remote is read here: a listing of a dead share
450    // freezes the UI, so local classification uses rows the probe already read.
451    let (kind, holds) = if remote || is_object_store_url(dir) {
452        #[cfg(feature = "cloud")]
453        {
454            crate::cloud::cloud_browse::look_at_listing(
455                &dir.to_string_lossy(),
456                &directories,
457                &objects,
458            )
459        }
460        // Without the cloud feature there is no remote classifier, and reading the share
461        // is what this avoids: the door is offered without a kind (losing only the lake
462        // check; its label is suppressed anyway).
463        #[cfg(not(feature = "cloud"))]
464        {
465            let _ = (&directories, &objects);
466            (EntryKind::Unknown, Default::default())
467        }
468    } else {
469        crate::home::discover::look_at_directory(dir)
470    };
471    // No door into a directory with nothing to open (empty, only `_SUCCESS`, or only
472    // subdirectories). Judged by what the directory holds, not only the listed rows:
473    // Spark and GBIF part files have no extension and list nothing, yet open by their
474    // bytes. See `holds_nothing_to_open`. A listing's object with no extension is a
475    // file row, but a prefix scan reads by name, so there the tally decides alone.
476    let object_store = is_object_store_url(dir);
477    let openable_row = !object_store && rows.iter().any(|r| r.kind == EntryKind::File);
478    let laid_out = !matches!(kind, EntryKind::Directory | EntryKind::Unknown);
479    if !openable_row && !laid_out && holds_nothing_to_open(&holds, object_store) {
480        return None;
481    }
482    let mut entry = Entry::directory(&directory_dataset_url(dir));
483    entry.kind = kind;
484    // This listing's tally; it may differ from the directory's own row upstairs,
485    // counted from one peek page.
486    entry.holds = holds;
487    entry.opens_whole_directory = true;
488    entry.name = door_name(&entry, rows);
489    Some(entry)
490}
491
492/// What a directory's door opens, from the kind and the tally already in hand.
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494pub enum DoorKind {
495    /// `key=value` partitions: one table, read with its partition columns.
496    Hive,
497    /// Files of one format that read as one table.
498    OneSchema,
499    /// Files of one format whose footers or headers disagree: the read is a union.
500    SchemasDiffer,
501    /// One data file, beside whatever else.
502    Single,
503    /// A Delta, Iceberg or Hudi root, whose files are not its rows.
504    Lake,
505    /// More than one format, files beside subdirectories, or only subdirectories.
506    Mixed,
507    /// Nothing has said what is here.
508    Unknown,
509}
510
511/// Which [`DoorKind`] a door is. A directory the footers or headers turned down as
512/// one table is `Directory` with one format in its tally; the listing alone calls
513/// it `MultiFile`.
514pub fn door_kind(door: &Entry) -> DoorKind {
515    let holds = &door.holds;
516    match door.kind {
517        EntryKind::Hive => DoorKind::Hive,
518        EntryKind::MultiFile => DoorKind::OneSchema,
519        k if k.is_lake_table() => DoorKind::Lake,
520        EntryKind::Unknown => DoorKind::Unknown,
521        _ => {
522            let Some(format) = holds.one_format() else {
523                return DoorKind::Mixed;
524            };
525            if holds.directories > 0 {
526                return DoorKind::Mixed;
527            }
528            let files = holds.data_files();
529            if files == 1 {
530                return DoorKind::Single;
531            }
532            let mostly_data = files * 2 >= files + holds.not_read + holds.unnamed;
533            let reads_many = crate::FileFormat::from_name(format)
534                .is_some_and(crate::FileFormat::reads_many_files);
535            if mostly_data && reads_many {
536                DoorKind::SchemasDiffer
537            } else {
538                DoorKind::Mixed
539            }
540        }
541    }
542}
543
544/// Whether stepping into a directory puts the cursor on its door: only when the door
545/// opens the one dataset the directory's own `Enter` opens; elsewhere it would start
546/// an unasked combined read.
547pub fn door_lands(door: &Entry) -> bool {
548    matches!(door_kind(door), DoorKind::Hive | DoorKind::OneSchema)
549}
550
551/// A format as prose names it, by the name the listing counted: `Parquet`, `CSV`.
552fn format_title(name: &str) -> String {
553    crate::FileFormat::from_name(name)
554        .map_or_else(|| name.to_ascii_uppercase(), |f| f.title().to_string())
555}
556
557/// The partition keys a hive door names: from the footer pass's layout, else the
558/// `key=value` names listed (in a bucket, the levels seen so far).
559fn door_keys(door: &Entry, rows: &[Entry]) -> Vec<String> {
560    if let Some(layout) = door.cost.partitions.as_ref()
561        && !layout.keys.is_empty()
562    {
563        return layout.keys.clone();
564    }
565    let mut keys: Vec<String> = Vec::new();
566    for row in rows {
567        if let Some((key, _)) = row.name.split_once('=')
568            && !key.is_empty()
569            && !keys.iter().any(|k| k == key)
570        {
571            keys.push(key.to_string());
572        }
573    }
574    keys
575}
576
577/// The door's name: what `Enter` on it opens, led by the action; the row draws the
578/// action as a chip and [`door_what`] beside it. Not the directory's
579/// name: the section title above says that, and a row named like the directory read as
580/// one more thing in it (a `~\VirtualBox VMs (all files, mixed)` row inside
581/// `~\VirtualBox VMs`).
582pub fn door_name(door: &Entry, rows: &[Entry]) -> String {
583    let holds = &door.holds;
584    let more = if holds.truncated { "+" } else { "" };
585    let files = |format: &str| {
586        let count = holds.data_files();
587        let word = if count == 1 { "file" } else { "files" };
588        format!("{count}{more} {} {word}", format_title(format))
589    };
590    let what = match door_kind(door) {
591        DoorKind::Hive => {
592            let keys = door_keys(door, rows);
593            if keys.is_empty() {
594                "hive table".to_string()
595            } else {
596                format!("hive table: {}", keys.join(", "))
597            }
598        }
599        DoorKind::OneSchema => match (holds.model_weights(), holds.one_format()) {
600            (Some((format, count)), _) => {
601                let word = if count == 1 { "file" } else { "files" };
602                format!("model, {count}{more} {} {word}", format_title(format))
603            }
604            (None, Some(format)) => format!("{}, one schema", files(format)),
605            (None, None) => "one table".to_string(),
606        },
607        DoorKind::SchemasDiffer => {
608            format!(
609                "{}, schemas differ",
610                files(holds.one_format().unwrap_or(""))
611            )
612        }
613        DoorKind::Single => files(holds.one_format().unwrap_or("")),
614        DoorKind::Lake => format!(
615            "{} files, not the table",
616            door.kind.lake_name().unwrap_or_default()
617        ),
618        DoorKind::Mixed => match door_reads(door) {
619            Some((reads, None)) => reads,
620            // One kind left out is named; several are counted, and the pane names them.
621            Some((reads, Some(_))) => match door_skips(door).as_slice() {
622                [(_, what)] => format!("{reads}, {what} skipped"),
623                skips => {
624                    let n: usize = skips.iter().map(|(n, _)| n).sum();
625                    format!("{reads}, {n}{more} skipped")
626                }
627            },
628            None if holds.unnamed > 0 => {
629                let word = if holds.unnamed == 1 { "file" } else { "files" };
630                format!("{}{more} {word} with no extension", holds.unnamed)
631            }
632            None => "mixed files".to_string(),
633        },
634        DoorKind::Unknown => return DOOR_ACTION.to_string(),
635    };
636    format!("{DOOR_ACTION} ({what})")
637}
638
639/// The words every door's name starts with.
640pub const DOOR_ACTION: &str = "Open all";
641
642/// What a door's name says it opens, without the action: `3 Parquet files, one
643/// schema` from `Open all (3 Parquet files, one schema)`; empty for a bare action.
644pub fn door_what(name: &str) -> &str {
645    let what = name.strip_prefix(DOOR_ACTION).unwrap_or(name).trim();
646    (what.strip_prefix('(').and_then(|w| w.strip_suffix(')'))).unwrap_or(what)
647}
648
649/// What `Enter` on a door that is not one table reads and leaves out, for the
650/// details pane and the door's name. Locally: the commonest format's files directly
651/// inside, or a whole Parquet scan when there are subdirectories or no own files. In
652/// an object store: the prefix scanned whole in its commonest format.
653pub fn door_reads(door: &Entry) -> Option<(String, Option<String>)> {
654    if !matches!(door_kind(door), DoorKind::Mixed | DoorKind::Single) {
655        return None;
656    }
657    let holds = &door.holds;
658    let more = if holds.truncated { "+" } else { "" };
659    let Some((format, count)) = holds.formats.first() else {
660        return (holds.directories > 0).then(|| ("every Parquet file below".to_string(), None));
661    };
662    let reads = if reads_below(door) && is_object_store_url(&door.path) {
663        format!("every {} file below", format_title(format))
664    } else if reads_below(door) {
665        "every Parquet file below".to_string()
666    } else {
667        counted(*count, more, &format_title(format), "file", "files")
668    };
669    let skips: Vec<String> = door_skips(door).into_iter().map(|(_, what)| what).collect();
670    Some((reads, (!skips.is_empty()).then(|| skips.join(", "))))
671}
672
673/// `3 CSV files`, `1 directory`.
674fn counted(n: usize, more: &str, title: &str, one: &str, many: &str) -> String {
675    let word = if n == 1 { one } else { many };
676    if title.is_empty() {
677        format!("{n}{more} {word}")
678    } else {
679        format!("{n}{more} {title} {word}")
680    }
681}
682
683/// Whether a door's read takes in its subdirectories: an object-store prefix is
684/// scanned whole, and on disk partitions or Parquet alone are scanned as a tree.
685fn reads_below(door: &Entry) -> bool {
686    let holds = &door.holds;
687    let only_parquet = holds.formats.len() == 1 && holds.formats[0].0 == "parquet";
688    holds.directories > 0
689        && (is_object_store_url(&door.path) || holds.partitions > 0 || only_parquet)
690}
691
692/// What a door's read leaves out, counted: the other formats' files, and on disk the
693/// plain subdirectories it does not go into. Each is its count and its words.
694fn door_skips(door: &Entry) -> Vec<(usize, String)> {
695    let holds = &door.holds;
696    let more = if holds.truncated { "+" } else { "" };
697    let mut skips: Vec<(usize, String)> = holds
698        .formats
699        .iter()
700        .skip(1)
701        .map(|(name, n)| (*n, counted(*n, more, &format_title(name), "file", "files")))
702        .collect();
703    let plain = holds.directories.saturating_sub(holds.partitions);
704    if !reads_below(door) && plain > 0 {
705        skips.push((plain, counted(plain, more, "", "directory", "directories")));
706    }
707    skips
708}
709
710/// Name files a format spec's glob matches as data, under the spec's name, from the
711/// name alone. Files a spec's magic matches were named by the scan's sniff.
712pub fn name_by_spec(formats: &crate::formats::Registry, rows: &mut [Entry]) {
713    if formats.is_empty() {
714        return;
715    }
716    let mut named = false;
717    for row in rows
718        .iter_mut()
719        .filter(|r| r.kind == EntryKind::Other && r.format_spec.is_none())
720    {
721        if let Some(spec) = formats.by_glob(&row.path, false).first() {
722            discover::name_spec_file(row, spec);
723            named = true;
724        }
725    }
726    // Delimited text a delimited spec's glob names keeps its place and gains the name.
727    for row in rows.iter_mut().filter(|r| {
728        r.kind == EntryKind::File
729            && r.format_spec.is_none()
730            && discover::data_format(&r.path).is_some_and(|f| f.separator().is_some())
731    }) {
732        if let Some(spec) = formats
733            .by_glob(&row.path, false)
734            .into_iter()
735            .find(|s| s.is_delimited())
736        {
737            row.format_spec = Some(spec.name.clone());
738        }
739    }
740    // Data sorts first, and these rows are data now.
741    if named {
742        discover::sort_entries(rows);
743    }
744}
745
746/// Whether a directory holds nothing an open-all row could read. Shared by the
747/// door and the details pane so they agree.
748///
749/// Subdirectories alone are not enough: on disk the scan of a tree refuses one whose
750/// files differ in extension, and in an object store a prefix of prefixes (a bucket's
751/// root, `by_year/` beside `by_station/`) is rarely one table. Partitions and a saved
752/// `DatasetDict` still are. Files with no extension count on disk, where the open
753/// reads them by their bytes; a prefix is scanned by name, so there they do not.
754pub fn holds_nothing_to_open(holds: &discover::Holds, object_store: bool) -> bool {
755    let below = holds.partitions > 0 || holds.dataset_dict;
756    let unnamed = holds.unnamed > 0 && !object_store;
757    holds.formats.is_empty() && !unnamed && !below
758}
759
760/// Whether `path` is one of datui's own `cloud://` places rather than a real location.
761pub fn is_cloud_place(path: &Path) -> bool {
762    path.to_string_lossy().starts_with(CLOUD_PLACE)
763}
764
765/// Whether `path` is the root of a bucket: `s3://bucket`, `s3://<id>@bucket`,
766/// `gs://bucket`, with no prefix.
767fn is_bucket_root(path: &Path) -> bool {
768    let text = path.to_string_lossy();
769    if let Some((_, _, key)) = crate::cloud::source::azure_parts(&text) {
770        return key.trim_matches('/').is_empty();
771    }
772    let Some((scheme, rest)) = text.split_once("://") else {
773        return false;
774    };
775    matches!(scheme, "s3" | "s3a" | "gs" | "gcs") && {
776        let rest = rest.trim_end_matches('/');
777        !rest.is_empty() && !rest.contains('/')
778    }
779}
780
781/// The location one level up from `path`, or `None` at the top. A URL's top is its
782/// bucket or host (`Path::parent` would make `gs://bucket` into `gs:`).
783pub fn parent_location(path: &Path) -> Option<PathBuf> {
784    if !matches!(
785        crate::cloud::source::input_source(path),
786        crate::cloud::source::InputSource::Local(_)
787    ) {
788        let s = path.to_string_lossy();
789        let (scheme, rest) = s.split_once("://")?;
790        let (up, _) = rest.trim_end_matches('/').rsplit_once('/')?;
791        return Some(PathBuf::from(format!("{scheme}://{up}")));
792    }
793    path.parent()
794        .filter(|p| !p.as_os_str().is_empty() && *p != path)
795        .map(Path::to_path_buf)
796}
797
798/// Whether reading `path` could block: an object-store or HTTP URL, or a network
799/// filesystem. Decides what home may touch on the UI thread; answered from the
800/// string and mount table alone.
801pub fn is_remote_path(path: &Path) -> bool {
802    is_cloud_place(path)
803        || !matches!(
804            crate::cloud::source::input_source(path),
805            crate::cloud::source::InputSource::Local(_)
806        )
807        || is_network_path(path)
808}
809
810/// Whether `path` is on a network filesystem by the mount table (longest matching
811/// mount point). False where the table is unavailable: a hint, never a gate. Uses
812/// [`crate::home::locality::Mounts::cached`], since this is asked per row per frame.
813pub fn is_network_path(path: &Path) -> bool {
814    crate::home::locality::Mounts::cached().is_network(path)
815}
816
817/// Whether a call on `path` can hang: a network filesystem or any FUSE one. What keeps
818/// a read off the threads that must not wait; see
819/// [`crate::home::locality::Mounts::could_block`].
820pub fn could_block_path(path: &Path) -> bool {
821    crate::home::locality::Mounts::cached().could_block(path)
822}
823
824/// The mount-table logic against a fixture, for tests (e.g. an NFS share shadowing
825/// an autofs entry at the same path).
826#[doc(hidden)]
827pub fn network_fs_for_test(mountinfo: &str, path: &Path) -> bool {
828    crate::home::locality::Mounts::parse(mountinfo).is_network(path)
829}
830
831/// A place datui will look, and whether it can currently be read.
832#[derive(Debug, Clone)]
833pub struct Root {
834    pub path: PathBuf,
835    pub origin: RootOrigin,
836    /// True when the root is on a network filesystem.
837    pub network: bool,
838    /// False when the directory cannot be read (an unmounted NAS, a deleted scratch
839    /// dir); shown, not hidden.
840    pub available: bool,
841}
842
843/// A titled group of rows on the home screen.
844#[derive(Debug, Clone, Default)]
845pub struct Section {
846    pub title: String,
847    /// The section's state at the far end of the rule: its filesystem, `first 5000` for
848    /// a cut listing, what a search covered. Why it exists is `origin`.
849    pub subtitle: Option<String>,
850    /// Why a path-titled section is here (`current directory`, `configured`), as a chip
851    /// beside the count.
852    pub origin: Option<&'static str>,
853    /// The directory a path-titled section lists (a root, or the browsed directory);
854    /// the title is abbreviated and cannot be turned back into a path.
855    pub root: Option<PathBuf>,
856    pub rows: Vec<Entry>,
857    /// The row opening this section's directory as one table, kept out of `rows`: its
858    /// path is the directory's own, so among the rows it would collide in every
859    /// path-keyed map with the directory's row one level up. Nothing that walks `rows`
860    /// or matches [`Row::Entry`] can reach it.
861    pub door: Option<Entry>,
862    /// Set when a root could not be read, so the UI can say why it is empty.
863    pub unavailable: bool,
864    /// What to say instead of "unavailable", when there is more to say (a refused bucket
865    /// listing's reason and fix).
866    pub unavailable_note: Option<String>,
867    /// Starts folded unless opened: places that are context rather than the reason you
868    /// came (directories promoted from recents, the desktop's list).
869    pub folded_by_default: bool,
870    /// The remote root whose background probe fills this section in.
871    pub remote_root: Option<PathBuf>,
872    /// The probe had not answered when this listing was built: empty means "wait".
873    pub waiting: bool,
874    /// Rows are grouped under the place each lives in, with a place row. Set on
875    /// `RECENT`, whose rows come from anywhere.
876    pub grouped_by_place: bool,
877    /// What the dataset index remembers each place to be, for a grouped section's place
878    /// rows; from the cache, never a read.
879    pub place_labels: std::collections::HashMap<PathBuf, String>,
880}
881
882impl Section {
883    /// A section of `rows` under `title`, everything else at its default.
884    pub fn titled(title: impl Into<String>, rows: Vec<Entry>) -> Self {
885        Section {
886            title: title.into(),
887            rows,
888            ..Default::default()
889        }
890    }
891}
892
893/// Where a cloud source's listing stands.
894#[derive(Debug, Clone, PartialEq, Eq, Default)]
895pub enum CloudStatus {
896    /// Asked, and no answer yet.
897    #[default]
898    Listing,
899    /// Not asked and not listed before; its rows, if any, are the config's named
900    /// buckets. Entering it or Ctrl+R lists it.
901    Unlisted,
902    /// Listed, now or on an earlier run.
903    Listed,
904    /// The listing was refused or never answered: `short` for the row, `detail` (what
905    /// happened, how to fix) for the details pane.
906    Failed { short: String, detail: String },
907}
908
909/// One cloud source on home: a row under `CLOUD` and its bucket list. Kept apart
910/// from [`Section`] to survive rebuilds: re-listing buckets would be a billed round
911/// trip per keystroke.
912#[derive(Debug, Clone, Default, PartialEq, Eq)]
913pub struct CloudSource {
914    /// The source ID, as in `[[cloud.connections]]` and `s3://<id>@bucket`.
915    pub id: String,
916    /// The row's name.
917    pub label: String,
918    /// The API spoken.
919    pub api: crate::cloud::source::ProviderKind,
920    /// The account, endpoint or project, and where the login came from.
921    pub note: String,
922    /// Bucket URLs, most useful first: `s3://bucket`, `s3://<id>@bucket`, `gs://bucket`.
923    pub buckets: Vec<PathBuf>,
924    pub status: CloudStatus,
925    /// When the buckets were listed, when they were.
926    pub listed_at: Option<std::time::SystemTime>,
927    /// A listing is out for buckets already on screen from an earlier run.
928    pub refreshing: bool,
929    /// Listed, or being listed, this session. Entering a source that is not lists it.
930    pub asked: bool,
931    /// `key  value` lines for the details pane: endpoint, region, login.
932    pub details: Vec<(String, String)>,
933    /// Lines for the details pane of places inside the source: an Azure account's
934    /// subscription, region and namespace.
935    pub place_details: std::collections::HashMap<PathBuf, Vec<(String, String)>>,
936}
937
938impl CloudSource {
939    /// What the row says instead of a size: the bucket count, or why there is none.
940    pub fn count_text(&self) -> String {
941        match &self.status {
942            CloudStatus::Failed { short, .. } if self.buckets.is_empty() => short.clone(),
943            CloudStatus::Listing if self.buckets.is_empty() => String::new(),
944            CloudStatus::Unlisted if self.buckets.is_empty() => "not listed".to_string(),
945            _ => {
946                let (one, many) = match self.api {
947                    crate::cloud::source::ProviderKind::Azure => ("account", "accounts"),
948                    crate::cloud::source::ProviderKind::Gcs => ("project", "projects"),
949                    crate::cloud::source::ProviderKind::S3 => ("bucket", "buckets"),
950                };
951                match self.buckets.len() {
952                    0 => format!("no {many}"),
953                    1 => format!("1 {one}"),
954                    n => format!("{n} {many}"),
955                }
956            }
957        }
958    }
959
960    /// Mark a listing as out: a spinner in place of the count when there are no buckets
961    /// to show yet, beside it when there are.
962    pub fn begin_listing(&mut self) {
963        self.asked = true;
964        if self.status == CloudStatus::Unlisted && self.buckets.is_empty() {
965            self.status = CloudStatus::Listing;
966        } else {
967            self.refreshing = true;
968        }
969    }
970
971    /// Whether the row should show a spinner.
972    pub fn busy(&self) -> bool {
973        self.refreshing || (self.status == CloudStatus::Listing && self.buckets.is_empty())
974    }
975
976    /// Whether the row reports a failure.
977    pub fn failed(&self) -> bool {
978        matches!(self.status, CloudStatus::Failed { .. })
979    }
980}
981
982/// A catalog as the home screen shows it: a section of named datasets, local and remote
983/// alike.
984#[derive(Debug, Clone, PartialEq)]
985pub struct ShownCatalog {
986    /// The catalog's id, as `[home] hide` names it.
987    pub id: String,
988    /// The section's title.
989    pub label: String,
990    /// `catalog.toml`, a listed file, or the bundled catalog.
991    pub origin: crate::home::catalog::Origin,
992    /// What the catalog says it is, for its heading's details.
993    pub description: String,
994    /// The file it was read from; none for the bundled one.
995    pub file: Option<PathBuf>,
996    pub datasets: Vec<ShownDataset>,
997    /// Left out for a mistake: the one line its section says instead of rows.
998    pub broken: Option<String>,
999}
1000
1001/// One dataset of a [`ShownCatalog`].
1002#[derive(Debug, Clone, Default, PartialEq)]
1003pub struct ShownDataset {
1004    /// The row's name.
1005    pub name: String,
1006    /// The local path with `~` and `$VAR` expanded, or the URL.
1007    pub location: PathBuf,
1008    /// `key  value` lines for the details pane.
1009    pub details: Vec<(String, String)>,
1010    /// What the catalog says a remote file weighs, until it is measured.
1011    pub size: Option<u64>,
1012    /// What its columns mean, when the catalog says.
1013    pub codebook: Option<std::sync::Arc<crate::home::codebook::Codebook>>,
1014    /// Places inside it to start from, by name, listed under its row.
1015    pub bookmarks: Vec<(String, PathBuf)>,
1016    /// The entry as its catalog writes it: what the Documentation view shows.
1017    pub entry: std::sync::Arc<crate::home::catalog::Dataset>,
1018}
1019
1020impl ShownCatalog {
1021    /// A catalog as the home screen shows it.
1022    pub fn from_catalog(catalog: &crate::home::catalog::Catalog) -> Self {
1023        Self {
1024            id: catalog.id.clone(),
1025            label: catalog.label.clone(),
1026            origin: catalog.origin,
1027            description: catalog.description.clone(),
1028            file: catalog.file.clone(),
1029            datasets: catalog
1030                .datasets
1031                .iter()
1032                .map(|dataset| {
1033                    let location = dataset.location();
1034                    let mut details: Vec<(String, String)> = [
1035                        ("about", &dataset.description),
1036                        ("publisher", &dataset.publisher),
1037                        ("license", &dataset.license),
1038                        ("homepage", &dataset.homepage),
1039                        ("documentation", &dataset.documentation),
1040                    ]
1041                    .into_iter()
1042                    .filter(|(_, value)| !value.is_empty())
1043                    .map(|(key, value)| (key.to_string(), value.clone()))
1044                    .collect();
1045                    match &dataset.url {
1046                        None => details.push(("path".to_string(), display_path(&location))),
1047                        Some(url) => {
1048                            details.push(("url".to_string(), url.clone()));
1049                            details.push(("login".to_string(), login_of(dataset)));
1050                        }
1051                    }
1052                    ShownDataset {
1053                        name: dataset.name.clone(),
1054                        location,
1055                        details,
1056                        size: dataset.size,
1057                        codebook: crate::home::codebook::Codebook::of(dataset)
1058                            .map(std::sync::Arc::new),
1059                        bookmarks: dataset
1060                            .bookmarks
1061                            .iter()
1062                            .map(|(name, path)| (name.clone(), dataset.bookmark_location(path)))
1063                            .collect(),
1064                        entry: std::sync::Arc::new(dataset.clone()),
1065                    }
1066                })
1067                .collect(),
1068            broken: None,
1069        }
1070    }
1071
1072    /// A catalog file left out for a mistake, as a section that says what is wrong.
1073    pub fn from_broken(broken: &crate::home::catalog::Broken) -> Self {
1074        Self {
1075            id: broken.id.clone(),
1076            label: broken.id.clone(),
1077            origin: broken.origin,
1078            description: String::new(),
1079            file: None,
1080            datasets: Vec::new(),
1081            broken: Some(broken.callout()),
1082        }
1083    }
1084
1085    /// The chip beside the section's title: where its datasets are written. Each is
1086    /// one of [`CATALOG_ORIGINS`].
1087    pub fn origin_note(&self) -> &'static str {
1088        match self.origin {
1089            crate::home::catalog::Origin::Mine => "catalog.toml",
1090            crate::home::catalog::Origin::Listed | crate::home::catalog::Origin::Folder => {
1091                "catalog"
1092            }
1093            crate::home::catalog::Origin::Bundled => BUNDLED_ORIGIN,
1094        }
1095    }
1096}
1097
1098/// The chip on the bundled catalog's section.
1099pub const BUNDLED_ORIGIN: &str = "comes with datui";
1100
1101/// The chips a catalog's section carries, and nothing else does.
1102pub const CATALOG_ORIGINS: [&str; 3] = ["catalog.toml", "catalog", BUNDLED_ORIGIN];
1103
1104/// Whether a section's origin chip says it is a catalog.
1105pub fn is_catalog_origin(origin: &str) -> bool {
1106    CATALOG_ORIGINS.contains(&origin)
1107}
1108
1109/// How a catalog URL is read, in words: what `auth` and `connection` say.
1110pub fn login_of(dataset: &crate::home::catalog::Dataset) -> String {
1111    match dataset.object_store_auth() {
1112        Some(crate::config::DatasetAuth::Connection(connection)) => connection,
1113        Some(crate::config::DatasetAuth::Anonymous) | None => "none".to_string(),
1114        Some(crate::config::DatasetAuth::Auto) => "auto".to_string(),
1115    }
1116}
1117
1118/// The catalogs home shows, in order. The bundled catalog keeps only what this
1119/// build can open (all its datasets are remote); a user's catalog is shown whole.
1120/// An empty catalog has no section.
1121pub fn catalogs(config: &crate::config::AppConfig) -> Vec<ShownCatalog> {
1122    let mut out: Vec<ShownCatalog> = config
1123        .shown_catalogs()
1124        .iter()
1125        .filter_map(|catalog| {
1126            let mut shown = ShownCatalog::from_catalog(catalog);
1127            if catalog.origin == crate::home::catalog::Origin::Bundled {
1128                shown
1129                    .datasets
1130                    .retain(|d| crate::cloud::source::opens_in_this_build(&d.location));
1131            }
1132            (!shown.datasets.is_empty()).then_some(shown)
1133        })
1134        .collect();
1135    // A broken file's section says so, before the bundled catalog, unless it is hidden.
1136    let at = out
1137        .iter()
1138        .position(|c| c.origin == crate::home::catalog::Origin::Bundled)
1139        .unwrap_or(out.len());
1140    let broken: Vec<ShownCatalog> = config
1141        .broken_catalogs
1142        .iter()
1143        .filter(|b| !config.home.hide.contains(&b.id))
1144        .map(ShownCatalog::from_broken)
1145        .collect();
1146    out.splice(at..at, broken);
1147    out
1148}
1149
1150/// The row for one catalog dataset. Reads nothing but a local path's directory
1151/// entry; a remote dataset is named by its URL.
1152fn catalog_entry(
1153    dataset: &ShownDataset,
1154    network_check: fn(&Path) -> bool,
1155    missing: &mut std::collections::HashSet<PathBuf>,
1156) -> Entry {
1157    let path = &dataset.location;
1158    let local = matches!(
1159        crate::cloud::source::input_source(path),
1160        crate::cloud::source::InputSource::Local(_)
1161    );
1162    let mut entry = if is_object_store_url(path) {
1163        if names_a_file(path) {
1164            entry_for_path(path, true)
1165        } else {
1166            Entry::directory(path)
1167        }
1168    } else if !local || network_check(path) {
1169        entry_for_path(path, true)
1170    } else if path.exists() {
1171        entry_for_path(path, false)
1172    } else {
1173        missing.insert(path.clone());
1174        let mut entry = entry_for_path(path, true);
1175        entry.kind = EntryKind::Unknown;
1176        entry
1177    };
1178    entry.name = dataset.name.clone();
1179    entry
1180}
1181
1182/// The column notes of the catalog dataset `path` is or is inside (the innermost),
1183/// among datasets with notes.
1184pub fn codebook_for(
1185    catalogs: &[ShownCatalog],
1186    path: &Path,
1187) -> Option<std::sync::Arc<crate::home::codebook::Codebook>> {
1188    let text = path.to_string_lossy();
1189    catalogs
1190        .iter()
1191        .flat_map(|c| c.datasets.iter())
1192        .filter(|d| d.codebook.is_some())
1193        .filter(|d| d.location == path || within(&text, &d.location.to_string_lossy()))
1194        .max_by_key(|d| d.location.to_string_lossy().trim_end_matches('/').len())
1195        .and_then(|d| d.codebook.clone())
1196}
1197
1198/// The catalog entry `path` is or is inside, with its catalog's label: the innermost,
1199/// or the first listed of two at one place.
1200pub fn catalog_entry_for(
1201    catalogs: &[ShownCatalog],
1202    path: &Path,
1203) -> Option<(String, std::sync::Arc<crate::home::catalog::Dataset>)> {
1204    let text = path.to_string_lossy();
1205    catalogs
1206        .iter()
1207        .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
1208        .filter(|(_, d)| {
1209            d.location == path
1210                || same_place(&d.location, path)
1211                || within(&text, &d.location.to_string_lossy())
1212        })
1213        .rev()
1214        .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
1215        .map(|(c, d)| (c.label.clone(), d.entry.clone()))
1216}
1217
1218/// The row for a bookmark inside one of a catalog's datasets.
1219fn bookmark_entry(name: &str, place: &Path, network_check: fn(&Path) -> bool) -> Entry {
1220    let local = matches!(
1221        crate::cloud::source::input_source(place),
1222        crate::cloud::source::InputSource::Local(_)
1223    );
1224    let mut entry = if is_object_store_url(place) && !names_a_file(place) {
1225        Entry::directory(place)
1226    } else {
1227        entry_for_path(place, !local || network_check(place) || !place.exists())
1228    };
1229    entry.name = name.to_string();
1230    entry
1231}
1232
1233/// A catalog's section.
1234fn catalog_section(
1235    catalog: &ShownCatalog,
1236    network_check: fn(&Path) -> bool,
1237    missing: &mut std::collections::HashSet<PathBuf>,
1238) -> Section {
1239    // Each dataset, and under it its bookmarks.
1240    let rows = catalog
1241        .datasets
1242        .iter()
1243        .flat_map(|dataset| {
1244            std::iter::once(catalog_entry(dataset, network_check, missing)).chain(
1245                dataset
1246                    .bookmarks
1247                    .iter()
1248                    .map(|(name, place)| bookmark_entry(name, place, network_check)),
1249            )
1250        })
1251        .collect();
1252    Section {
1253        origin: Some(catalog.origin_note()),
1254        unavailable: catalog.broken.is_some(),
1255        unavailable_note: catalog.broken.clone(),
1256        ..Section::titled(catalog.label.clone(), rows)
1257    }
1258}
1259
1260/// What measuring a dataset yielded; each part absent when unknowable without
1261/// reading the data.
1262#[derive(Debug, Clone, Default, PartialEq)]
1263pub struct Measured {
1264    pub rows: Option<usize>,
1265    pub cols: Option<usize>,
1266    /// Whether `cols` is a floor rather than a total. See [`crate::home::discover::Entry`].
1267    pub cols_sampled: bool,
1268    pub size: Option<u64>,
1269    /// When it last changed, from the stat a listing leaves to the rows shown.
1270    pub modified: Option<std::time::SystemTime>,
1271    /// A stat alone (`Reads::StatOnly`): its size and mtime, not a measurement, so the
1272    /// row is still measured when it comes on screen.
1273    pub stat_only: bool,
1274    /// Column names, when the format gave them up for free.
1275    pub columns: Vec<String>,
1276    /// What opening it costs: compression, layout, partitioning, carried to the screen
1277    /// with the row count.
1278    pub cost: crate::home::discover::Cost,
1279    /// What the footers said it is when that differs from its filenames (separate tables
1280    /// make a plain directory); usually `None`. See [`crate::home::discover::enrich`].
1281    pub kind: Option<crate::home::discover::EntryKind>,
1282    /// What one listing found, which the row's label says; only the classify pass counts
1283    /// a local directory.
1284    pub holds: crate::home::discover::Holds,
1285}
1286
1287/// How rows are ordered within each section.
1288#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1289pub enum SortMode {
1290    /// Each section's natural order: recency under Recent, name under a directory.
1291    #[default]
1292    Natural,
1293    /// Largest first: the question is "what is big in here".
1294    Size,
1295    /// Most recently changed first: the question is "what moved".
1296    Modified,
1297    /// Most rows first.
1298    Rows,
1299}
1300
1301impl SortMode {
1302    /// What this mode does in a section, since `Natural` means recency or name depending
1303    /// on where the cursor is.
1304    pub fn label_in(self, section_is_recency_ordered: bool) -> &'static str {
1305        match self {
1306            SortMode::Natural if section_is_recency_ordered => "recent",
1307            SortMode::Natural => "name",
1308            SortMode::Size => "size",
1309            SortMode::Modified => "modified",
1310            SortMode::Rows => "rows",
1311        }
1312    }
1313
1314    pub fn next(self) -> Self {
1315        match self {
1316            SortMode::Natural => SortMode::Size,
1317            SortMode::Size => SortMode::Modified,
1318            SortMode::Modified => SortMode::Rows,
1319            SortMode::Rows => SortMode::Natural,
1320        }
1321    }
1322}
1323
1324/// One line of the home screen; headers are selectable to fold. Places and `more`
1325/// rows are view rows, not entries: an [`Entry`]'s kind triggers probes,
1326/// measurement and caching, none of which may happen to a place.
1327#[derive(Debug, Clone)]
1328pub enum Row<'a> {
1329    Header {
1330        section: usize,
1331        /// Rows this section holds under the current filter.
1332        matches: usize,
1333        collapsed: bool,
1334    },
1335    Entry {
1336        section: usize,
1337        entry: &'a Entry,
1338        /// Drawn two cells in, under the place row above it.
1339        nested: bool,
1340        /// How it answers the filter, for the marks the row is drawn with.
1341        hit: Hit,
1342    },
1343    /// The directory or prefix the entries below it live in, under `RECENT`.
1344    Place {
1345        section: usize,
1346        path: PathBuf,
1347        /// What the place was last found to be (`hive`, `12 parquet`), from the dataset
1348        /// index only, never a read.
1349        label: Option<String>,
1350        /// The filesystem it is on, or the object store's scheme.
1351        source: Option<String>,
1352        /// How many recents live there, whether or not the filter shows them.
1353        held: usize,
1354    },
1355    /// The door opening the browsed directory as one table; see [`Section::door`]. Not
1356    /// an `Entry` row, since it carries the directory's own path.
1357    Door { section: usize, entry: &'a Entry },
1358    /// What a cap is hiding: `RECENT`'s, `… 13 more in 5 places`, or a directory's
1359    /// at the root listing, `… 4,958 more` (`places` is 0).
1360    More {
1361        section: usize,
1362        hidden: usize,
1363        places: usize,
1364        /// Sorted by rows with some hidden rows still being measured, so the order may
1365        /// change.
1366        measuring: bool,
1367    },
1368    /// The last row of a browsed directory hiding unreadable files (`… 10 files with no
1369    /// reader`), so a directory of notes does not look empty. `Enter` shows them, as
1370    /// `Ctrl+A` does.
1371    Hidden { section: usize, count: usize },
1372    /// The way up, first in a directory's section: `..`; Enter goes to the parent, as
1373    /// Backspace does.
1374    Up { section: usize },
1375}
1376
1377impl Row<'_> {
1378    pub fn section(&self) -> usize {
1379        match self {
1380            Row::Header { section, .. }
1381            | Row::Entry { section, .. }
1382            | Row::Door { section, .. }
1383            | Row::Place { section, .. }
1384            | Row::More { section, .. }
1385            | Row::Hidden { section, .. }
1386            | Row::Up { section } => *section,
1387        }
1388    }
1389}
1390
1391/// How a row answers the filter: its score, and whether its name or a column matched.
1392/// Scored when rows are listed; the matched characters are found when drawn, for the
1393/// rows on screen rather than every row of thousands.
1394#[derive(Debug, Clone, Copy, Default, PartialEq)]
1395pub struct Hit {
1396    pub score: i32,
1397    /// Index into the entry's `columns` of the matching column, when the name did not
1398    /// match.
1399    pub column: Option<usize>,
1400}
1401
1402impl Hit {
1403    /// The column that matched, when the name did not.
1404    pub fn column_of<'a>(&self, entry: &'a Entry) -> Option<&'a str> {
1405        self.column
1406            .and_then(|i| entry.columns.get(i))
1407            .map(String::as_str)
1408    }
1409
1410    /// Character positions to mark for `filter`: in the name, or in the matched
1411    /// column's name. From the same alignment that scored it.
1412    pub fn positions(&self, filter: &str, entry: &Entry) -> Vec<usize> {
1413        match self.column_of(entry) {
1414            Some(column) => substring_positions(filter, column),
1415            None => fuzzy_positions(filter, &entry.name),
1416        }
1417    }
1418}
1419
1420/// [`HomeState::visible`]'s rows, cached until their inputs change: building scores
1421/// every row and sorts each section, and every pass reads them. `HomeState`'s own
1422/// row-changing methods drop it; fields set elsewhere (filter, sort, folds) are
1423/// compared on every read.
1424#[derive(Debug, Default)]
1425pub struct RowsCache {
1426    built: std::cell::RefCell<Option<View>>,
1427    builds: std::cell::Cell<usize>,
1428    /// The last build's hits, kept for the next build when only some rows changed
1429    /// (measured, or `Found` replaced): see [`HomeState::rows_changed`].
1430    rescored: std::cell::RefCell<Option<Hits>>,
1431}
1432
1433/// Every row's match against the filter, by section and row index; `None` where the row
1434/// does not match. A section with no hits is scored at the next build.
1435#[derive(Debug, Clone)]
1436struct Hits {
1437    filter: String,
1438    sections: Vec<Option<Vec<Option<Hit>>>>,
1439}
1440
1441/// The rows as last built, and what they were built from.
1442#[derive(Debug)]
1443struct View {
1444    key: ViewKey,
1445    slots: Vec<Slot>,
1446    /// Which slots are section headers, in order: few, so the list's lines are found
1447    /// from them without walking every row.
1448    headers: Vec<usize>,
1449    /// Which slots are doors, in order.
1450    doors: Vec<usize>,
1451    hits: Hits,
1452    /// See [`HomeState::has_any_dataset`].
1453    has_dataset: bool,
1454}
1455
1456/// The rows' inputs not changed only through [`HomeState`]'s methods, plus the
1457/// sections' shape, so a stale index is never read.
1458#[derive(Debug, PartialEq)]
1459struct ViewKey {
1460    filter: String,
1461    sort: SortMode,
1462    hide_unreadable: bool,
1463    recent_expanded: bool,
1464    shown_whole: std::collections::HashSet<PathBuf>,
1465    view_height: usize,
1466    browsing: Option<PathBuf>,
1467    folds: std::collections::HashMap<String, bool>,
1468    shape: Vec<(usize, bool)>,
1469}
1470
1471impl ViewKey {
1472    fn of(home: &HomeState) -> Self {
1473        ViewKey {
1474            filter: home.filter.clone(),
1475            sort: home.sort,
1476            hide_unreadable: home.hide_unreadable,
1477            recent_expanded: home.recent_expanded,
1478            shown_whole: home.shown_whole.clone(),
1479            view_height: home.view_height,
1480            browsing: home.browsing.clone(),
1481            folds: home.folds.clone(),
1482            shape: (home.sections.iter())
1483                .map(|s| (s.rows.len(), s.door.is_some()))
1484                .collect(),
1485        }
1486    }
1487
1488    /// Whether `home` would make this key, compared without cloning.
1489    fn matches(&self, home: &HomeState) -> bool {
1490        self.filter == home.filter
1491            && self.sort == home.sort
1492            && self.hide_unreadable == home.hide_unreadable
1493            && self.recent_expanded == home.recent_expanded
1494            && self.shown_whole == home.shown_whole
1495            && self.view_height == home.view_height
1496            && self.browsing == home.browsing
1497            && self.folds == home.folds
1498            && self.shape.iter().copied().eq(home
1499                .sections
1500                .iter()
1501                .map(|s| (s.rows.len(), s.door.is_some())))
1502    }
1503}
1504
1505/// A built row, by index into the sections for the rows that are entries.
1506#[derive(Debug)]
1507enum Slot {
1508    /// A row that holds no entry, as it is drawn.
1509    Plain(Row<'static>),
1510    Entry {
1511        section: usize,
1512        index: usize,
1513        nested: bool,
1514        hit: Hit,
1515    },
1516    Door {
1517        section: usize,
1518    },
1519}
1520
1521/// Where the listed rows fall in the list: with `spaced`, a blank line comes before
1522/// every header but the first, and one always follows a door, setting the directory's
1523/// action apart from its listing. Answers in a few steps for any row or line, so a
1524/// frame never walks thousands of rows to find its own.
1525#[derive(Debug, Clone)]
1526pub struct ListLines {
1527    /// The rows a blank line comes before, in order.
1528    gaps: Vec<usize>,
1529    rows: usize,
1530}
1531
1532impl ListLines {
1533    /// How many rows are listed.
1534    pub fn rows(&self) -> usize {
1535        self.rows
1536    }
1537
1538    /// How many blank lines the list has.
1539    pub fn spacers(&self) -> usize {
1540        self.gaps.len()
1541    }
1542
1543    /// The line row `row` is drawn on.
1544    pub fn line_of(&self, row: usize) -> usize {
1545        row + self.gaps.partition_point(|&g| g <= row)
1546    }
1547
1548    /// Lines the whole list takes.
1549    pub fn total(&self) -> usize {
1550        match self.rows {
1551            0 => 0,
1552            n => self.line_of(n - 1) + 1,
1553        }
1554    }
1555
1556    /// The first row drawn on `line` or below it.
1557    pub fn first_row_from(&self, line: usize) -> usize {
1558        let (mut lo, mut hi) = (0, self.rows);
1559        while lo < hi {
1560            let mid = lo + (hi - lo) / 2;
1561            if self.line_of(mid) < line {
1562                lo = mid + 1;
1563            } else {
1564                hi = mid;
1565            }
1566        }
1567        lo
1568    }
1569
1570    /// The row drawn on `line`; `None` for a blank line or past the end.
1571    pub fn row_on(&self, line: usize) -> Option<usize> {
1572        let row = self.first_row_from(line);
1573        (row < self.rows && self.line_of(row) == line).then_some(row)
1574    }
1575}
1576
1577/// The place a recent lives in: its directory or object-store prefix; a bare bucket
1578/// or host is its own place.
1579pub fn place_of(path: &Path) -> PathBuf {
1580    parent_location(path).unwrap_or_else(|| path.to_path_buf())
1581}
1582
1583/// Whether a place can be listed: a directory or an object-store prefix. An HTTP
1584/// server has no listing, so a URL recent's place is a heading, not a door.
1585pub fn place_is_browsable(path: &Path) -> bool {
1586    is_cloud_place(path)
1587        || is_object_store_url(path)
1588        || matches!(
1589            crate::cloud::source::input_source(path),
1590            crate::cloud::source::InputSource::Local(_)
1591        )
1592}
1593
1594/// A row's identity apart from its index, to find it again after a rebuild or a cap
1595/// change: the cursor's index points at different rows whenever a listing lands or
1596/// the terminal resizes, which would make `Enter` open the wrong row.
1597#[derive(Debug, Clone, PartialEq, Eq)]
1598pub enum RowKey {
1599    Header(String),
1600    Entry(PathBuf),
1601    /// The door, by the directory it opens; keyed as an `Entry` it would land on the
1602    /// directory's row.
1603    Door(PathBuf),
1604    Place(PathBuf),
1605    More(String),
1606    Hidden(String),
1607    Up(String),
1608}
1609
1610/// Home screen state.
1611#[derive(Debug)]
1612pub struct HomeState {
1613    pub sections: Vec<Section>,
1614    /// Fuzzy filter over every row in every section.
1615    pub filter: String,
1616    /// The filter kept from before a dataset opened, shown selected: the next character
1617    /// replaces it and `~` opens the path prompt. Any other key keeps it.
1618    pub filter_selected: bool,
1619    /// The most search matches listed under `Found`: `[home.search] max_results`.
1620    pub search_limit: usize,
1621    /// Hide files datui has no reader for (default); `Ctrl+A` flips it.
1622    pub hide_unreadable: bool,
1623    /// The format specs on the search path: files one names by glob list as data under
1624    /// its name.
1625    pub formats: std::sync::Arc<crate::formats::Registry>,
1626    /// The lake table being browsed and its format, said on its heading for the whole
1627    /// browse.
1628    pub lake_here: Option<(PathBuf, &'static str)>,
1629    /// Index into the flattened list of currently visible rows.
1630    pub selected: usize,
1631    /// First row of the last frame, as an index into [`HomeState::visible`]. Written by
1632    /// the renderer (which knows the list's height) and read by
1633    /// [`HomeState::unclassified_visible`], so what is looked into is what is on
1634    /// screen. Zero until the first frame.
1635    pub scroll: usize,
1636    /// How many rows the last frame had room for. See [`HomeState::scroll`].
1637    pub view_height: usize,
1638    /// The last frame's list in lines, and whether it was spaced: the scroll is settled in
1639    /// lines, as the frame draws it. Zero until the first frame.
1640    pub view_lines: usize,
1641    pub spaced: bool,
1642    /// True while the user is typing a path directly.
1643    pub path_input_active: bool,
1644    pub path_input: String,
1645    /// What the `~` prompt lists: the directory being typed and the names in it.
1646    pub path_listing: Option<PathListing>,
1647    /// The name ↑↓ put the cursor on, among those the last segment matches.
1648    pub path_pick: Option<usize>,
1649    /// Directory the user has descended into, if any. `None` means the root listing.
1650    pub browsing: Option<PathBuf>,
1651    /// Where the browse began (entered from the root listing or jumped to). Esc climbs
1652    /// back to here, then to the listing, never above. `None`: `browsing` is the start.
1653    pub browse_start: Option<PathBuf>,
1654    /// Transient message (e.g. a path that does not exist).
1655    pub status: Option<String>,
1656    /// How a path is judged network-backed; swappable so the never-touch-remote rule is
1657    /// testable without a remote.
1658    pub network_check: fn(&Path) -> bool,
1659    /// How often and how lately each recent was opened, ranking matches. Set with
1660    /// [`HomeState::set_visits`], which relists.
1661    pub visits: std::collections::HashMap<PathBuf, crate::cache::Visits>,
1662    /// The recent opened last: the cursor lands here, one Enter from the last file.
1663    pub newest_recent: Option<PathBuf>,
1664    /// Where the listing of each network root and remote directory is.
1665    pub probes: Probes,
1666    /// The names a filter asked the server for, in a cloud directory cut short.
1667    pub narrowed: Option<Narrowed>,
1668    /// What peeked cloud directories hold (`hive`, `multi`), kept for the session so each
1669    /// is peeked once.
1670    pub cloud_kinds: std::collections::HashMap<PathBuf, (EntryKind, crate::home::discover::Holds)>,
1671    /// How rows are ordered inside each section.
1672    pub sort: SortMode,
1673    /// A listing is being built on a worker; the previous one stays on screen.
1674    pub listing_in_flight: bool,
1675    /// True while a measurement batch is out, so only one is in flight at a time.
1676    pub measure_in_flight: bool,
1677    /// The filesystems (by mount point) with a classification pass out. One pass per
1678    /// filesystem, each chosen from the viewport then, so fast paging does not queue
1679    /// every row it passed and a share that stopped answering stops only its own rows.
1680    pub classifying: std::collections::HashSet<PathBuf>,
1681    /// Cloud directories with a peek out, kept apart from [`Self::cloud_kinds`] so no
1682    /// answer is claimed before the request returns.
1683    pub peeking: std::collections::HashSet<PathBuf>,
1684    /// Cloud directories whose peek failed: not asked again until Ctrl+R, labeled `?`
1685    /// (not `dir`, which would claim no data inside).
1686    pub peek_failed: std::collections::HashSet<PathBuf>,
1687    /// Row and column counts already read, by path, so each dataset is measured once a
1688    /// session.
1689    pub enriched: std::collections::HashMap<PathBuf, Measured>,
1690    /// Each row's latest stat this session, and the refresh it was taken in: a stat-only
1691    /// answer lands here and not in `enriched`.
1692    pub stated: std::collections::HashMap<PathBuf, Stated>,
1693    /// Which refresh the stats are of: Ctrl+R moves it, and the rows shown are stat'ed
1694    /// again, their counts dropped where the file changed.
1695    pub stat_epoch: u64,
1696    /// Rows whose counts went stale: the file changed since they were measured.
1697    pub stale: std::collections::HashSet<PathBuf>,
1698    /// Paths recorded in `enriched` since the rows last took them in.
1699    pub unapplied: std::collections::HashSet<PathBuf>,
1700    /// Sections folded (`true`) or opened by the user, by title so it survives rebuilds
1701    /// that renumber sections, and cached across restarts. Unlisted sections take their
1702    /// default. Use [`HomeState::toggle_collapsed`] and [`HomeState::set_collapsed`].
1703    pub folds: std::collections::HashMap<String, bool>,
1704    /// The saved folds are to be read again with the next listing, arriving with the
1705    /// rows they fold.
1706    pub folds_owed: bool,
1707    /// Datasets found by walking below the working directory.
1708    pub search: SearchState,
1709    /// What earlier runs measured, by path: the index the listing was annotated from,
1710    /// kept so search results fill in the same way (the filter matches columns). Read
1711    /// once a session; what this session measures is in [`Self::enriched`].
1712    pub known: Known,
1713    /// Cloud sources found on this machine or in the config, with their buckets. Empty
1714    /// without cloud credentials, which is normal.
1715    pub cloud: Vec<CloudSource>,
1716    /// The catalogs shown, a section each. Set with [`HomeState::set_catalogs`], which
1717    /// indexes their places.
1718    pub catalogs: Vec<ShownCatalog>,
1719    /// HTTP(S) catalog files whose size was asked for this session (a HEAD).
1720    pub sized: std::collections::HashSet<PathBuf>,
1721    /// HTTP(S) catalog files a HEAD showed unavailable (missing, no server); retried on
1722    /// Ctrl+R.
1723    pub web_gone: std::collections::HashMap<PathBuf, crate::error_display::HttpGone>,
1724    /// Local datasets of a catalog that the last listing found missing.
1725    pub missing: std::collections::HashSet<PathBuf>,
1726    /// When the current wait for a remote listing began, for the elapsed time on screen.
1727    pub waiting_since: Option<std::time::Instant>,
1728    /// `RECENT` shows every place, for the session: `Enter` on its `… N more` row.
1729    pub recent_expanded: bool,
1730    /// Directory sections shown whole rather than cut, by directory, for the session.
1731    /// See [`HomeState::show_all`].
1732    pub shown_whole: std::collections::HashSet<PathBuf>,
1733    /// The listings entered from, outermost first, to restore the cursor on the way out.
1734    /// See [`HomeState::leave_mark`].
1735    pub trail: Vec<Mark>,
1736    /// The row the cursor returns to once the returned-to listing lands; held across
1737    /// listings while rows arrive, dropped when the user moves.
1738    pub returning: Option<RowKey>,
1739    /// How far down the returned-to row was, so it comes back on the same line.
1740    pub returning_line: Option<usize>,
1741    /// The cursor is where [`HomeState::select_first_entry`] put it and has not moved: a
1742    /// door the footers later turn down sends it to the first row.
1743    pub landing: bool,
1744    /// No row is picked: datui opened here and nothing has been chosen. The cursor's
1745    /// place is kept (where Enter and the first ↑ or ↓ go), but no row is marked and
1746    /// nothing is read for one, so opening datui starts no reader.
1747    pub resting: bool,
1748    /// The rows as last listed. See [`RowsCache`].
1749    pub rows_cache: RowsCache,
1750    /// The catalogs' places. See [`HomeState::set_catalogs`].
1751    pub catalog_places: CatalogPlaces,
1752}
1753
1754/// Where the cursor was in a listing the user went inside from, by row identity:
1755/// the listing is rebuilt on a worker and may change.
1756#[derive(Debug, Clone)]
1757pub struct Mark {
1758    /// The listing left: the place browsed, or `None` for the root listing.
1759    pub place: Option<PathBuf>,
1760    pub key: Option<RowKey>,
1761    /// The filter typed there, which entering cleared.
1762    pub filter: String,
1763    /// The search below that place, if finished or not started; a running walk is
1764    /// dropped on leaving and restarted.
1765    pub search: Option<SearchState>,
1766    /// Rows between the top of the list and the cursor.
1767    pub line: usize,
1768}
1769
1770/// One recursive walk below the working directory. Kept apart from `sections`,
1771/// which rebuild often: re-walking each time would cost per keystroke.
1772#[derive(Debug, Clone, Default)]
1773pub struct SearchState {
1774    /// Where the walk started. `None` means no search has been asked for yet.
1775    pub root: Option<PathBuf>,
1776    /// Which walk this is, so an earlier walk's scoring is never taken for this one's.
1777    pub epoch: u64,
1778    /// Every data file found so far, unfiltered, in arrival batches; shared with the
1779    /// scoring worker without copying.
1780    pub results: Vec<std::sync::Arc<[Entry]>>,
1781    /// How many files `results` holds.
1782    pub indexed: usize,
1783    /// The last scored matches, possibly for an older filter or fewer files while a
1784    /// scoring is out.
1785    pub matches: Option<crate::home::search::Matches>,
1786    /// A scoring is out on a worker.
1787    pub scoring: bool,
1788    /// Directory entries examined, for the progress note.
1789    pub scanned: usize,
1790    /// A walk is out. Results may still be arriving.
1791    pub running: bool,
1792    /// The walk has finished, successfully or against a limit.
1793    pub done: bool,
1794    /// Why the walk stopped short, when it did.
1795    pub limited: Option<String>,
1796}
1797
1798/// Below this many files the filter is scored inline: a millisecond or two, answered
1799/// in the key's frame.
1800const SCORE_INLINE_MAX: usize = 2_000;
1801
1802/// Match score per unit of frecency, up to ten units: a daily file outranks a
1803/// slightly better name match, never a far better one.
1804const FRECENCY_LIFT: f64 = 3.0;
1805
1806/// What a worker needs to score the filter against a walk's files.
1807#[derive(Debug, Clone)]
1808pub struct ScoreJob {
1809    pub epoch: u64,
1810    pub results: Vec<std::sync::Arc<[Entry]>>,
1811    pub query: String,
1812    pub base: Option<crate::home::search::Matches>,
1813    pub limit: usize,
1814}
1815
1816impl SearchState {
1817    /// Forget everything, because the place being searched has changed.
1818    pub fn reset(&mut self) {
1819        *self = Self::default();
1820    }
1821
1822    /// Set the files found, all at once: what a walk would have handed over in batches.
1823    pub fn set_results(&mut self, results: Vec<Entry>) {
1824        self.indexed = results.len();
1825        self.results = vec![results.into()];
1826        self.matches = None;
1827    }
1828
1829    /// Every file found, in the order found.
1830    pub fn files(&self) -> impl Iterator<Item = &Entry> {
1831        self.results.iter().flat_map(|batch| batch.iter())
1832    }
1833
1834    /// Whether the matches in hand are for `query` over every file found.
1835    fn scored_for(&self, query: &str) -> bool {
1836        self.matches
1837            .as_ref()
1838            .is_some_and(|m| m.query == query && m.upto == self.indexed)
1839    }
1840
1841    /// The matches to narrow from for `query`, and how many files scoring it will look at.
1842    fn base_for(&self, query: &str) -> (Option<&crate::home::search::Matches>, usize) {
1843        match self.matches.as_ref() {
1844            Some(m) if m.narrows_to(query) && m.upto <= self.indexed => {
1845                (Some(m), m.ids.len() + self.indexed - m.upto)
1846            }
1847            _ => (None, self.indexed),
1848        }
1849    }
1850}
1851
1852impl Default for HomeState {
1853    fn default() -> Self {
1854        Self {
1855            sections: Vec::new(),
1856            cloud: Vec::new(),
1857            catalogs: Vec::new(),
1858            sized: std::collections::HashSet::new(),
1859            web_gone: Default::default(),
1860            missing: Default::default(),
1861            filter: String::new(),
1862            search_limit: crate::config::SearchConfig::default().max_results,
1863            rows_cache: RowsCache::default(),
1864            catalog_places: CatalogPlaces::default(),
1865            hide_unreadable: true,
1866            formats: Default::default(),
1867            lake_here: None,
1868            selected: 0,
1869            scroll: 0,
1870            view_height: 0,
1871            view_lines: 0,
1872            spaced: false,
1873            path_input_active: false,
1874            path_input: String::new(),
1875            path_listing: None,
1876            path_pick: None,
1877            filter_selected: false,
1878            browsing: None,
1879            browse_start: None,
1880            status: None,
1881            network_check: is_remote_path,
1882            visits: Default::default(),
1883            newest_recent: None,
1884            sort: SortMode::default(),
1885            listing_in_flight: false,
1886            measure_in_flight: false,
1887            classifying: Default::default(),
1888            peeking: std::collections::HashSet::new(),
1889            probes: Probes::default(),
1890            narrowed: None,
1891            cloud_kinds: std::collections::HashMap::new(),
1892            peek_failed: std::collections::HashSet::new(),
1893            waiting_since: None,
1894            enriched: std::collections::HashMap::new(),
1895            stated: Default::default(),
1896            stat_epoch: 0,
1897            stale: Default::default(),
1898            unapplied: Default::default(),
1899            folds: std::collections::HashMap::new(),
1900            folds_owed: false,
1901            search: SearchState::default(),
1902            known: Default::default(),
1903            recent_expanded: false,
1904            shown_whole: Default::default(),
1905            trail: Vec::new(),
1906            returning: None,
1907            returning_line: None,
1908            landing: false,
1909            resting: false,
1910        }
1911    }
1912}
1913
1914/// The dataset index as read from the cache, by path; shared, never copied, with the
1915/// listing workers.
1916pub type Known = std::sync::Arc<std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>>;
1917
1918/// Everything [`build_listing`] needs, gathered on the UI thread so the worker never
1919/// reaches into the app.
1920#[derive(Debug, Clone)]
1921pub struct ListingRequest {
1922    pub recents: Vec<PathBuf>,
1923    pub desktop_dirs: Vec<PathBuf>,
1924    pub browsing: Option<PathBuf>,
1925    /// See [`HomeState::probes`].
1926    pub probes: Probes,
1927    /// See [`HomeState::narrowed`].
1928    pub narrowed: Option<Narrowed>,
1929    pub network_check: fn(&Path) -> bool,
1930    /// Cloud sources and the buckets already enumerated for them.
1931    pub cloud: Vec<CloudSource>,
1932    /// The catalogs to list.
1933    pub catalogs: Vec<ShownCatalog>,
1934    /// What earlier runs measured: a row whose size and mtime still match is filled in
1935    /// from here before anything is read.
1936    pub known: Known,
1937    /// The format specs on the search path: a file one reads as several variants is a
1938    /// place listing its variants.
1939    pub formats: std::sync::Arc<crate::formats::Registry>,
1940}
1941
1942/// What a listing pass produced.
1943#[derive(Debug, Clone, Default)]
1944pub struct Listing {
1945    pub sections: Vec<Section>,
1946    /// Local datasets of a catalog that do not exist.
1947    pub missing: std::collections::HashSet<PathBuf>,
1948}
1949
1950impl Listing {
1951    /// Fill in rows from records read since the index was: the dataset just left,
1952    /// whose open recorded what it found.
1953    pub fn learn(
1954        &mut self,
1955        learned: &[(PathBuf, crate::cache::DatasetFacts)],
1956        network_check: fn(&Path) -> bool,
1957    ) {
1958        if learned.is_empty() {
1959            return;
1960        }
1961        let learned: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts> =
1962            learned.iter().cloned().collect();
1963        for row in self.sections.iter_mut().flat_map(|s| s.rows.iter_mut()) {
1964            if known_facts(&learned, &row.path).is_some() {
1965                apply_known_facts(row, &learned, network_check(&row.path));
1966            }
1967        }
1968    }
1969
1970    /// Add each row's own path to `visits` where its canonical path has visits: recents
1971    /// are keyed canonically, catalogs spell paths as written (`/var/…` vs
1972    /// `/private/var/…` on macOS, short names on Windows). Canonicalizing touches the
1973    /// filesystem, so this runs on the listing worker, only for local rows named like a
1974    /// visited file.
1975    pub fn alias_visits(
1976        &self,
1977        visits: &mut std::collections::HashMap<PathBuf, crate::cache::Visits>,
1978    ) {
1979        let names: std::collections::HashSet<std::ffi::OsString> = visits
1980            .keys()
1981            .filter_map(|p| p.file_name().map(|n| n.to_os_string()))
1982            .collect();
1983        let mut aliases = Vec::new();
1984        for row in self.sections.iter().flat_map(|s| &s.rows) {
1985            let path = &row.path;
1986            if visits.contains_key(path)
1987                || row.table.is_some()
1988                || !path.file_name().is_some_and(|n| names.contains(n))
1989                || is_network_path(path)
1990            {
1991                continue;
1992            }
1993            if let Some(v) = crate::canonical::canonicalize(path)
1994                .ok()
1995                .and_then(|canonical| visits.get(&canonical))
1996            {
1997                aliases.push((path.clone(), *v));
1998            }
1999        }
2000        visits.extend(aliases);
2001    }
2002}
2003
2004/// Find out what a row is, then what is in it, in one pass on the same filesystem
2005/// (`measure_row` does nothing for a plain directory), reading files as the following
2006/// open will: the command line passes the user's reader settings; listing passes use
2007/// the defaults, as a home open does.
2008pub fn look_into_as(entry: &Entry, as_read: &crate::formats::schema_union::ReadAs) -> Entry {
2009    let mut probe = classify_row(entry);
2010    measure_row(&mut probe, entry, as_read, None);
2011    probe
2012}
2013
2014/// The first half of [`look_into_as`]: what a row nothing has looked into is.
2015fn classify_row(entry: &Entry) -> Entry {
2016    let mut probe = entry.clone();
2017    if probe.kind == EntryKind::Unknown && probe.path.is_dir() {
2018        let (kind, holds) = discover::look_at_directory(&probe.path);
2019        probe.kind = kind;
2020        probe.holds = holds;
2021    }
2022    probe
2023}
2024
2025/// A row as far as a stat and the dataset index tell, before anything is read: its
2026/// size and mtime when its listing left them out, then what an earlier run recorded
2027/// for it at that size and mtime. Whether it is there.
2028fn stat_and_recall(
2029    probe: &mut Entry,
2030    known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2031) -> bool {
2032    if discover::unstated(probe) && !discover::stat_row(probe) {
2033        return false;
2034    }
2035    // Stat'ed now or before (a sort's stat-only look): what the index has at that size
2036    // and mtime is taken rather than read.
2037    if probe.rows.is_none() && probe.columns.is_empty() && discover::on_disk(probe) {
2038        apply_known_facts(probe, known, false);
2039    }
2040    true
2041}
2042
2043/// The second half of [`look_into_as`]: what is in it, from the files or from what an
2044/// open kept in `remembered`.
2045fn measure_row(
2046    probe: &mut Entry,
2047    entry: &Entry,
2048    as_read: &crate::formats::schema_union::ReadAs,
2049    remembered: Option<&crate::cache::CacheManager>,
2050) {
2051    discover::enrich_with(probe, as_read, remembered);
2052    probe.size = probe.size.or(entry.size);
2053    probe.modified = probe.modified.or(entry.modified);
2054}
2055
2056/// How far a look into a row goes.
2057#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2058pub enum Reads {
2059    /// Its stat, what the index recalls, and its files: a row on screen.
2060    Files,
2061    /// Its stat and what the index recalls: a row a sort by size or time needs.
2062    StatOnly,
2063}
2064
2065/// Look into a batch of rows on a worker, passing each answer to `each` and caching
2066/// what was learned. Shared by the measure and classify passes. Every row is
2067/// classified before any is measured: a kind is one directory read, a count up to
2068/// sixty-four footers.
2069pub fn look_into_batch(
2070    rows: Vec<Entry>,
2071    cache: &crate::cache::CacheManager,
2072    known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2073    reads: Reads,
2074    mut each: impl FnMut(PathBuf, Measured),
2075) {
2076    let as_read = crate::formats::schema_union::ReadAs::default();
2077    let answer = |probe: &Entry, entry: &Entry| Measured {
2078        stat_only: reads == Reads::StatOnly,
2079        ..measured_from(probe, entry)
2080    };
2081    let classified: Vec<(Entry, Entry)> = rows
2082        .into_iter()
2083        .map(|entry| {
2084            let mut probe = entry.clone();
2085            // Gone since it was listed: said as measured, so it is not asked again.
2086            if !stat_and_recall(&mut probe, known) {
2087                return (probe, entry);
2088            }
2089            // A kind the index restored is not looked for again; a stat alone reads nothing.
2090            let probe = match reads {
2091                Reads::Files => classify_row(&probe),
2092                Reads::StatOnly => probe,
2093            };
2094            if probe.kind != entry.kind || probe.modified != entry.modified {
2095                each(entry.path.clone(), answer(&probe, &entry));
2096            }
2097            (probe, entry)
2098        })
2099        .collect();
2100
2101    let mut facts = Vec::new();
2102    for (mut probe, entry) in classified {
2103        // What the index recalled is not read again, nor recorded again.
2104        if reads == Reads::Files && probe.rows.is_none() && probe.columns.is_empty() {
2105            measure_row(&mut probe, &entry, &as_read, Some(cache));
2106            facts.extend(facts_for(&probe));
2107        }
2108        each(entry.path.clone(), answer(&probe, &entry));
2109    }
2110    // Cache what was learned; each record carries its size and mtime and invalidates
2111    // itself when they change.
2112    cache.record_dataset_facts(&facts);
2113}
2114
2115/// Fold a measured probe into the record kept for a row.
2116pub fn measured_from(probe: &Entry, original: &Entry) -> Measured {
2117    Measured {
2118        rows: probe.rows,
2119        cols: probe.cols,
2120        cols_sampled: probe.cols_sampled,
2121        size: probe.size.or(original.size),
2122        modified: probe.modified.or(original.modified),
2123        stat_only: false,
2124        columns: probe.columns.clone(),
2125        kind: (probe.kind != original.kind).then_some(probe.kind),
2126        holds: probe.holds.clone(),
2127        // The source is resolved from the live mount table on every listing; only what the
2128        // file said of itself carries forward.
2129        cost: crate::home::discover::Cost {
2130            source: None,
2131            ..probe.cost.clone()
2132        },
2133    }
2134}
2135
2136/// Where the listing of one network root or remote directory stands; read off the UI
2137/// thread (see [`HomeState::pending_probes`]).
2138#[derive(Debug, Clone)]
2139pub enum Probe {
2140    /// Still being read: the rows so far, in the order they came.
2141    Listing(Vec<Entry>),
2142    /// Answered. `cut_short`: the listing stopped at [`discover::MAX_ENTRIES_PER_DIR`].
2143    Listed {
2144        rows: std::sync::Arc<[Entry]>,
2145        cut_short: bool,
2146    },
2147    /// Did not answer, with why when the service said.
2148    Unreachable(Option<String>),
2149    /// Still out, but nothing has come for longer than a listing is waited on: the rows
2150    /// so far stand, the spinner stops, and Ctrl+R waits again. A page revives it.
2151    Silent(Vec<Entry>),
2152}
2153
2154/// What a section says of a place whose listing stopped answering.
2155pub const NOT_ANSWERING: &str = "not answering · Ctrl+R retries";
2156
2157/// Every probe, by the place it lists.
2158#[derive(Debug, Clone, Default)]
2159pub struct Probes(std::collections::HashMap<PathBuf, Probe>);
2160
2161impl Probes {
2162    /// The rows of a listing that has answered.
2163    pub fn listed(&self, place: &Path) -> Option<&[Entry]> {
2164        match self.0.get(place)? {
2165            Probe::Listed { rows, .. } => Some(rows),
2166            _ => None,
2167        }
2168    }
2169
2170    /// The rows read so far of a listing still going on.
2171    pub fn so_far(&self, place: &Path) -> Option<&[Entry]> {
2172        match self.0.get(place)? {
2173            Probe::Listing(rows) => Some(rows),
2174            _ => None,
2175        }
2176    }
2177
2178    /// Answered, written off, or gone silent: nothing more to wait for.
2179    pub fn settled(&self, place: &Path) -> bool {
2180        matches!(
2181            self.0.get(place),
2182            Some(Probe::Listed { .. } | Probe::Unreachable(_) | Probe::Silent(_))
2183        )
2184    }
2185
2186    /// Whether a listing still out has gone without news past the wait.
2187    pub fn silent(&self, place: &Path) -> bool {
2188        matches!(self.0.get(place), Some(Probe::Silent(_)))
2189    }
2190
2191    /// The places whose listings went silent.
2192    pub fn silent_places(&self) -> Vec<PathBuf> {
2193        (self.0.iter())
2194            .filter(|(_, probe)| matches!(probe, Probe::Silent(_)))
2195            .map(|(place, _)| place.clone())
2196            .collect()
2197    }
2198
2199    /// Whether pages of a listing of `place` are still news: nothing has settled it, or
2200    /// it only went silent.
2201    pub fn takes_pages(&self, place: &Path) -> bool {
2202        matches!(
2203            self.0.get(place),
2204            None | Some(Probe::Listing(_) | Probe::Silent(_))
2205        )
2206    }
2207
2208    /// Say the listing of `place` has gone without news too long, keeping its rows.
2209    pub fn go_silent(&mut self, place: &Path) {
2210        let rows = match self.0.remove(place) {
2211            Some(Probe::Listing(rows) | Probe::Silent(rows)) => rows,
2212            None => Vec::new(),
2213            Some(settled) => {
2214                self.0.insert(place.to_path_buf(), settled);
2215                return;
2216            }
2217        };
2218        self.0.insert(place.to_path_buf(), Probe::Silent(rows));
2219    }
2220
2221    pub fn cut_short(&self, place: &Path) -> bool {
2222        matches!(
2223            self.0.get(place),
2224            Some(Probe::Listed {
2225                cut_short: true,
2226                ..
2227            })
2228        )
2229    }
2230
2231    pub fn unreachable(&self, place: &Path) -> bool {
2232        matches!(self.0.get(place), Some(Probe::Unreachable(_)))
2233    }
2234
2235    /// Why the listing was refused, when the service said.
2236    pub fn error(&self, place: &Path) -> Option<&str> {
2237        match self.0.get(place)? {
2238            Probe::Unreachable(why) => why.as_deref(),
2239            _ => None,
2240        }
2241    }
2242
2243    /// What the place lists now: its answer, the rows so far in the finished order
2244    /// (bucket directories above objects, or [`discover::sort_entries`]), or nothing.
2245    fn rows(&self, place: &Path) -> Vec<Entry> {
2246        match self.0.get(place) {
2247            Some(Probe::Listed { rows, .. }) => rows.to_vec(),
2248            Some(Probe::Listing(rows) | Probe::Silent(rows)) => {
2249                let mut rows = rows.clone();
2250                if is_object_store_url(place) {
2251                    rows.sort_by_key(|row| row.kind != EntryKind::Directory);
2252                } else {
2253                    discover::sort_entries(&mut rows);
2254                }
2255                rows
2256            }
2257            _ => Vec::new(),
2258        }
2259    }
2260
2261    /// The places answered, with their rows.
2262    pub fn answered(&self) -> impl Iterator<Item = (&PathBuf, &[Entry])> {
2263        self.0.iter().filter_map(|(place, probe)| match probe {
2264            Probe::Listed { rows, .. } => Some((place, &rows[..])),
2265            _ => None,
2266        })
2267    }
2268
2269    /// A row an answered probe produced for this exact path, if any.
2270    fn entry(&self, path: &Path) -> Option<Entry> {
2271        self.answered()
2272            .flat_map(|(_, rows)| rows.iter())
2273            .find(|e| e.path == path)
2274            .cloned()
2275    }
2276
2277    /// Rows read since the last batch, while the listing is still out.
2278    pub fn read(&mut self, place: &Path, rows: &[Entry]) {
2279        let probe = (self.0)
2280            .entry(place.to_path_buf())
2281            .or_insert(Probe::Listing(Vec::new()));
2282        // A page from a listing that went silent: it is answering again.
2283        if let Probe::Silent(so_far) = probe {
2284            *probe = Probe::Listing(std::mem::take(so_far));
2285        }
2286        if let Probe::Listing(so_far) = probe {
2287            so_far.extend_from_slice(rows);
2288        }
2289    }
2290
2291    pub fn insert(&mut self, place: PathBuf, probe: Probe) {
2292        self.0.insert(place, probe);
2293    }
2294
2295    /// Forget a place's listing, so it is asked for again.
2296    pub fn forget(&mut self, place: &Path) {
2297        self.0.remove(place);
2298    }
2299
2300    /// Forget a listing still being read: it was stopped.
2301    pub fn stopped(&mut self, place: &Path) {
2302        if let Some(Probe::Listing(_) | Probe::Silent(_)) = self.0.get(place) {
2303            self.0.remove(place);
2304        }
2305    }
2306
2307    fn listed_mut(&mut self, place: &Path) -> Option<&mut std::sync::Arc<[Entry]>> {
2308        match self.0.get_mut(place)? {
2309            Probe::Listed { rows, .. } => Some(rows),
2310            _ => None,
2311        }
2312    }
2313}
2314
2315/// What a cut-short cloud directory holds under one name prefix, asked of the server
2316/// for a typed filter.
2317#[derive(Debug, Clone)]
2318pub struct Narrowed {
2319    pub dir: PathBuf,
2320    /// The start of every name asked for (`STATION=USW`).
2321    pub prefix: String,
2322    pub rows: Vec<Entry>,
2323    /// These stopped at the cap too.
2324    pub truncated: bool,
2325}
2326
2327/// Build the home listing. A free function so it runs on a worker: it is home's only
2328/// filesystem access, and a wedged mount, FIFO or failing disk blocks here, so never
2329/// call it from the drawing thread.
2330pub fn build_listing(request: &ListingRequest) -> Listing {
2331    let ListingRequest {
2332        recents,
2333        desktop_dirs,
2334        browsing,
2335        probes,
2336        narrowed,
2337        network_check,
2338        cloud,
2339        catalogs,
2340        known,
2341        formats,
2342    } = request;
2343    let network_check = *network_check;
2344    // One read of the mount table for the listing: a kernel-generated file, so it cannot
2345    // block on a share that stopped answering.
2346    let mounts = crate::home::locality::Mounts::current();
2347    let mut sections: Vec<Section> = Vec::new();
2348
2349    // Inside a cloud source: its buckets, and nothing else.
2350    if let Some(id) = browsing.as_deref().and_then(cloud_source_id) {
2351        sections.push(source_section(&id, cloud));
2352        annotate(&mut sections, known, network_check, &mounts);
2353        return Listing {
2354            sections,
2355            ..Default::default()
2356        };
2357    }
2358
2359    // Descended into a directory: show only that.
2360    if let Some(dir) = browsing.clone() {
2361        let remote = network_check(&dir);
2362        sections.push(browsed_section(
2363            &dir,
2364            remote,
2365            &Listed {
2366                probes,
2367                narrowed: narrowed.as_ref(),
2368                catalogs,
2369                known,
2370                formats,
2371            },
2372        ));
2373        annotate(&mut sections, known, network_check, &mounts);
2374        return Listing {
2375            sections,
2376            ..Default::default()
2377        };
2378    }
2379
2380    // Recents that still exist, most recent first.
2381    let recent_rows: Vec<Entry> = recents
2382        .iter()
2383        // `exists()` stats, so a remote entry is trusted and dropped only if its probe says
2384        // it is gone.
2385        .filter(|p| {
2386            network_check(p)
2387                || p.exists()
2388                || crate::formats::members::split(p).is_some()
2389                || crate::formats::members::split_variant(p, formats).is_some()
2390                || crate::formats::hf_splits::split_place(p).is_some()
2391        })
2392        // No display cap: the store bounds it, the header counts it, and the section folds.
2393        .map(|p| {
2394            // Reuse the containing root probe's classification, so a dataset reads the same
2395            // under its directory and under Recent.
2396            if let Some(known) = probes.entry(p) {
2397                return known;
2398            }
2399            if let Some(variant) = discover::variant_row(p, formats) {
2400                return variant;
2401            }
2402            if !network_check(p)
2403                && let Some(split) = discover::split_row(p)
2404            {
2405                return split;
2406            }
2407            let mut entry = entry_for_path(p, network_check(p));
2408            if !network_check(p) {
2409                discover::name_unlisted_file(&mut entry, formats);
2410            }
2411            // A dataset opened from a catalog keeps the catalog's name, not its URL's last
2412            // segment.
2413            if let Some(dataset) = catalogs
2414                .iter()
2415                .flat_map(|c| &c.datasets)
2416                .find(|d| d.location == *p)
2417            {
2418                entry.name = dataset.name.clone();
2419            }
2420            entry
2421        })
2422        .collect();
2423
2424    // Desktop-derived places are collected rather than expanded — see below.
2425    let mut elsewhere: Vec<Entry> = Vec::new();
2426
2427    let roots = HomeState::roots_with(desktop_dirs, network_check);
2428    let mut root_sections: Vec<(RootOrigin, Section)> = Vec::new();
2429    // Where the current-directory section is and the names it lists, for the RECENT
2430    // dedupe below.
2431    let mut cwd_listing: Option<(PathBuf, std::collections::HashSet<std::ffi::OsString>)> = None;
2432    for root in roots {
2433        // A desktop place is a directory to step into, never expanded: its contents may be
2434        // private (whatever was opened anywhere). Enter is the ask.
2435        if root.origin == RootOrigin::Desktop {
2436            if root.available {
2437                let mut entry = Entry::directory(&root.path);
2438                // The full place ("~/Downloads"), since the section has no path of its own.
2439                entry.name = display_path(&root.path);
2440                elsewhere.push(entry);
2441            }
2442            continue;
2443        }
2444
2445        // A local root is scanned here (truncation is known for it); a remote one shows
2446        // only what its background probe returned, since scanning it can freeze datui.
2447        let mut truncated = false;
2448        let rows = if root.network {
2449            truncated = probes.cut_short(&root.path);
2450            probes.rows(&root.path)
2451        } else if root.available {
2452            let scan = discover::scan_dir_specs(&root.path, formats);
2453            truncated = scan.truncated;
2454            scan.entries
2455        } else {
2456            Vec::new()
2457        };
2458        if root.origin == RootOrigin::Cwd {
2459            // Compared canonically, as recents are stored; a network cwd as spelled, since
2460            // canonicalizing would stat a mount that may not answer.
2461            let key = if root.network {
2462                root.path.clone()
2463            } else {
2464                crate::canonical::canonicalize(&root.path).unwrap_or_else(|_| root.path.clone())
2465            };
2466            let names = rows
2467                .iter()
2468                .filter_map(|row| row.path.file_name().map(|n| n.to_os_string()))
2469                .collect();
2470            cwd_listing = Some((key, names));
2471        }
2472        // An unreadable root stays: a dead share is what the section heading reports.
2473        let unreachable = root.network && probes.unreachable(&root.path);
2474        let silent = root.network && probes.silent(&root.path);
2475        let waiting = root.network && !probes.settled(&root.path);
2476        // Flag a network root, the one that will be slow or stop answering, by its
2477        // filesystem (nfs4, cifs, fuse.sshfs fail differently) when the mount table agrees
2478        // it is remote; otherwise just "network", rather than lose the warning.
2479        let described = mounts.describe(&root.path);
2480        let fstype = if described.network() {
2481            described.fstype
2482        } else {
2483            "network".to_string()
2484        };
2485        // Say when the list is a prefix: a directory cut at the cap otherwise looks like one
2486        // holding exactly that many.
2487        let mut state: Vec<String> = Vec::new();
2488        if truncated {
2489            state.push(format!(
2490                "first {}",
2491                crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2492            ));
2493        }
2494        if root.network {
2495            state.push(fstype);
2496        }
2497        root_sections.push((
2498            root.origin,
2499            Section {
2500                subtitle: (!state.is_empty()).then(|| crate::glyphs::dotted(&state.join(" · "))),
2501                origin: Some(root.origin.note()),
2502                root: Some(root.path.clone()),
2503                unavailable: !root.available || unreachable || silent,
2504                unavailable_note: silent.then(|| NOT_ANSWERING.to_string()),
2505                remote_root: root.network.then(|| root.path.clone()),
2506                waiting,
2507                ..Section::titled(display_path(&root.path), rows)
2508            },
2509        ));
2510    }
2511
2512    // The current directory's section sits right below RECENT, so a recent it already
2513    // lists is dropped from RECENT. Decided by what that section contains, not by path:
2514    // a recent the scan did not surface (hidden, past the cap) stays under RECENT.
2515    // Browsing returned above with no RECENT at all.
2516    let recent_rows: Vec<Entry> = match &cwd_listing {
2517        None => recent_rows,
2518        Some((cwd, names)) => recent_rows
2519            .into_iter()
2520            .filter(|row| {
2521                let place = place_of(&row.path);
2522                // A local place is resolved before comparing, as the store resolves; a remote one
2523                // is compared as written, to avoid a stat.
2524                let place = if network_check(&place) {
2525                    place
2526                } else {
2527                    crate::canonical::canonicalize(&place).unwrap_or(place)
2528                };
2529                place != *cwd || !row.path.file_name().is_some_and(|n| names.contains(n))
2530            })
2531            .collect(),
2532    };
2533    if !recent_rows.is_empty() {
2534        let place_labels = place_labels(&recent_rows, known, network_check);
2535        sections.push(Section {
2536            // Every trace of recent use lives here, the places as rows of this section.
2537            grouped_by_place: true,
2538            place_labels,
2539            ..Section::titled(HomeState::RECENT_SECTION, recent_rows)
2540        });
2541    }
2542
2543    // Ordered by why you came: what you opened last, where you are, the object stores
2544    // your credentials reach (deliberate setup, unreachable by directory listing), then
2545    // the catalogs.
2546    sections.extend(root_sections.into_iter().map(|(_, s)| s));
2547
2548    // One section for all cloud sources, each a row to step into; buckets are listed
2549    // one level down, once per session.
2550    sections.extend(cloud_section(cloud));
2551
2552    // Catalogs in order: yours, the listed files, then the bundled one.
2553    let mut missing = std::collections::HashSet::new();
2554    for catalog in catalogs {
2555        let mut section = catalog_section(catalog, network_check, &mut missing);
2556        // A catalog's local file is named by a spec whose glob matches; nothing is read.
2557        for row in section.rows.iter_mut().filter(|r| {
2558            r.kind == EntryKind::File
2559                && r.format_spec.is_none()
2560                && r.table.is_none()
2561                && !network_check(&r.path)
2562                && !discover::is_data_file(&r.path)
2563        }) {
2564            if let Some(spec) = formats.by_glob(&row.path, false).first() {
2565                discover::name_spec_file(row, spec);
2566            }
2567        }
2568        sections.push(section);
2569    }
2570
2571    if !elsewhere.is_empty() {
2572        sections.push(Section {
2573            // Places to look, not datasets: folded until asked for.
2574            folded_by_default: true,
2575            ..Section::titled("Elsewhere", elsewhere)
2576        });
2577    }
2578
2579    // Fill in earlier measurements that still match: the listing already stat'ed every
2580    // row, and the mount table is read once and resolved as strings, so nothing blocks.
2581    annotate(&mut sections, known, network_check, &mounts);
2582
2583    Listing { sections, missing }
2584}
2585
2586/// The section of a cloud source's buckets, browsed into. Built from what the source
2587/// listing said, so on any thread.
2588fn source_section(id: &str, cloud: &[CloudSource]) -> Section {
2589    let source = cloud.iter().find(|s| s.id == id);
2590    let rows = source
2591        .map(|s| s.buckets.iter().map(|b| bucket_entry(b)).collect())
2592        .unwrap_or_default();
2593    let failure = source.and_then(|s| match &s.status {
2594        CloudStatus::Failed { short, .. } => Some(short.clone()),
2595        _ => None,
2596    });
2597    Section {
2598        subtitle: source.map(|s| s.note.clone()).filter(|n| !n.is_empty()),
2599        unavailable: source.is_none() || failure.is_some(),
2600        unavailable_note: if source.is_none() {
2601            Some("source not found".to_string())
2602        } else {
2603            failure
2604        },
2605        waiting: source.is_some_and(|s| s.busy()),
2606        ..Section::titled(
2607            source.map_or_else(|| id.to_string(), |s| s.label.clone()),
2608            rows,
2609        )
2610    }
2611}
2612
2613/// The listing's section of cloud sources, when there are any.
2614fn cloud_section(cloud: &[CloudSource]) -> Option<Section> {
2615    (!cloud.is_empty()).then(|| {
2616        Section::titled(
2617            HomeState::CLOUD_SECTION.to_string(),
2618            cloud.iter().map(source_entry).collect(),
2619        )
2620    })
2621}
2622
2623/// What a listing is built from beside the places themselves, borrowed so a
2624/// remote place's section can be built again on the UI thread as its pages arrive.
2625pub(crate) struct Listed<'a> {
2626    pub probes: &'a Probes,
2627    pub narrowed: Option<&'a Narrowed>,
2628    pub catalogs: &'a [ShownCatalog],
2629    pub known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2630    pub formats: &'a crate::formats::Registry,
2631}
2632
2633/// The section of a browsed directory. A `remote` one is built from its probe alone,
2634/// touching nothing, so it can be built on any thread; a local one is read here.
2635fn browsed_section(dir: &Path, remote: bool, listed: &Listed) -> Section {
2636    let Listed {
2637        probes,
2638        narrowed,
2639        catalogs,
2640        known,
2641        formats,
2642    } = *listed;
2643    let dir = dir.to_path_buf();
2644
2645    // A remote directory is never read here (that freezes the UI): rows come from the
2646    // background probe, and the section is empty until it answers.
2647    // A remote listing still being read shows what it has, and says so.
2648    let so_far = remote && probes.so_far(&dir).is_some();
2649    // A SQLite database is a place too, whose rows are its tables.
2650    let database = !remote && dir.is_file();
2651    let (mut rows, truncated) = if remote {
2652        (probes.rows(&dir), probes.cut_short(&dir))
2653    } else if database {
2654        let tables = discover::database_rows(&dir);
2655        let rows = if tables.is_empty() {
2656            discover::variant_rows(&dir, formats)
2657        } else {
2658            tables
2659        };
2660        (rows, false)
2661    } else {
2662        let scan = discover::scan_dir_specs(&dir, formats);
2663        // A Hugging Face cache's splits, before the files they are made of.
2664        let mut rows = discover::split_rows(&dir);
2665        rows.extend(scan.entries);
2666        (rows, scan.truncated)
2667    };
2668    // A cut-short level holds the names a filter asked the server for, beside the first
2669    // of the rest.
2670    let narrowed = narrowed.filter(|n| remote && truncated && n.dir == dir);
2671    if let Some(narrowed) = narrowed {
2672        let listed: std::collections::HashSet<PathBuf> =
2673            rows.iter().map(|row| row.path.clone()).collect();
2674        rows.extend(
2675            narrowed
2676                .rows
2677                .iter()
2678                .filter(|row| !listed.contains(&row.path))
2679                .cloned(),
2680        );
2681    }
2682    // Otherwise a directory cut at the cap looks like one holding exactly that many.
2683    let subtitle = if so_far {
2684        Some(format!(
2685            "{} so far",
2686            crate::numfmt::group_chrome(rows.len())
2687        ))
2688    } else if truncated {
2689        let first = format!(
2690            "first {}",
2691            crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2692        );
2693        Some(match narrowed {
2694            Some(n) => format!(
2695                "{first} + {}{} {}*",
2696                crate::numfmt::group_chrome(n.rows.len()),
2697                if n.truncated { "+" } else { "" },
2698                n.prefix
2699            ),
2700            None => first,
2701        })
2702    } else {
2703        None
2704    };
2705    let unavailable = remote && (probes.unreachable(&dir) || probes.silent(&dir));
2706    // The first row inside any directory opens all of it, since `Enter` below opens one
2707    // file: the other door.
2708    let mut door = (!database)
2709        .then(|| whole_directory_row(&dir, &rows, remote))
2710        .flatten();
2711    // What an earlier run's footers made of this directory, as for its row upstairs
2712    // (fingerprinted by its mtime), so a directory of separate tables reads the same in
2713    // both places.
2714    if !remote
2715        && let Some(door) = door.as_mut()
2716        && let Ok(meta) = std::fs::metadata(&dir)
2717    {
2718        door.modified = meta.modified().ok();
2719        apply_known_facts(door, known, false);
2720        door.modified = None;
2721        door.name = door_name(door, &rows);
2722    }
2723    // The URL without a source id (the trail names the source); an Azure account or
2724    // container by name, not its long URL.
2725    let title = {
2726        let text = dir.to_string_lossy();
2727        if let Some(dataset) = catalogs
2728            .iter()
2729            .flat_map(|c| c.datasets.iter())
2730            .find(|d| is_object_store_url(&d.location) && same_place(&d.location, &dir))
2731        {
2732            dataset.name.clone()
2733        } else if let Some((_, account)) = cloud_account(&dir) {
2734            account
2735        } else if let Some((_, container, key)) = crate::cloud::source::azure_parts(&text) {
2736            format!("{container}/{}", key.trim_matches('/'))
2737                .trim_end_matches('/')
2738                .to_string()
2739        } else {
2740            match crate::cloud::source::split_source_id(&text) {
2741                (Some(_), plain) => plain.into_owned(),
2742                (None, _) => display_path(&dir),
2743            }
2744        }
2745    };
2746    Section {
2747        subtitle,
2748        root: Some(dir.clone()),
2749        unavailable,
2750        // A browsed remote place that did not answer has nothing to add; a refused listing
2751        // says why, and one gone quiet says so.
2752        unavailable_note: probes
2753            .error(&dir)
2754            .map(str::to_string)
2755            .or_else(|| probes.silent(&dir).then(|| NOT_ANSWERING.to_string())),
2756        // Its wait replaces the whole list until rows arrive (`awaiting_listing`), then sits
2757        // on the heading; no `remote_root`.
2758        waiting: so_far,
2759        door,
2760        ..Section::titled(title, rows)
2761    }
2762}
2763
2764/// The key a record about `path` is filed under in the dataset index. Opens record
2765/// under the resolved URL (`s3://bucket/x` for `s3://lab@bucket/x`, one `abfss://`
2766/// spelling for Azure) while recents are stored as typed; both must meet here.
2767pub fn index_key(path: &Path) -> PathBuf {
2768    let text = path.to_string_lossy();
2769    if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
2770        return PathBuf::from(crate::cloud::source::azure_url(&account, &container, &key));
2771    }
2772    match crate::cloud::source::split_source_id(&text) {
2773        (Some(_), plain) => PathBuf::from(plain.into_owned()),
2774        (None, _) => path.to_path_buf(),
2775    }
2776}
2777
2778/// A record about `path`, under the path itself or the key an open files it under.
2779fn known_facts<'a>(
2780    known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2781    path: &Path,
2782) -> Option<&'a crate::cache::DatasetFacts> {
2783    known.get(path).or_else(|| known.get(&index_key(path)))
2784}
2785
2786/// What the dataset index remembers each row's place to be (`bitcoin/  2 parquet`),
2787/// from records this build's classifier would have written, skipping `dir`. A local
2788/// place must still match its recorded mtime, as `apply_known_facts` requires; a
2789/// remote place is taken as recorded.
2790fn place_labels(
2791    rows: &[Entry],
2792    known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2793    network_check: fn(&Path) -> bool,
2794) -> std::collections::HashMap<PathBuf, String> {
2795    let mut labels = std::collections::HashMap::new();
2796    for row in rows {
2797        let place = place_of(&row.path);
2798        if labels.contains_key(&place) {
2799            continue;
2800        }
2801        let Some(facts) = known_facts(known, &place) else {
2802            continue;
2803        };
2804        if facts.classified_by != crate::home::discover::CLASSIFIER_VERSION {
2805            continue;
2806        }
2807        if !network_check(&place) {
2808            let same_mtime = std::fs::metadata(&place)
2809                .and_then(|m| m.modified())
2810                .ok()
2811                .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2812                .is_some_and(|d| d.as_secs() == facts.mtime);
2813            if !same_mtime {
2814                continue;
2815            }
2816        }
2817        let Some(kind) = facts.kind else {
2818            continue;
2819        };
2820        let mut probe = Entry::directory(&place);
2821        probe.kind = kind;
2822        probe.holds = facts.holds.clone();
2823        let label = probe.label();
2824        if !label.is_empty() && !label.starts_with("dir") {
2825            labels.insert(place, label.into_owned());
2826        }
2827    }
2828    labels
2829}
2830
2831/// Fill every row in with what is already known about it and where it lives. Called
2832/// from each of `build_listing`'s exits, so no exit's rows miss facts.
2833fn annotate(
2834    sections: &mut [Section],
2835    known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2836    network_check: fn(&Path) -> bool,
2837    mounts: &crate::home::locality::Mounts,
2838) {
2839    for section in sections {
2840        for row in &mut section.rows {
2841            if is_cloud_place(&row.path) {
2842                row.cost.source = Some("cloud".to_string());
2843                continue;
2844            }
2845            let remote = network_check(&row.path);
2846            // A listing stats nothing, but a row the index has a record of is stat'ed, so
2847            // what an earlier run measured (its columns, which a filter matches) applies
2848            // wherever the row is.
2849            if !remote && discover::unstated(row) && known_facts(known, &row.path).is_some() {
2850                discover::stat_row(row);
2851            }
2852            apply_known_facts(row, known, remote);
2853            row.cost.source = Some(mounts.describe(&row.path).fstype);
2854        }
2855        // The door reads where its directory is (the local glyph on local disk).
2856        if let Some(door) = section.door.as_mut()
2857            && !is_cloud_place(&door.path)
2858        {
2859            door.cost.source = Some(mounts.describe(&door.path).fstype);
2860        }
2861    }
2862}
2863
2864/// A row's stat as last taken this session, and the refresh it was taken in.
2865#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2866pub struct Stated {
2867    pub size: Option<u64>,
2868    pub modified: Option<std::time::SystemTime>,
2869    /// [`HomeState::stat_epoch`] when it was taken.
2870    pub epoch: u64,
2871}
2872
2873/// Fold what this session learned of a row into it: counts gone stale dropped, its
2874/// measurement, then its latest stat. Whether there was anything.
2875fn fold_known(
2876    row: &mut Entry,
2877    enriched: &std::collections::HashMap<PathBuf, Measured>,
2878    stated: &std::collections::HashMap<PathBuf, Stated>,
2879    stale: &std::collections::HashSet<PathBuf>,
2880) -> bool {
2881    let mut any = false;
2882    if stale.contains(&row.path) {
2883        forget_counts(row);
2884        any = true;
2885    }
2886    if let Some(m) = enriched.get(&row.path) {
2887        fold_measured(row, m);
2888        any = true;
2889    }
2890    if let Some(stat) = stated.get(&row.path) {
2891        if stat.size.is_some() {
2892            row.size = stat.size;
2893        }
2894        if stat.modified.is_some() {
2895            row.modified = stat.modified;
2896        }
2897        any = true;
2898    }
2899    any
2900}
2901
2902/// Drop what a row's file said of itself, which a rewrite has made stale; where it
2903/// lives stays.
2904fn forget_counts(row: &mut Entry) {
2905    row.measured = false;
2906    row.rows = None;
2907    row.cols = None;
2908    row.cols_sampled = false;
2909    row.columns.clear();
2910    take_cost(row, &discover::Cost::default());
2911}
2912
2913/// What of a row decides where the list puts it, or whether it lists it at all: a
2914/// measurement that changes none of this leaves the list as built.
2915#[derive(PartialEq)]
2916struct Standing {
2917    hidden: bool,
2918    dataset: bool,
2919    /// The value the sort orders by, when it orders by something measured.
2920    sorted_by: Option<u64>,
2921}
2922
2923impl Standing {
2924    fn of(row: &Entry, sort: SortMode) -> Self {
2925        Standing {
2926            hidden: row.hidden_by_default(),
2927            dataset: row.kind.is_dataset() || row.kind.is_lake_table(),
2928            sorted_by: match sort {
2929                SortMode::Size => Some(row.size.unwrap_or(0)),
2930                SortMode::Rows => Some(row.rows.unwrap_or(0) as u64),
2931                SortMode::Modified => Some(
2932                    (row.modified)
2933                        .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2934                        .map_or(0, |d| d.as_secs()),
2935                ),
2936                SortMode::Natural => None,
2937            },
2938        }
2939    }
2940}
2941
2942/// Write a measurement into its row.
2943fn fold_measured(row: &mut Entry, m: &Measured) {
2944    row.measured = true;
2945    row.rows = m.rows;
2946    row.cols = m.cols;
2947    row.cols_sampled = m.cols_sampled;
2948    if let Some(kind) = m.kind {
2949        row.kind = kind;
2950    }
2951    if m.size.is_some() {
2952        row.size = m.size;
2953    }
2954    if m.modified.is_some() {
2955        row.modified = m.modified;
2956    }
2957    if !m.columns.is_empty() && row.columns != m.columns {
2958        row.columns.clone_from(&m.columns);
2959    }
2960    if !m.holds.is_empty() && row.holds != m.holds {
2961        row.holds.clone_from(&m.holds);
2962    }
2963    // Keep the source (from the mount table); take everything else (from the file).
2964    take_cost(row, &m.cost);
2965}
2966
2967/// Take a measured or remembered row cost, keeping what came from elsewhere: its
2968/// location (mount table) and a spec file's variant count (an older record lacks
2969/// it, which would leave no tables to list).
2970fn take_cost(row: &mut Entry, cost: &discover::Cost) {
2971    let source = row.cost.source.take();
2972    let variants = row.cost.tables.filter(|_| row.format_spec.is_some());
2973    row.cost = cost.clone();
2974    row.cost.source = source;
2975    if variants.is_some() {
2976        row.cost.tables = variants;
2977    }
2978}
2979
2980/// Apply a cached measurement to a row. A local row must still match its size and
2981/// mtime (the listing already stat'ed it). A remote row cannot be stat'ed safely, so
2982/// its cached facts are used as is: a stale count beats none for hard-to-reach data.
2983fn apply_known_facts(
2984    row: &mut Entry,
2985    known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2986    remote: bool,
2987) {
2988    let Some(facts) = known_facts(known, &row.path) else {
2989        return;
2990    };
2991
2992    // What an earlier run found this directory to be: listings no longer read
2993    // directories, so rows arrive `Unknown`. Only directories a run measured (with a
2994    // size) have a record; what it saves is re-reading footers that found separate
2995    // tables. Checked before the file fingerprint below, which never matches a
2996    // directory: the directory's mtime is its fingerprint, moving as files come and go.
2997    // Gated on the classifier version, since `is_one_table` is version-sensitive.
2998    if !remote
2999        && matches!(row.kind, EntryKind::Unknown | EntryKind::MultiFile)
3000        && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
3001        && let Some(kind) = facts.kind
3002    {
3003        let same_mtime = row
3004            .modified
3005            .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
3006            .is_some_and(|d| d.as_secs() == facts.mtime);
3007        if same_mtime {
3008            row.kind = kind;
3009            // The holdings come back with the kind: a row given its kind from the cache is
3010            // never looked into again, so a missing count would stay missing all session.
3011            if row.holds.is_empty() {
3012                row.holds = facts.holds.clone();
3013            }
3014            // Only kind and count: both come from the directory's names, which its mtime
3015            // fingerprints. Footer facts (width, size, columns) can change by a file rewritten
3016            // in place, which a directory mtime cannot see, so they are not restored.
3017        }
3018    }
3019
3020    if !remote {
3021        let same_bytes = row.size.map(|s| s == facts.size).unwrap_or(false)
3022            && row
3023                .modified
3024                .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
3025                .map(|d| d.as_secs() == facts.mtime)
3026                .unwrap_or(false);
3027        if !same_bytes {
3028            return;
3029        }
3030    }
3031
3032    row.rows = facts.rows;
3033    row.cols = facts.cols;
3034    row.cols_sampled = facts.cols_sampled;
3035    if !facts.columns.is_empty() {
3036        row.columns = facts.columns.clone();
3037    }
3038    // The source comes from the live mount table afterwards; only what the file said of
3039    // itself is restored.
3040    take_cost(row, &facts.cost);
3041    if remote {
3042        // A remote row was never stat'ed, so these are all it has; a record with no size
3043        // (one object's footer read) gives none rather than zero.
3044        if facts.size > 0 {
3045            row.size = row.size.or(Some(facts.size));
3046        }
3047        // What it was last seen to be, not what its name suggests, so a dataset reads the
3048        // same in every section. Only from this classifier version: an older record could
3049        // call a Delta root `multifile`.
3050        if row.kind == EntryKind::Unknown
3051            && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
3052            && let Some(kind) = facts.kind
3053        {
3054            row.kind = kind;
3055            // The holdings come back with the kind, or the row would say `dir` all session.
3056            if row.holds.is_empty() {
3057                row.holds = facts.holds.clone();
3058            }
3059        }
3060    }
3061}
3062
3063/// The record to keep for a row that has just been measured.
3064pub fn facts_for(entry: &Entry) -> Option<(PathBuf, crate::cache::DatasetFacts)> {
3065    let size = entry.size?;
3066    let mtime = entry
3067        .modified?
3068        .duration_since(std::time::UNIX_EPOCH)
3069        .ok()?
3070        .as_secs();
3071    // Where it lives is not learned: it is never recorded.
3072    let cost = crate::home::discover::Cost {
3073        source: None,
3074        ..entry.cost.clone()
3075    };
3076    if entry.rows.is_none() && entry.columns.is_empty() && cost == Default::default() {
3077        return None; // Nothing learned worth keeping.
3078    }
3079    Some((
3080        entry.path.clone(),
3081        crate::cache::DatasetFacts {
3082            mtime,
3083            size,
3084            rows: entry.rows,
3085            cols: entry.cols,
3086            cols_sampled: entry.cols_sampled,
3087            columns: entry.columns.clone(),
3088            kind: Some(entry.kind),
3089            holds: entry.holds.clone(),
3090            classified_by: crate::home::discover::CLASSIFIER_VERSION,
3091            // The source is where it is now: paths move between mounts.
3092            cost: crate::home::discover::Cost {
3093                source: None,
3094                ..entry.cost.clone()
3095            },
3096        },
3097    ))
3098}
3099
3100/// How well an entry answers the filter, by name or by column (the footer's column
3101/// names: "which has a `customer_id`?"). A name match always outranks a column
3102/// match. Higher is better, as in fzf; see [`crate::home::fuzzy`].
3103pub fn match_score(filter: &str, entry: &Entry) -> Option<i32> {
3104    match crate::home::fuzzy::best_match(filter, &entry.name) {
3105        Some(m) => Some(m.score),
3106        // Below every name match; a column match is a substring test with no score of its
3107        // own.
3108        None => matching_column(filter, entry).map(|_| -COLUMN_MATCH_PENALTY),
3109    }
3110}
3111
3112/// [`match_score`], with what the row is marked by when drawn.
3113pub fn match_hit(filter: &str, entry: &Entry) -> Option<Hit> {
3114    Needle::new(filter).hit(entry)
3115}
3116
3117/// A filter prepared once for scoring a whole listing: lowered once, not once per row
3118/// and column.
3119struct Needle<'a> {
3120    filter: &'a str,
3121    lower: String,
3122}
3123
3124impl<'a> Needle<'a> {
3125    fn new(filter: &'a str) -> Self {
3126        Needle {
3127            filter,
3128            lower: filter.to_lowercase(),
3129        }
3130    }
3131
3132    fn hit(&self, entry: &Entry) -> Option<Hit> {
3133        let named = crate::home::fuzzy::best_match_with(self.filter, &entry.name, |score, _| Hit {
3134            score,
3135            column: None,
3136        });
3137        if named.is_some() {
3138            return named;
3139        }
3140        Some(Hit {
3141            score: -COLUMN_MATCH_PENALTY,
3142            column: Some(self.column(entry)?),
3143        })
3144    }
3145
3146    /// The first column of `entry` containing the filter, case-insensitively.
3147    fn column(&self, entry: &Entry) -> Option<usize> {
3148        if self.filter.is_empty() {
3149            return None;
3150        }
3151        (entry.columns.iter()).position(|c| contains_folded(c, &self.lower))
3152    }
3153}
3154
3155/// Whether `haystack` contains `lower` (already lowercase), ignoring case; without
3156/// allocating when both are ASCII.
3157fn contains_folded(haystack: &str, lower: &str) -> bool {
3158    if haystack.is_ascii() && lower.is_ascii() {
3159        let (hay, needle) = (haystack.as_bytes(), lower.as_bytes());
3160        return needle.is_empty()
3161            || hay
3162                .windows(needle.len())
3163                .any(|w| w.eq_ignore_ascii_case(needle));
3164    }
3165    haystack.to_lowercase().contains(lower)
3166}
3167
3168/// How far a column match sits below any name match: more than any name score, so
3169/// they never interleave.
3170const COLUMN_MATCH_PENALTY: i32 = 1_000_000;
3171
3172/// The first column of `entry` containing `filter`, case-insensitively. Substring,
3173/// not subsequence: fuzzy matching dozens of names matches nearly everything.
3174pub fn matching_column<'a>(filter: &str, entry: &'a Entry) -> Option<&'a str> {
3175    (Needle::new(filter).column(entry)).map(|i| entry.columns[i].as_str())
3176}
3177
3178/// Character positions in `haystack` that `needle` matched, from the same alignment
3179/// that scored it.
3180pub fn fuzzy_positions(needle: &str, haystack: &str) -> Vec<usize> {
3181    crate::home::fuzzy::best_match(needle, haystack)
3182        .map(|m| m.positions)
3183        .unwrap_or_default()
3184}
3185
3186/// Character positions of the first case-insensitive occurrence of `needle`, since
3187/// column matching is a substring test.
3188pub fn substring_positions(needle: &str, haystack: &str) -> Vec<usize> {
3189    if needle.is_empty() {
3190        return Vec::new();
3191    }
3192    let hay: Vec<char> = haystack.to_lowercase().chars().collect();
3193    let need: Vec<char> = needle.to_lowercase().chars().collect();
3194    if need.len() > hay.len() {
3195        return Vec::new();
3196    }
3197    for start in 0..=(hay.len() - need.len()) {
3198        if hay[start..start + need.len()] == need[..] {
3199            return (start..start + need.len()).collect();
3200        }
3201    }
3202    Vec::new()
3203}
3204
3205/// Whether and how well `needle` matches `haystack` (higher is better), through
3206/// [`crate::home::fuzzy::best_match`] like every ranking and highlight.
3207pub fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
3208    crate::home::fuzzy::best_match(needle, haystack).map(|m| m.score)
3209}
3210
3211impl HomeState {
3212    /// Roots from the working directory, then the desktop's data directories;
3213    /// duplicates collapse to the first. Recents' places are `RECENT` rows, not roots.
3214    pub fn roots(desktop_dirs: &[PathBuf]) -> Vec<Root> {
3215        Self::roots_with(desktop_dirs, is_remote_path)
3216    }
3217
3218    /// As [`HomeState::roots`], with the network test injected.
3219    pub fn roots_with(desktop_dirs: &[PathBuf], is_network: fn(&Path) -> bool) -> Vec<Root> {
3220        let mut roots: Vec<Root> = Vec::new();
3221        let mut seen: Vec<PathBuf> = Vec::new();
3222
3223        let push =
3224            |path: PathBuf, origin: RootOrigin, roots: &mut Vec<Root>, seen: &mut Vec<PathBuf>| {
3225                // The network test reads only the mount table, safe on a blocking path.
3226                let network = is_network(&path);
3227
3228                // Canonicalizing and listing touch the filesystem and block on a dead NFS share
3229                // (indefinitely on a `hard` mount): a remote root is taken as is and probed in the
3230                // background.
3231                let key = if network {
3232                    path.clone()
3233                } else {
3234                    crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone())
3235                };
3236                if seen.contains(&key) {
3237                    return;
3238                }
3239                seen.push(key);
3240
3241                let available = if network {
3242                    true // unknown until probed; assumed present so it is listed
3243                } else {
3244                    std::fs::read_dir(&path).is_ok()
3245                };
3246                roots.push(Root {
3247                    path,
3248                    origin,
3249                    available,
3250                    network,
3251                });
3252            };
3253
3254        // Where you are comes first. Standing in a directory is the strongest
3255        // statement of what you are working on right now — stronger than a directory
3256        // configured months ago.
3257        if let Ok(cwd) = std::env::current_dir() {
3258            push(cwd, RootOrigin::Cwd, &mut roots, &mut seen);
3259        }
3260
3261        // Last, and weakest: places the desktop says you have opened data from. Only
3262        // useful before datui has recents of its own.
3263        for dir in desktop_dirs {
3264            push(dir.clone(), RootOrigin::Desktop, &mut roots, &mut seen);
3265        }
3266
3267        roots
3268    }
3269
3270    /// Build the home listing: recents first (on a mount, the dataset you want was
3271    /// usually opened before), then roots scanned one level deep.
3272    pub fn rebuild(&mut self, recents: &[PathBuf]) {
3273        self.rebuild_with(recents, &[])
3274    }
3275
3276    /// As [`HomeState::rebuild`], plus directories derived from the desktop's own
3277    /// recently-used list.
3278    pub fn rebuild_with(&mut self, recents: &[PathBuf], desktop_dirs: &[PathBuf]) {
3279        let request = ListingRequest {
3280            recents: recents.to_vec(),
3281            desktop_dirs: desktop_dirs.to_vec(),
3282            browsing: self.browsing.clone(),
3283            probes: self.probes.clone(),
3284            narrowed: self.narrowed.clone(),
3285            network_check: self.network_check,
3286            cloud: self.cloud.clone(),
3287            catalogs: self.catalogs.clone(),
3288            // The synchronous path (tests, library callers) uses no cache: exactly what is on
3289            // disk now.
3290            known: Default::default(),
3291            formats: self.formats.clone(),
3292        };
3293        let listing = build_listing(&request);
3294        self.apply_listing(listing);
3295    }
3296
3297    /// Install a listing built elsewhere, keeping the cursor on whatever it was on.
3298    pub fn apply_listing(&mut self, listing: Listing) {
3299        let mut listing = listing;
3300        for section in &mut listing.sections {
3301            name_by_spec(&self.formats, &mut section.rows);
3302        }
3303        self.missing = listing.missing;
3304        self.replace_sections(|home| home.sections = listing.sections);
3305    }
3306
3307    /// Build again, from its probe alone, the section showing the remote place `root`:
3308    /// the browsed directory or a network root. Pages of a listing land several times a
3309    /// second; this touches no file and leaves every other section as it is.
3310    pub fn relist_remote(&mut self, root: &Path) {
3311        let browsed = self.browsing.as_deref() == Some(root)
3312            && (self.network_check)(root)
3313            && cloud_source_id(root).is_none();
3314        let at = (self.sections.iter()).position(|s| {
3315            s.root.as_deref() == Some(root) && (browsed || s.remote_root.as_deref() == Some(root))
3316        });
3317        let Some(at) = at else {
3318            return;
3319        };
3320        let mut section = if browsed {
3321            browsed_section(
3322                root,
3323                true,
3324                &Listed {
3325                    probes: &self.probes,
3326                    narrowed: self.narrowed.as_ref(),
3327                    catalogs: &self.catalogs,
3328                    known: &self.known,
3329                    formats: &self.formats,
3330                },
3331            )
3332        } else {
3333            Section::titled("", self.probes.rows(root))
3334        };
3335        let mounts = crate::home::locality::Mounts::cached();
3336        annotate(
3337            std::slice::from_mut(&mut section),
3338            &self.known,
3339            self.network_check,
3340            &mounts,
3341        );
3342        name_by_spec(&self.formats, &mut section.rows);
3343        let silent = self.probes.silent(root);
3344        let unreachable = self.probes.unreachable(root);
3345        let waiting = !self.probes.settled(root);
3346        self.replace_sections(|home| {
3347            if browsed {
3348                home.sections[at] = section;
3349                return;
3350            }
3351            // A root keeps its heading; its rows, its wait and whether it answers are the
3352            // probe's, so a root that speaks again is no longer said not to answer.
3353            let shown = &mut home.sections[at];
3354            shown.rows = section.rows;
3355            shown.waiting = waiting;
3356            shown.unavailable = silent || unreachable;
3357            shown.unavailable_note = silent.then(|| NOT_ANSWERING.to_string());
3358        });
3359    }
3360
3361    /// Show `cloud` as the cloud sources: the Cloud section, or the browsed source's
3362    /// buckets, built again in place. Nothing else changes, so nothing is read.
3363    pub fn set_cloud(&mut self, cloud: Vec<CloudSource>) {
3364        self.cloud = cloud;
3365        let mounts = crate::home::locality::Mounts::cached();
3366        let annotated = |home: &Self, mut section: Section| {
3367            annotate(
3368                std::slice::from_mut(&mut section),
3369                &home.known,
3370                home.network_check,
3371                &mounts,
3372            );
3373            section
3374        };
3375        match self.browsing.as_deref().map(cloud_source_id) {
3376            Some(Some(id)) => {
3377                let section = annotated(self, source_section(&id, &self.cloud));
3378                self.replace_sections(|home| home.sections = vec![section]);
3379            }
3380            Some(None) => {}
3381            None => {
3382                let section = cloud_section(&self.cloud).map(|s| annotated(self, s));
3383                self.replace_sections(|home| {
3384                    let at = (home.sections.iter()).position(|s| s.title == Self::CLOUD_SECTION);
3385                    match (at, section) {
3386                        (Some(at), Some(section)) => home.sections[at] = section,
3387                        (Some(at), None) => {
3388                            home.sections.remove(at);
3389                        }
3390                        // Below the places, above the catalogs, as a listing puts it.
3391                        (None, Some(section)) => {
3392                            let at = (home.sections.iter())
3393                                .position(|s| {
3394                                    s.origin.is_some_and(is_catalog_origin)
3395                                        || s.title == "Elsewhere"
3396                                        || s.title == Self::SEARCH_SECTION
3397                                })
3398                                .unwrap_or(home.sections.len());
3399                            home.sections.insert(at, section);
3400                        }
3401                        (None, None) => {}
3402                    }
3403                });
3404            }
3405        }
3406    }
3407
3408    /// Change the sections through `change`, then put back what rides on them: the search
3409    /// results, this session's measurements, and the cursor on whatever it was on.
3410    fn replace_sections(&mut self, change: impl FnOnce(&mut Self)) {
3411        let returning = self.returning.take();
3412        let previous = returning.clone().or_else(|| self.selected_key());
3413        // Rows landing above the cursor move the list, not the cursor.
3414        let line = self.selected.saturating_sub(self.scroll);
3415        change(self);
3416        self.changed();
3417        // Browsing, the first section is the directory browsed.
3418        if let (Some(browsing), Some((dir, format))) = (&self.browsing, &self.lake_here)
3419            && browsing == dir
3420            && let Some(section) = self.sections.first_mut()
3421        {
3422            let note = crate::glyphs::dotted(&format!(
3423                "{} · not read as a table",
3424                format.to_ascii_lowercase()
3425            ));
3426            section.subtitle = Some(match section.subtitle.take() {
3427                Some(state) => crate::glyphs::dotted(&format!("{note} · {state}")),
3428                None => note,
3429            });
3430        }
3431        // Search results outlive rebuilds (they came from a walk): put them back.
3432        self.sync_search_section();
3433        // So does what this session looked into: rebuilds read names cheaply, and kinds and
3434        // counts cost round trips.
3435        self.apply_measurements();
3436
3437        // Keep the cursor on its row across refreshes, not back at the top on every result.
3438        let placed = self.reselect(previous);
3439        // A returned-to row is where the user left it, not a landing a late footer may
3440        // move.
3441        if placed && returning.is_some() {
3442            self.landing = false;
3443        }
3444        if !placed {
3445            self.select_first_entry();
3446            // The returned-to row may be in a later listing (a remote place still answering, a
3447            // search still walking).
3448            if self.rows_still_arriving() {
3449                self.returning = returning;
3450            }
3451        } else if returning.is_some() {
3452            self.scroll_to_returning_line();
3453        } else {
3454            self.scroll = self.selected.saturating_sub(line);
3455        }
3456        self.follow_selection();
3457    }
3458
3459    /// Remember the cursor before going inside something, so leaving returns to it. Call
3460    /// before `browsing` changes.
3461    pub fn leave_mark(&mut self) {
3462        let mark = Mark {
3463            place: self.browsing.clone(),
3464            key: self.selected_key(),
3465            filter: self.filter.clone(),
3466            // A scoring out now would answer while away and be dropped; the kept copy asks
3467            // again on return.
3468            search: (!self.search.running).then(|| SearchState {
3469                scoring: false,
3470                ..self.search.clone()
3471            }),
3472            line: {
3473                let lines = self.list_lines(self.spaced);
3474                (lines.line_of(self.selected)).saturating_sub(lines.line_of(self.scroll))
3475            },
3476        };
3477        // A place already on the trail is being re-entered from elsewhere; its old mark is
3478        // over.
3479        self.trail.retain(|m| m.place != mark.place);
3480        self.trail.push(mark);
3481    }
3482
3483    /// Return to the place now browsed, from `from`: its filter, search, and the cursor
3484    /// on its row once the listing lands. Call after `browsing` is set. A place never
3485    /// entered from (Backspace above the browse start) puts the cursor on the place just
3486    /// left.
3487    pub fn come_back(&mut self, from: Option<PathBuf>) {
3488        let to = self.browsing.clone();
3489        let mark = self
3490            .trail
3491            .iter()
3492            .rposition(|m| m.place == to)
3493            .map(|at| self.trail.split_off(at).remove(0));
3494        match mark {
3495            Some(mark) => {
3496                self.filter = mark.filter;
3497                self.search = mark.search.unwrap_or_default();
3498                self.returning = mark.key;
3499                self.returning_line = Some(mark.line);
3500            }
3501            None => {
3502                self.filter.clear();
3503                self.search.reset();
3504                self.returning = from.map(RowKey::Entry);
3505                self.returning_line = None;
3506            }
3507        }
3508    }
3509
3510    /// Whether rows may yet arrive without the user asking: a remote listing still
3511    /// answering, or a search still walking.
3512    fn rows_still_arriving(&self) -> bool {
3513        self.listing_in_flight
3514            || self.sections_waiting()
3515            || self.awaiting_listing().is_some()
3516            || self.search.running
3517    }
3518
3519    /// Put the cursor on the row being returned to, if it has arrived.
3520    fn settle_return(&mut self) {
3521        let Some(key) = self.returning.clone() else {
3522            return;
3523        };
3524        if let Some(idx) = self.place_key(&key) {
3525            self.selected = idx;
3526            self.returning = None;
3527            self.landing = false;
3528            self.scroll_to_returning_line();
3529            self.follow_selection();
3530        } else if !self.rows_still_arriving() {
3531            self.returning = None;
3532        }
3533    }
3534
3535    /// Put the row just returned to on the line it was left on, when that is known.
3536    fn scroll_to_returning_line(&mut self) {
3537        if let Some(line) = self.returning_line.take() {
3538            let lines = self.list_lines(self.spaced);
3539            self.scroll = lines.first_row_from(lines.line_of(self.selected).saturating_sub(line));
3540        }
3541    }
3542
3543    /// What the cursor is on, picked or resting, as something that survives the rows
3544    /// changing: where it is put back after a listing, a resize or a fold.
3545    pub fn selected_key(&self) -> Option<RowKey> {
3546        let title = |section: usize| self.sections.get(section).map(|s| s.title.clone());
3547        Some(match self.cursor_row()? {
3548            Row::Header { section, .. } => RowKey::Header(title(section)?),
3549            Row::More { section, .. } => RowKey::More(title(section)?),
3550            Row::Hidden { section, .. } => RowKey::Hidden(title(section)?),
3551            Row::Up { section } => RowKey::Up(title(section)?),
3552            Row::Entry { entry, .. } => RowKey::Entry(entry.path.clone()),
3553            Row::Door { entry, .. } => RowKey::Door(entry.path.clone()),
3554            Row::Place { path, .. } => RowKey::Place(path),
3555        })
3556    }
3557
3558    /// Put the cursor back on the row `key` names, if still listed; true if placed. A
3559    /// row the cap now hides counts as placed on its `more` row. Otherwise the cursor
3560    /// is clamped so it never sits past the end.
3561    pub fn reselect(&mut self, key: Option<RowKey>) -> bool {
3562        let Some(key) = key else {
3563            self.clamp_selection();
3564            return false;
3565        };
3566        match self.place_key(&key) {
3567            Some(idx) => {
3568                self.selected = idx;
3569                true
3570            }
3571            None => {
3572                self.clamp_selection();
3573                false
3574            }
3575        }
3576    }
3577
3578    /// Where the row `key` names is on screen. A row a directory's cut hides makes that
3579    /// directory show whole; a row `RECENT`'s cap hides answers with its `more` row.
3580    fn place_key(&mut self, key: &RowKey) -> Option<usize> {
3581        if let Some(found) = self.listed(key) {
3582            return Some(found);
3583        }
3584        if let RowKey::Entry(path) = key
3585            && let Some(root) = self
3586                .sections
3587                .iter()
3588                .find(|s| !s.grouped_by_place && s.rows.iter().any(|r| r.path == *path))
3589                .and_then(|s| s.root.clone())
3590            && self.shown_whole.insert(root.clone())
3591        {
3592            match self.listed(key) {
3593                Some(found) => return Some(found),
3594                None => {
3595                    self.shown_whole.remove(&root);
3596                }
3597            }
3598        }
3599        self.behind_recent_cap(key)
3600    }
3601
3602    /// Where the row `key` names is on screen, as it is listed now.
3603    fn listed(&self, key: &RowKey) -> Option<usize> {
3604        self.position(|row| match (row, key) {
3605            (Row::Entry { entry, .. }, RowKey::Entry(path)) => entry.path == *path,
3606            (Row::Door { entry, .. }, RowKey::Door(path)) => entry.path == *path,
3607            (Row::Place { path, .. }, RowKey::Place(wanted)) => path == wanted,
3608            (Row::Header { section, .. }, RowKey::Header(title))
3609            | (Row::More { section, .. }, RowKey::More(title))
3610            | (Row::Hidden { section, .. }, RowKey::Hidden(title))
3611            | (Row::Up { section }, RowKey::Up(title)) => self
3612                .sections
3613                .get(*section)
3614                .is_some_and(|s| s.title == *title),
3615            _ => false,
3616        })
3617    }
3618
3619    /// The `more` row of `RECENT` when its cap hides the row `key` names.
3620    fn behind_recent_cap(&self, key: &RowKey) -> Option<usize> {
3621        let (RowKey::Entry(path) | RowKey::Place(path)) = key else {
3622            return None;
3623        };
3624        self.position(|row| {
3625            matches!(row, Row::More { section, .. }
3626            if self.sections.get(*section).is_some_and(|s| {
3627                s.grouped_by_place
3628                    && s.rows
3629                        .iter()
3630                        .any(|r| r.path == *path || place_of(&r.path) == *path)
3631            }))
3632        })
3633    }
3634
3635    /// Tell the listing the list's height, keeping the cursor on its row: `RECENT`'s cap
3636    /// is a share of the height. Called every frame; only a change does work.
3637    pub fn set_view_height(&mut self, height: usize) {
3638        if height == self.view_height {
3639            return;
3640        }
3641        let key = self.selected_key();
3642        self.view_height = height;
3643        self.reselect(key);
3644    }
3645
3646    /// Tell the listing the list's lines as the frame draws them: `lines` tall, `spaced`
3647    /// or dense. Rows and lines differ by the blank lines, so the scroll the frame settles
3648    /// is the one [`HomeState::follow_selection`] and a return to a place settle.
3649    pub fn set_list_shape(&mut self, lines: usize, spaced: bool) {
3650        self.view_lines = lines;
3651        self.spaced = spaced;
3652    }
3653
3654    /// Settle the viewport now, as the next frame will: the look-into pass is asked for
3655    /// when a listing lands, before that frame, and a stale `scroll` from the replaced
3656    /// listing would spend the batch on rows nobody sees.
3657    fn follow_selection(&mut self) {
3658        if self.view_lines == 0 {
3659            let rows = self.row_count();
3660            self.scroll = settle_top(self.scroll, self.selected, self.view_height, rows);
3661            return;
3662        }
3663        let lines = self.list_lines(self.spaced);
3664        let rows = lines.rows();
3665        let line_of = |row: usize| lines.line_of(row.min(rows.saturating_sub(1)));
3666        let top = settle_top(
3667            line_of(self.scroll),
3668            line_of(self.selected),
3669            self.view_lines,
3670            lines.total(),
3671        );
3672        self.scroll = lines.first_row_from(top);
3673    }
3674
3675    /// Whether a section is folded: the user's last choice, else its default. Never
3676    /// while browsing: the browsed listing is the whole screen, and a fold remembered
3677    /// for a section once titled by that path must not hide it.
3678    fn section_folded(&self, section: &Section) -> bool {
3679        if self.browsing.is_some() {
3680            return false;
3681        }
3682        self.folds
3683            .get(&section.title)
3684            .copied()
3685            .unwrap_or(section.folded_by_default)
3686    }
3687
3688    /// Whether a section is collapsed.
3689    pub fn is_collapsed(&self, section: usize) -> bool {
3690        self.sections
3691            .get(section)
3692            .is_some_and(|s| self.section_folded(s))
3693    }
3694
3695    /// Collapse or expand a section.
3696    pub fn toggle_collapsed(&mut self, section: usize) {
3697        let folded = self.is_collapsed(section);
3698        self.set_collapsed(section, !folded);
3699    }
3700
3701    /// Fold or unfold `section`, remembered by title. Nothing is remembered while
3702    /// browsing: the browsed listing never folds, and its path is also a root section's
3703    /// title.
3704    pub fn set_collapsed(&mut self, section: usize, collapsed: bool) {
3705        if self.browsing.is_some() {
3706            return;
3707        }
3708        let Some(title) = self.sections.get(section).map(|s| s.title.clone()) else {
3709            return;
3710        };
3711        self.folds.insert(title, collapsed);
3712    }
3713
3714    /// Move the cursor to the next (`delta` > 0) or previous section header, wrapping.
3715    pub fn jump_section(&mut self, delta: isize) {
3716        self.resting = false;
3717        self.returning = None;
3718        self.landing = false;
3719        let headers = self.view().headers.clone();
3720        if headers.is_empty() {
3721            return;
3722        }
3723        let current = self.selected;
3724        self.selected = if delta > 0 {
3725            headers
3726                .iter()
3727                .copied()
3728                .find(|&h| h > current)
3729                .unwrap_or(headers[0])
3730        } else {
3731            headers
3732                .iter()
3733                .rev()
3734                .copied()
3735                .find(|&h| h < current)
3736                .unwrap_or(*headers.last().unwrap())
3737        };
3738    }
3739
3740    /// Whether the listing holds anywhere to go, folded or not: datasets, lake tables
3741    /// (not readable as tables, but places), and rows not yet looked into, which may be
3742    /// datasets. Hence [`EntryKind::is_dataset`], not [`EntryKind::is_known_dataset`].
3743    pub fn has_any_dataset(&self) -> bool {
3744        self.view().has_dataset
3745    }
3746
3747    /// Title of the section of recursive search results; fold state is keyed by title.
3748    pub const SEARCH_SECTION: &'static str = "Found";
3749
3750    /// Title of the section listing cloud sources.
3751    pub const CLOUD_SECTION: &'static str = "Cloud";
3752
3753    /// Title of the section listing what has been opened, grouped by place.
3754    pub const RECENT_SECTION: &'static str = "Recent";
3755
3756    /// The source a place belongs to: `cloud://<id>` itself, a bucket named with a
3757    /// source (`s3://<id>@bucket`), or a bucket some source listed.
3758    pub fn cloud_source_of(&self, path: &Path) -> Option<&CloudSource> {
3759        if let Some(id) = cloud_source_id(path) {
3760            return self.cloud.iter().find(|s| s.id == id);
3761        }
3762        if let Some((id, _)) = cloud_account(path) {
3763            return self.cloud.iter().find(|s| s.id == id);
3764        }
3765        let text = path.to_string_lossy();
3766        if let Some((account, _, _)) = crate::cloud::source::azure_parts(&text) {
3767            return self.azure_account_place(&account).and_then(|place| {
3768                cloud_account(&place).and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id))
3769            });
3770        }
3771        if let (Some(id), _) = crate::cloud::source::split_source_id(&text) {
3772            return self.cloud.iter().find(|s| s.id == id);
3773        }
3774        if let Some(project) =
3775            Self::google_bucket_root(path).and_then(|b| self.project_of_bucket(&b))
3776        {
3777            return cloud_account(&project)
3778                .and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id));
3779        }
3780        let (_, plain) = crate::cloud::source::split_source_id(&text);
3781        let (scheme, rest) = plain.split_once("://")?;
3782        let bucket = rest.split('/').next()?;
3783        let root = PathBuf::from(format!("{scheme}://{bucket}"));
3784        self.cloud.iter().find(|s| s.buckets.contains(&root))
3785    }
3786
3787    /// One level up from `path`. A bucket's parent is its source, so Backspace from a
3788    /// bucket returns to the source.
3789    pub fn parent_of(&self, path: &Path) -> Option<PathBuf> {
3790        if cloud_source_id(path).is_some() {
3791            return None;
3792        }
3793        // Out of a remote dataset's root goes back to its catalog, not into a bucket that
3794        // may not be listable.
3795        if let Some((_, dataset)) = self.remote_dataset_of(path) {
3796            let place = &dataset.location;
3797            if same_place(path, place) {
3798                return None;
3799            }
3800            let up = self.parent_within(path)?;
3801            // The dataset's own place, as listed, so its listing is found again.
3802            return Some(if same_place(&up, place) {
3803                place.clone()
3804            } else {
3805                up
3806            });
3807        }
3808        if let Some((id, _)) = cloud_account(path) {
3809            return Some(cloud_place(&id));
3810        }
3811        let text = path.to_string_lossy();
3812        if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3813            let key = key.trim_matches('/');
3814            if key.is_empty() {
3815                return self.azure_account_place(&account);
3816            }
3817            let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3818            let up = if up.is_empty() {
3819                String::new()
3820            } else {
3821                format!("{up}/")
3822            };
3823            return Some(PathBuf::from(crate::cloud::source::azure_url(
3824                &account, &container, &up,
3825            )));
3826        }
3827        if is_bucket_root(path) {
3828            // A Google bucket's parent is the project it was listed under.
3829            if let Some(project) = self.project_of_bucket(path) {
3830                return Some(project);
3831            }
3832            return self.cloud_source_of(path).map(|s| cloud_place(&s.id));
3833        }
3834        parent_location(path)
3835    }
3836
3837    /// One level up inside a bucket or container, whatever the provider.
3838    fn parent_within(&self, path: &Path) -> Option<PathBuf> {
3839        let text = path.to_string_lossy();
3840        if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3841            let key = key.trim_matches('/');
3842            let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3843            let up = if up.is_empty() {
3844                String::new()
3845            } else {
3846                format!("{up}/")
3847            };
3848            return Some(PathBuf::from(crate::cloud::source::azure_url(
3849                &account, &container, &up,
3850            )));
3851        }
3852        parent_location(path)
3853    }
3854
3855    /// The remote catalog dataset `path` is in: the innermost, or the first listed of two
3856    /// at the same place.
3857    fn remote_dataset_of(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3858        if !is_object_store_url(path) {
3859            return None;
3860        }
3861        let text = path.to_string_lossy();
3862        self.catalogs
3863            .iter()
3864            .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3865            .filter(|(_, d)| {
3866                is_object_store_url(&d.location) && within(&text, &d.location.to_string_lossy())
3867            })
3868            .rev()
3869            .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
3870    }
3871
3872    /// The catalog dataset listed at `path` itself.
3873    pub fn catalog_dataset(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3874        let places = &self.catalog_places;
3875        if places.indexes(&self.catalogs) {
3876            let key = place_key(path);
3877            let &(c, d) = places.datasets.get(&key)?;
3878            let catalog = self.catalogs.get(c)?;
3879            if let Some(dataset) = catalog.datasets.get(d)
3880                && place_key(&dataset.location) == key
3881            {
3882                return Some((catalog, dataset));
3883            }
3884        }
3885        self.catalogs
3886            .iter()
3887            .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3888            .find(|(_, d)| same_place(&d.location, path))
3889    }
3890
3891    /// The dataset a bookmark is listed under, and the bookmark's name.
3892    pub fn bookmark(&self, path: &Path) -> Option<(&ShownDataset, &str)> {
3893        let places = &self.catalog_places;
3894        if places.indexes(&self.catalogs) {
3895            let key = place_key(path);
3896            let &(c, d, b) = places.bookmarks.get(&key)?;
3897            if let Some(dataset) = self.catalogs.get(c).and_then(|c| c.datasets.get(d))
3898                && let Some((name, place)) = dataset.bookmarks.get(b)
3899                && place_key(place) == key
3900            {
3901                return Some((dataset, name.as_str()));
3902            }
3903        }
3904        self.catalogs
3905            .iter()
3906            .flat_map(|c| c.datasets.iter())
3907            .find_map(|d| {
3908                d.bookmarks
3909                    .iter()
3910                    .find(|(_, place)| same_place(place, path))
3911                    .map(|(name, _)| (d, name.as_str()))
3912            })
3913    }
3914
3915    /// What a catalog says an unmeasured HTTP(S) file weighs, shown as `~33 MB`.
3916    pub fn size_hint(&self, path: &Path) -> Option<u64> {
3917        self.catalog_dataset(path).and_then(|(_, d)| d.size)
3918    }
3919
3920    /// The place of an Azure account, from whichever source lists it.
3921    fn azure_account_place(&self, account: &str) -> Option<PathBuf> {
3922        self.cloud
3923            .iter()
3924            .flat_map(|s| s.buckets.iter())
3925            .find(|place| cloud_account(place).is_some_and(|(_, a)| a == account))
3926            .cloned()
3927    }
3928
3929    /// How far an object-store directory is in being looked into, while its row has no
3930    /// label. `None` once labeled, or for other rows.
3931    pub fn cloud_look(&self, entry: &Entry) -> Option<CloudLook> {
3932        if entry.kind != EntryKind::Directory
3933            || !entry.holds.is_empty()
3934            || !is_object_store_url(&entry.path)
3935            || is_cloud_place(&entry.path)
3936            || object_place_label(&entry.path).is_some()
3937        {
3938            return None;
3939        }
3940        if self.peeking.contains(&entry.path) {
3941            return Some(CloudLook::Looking);
3942        }
3943        if self.peek_failed.contains(&entry.path) {
3944            return Some(CloudLook::Failed);
3945        }
3946        match self.cloud_kinds.get(&entry.path) {
3947            None => Some(CloudLook::Waiting),
3948            // Answered, but the row is from a listing being rebuilt: `dir` meanwhile would claim
3949            // no data.
3950            Some((kind, holds)) if *kind != EntryKind::Directory || !holds.is_empty() => {
3951                Some(CloudLook::Looking)
3952            }
3953            Some(_) => None,
3954        }
3955    }
3956
3957    /// What a source or catalog calls a place: a catalog's remote dataset, or `missing`
3958    /// for an absent local one.
3959    pub fn place_kind(&self, path: &Path) -> Option<&'static str> {
3960        if self.missing.contains(path) {
3961            return Some("missing");
3962        }
3963        if let Some((id, _)) = cloud_account(path) {
3964            return self
3965                .cloud
3966                .iter()
3967                .any(|s| s.id == id && s.api == crate::cloud::source::ProviderKind::Gcs)
3968                .then_some("project");
3969        }
3970        // A bookmark inside a catalog dataset opens whole, as a dataset does.
3971        (is_object_store_url(path)
3972            && (self
3973                .catalog_dataset(path)
3974                .is_some_and(|(_, d)| is_object_store_url(&d.location))
3975                || (self.browsing.is_none() && self.bookmark(path).is_some())))
3976        .then_some("dataset")
3977    }
3978
3979    /// The project place a Google bucket was listed under, when it was.
3980    fn project_of_bucket(&self, bucket_root: &Path) -> Option<PathBuf> {
3981        let root = bucket_root.to_string_lossy();
3982        let root = root.trim_end_matches('/');
3983        self.probes
3984            .answered()
3985            .filter(|(place, _)| cloud_account(place).is_some())
3986            .find(|(_, rows)| {
3987                rows.iter()
3988                    .any(|row| row.path.to_string_lossy().trim_end_matches('/') == root)
3989            })
3990            .map(|(place, _)| place.clone())
3991    }
3992
3993    /// The bucket root of a Google URL: `gs://bucket`.
3994    fn google_bucket_root(path: &Path) -> Option<PathBuf> {
3995        let text = path.to_string_lossy();
3996        let rest = text
3997            .strip_prefix("gs://")
3998            .or_else(|| text.strip_prefix("gcs://"))?;
3999        let bucket = rest.split('/').next().filter(|b| !b.is_empty())?;
4000        Some(PathBuf::from(format!("gs://{bucket}")))
4001    }
4002
4003    /// Details-pane lines for a place a cloud source listed or a catalog names, when
4004    /// it has any.
4005    pub fn place_details(&self, path: &Path) -> Option<&[(String, String)]> {
4006        self.cloud
4007            .iter()
4008            .find_map(|s| s.place_details.get(path))
4009            .or_else(|| self.catalog_dataset(path).map(|(_, d)| &d.details))
4010            .or_else(|| self.bookmark(path).map(|(d, _)| &d.details))
4011            .map(Vec::as_slice)
4012    }
4013
4014    /// The location as the title bar names it; cloud places read as a trail through the
4015    /// source's label.
4016    pub fn location_label(&self, path: &Path) -> String {
4017        let sep = crate::glyphs::get().trail;
4018        // Inside a catalog's remote dataset: the catalog, the dataset's name, and the path
4019        // below it.
4020        if let Some((catalog, dataset)) = self.remote_dataset_of(path) {
4021            let text = path.to_string_lossy();
4022            let rest = within_rest(&text, &dataset.location.to_string_lossy());
4023            let mut parts = vec![catalog.label.clone(), dataset.name.clone()];
4024            parts.extend(
4025                rest.split('/')
4026                    .filter(|p| !p.is_empty())
4027                    .map(str::to_string),
4028            );
4029            return parts.join(&format!(" {sep} "));
4030        }
4031        if let Some(source) = self.cloud_source_of(path) {
4032            let mut parts = vec!["cloud".to_string(), source.label.clone()];
4033            let text = path.to_string_lossy();
4034            if let Some((_, account)) = cloud_account(path) {
4035                parts.push(account);
4036            } else if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text)
4037            {
4038                parts.push(account);
4039                parts.push(container);
4040                parts.extend(key.split('/').filter(|p| !p.is_empty()).map(str::to_string));
4041            } else if cloud_source_id(path).is_none() {
4042                if let Some((_, project)) = Self::google_bucket_root(path)
4043                    .and_then(|b| self.project_of_bucket(&b))
4044                    .as_deref()
4045                    .and_then(cloud_account)
4046                {
4047                    parts.push(project);
4048                }
4049                let (_, plain) = crate::cloud::source::split_source_id(&text);
4050                if let Some((_, rest)) = plain.split_once("://") {
4051                    parts.extend(
4052                        rest.split('/')
4053                            .filter(|p| !p.is_empty())
4054                            .map(str::to_string),
4055                    );
4056                }
4057            }
4058            return parts.join(&format!(" {sep} "));
4059        }
4060        display_path(path)
4061    }
4062
4063    /// Put the search results into `sections`, or take them out, after every rebuild and
4064    /// batch. Only while there is a filter: unfiltered, everything matches.
4065    pub fn sync_search_section(&mut self) {
4066        // The other sections keep their hits: results land in batches while the user types.
4067        let found = (self.sections.iter()).position(|s| s.title == Self::SEARCH_SECTION);
4068        let mut kept = self.take_hits();
4069        self.sections.retain(|s| s.title != Self::SEARCH_SECTION);
4070        self.changed();
4071        if let (Some(hits), Some(at)) = (kept.as_mut(), found)
4072            && at < hits.sections.len()
4073        {
4074            hits.sections.remove(at);
4075        }
4076        *self.rows_cache.rescored.get_mut() = kept;
4077
4078        if self.filter.is_empty() {
4079            return;
4080        }
4081        // Bucket names already listed; nothing is fetched (that would bill per keystroke).
4082        let cloud_rows: Vec<Entry> = if self.browsing.is_none() {
4083            self.cloud
4084                .iter()
4085                .flat_map(|source| {
4086                    source.buckets.iter().map(move |bucket| {
4087                        let mut entry = bucket_entry(bucket);
4088                        entry.name = format!(
4089                            "{} {} {}",
4090                            source.label,
4091                            crate::glyphs::get().trail,
4092                            entry.name
4093                        );
4094                        entry.cost.source = Some(source.api.name().to_string());
4095                        entry
4096                    })
4097                })
4098                .filter(|e| match_score(&self.filter, e).is_some())
4099                .collect()
4100        } else {
4101            Vec::new()
4102        };
4103        self.score_search_inline();
4104        let local = self.search.root.is_some()
4105            && (self.search.indexed > 0 || self.search.running || self.search.limited.is_some());
4106        if !local {
4107            if !cloud_rows.is_empty() {
4108                let subtitle =
4109                    crate::glyphs::dotted(&format!("cloud · {} names", cloud_rows.len()));
4110                self.sections.push(Section {
4111                    subtitle: Some(subtitle),
4112                    ..Section::titled(Self::SEARCH_SECTION, cloud_rows)
4113                });
4114            }
4115            return;
4116        }
4117
4118        // The last scored matches; those for an older filter (a scoring out) are rescored
4119        // here once, so nothing stale shows.
4120        let matches = self.search.matches.as_ref();
4121        // A dataset already listed under its directory does not appear again under the
4122        // search. Only rows named as a match can be one: the rest of thousands are passed
4123        // over on a short name, not a path hashed component by component.
4124        let names: std::collections::HashSet<&std::ffi::OsStr> = (matches.iter())
4125            .flat_map(|m| m.top.iter())
4126            .map(|e| e.path.file_name().unwrap_or(e.path.as_os_str()))
4127            .collect();
4128        let listed: std::collections::HashSet<&PathBuf> = (self.sections.iter())
4129            .flat_map(|s| s.rows.iter().map(|r| &r.path))
4130            .filter(|path| path.file_name().is_none_or(|name| names.contains(name)))
4131            .collect();
4132        let kept: Vec<(&Entry, i32)> = matches
4133            .map(|m| {
4134                let fresh = m.query == self.filter;
4135                m.top
4136                    .iter()
4137                    .zip(m.scores.iter().copied())
4138                    .filter(|(e, _)| !listed.contains(&e.path))
4139                    .filter_map(|(e, score)| {
4140                        if fresh {
4141                            Some((e, score))
4142                        } else {
4143                            match_score(&self.filter, e).map(|s| (e, s))
4144                        }
4145                    })
4146                    .collect()
4147            })
4148            .unwrap_or_default();
4149        let mut rows: Vec<Entry> = kept.into_iter().map(|(e, _)| e.clone()).collect();
4150        rows.extend(cloud_rows);
4151        // The walk's rows are snapshots: what this session measured of them is folded in
4152        // here, as nothing measures them again.
4153        for row in &mut rows {
4154            fold_known(row, &self.enriched, &self.stated, &self.stale);
4155        }
4156
4157        // Say an empty result when the walk stopped short: "no match" may be wrong then.
4158        let partial = self.search.limited.is_some();
4159        let scored = self.search.scored_for(&self.filter);
4160        if rows.is_empty() && !self.search.running && !partial && scored {
4161            return;
4162        }
4163
4164        let subtitle = self.found_subtitle(rows.is_empty());
4165
4166        self.sections.push(Section {
4167            subtitle: Some(subtitle),
4168            ..Section::titled(Self::SEARCH_SECTION, rows)
4169        });
4170    }
4171
4172    /// `Found`'s rule: where it looked, how many matched, how far the walk got.
4173    fn found_subtitle(&self, empty: bool) -> String {
4174        let root = self.search.root.clone().unwrap_or_default();
4175        let dot = crate::glyphs::get().middot;
4176        let files = crate::numfmt::group_chrome(self.search.indexed);
4177        let files = if self.search.indexed == 1 {
4178            format!("{files} file")
4179        } else {
4180            format!("{files} files")
4181        };
4182        let subtitle = display_path(&root);
4183        // How many matched when more matched than listed; last, since the rule cuts long
4184        // notes from the start.
4185        let counted = self
4186            .search
4187            .matches
4188            .as_ref()
4189            .filter(|m| m.query == self.filter && m.ids.len() > m.top.len())
4190            .map(|m| {
4191                format!(
4192                    " {dot} {} of {} matches",
4193                    crate::numfmt::group_chrome(m.top.len()),
4194                    crate::numfmt::group_chrome(m.ids.len())
4195                )
4196            })
4197            .unwrap_or_default();
4198        if self.search.running {
4199            format!(
4200                "{subtitle} {dot} searching {}{counted}",
4201                crate::numfmt::group_chrome(self.search.scanned)
4202            )
4203        } else if !self.search.scored_for(&self.filter) {
4204            format!("{subtitle} {dot} matching {files}")
4205        } else if empty {
4206            match &self.search.limited {
4207                Some(limit) => format!("{subtitle} {dot} no match in {files} {dot} {limit}"),
4208                None => format!("{subtitle} {dot} no match in {files}"),
4209            }
4210        } else {
4211            let searched = crate::numfmt::group_chrome(self.search.scanned);
4212            match &self.search.limited {
4213                Some(limit) => {
4214                    format!("{subtitle} {dot} {limit} {dot} {searched} searched{counted}")
4215                }
4216                None => format!("{subtitle} {dot} {searched} searched{counted}"),
4217            }
4218        }
4219    }
4220
4221    /// Fold a batch of search results in, keeping the list free of duplicates.
4222    pub fn search_batch(&mut self, root: &Path, mut found: Vec<Entry>, scanned: usize) {
4223        // A batch from a walk the user left is dropped: walks are abandoned, not cancelled.
4224        if self.search.root.as_deref() != Some(root) {
4225            return;
4226        }
4227        // Earlier measurements give found rows their shape and columns (the filter matches
4228        // columns), strictly fingerprinted by size and mtime.
4229        for row in &mut found {
4230            apply_known_facts(row, &self.known, false);
4231        }
4232        self.search.scanned = scanned;
4233        let start = self.search.indexed;
4234        let batch: std::sync::Arc<[Entry]> = found.into();
4235        if !batch.is_empty() {
4236            self.search.indexed += batch.len();
4237            self.search.results.push(batch.clone());
4238        }
4239        // Carry matches forward over the new files alone: rescoring everything per batch
4240        // delayed typed keys during large walks.
4241        let limit = self.search_limit;
4242        let changed = match self.search.matches.as_mut() {
4243            Some(m) if m.query == self.filter && m.upto == start => m.extend(&batch, start, limit),
4244            _ => {
4245                let before = self.search.matches.as_ref().map(|m| m.upto);
4246                self.score_search_inline();
4247                self.search.matches.as_ref().map(|m| m.upto) != before
4248            }
4249        };
4250        // Rebuild `Found` only when its contents changed; otherwise only its progress moves.
4251        let found_listed = self
4252            .sections
4253            .iter()
4254            .position(|s| s.title == Self::SEARCH_SECTION);
4255        match found_listed {
4256            Some(at) if !changed => {
4257                let empty = self.sections[at].rows.is_empty();
4258                self.sections[at].subtitle = Some(self.found_subtitle(empty));
4259            }
4260            _ => self.sync_search_section(),
4261        }
4262        self.settle_return();
4263    }
4264
4265    /// Score the filter against the files found, here and now, when that is cheap.
4266    fn score_search_inline(&mut self) {
4267        if self.filter.is_empty() || self.search.scored_for(&self.filter) {
4268            return;
4269        }
4270        let (base, looks_at) = self.search.base_for(&self.filter);
4271        if looks_at > SCORE_INLINE_MAX {
4272            return;
4273        }
4274        let scored =
4275            crate::home::search::score(&self.search.results, &self.filter, base, self.search_limit);
4276        self.search.matches = Some(scored);
4277    }
4278
4279    /// The scoring a worker should do next, if owed (the filter changed, or files
4280    /// arrived). One at a time; the next is asked when it answers.
4281    pub fn score_job(&mut self) -> Option<ScoreJob> {
4282        if self.filter.is_empty() || self.search.scoring || self.search.scored_for(&self.filter) {
4283            return None;
4284        }
4285        let base = self.search.base_for(&self.filter).0.cloned();
4286        self.search.scoring = true;
4287        Some(ScoreJob {
4288            epoch: self.search.epoch,
4289            results: self.search.results.clone(),
4290            query: self.filter.clone(),
4291            base,
4292            limit: self.search_limit,
4293        })
4294    }
4295
4296    /// A worker's scoring is in.
4297    pub fn search_scored(&mut self, epoch: u64, scored: crate::home::search::Matches) {
4298        if epoch != self.search.epoch {
4299            return;
4300        }
4301        self.search.scoring = false;
4302        // Batches may have carried the same filter's matches further meanwhile.
4303        if self
4304            .search
4305            .matches
4306            .as_ref()
4307            .is_some_and(|m| m.query == scored.query && m.upto >= scored.upto)
4308        {
4309            return;
4310        }
4311        self.search.matches = Some(scored);
4312        self.sync_search_section();
4313        // If typing left the cursor on nothing, the first match is where it belongs.
4314        if !matches!(self.selected_row(), Some(Row::Entry { .. })) {
4315            self.select_first_entry();
4316        }
4317        self.settle_return();
4318    }
4319
4320    /// Record that the walk under `root` has finished.
4321    pub fn search_finished(&mut self, root: &Path, scanned: usize, limited: Option<String>) {
4322        if self.search.root.as_deref() != Some(root) {
4323            return;
4324        }
4325        self.search.running = false;
4326        self.search.done = true;
4327        // Never fewer than the batches reported: a walk that died says nothing of its own.
4328        self.search.scanned = self.search.scanned.max(scanned);
4329        self.search.limited = limited;
4330        self.sync_search_section();
4331        self.settle_return();
4332    }
4333
4334    /// Lines on screen: a header per non-empty section, then its matching rows unless
4335    /// folded. Results stay grouped while filtering, to show where a dataset lives.
4336    pub fn visible(&self) -> Vec<Row<'_>> {
4337        let view = self.view();
4338        view.slots.iter().map(|slot| self.row(slot)).collect()
4339    }
4340
4341    /// Where each row of [`HomeState::visible`] falls in the list, `spaced` with a
4342    /// blank line before every header but the first, and a blank line after each door;
4343    /// from the headers and doors alone, so a frame costs the rows it shows, not the
4344    /// rows listed.
4345    pub fn list_lines(&self, spaced: bool) -> ListLines {
4346        let view = self.view();
4347        let rows = view.slots.len();
4348        let headers = (view.headers.iter().copied()).filter(|&h| spaced && h > 0);
4349        let doors = (view.doors.iter().map(|&d| d + 1)).filter(|&r| r < rows);
4350        let mut gaps: Vec<usize> = headers.chain(doors).collect();
4351        gaps.sort_unstable();
4352        gaps.dedup();
4353        ListLines { gaps, rows }
4354    }
4355
4356    /// How many rows the filter matches: the sum of the section headers' counts.
4357    pub fn matched(&self) -> usize {
4358        let view = self.view();
4359        (view.headers.iter())
4360            .map(|&i| match &view.slots[i] {
4361                Slot::Plain(Row::Header { matches, .. }) => *matches,
4362                _ => 0,
4363            })
4364            .sum()
4365    }
4366
4367    /// The first row of [`HomeState::visible`] that `wanted` picks, without building
4368    /// the rest.
4369    pub fn position(&self, mut wanted: impl FnMut(&Row<'_>) -> bool) -> Option<usize> {
4370        let view = self.view();
4371        view.slots.iter().position(|slot| wanted(&self.row(slot)))
4372    }
4373
4374    /// How many rows [`HomeState::visible`] lists.
4375    pub fn row_count(&self) -> usize {
4376        self.view().slots.len()
4377    }
4378
4379    /// Row `index` of [`HomeState::visible`].
4380    pub fn row_at(&self, index: usize) -> Option<Row<'_>> {
4381        self.view().slots.get(index).map(|slot| self.row(slot))
4382    }
4383
4384    /// How many times the rows were built, for the test that a frame builds them at most
4385    /// once.
4386    pub fn rows_built(&self) -> usize {
4387        self.rows_cache.builds.get()
4388    }
4389
4390    /// The rows have changed under the cache: built again on the next read.
4391    fn changed(&mut self) {
4392        *self.rows_cache.built.get_mut() = None;
4393        *self.rows_cache.rescored.get_mut() = None;
4394    }
4395
4396    /// The hits the cache holds for the rows as listed now, for the filter as it is;
4397    /// the rows are built again on the next read.
4398    fn take_hits(&mut self) -> Option<Hits> {
4399        let built = self.rows_cache.built.get_mut().take();
4400        match self.rows_cache.rescored.get_mut().take() {
4401            Some(hits) => Some(hits),
4402            None => built
4403                .filter(|view| view.key.matches(self))
4404                .map(|view| view.hits),
4405        }
4406        .filter(|hits| hits.filter == self.filter)
4407    }
4408
4409    /// Only `touched` rows (section, index) changed, in what they hold rather than in
4410    /// name or number: the next build scores those again and keeps the last build's
4411    /// hits for the rest.
4412    fn rows_changed(&mut self, touched: &[(usize, usize)]) {
4413        let Some(mut hits) = self.take_hits() else {
4414            return;
4415        };
4416        let needle = Needle::new(&self.filter);
4417        for &(si, i) in touched {
4418            if let Some(Some(section)) = hits.sections.get_mut(si)
4419                && let Some(hit) = section.get_mut(i)
4420            {
4421                *hit = needle.hit(&self.sections[si].rows[i]);
4422            }
4423        }
4424        *self.rows_cache.rescored.get_mut() = Some(hits);
4425    }
4426
4427    /// Show these catalogs.
4428    pub fn set_catalogs(&mut self, catalogs: Vec<ShownCatalog>) {
4429        self.catalog_places = CatalogPlaces::of(&catalogs);
4430        self.catalogs = catalogs;
4431        self.changed();
4432    }
4433
4434    /// How often and how lately each recent was opened, which ranks matches.
4435    pub fn set_visits(&mut self, visits: std::collections::HashMap<PathBuf, crate::cache::Visits>) {
4436        self.visits = visits;
4437        self.changed();
4438    }
4439
4440    /// Show the whole of the section the `more` row at `section` cuts.
4441    pub fn show_all(&mut self, section: usize) {
4442        match self.sections.get(section) {
4443            Some(s) if s.grouped_by_place => self.recent_expanded = true,
4444            Some(Section {
4445                root: Some(root), ..
4446            }) => {
4447                self.shown_whole.insert(root.clone());
4448            }
4449            _ => {}
4450        }
4451    }
4452
4453    /// With the cursor on a row the cut would hide in a section shown whole, cut it back,
4454    /// the cursor on the row standing for the rest. Whether it did.
4455    pub fn cut_again(&mut self, section: usize) -> bool {
4456        let Some(key) = self.selected_key() else {
4457            return false;
4458        };
4459        let Some(s) = self.sections.get(section) else {
4460            return false;
4461        };
4462        let title = s.title.clone();
4463        let whole = if s.grouped_by_place {
4464            None
4465        } else {
4466            match s.root.clone() {
4467                Some(root) => Some(root),
4468                None => return false,
4469            }
4470        };
4471        let was_whole = match &whole {
4472            None => std::mem::replace(&mut self.recent_expanded, false),
4473            Some(root) => self.shown_whole.remove(root),
4474        };
4475        if !was_whole {
4476            return false;
4477        }
4478        if self.listed(&key).is_some() {
4479            match whole {
4480                None => self.recent_expanded = true,
4481                Some(root) => {
4482                    self.shown_whole.insert(root);
4483                }
4484            }
4485            return false;
4486        }
4487        self.reselect(Some(RowKey::More(title)));
4488        true
4489    }
4490
4491    /// The sections, to change in place; rows are relisted on the next read.
4492    pub fn sections_mut(&mut self) -> &mut Vec<Section> {
4493        self.changed();
4494        &mut self.sections
4495    }
4496
4497    fn view(&self) -> std::cell::Ref<'_, View> {
4498        let fresh = self
4499            .rows_cache
4500            .built
4501            .borrow()
4502            .as_ref()
4503            .is_some_and(|view| view.key.matches(self));
4504        if !fresh {
4505            let view = self.build_view();
4506            self.rows_cache.builds.set(self.rows_cache.builds.get() + 1);
4507            *self.rows_cache.built.borrow_mut() = Some(view);
4508        }
4509        std::cell::Ref::map(self.rows_cache.built.borrow(), |view| {
4510            view.as_ref().expect("built above")
4511        })
4512    }
4513
4514    fn row<'a>(&'a self, slot: &Slot) -> Row<'a> {
4515        match slot {
4516            Slot::Plain(row) => row.clone(),
4517            Slot::Entry {
4518                section,
4519                index,
4520                nested,
4521                hit,
4522            } => Row::Entry {
4523                section: *section,
4524                entry: &self.sections[*section].rows[*index],
4525                nested: *nested,
4526                hit: *hit,
4527            },
4528            Slot::Door { section } => Row::Door {
4529                section: *section,
4530                entry: self.sections[*section]
4531                    .door
4532                    .as_ref()
4533                    .expect("the shape says it has a door"),
4534            },
4535        }
4536    }
4537
4538    fn build_view(&self) -> View {
4539        // Taken, so a build for any other change scores every row.
4540        let kept = (self.rows_cache.rescored.take()).filter(|hits| {
4541            hits.filter == self.filter && hits.sections.len() <= self.sections.len()
4542        });
4543        let hits = self.score_rows(kept);
4544        let slots = self.slots(&hits);
4545        View {
4546            key: ViewKey::of(self),
4547            headers: (slots.iter().enumerate())
4548                .filter(|(_, slot)| matches!(slot, Slot::Plain(Row::Header { .. })))
4549                .map(|(i, _)| i)
4550                .collect(),
4551            doors: (slots.iter().enumerate())
4552                .filter(|(_, slot)| matches!(slot, Slot::Door { .. }))
4553                .map(|(i, _)| i)
4554                .collect(),
4555            slots,
4556            hits,
4557            has_dataset: self
4558                .sections
4559                .iter()
4560                .flat_map(|s| s.rows.iter())
4561                .any(|e| e.kind.is_dataset() || e.kind.is_lake_table()),
4562        }
4563    }
4564
4565    /// Every row scored against the filter, but for the sections `kept` holds for the
4566    /// rows as listed.
4567    fn score_rows(&self, kept: Option<Hits>) -> Hits {
4568        let needle = Needle::new(&self.filter);
4569        let mut kept = kept.map(|hits| hits.sections).unwrap_or_default();
4570        let sections =
4571            (self.sections.iter().enumerate())
4572                .map(|(si, section)| {
4573                    let reused = (kept.get_mut(si).and_then(Option::take))
4574                        .filter(|hits| hits.len() == section.rows.len());
4575                    Some(reused.unwrap_or_else(|| {
4576                        (section.rows.iter()).map(|row| needle.hit(row)).collect()
4577                    }))
4578                })
4579                .collect();
4580        Hits {
4581            filter: self.filter.clone(),
4582            sections,
4583        }
4584    }
4585
4586    fn slots(&self, hits: &Hits) -> Vec<Slot> {
4587        let mut out: Vec<Slot> = Vec::new();
4588        for (si, section) in self.sections.iter().enumerate() {
4589            let mut matched: Vec<(usize, Hit)> = (section.rows.iter())
4590                .zip(hits.sections[si].iter().flatten())
4591                .enumerate()
4592                .filter(|(_, (row, _))| !(self.hide_unreadable && row.hidden_by_default()))
4593                .filter_map(|(i, (_, hit))| hit.map(|hit| (i, hit)))
4594                .collect();
4595
4596            // Drop a section with nothing to show, unless it stands for a named or current root,
4597            // its rows are on the way, or it has a door (extensionless part files list nothing,
4598            // and the door is the only way to read them).
4599            let keep_empty = section.unavailable || section.waiting || section.origin.is_some();
4600            let has_door = section.door.is_some() && self.filter.is_empty();
4601            // Only inside a directory, where an empty listing needs a reason; root sections
4602            // leave them out quietly.
4603            let hidden =
4604                if self.browsing.is_some() && self.hide_unreadable && self.filter.is_empty() {
4605                    section
4606                        .rows
4607                        .iter()
4608                        .filter(|row| row.hidden_by_default())
4609                        .count()
4610                } else {
4611                    0
4612                };
4613            // `Found` shows empty only to say something: a walk stopped short with no match.
4614            let says_why = section.title == Self::SEARCH_SECTION;
4615            if matched.is_empty()
4616                && !has_door
4617                && hidden == 0
4618                && !says_why
4619                && !(keep_empty && self.filter.is_empty())
4620            {
4621                continue;
4622            }
4623
4624            // Rank by match quality within a section (unfiltered, scores tie and the curated
4625            // order stays). Ties go to the shorter name, fzf's tiebreak. Frecency lifts an
4626            // often-opened row by up to a few characters' worth of match.
4627            let entry = |i: usize| &section.rows[i];
4628            if !self.filter.is_empty() {
4629                let now = std::time::SystemTime::now()
4630                    .duration_since(std::time::UNIX_EPOCH)
4631                    .map(|d| d.as_secs())
4632                    .unwrap_or_default();
4633                let lifted = |i: usize, score: i32| {
4634                    let frecency = self
4635                        .visits
4636                        .get(&entry(i).path)
4637                        .map_or(0.0, |v| v.frecency(now));
4638                    score.saturating_add((frecency.min(10.0) * FRECENCY_LIFT) as i32)
4639                };
4640                matched.sort_by_cached_key(|(i, hit)| {
4641                    (
4642                        std::cmp::Reverse(lifted(*i, hit.score)),
4643                        entry(*i).name.len(),
4644                    )
4645                });
4646            }
4647
4648            // An explicit sort overrides both; rows with nothing to sort by go last, not as
4649            // zero.
4650            match self.sort {
4651                SortMode::Natural => {}
4652                SortMode::Size => {
4653                    matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).size.unwrap_or(0)));
4654                }
4655                SortMode::Rows => {
4656                    matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).rows.unwrap_or(0)));
4657                }
4658                SortMode::Modified => {
4659                    matched.sort_by_key(|(i, _)| {
4660                        std::cmp::Reverse(
4661                            entry(*i)
4662                                .modified
4663                                .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
4664                                .map(|d| d.as_secs())
4665                                .unwrap_or(0),
4666                        )
4667                    });
4668                }
4669            }
4670
4671            let collapsed = self.section_folded(section);
4672            out.push(Slot::Plain(Row::Header {
4673                section: si,
4674                // The door is not counted: it opens the directory, so three files must not read four.
4675                matches: matched.len(),
4676                collapsed,
4677            }));
4678            if collapsed {
4679                continue;
4680            }
4681            // The door first, whatever the sort: the directory's action, under the header
4682            // that names the directory and apart from its listing. Not while filtering: its
4683            // `Open all` name fuzzy-matches most of the alphabet.
4684            if has_door {
4685                out.push(Slot::Door { section: si });
4686            }
4687            // Then the way up, as `..` in any listing.
4688            let root = section.root.as_deref();
4689            if self.filter.is_empty()
4690                && root
4691                    .is_some_and(|root| self.browsing.is_some() || self.parent_of(root).is_some())
4692            {
4693                out.push(Slot::Plain(Row::Up { section: si }));
4694            }
4695            // At the root listing a huge directory shows its first rows (a share of the
4696            // height) and one row for the rest, so it does not bury the sections below. A filter
4697            // searches them all.
4698            let shown = match root {
4699                Some(_)
4700                    if self.browsing.is_none()
4701                        && self.filter.is_empty()
4702                        && self.view_height > 0
4703                        && !root.is_some_and(|root| self.shown_whole.contains(root)) =>
4704                {
4705                    (self.view_height * 2 / 5).max(8)
4706                }
4707                _ => usize::MAX,
4708            };
4709            let rest = if matched.len() > shown.saturating_add(1) {
4710                matched.split_off(shown)
4711            } else {
4712                Vec::new()
4713            };
4714            // Sorted by something measured, rows past the cut are measured too, or the biggest
4715            // shown would pass for the biggest of all.
4716            let measuring = rest.iter().any(|(i, _)| self.sort_wants(entry(*i)));
4717            if section.grouped_by_place {
4718                self.slots_by_place(si, section, &matched, &mut out);
4719            } else {
4720                // A bookmark sits under its dataset while rows keep the catalog's order.
4721                let in_order = self.sort == SortMode::Natural
4722                    && self.filter.is_empty()
4723                    && section.origin.is_some_and(is_catalog_origin)
4724                    && section.root.is_none();
4725                out.extend(matched.into_iter().map(|(index, hit)| Slot::Entry {
4726                    section: si,
4727                    index,
4728                    nested: in_order && self.bookmark(&entry(index).path).is_some(),
4729                    hit,
4730                }));
4731            }
4732            if !rest.is_empty() {
4733                out.push(Slot::Plain(Row::More {
4734                    section: si,
4735                    hidden: rest.len(),
4736                    places: 0,
4737                    measuring,
4738                }));
4739            }
4740            if hidden > 0 {
4741                out.push(Slot::Plain(Row::Hidden {
4742                    section: si,
4743                    count: hidden,
4744                }));
4745            }
4746        }
4747        out
4748    }
4749
4750    /// A grouped section's rows under each one's place, and what the cap hides. Places
4751    /// in order of their newest row; within a place the rows keep `matched`'s order, so
4752    /// a sort orders each place. Whole places are shown newest first until a third of
4753    /// the height is used (at least one), then one `… N more in M places` row. A filter
4754    /// shows every match, without the cap.
4755    fn slots_by_place(
4756        &self,
4757        si: usize,
4758        section: &Section,
4759        matched: &[(usize, Hit)],
4760        out: &mut Vec<Slot>,
4761    ) {
4762        let places: Vec<PathBuf> = section.rows.iter().map(|row| place_of(&row.path)).collect();
4763        let mut order: Vec<&PathBuf> = Vec::new();
4764        for place in &places {
4765            if !order.contains(&place) {
4766                order.push(place);
4767            }
4768        }
4769        let groups: Vec<(&PathBuf, Vec<&(usize, Hit)>)> = order
4770            .into_iter()
4771            .filter_map(|place| {
4772                let rows: Vec<&(usize, Hit)> = matched
4773                    .iter()
4774                    .filter(|(i, _)| places[*i] == *place)
4775                    .collect();
4776                (!rows.is_empty()).then_some((place, rows))
4777            })
4778            .collect();
4779
4780        // Before the first frame there is no height; a caller with no screen gets it whole.
4781        let capped = !self.recent_expanded && self.filter.is_empty() && self.view_height > 0;
4782        let budget = self.view_height / 3;
4783        let mut used = 0usize;
4784        let mut shown = 0usize;
4785        for (place, rows) in &groups {
4786            let cost = 1 + rows.len();
4787            if capped && shown > 0 && used + cost > budget {
4788                break;
4789            }
4790            out.push(Slot::Plain(Row::Place {
4791                section: si,
4792                path: (*place).clone(),
4793                label: section.place_labels.get(*place).cloned(),
4794                // A place's rows share its filesystem, so the first speaks for it (from `annotate`).
4795                source: section.rows[rows[0].0].cost.source.clone(),
4796                held: places.iter().filter(|p| p == place).count(),
4797            }));
4798            out.extend(rows.iter().map(|(index, hit)| Slot::Entry {
4799                section: si,
4800                index: *index,
4801                nested: true,
4802                hit: *hit,
4803            }));
4804            used += cost;
4805            shown += 1;
4806        }
4807        if shown < groups.len() {
4808            out.push(Slot::Plain(Row::More {
4809                section: si,
4810                hidden: groups[shown..].iter().map(|(_, rows)| rows.len()).sum(),
4811                places: groups.len() - shown,
4812                measuring: false,
4813            }));
4814        }
4815    }
4816
4817    /// The names the `~` prompt offers: those in the typed directory matching its last
4818    /// segment, best first; hidden names only after a typed dot.
4819    pub fn path_candidates(&self) -> Vec<&PathName> {
4820        let Some(listing) = self
4821            .path_listing
4822            .as_ref()
4823            .filter(|l| l.dir == typed_dir(&self.path_input))
4824        else {
4825            return Vec::new();
4826        };
4827        let segment = &self.path_input[listing.dir.len()..];
4828        let mut memo = (listing.matched.0.lock()).unwrap_or_else(|e| e.into_inner());
4829        let at = match memo.as_ref() {
4830            Some((typed, at)) if typed == segment => at.clone(),
4831            _ => {
4832                let at = path_matches(&listing.names, segment);
4833                *memo = Some((segment.to_string(), at.clone()));
4834                at
4835            }
4836        };
4837        drop(memo);
4838        at.iter().map(|&i| &listing.names[i]).collect()
4839    }
4840
4841    /// Put the `~` prompt's pick on the first match, or none, so the list always shows
4842    /// what Enter and Tab take. ↑ from the first takes the typed path as is.
4843    pub fn pick_first_path(&mut self) {
4844        self.path_pick = (!self.path_candidates().is_empty()).then_some(0);
4845    }
4846
4847    /// The path the picked candidate names, with its separator when it is a directory.
4848    pub fn picked_path(&self) -> Option<String> {
4849        let pick = self.path_pick?;
4850        let name = *self.path_candidates().get(pick)?;
4851        let dir = typed_dir(&self.path_input);
4852        let mut path = format!("{dir}{}", name.name);
4853        if name.dir {
4854            path.push(separator_in(dir));
4855        }
4856        Some(path)
4857    }
4858
4859    /// What Tab makes of the typed path: the sole candidate, or the candidates' longest
4860    /// common start. `None` when it adds nothing.
4861    pub fn path_completion(&self) -> Option<String> {
4862        let dir = typed_dir(&self.path_input);
4863        let segment = &self.path_input[dir.len()..];
4864        let candidates = self.path_candidates();
4865        match candidates.as_slice() {
4866            [] => None,
4867            [one] => {
4868                let mut path = format!("{dir}{}", one.name);
4869                if one.dir {
4870                    path.push(separator_in(dir));
4871                }
4872                Some(path)
4873            }
4874            many => {
4875                let starting: Vec<&str> = many
4876                    .iter()
4877                    .map(|n| n.name.as_str())
4878                    .filter(|n| n.starts_with(segment))
4879                    .collect();
4880                let first = starting.first()?;
4881                let shared = starting
4882                    .iter()
4883                    .skip(1)
4884                    .fold(first.to_string(), |acc, n| common_prefix(&acc, n));
4885                (shared.len() > segment.len()).then(|| format!("{dir}{shared}"))
4886            }
4887        }
4888    }
4889
4890    /// Every URL the screen knows (catalogs, buckets, listings, the index): what `s3://`
4891    /// completes from.
4892    pub fn known_urls(&self) -> Vec<String> {
4893        let mut urls: Vec<String> = Vec::new();
4894        let mut add = |path: &Path| {
4895            let text = path.to_string_lossy();
4896            if text.contains("://") && !is_cloud_place(path) {
4897                urls.push(text.into_owned());
4898            }
4899        };
4900        for catalog in &self.catalogs {
4901            for dataset in &catalog.datasets {
4902                add(&dataset.location);
4903            }
4904        }
4905        for source in &self.cloud {
4906            for bucket in &source.buckets {
4907                add(bucket);
4908            }
4909        }
4910        for (root, rows) in self.probes.answered() {
4911            add(root);
4912            for row in rows.iter() {
4913                add(&row.path);
4914            }
4915        }
4916        for path in self.known.keys() {
4917            add(path);
4918        }
4919        for section in &self.sections {
4920            for row in &section.rows {
4921                add(&row.path);
4922            }
4923        }
4924        urls
4925    }
4926
4927    /// The highlighted row, whatever it is.
4928    pub fn selected_row(&self) -> Option<Row<'_>> {
4929        if self.resting {
4930            return None;
4931        }
4932        self.row_at(self.selected)
4933    }
4934
4935    /// The row Enter and the first ↑ or ↓ take, picked or not.
4936    pub fn cursor_row(&self) -> Option<Row<'_>> {
4937        self.row_at(self.selected)
4938    }
4939
4940    /// The highlighted row when it is a dataset, door included (it can be opened; it is
4941    /// [`Row::Door`] only to stay out of path-keyed maps).
4942    pub fn selected_entry(&self) -> Option<&Entry> {
4943        match self.selected_row()? {
4944            Row::Entry { entry, .. } | Row::Door { entry, .. } => Some(entry),
4945            _ => None,
4946        }
4947    }
4948
4949    /// Whether the cursor is on the door rather than on something in the directory.
4950    pub fn selection_is_the_door(&self) -> bool {
4951        matches!(self.selected_row(), Some(Row::Door { .. }))
4952    }
4953
4954    /// From `..` or the door, the first row of the directory: what → means there,
4955    /// since those rows are already inside it. False elsewhere, or with nothing below.
4956    pub fn step_past_door(&mut self) -> bool {
4957        let Some(Row::Up { section } | Row::Door { section, .. }) = self.selected_row() else {
4958            return false;
4959        };
4960        let first = (self.selected + 1..self.row_count()).find(
4961            |&i| matches!(self.row_at(i), Some(Row::Entry { section: s, .. }) if s == section),
4962        );
4963        let Some(first) = first else {
4964            return false;
4965        };
4966        self.move_selection((first - self.selected) as isize);
4967        self.follow_selection();
4968        true
4969    }
4970
4971    /// The recents that live in `place`: what `Delete` on its row forgets.
4972    pub fn recents_in(&self, place: &Path) -> Vec<PathBuf> {
4973        self.sections
4974            .iter()
4975            .filter(|s| s.grouped_by_place)
4976            .flat_map(|s| s.rows.iter())
4977            .filter(|row| place_of(&row.path) == place)
4978            .map(|row| row.path.clone())
4979            .collect()
4980    }
4981
4982    /// The section the highlighted row belongs to.
4983    pub fn selected_section(&self) -> Option<usize> {
4984        self.selected_row().map(|r| r.section())
4985    }
4986
4987    /// The catalog whose section heading is selected, if the selection is one.
4988    pub fn selected_catalog(&self) -> Option<&ShownCatalog> {
4989        if !self.selection_is_header() {
4990            return None;
4991        }
4992        let section = self.sections.get(self.selected_section()?)?;
4993        let origin = section.origin?;
4994        self.catalogs
4995            .iter()
4996            .find(|c| c.label == section.title && c.origin_note() == origin)
4997    }
4998
4999    /// Whether the highlighted row is a section header.
5000    pub fn selection_is_header(&self) -> bool {
5001        matches!(self.selected_row(), Some(Row::Header { .. }))
5002    }
5003
5004    /// Remote roots that have neither answered nor been written off, for the caller to
5005    /// probe off the UI thread.
5006    pub fn pending_probes(&self) -> Vec<PathBuf> {
5007        let check = self.network_check;
5008        let mut out = Vec::new();
5009        // From the section, not its subtitle (which names the filesystem, not "network").
5010        for root in self.sections.iter().filter_map(|s| s.remote_root.as_ref()) {
5011            if !self.probes.settled(root) && !out.contains(root) {
5012                out.push(root.clone());
5013            }
5014        }
5015        // A browsed remote directory: its rows can only come from a probe, and the root scan
5016        // above does not cover it.
5017        if let Some(dir) = &self.browsing
5018            && check(dir)
5019            && cloud_source_id(dir).is_none()
5020            && !self.probes.settled(dir)
5021            && !out.contains(dir)
5022        {
5023            out.push(dir.clone());
5024        }
5025        out
5026    }
5027
5028    /// Whether the browsed directory is below the browse start, so Esc has a level to
5029    /// climb before the listing.
5030    pub fn below_browse_start(&self) -> bool {
5031        let (Some(dir), Some(start)) = (&self.browsing, &self.browse_start) else {
5032            return false;
5033        };
5034        if dir == start {
5035            return false;
5036        }
5037        // Up through parents, not a path prefix: `s3://bucket` sits below `cloud://<id>`.
5038        let mut current = self.parent_of(dir);
5039        let mut steps = 0;
5040        while let Some(place) = current {
5041            if &place == start {
5042                return true;
5043            }
5044            steps += 1;
5045            if steps > 64 {
5046                break;
5047            }
5048            current = self.parent_of(&place);
5049        }
5050        false
5051    }
5052
5053    /// Whether any section on screen is still waiting for its rows.
5054    pub fn sections_waiting(&self) -> bool {
5055        self.sections.iter().any(|s| s.waiting)
5056    }
5057
5058    /// The remote location being browsed, while its listing has not come back.
5059    pub fn awaiting_listing(&self) -> Option<&Path> {
5060        let dir = self.browsing.as_deref()?;
5061        if cloud_source_id(dir).is_some() {
5062            return self
5063                .cloud_source_of(dir)
5064                .is_some_and(|s| s.status == CloudStatus::Listing && s.buckets.is_empty())
5065                .then_some(dir);
5066        }
5067        ((self.network_check)(dir) && !self.probes.settled(dir)).then_some(dir)
5068    }
5069
5070    /// Record what a probe found. An empty listing is still an answer.
5071    pub fn probe_ready(&mut self, root: PathBuf, rows: Vec<Entry>, cut_short: bool) {
5072        self.probes.insert(
5073            root.clone(),
5074            Probe::Listed {
5075                rows: rows.into(),
5076                cut_short,
5077            },
5078        );
5079        self.apply_cloud_kinds(&root);
5080    }
5081
5082    /// Label every listed row a peek answered, in place: the probes' rows, then the
5083    /// sections showing them (a remote place's built again from its probe, Recent's
5084    /// rows directly). No listing is read again.
5085    pub fn take_cloud_kinds(&mut self) {
5086        let roots: Vec<PathBuf> = (self.probes.answered())
5087            .map(|(root, _)| root.clone())
5088            .collect();
5089        for root in &roots {
5090            self.apply_cloud_kinds(root);
5091        }
5092        let mut labeled = false;
5093        for section in &mut self.sections {
5094            for row in &mut section.rows {
5095                if row.kind == EntryKind::Directory
5096                    && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
5097                {
5098                    row.kind = *kind;
5099                    if !holds.is_empty() {
5100                        row.holds = holds.clone();
5101                    }
5102                    labeled = true;
5103                }
5104            }
5105        }
5106        if labeled {
5107            self.changed();
5108        }
5109        // A browsed directory's door is judged from its rows' kinds.
5110        if let Some(dir) = self.browsing.clone()
5111            && roots.contains(&dir)
5112        {
5113            self.relist_remote(&dir);
5114        }
5115    }
5116
5117    /// Label the rows of a cloud listing with what peeking inside them found.
5118    pub fn apply_cloud_kinds(&mut self, root: &Path) {
5119        let Some(rows) = self.probes.listed_mut(root) else {
5120            return;
5121        };
5122        // Copied only when a listing being built still holds these rows.
5123        for row in std::sync::Arc::make_mut(rows).iter_mut() {
5124            if row.kind == EntryKind::Directory
5125                && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
5126            {
5127                row.kind = *kind;
5128                // The label is what the peek counted (`12 parquet`), as on disk. Only when there is
5129                // something: a claim without a count must not erase one the row has.
5130                if !holds.is_empty() {
5131                    row.holds = holds.clone();
5132                }
5133            }
5134        }
5135    }
5136
5137    /// Cloud directories on or near the screen not yet peeked into, at most `limit`, the
5138    /// highlighted first. The cloud twin of [`Self::unclassified_visible`]: a peek is a
5139    /// request, worth spending on rows someone is looking at.
5140    pub fn cloud_directories_to_peek(&self, limit: usize) -> Vec<PathBuf> {
5141        if limit == 0 {
5142            return Vec::new();
5143        }
5144        let view = self.view();
5145        let mut out: Vec<PathBuf> = Vec::new();
5146        for entry in self.entries_near_cursor(&view, limit) {
5147            // Object-store directories by URL (`read_dir` on `s3://` finds nothing), hence a pass
5148            // of their own.
5149            if !is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
5150                continue;
5151            }
5152            if !matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown) {
5153                continue;
5154            }
5155            // Catalog datasets and their bookmarks are listed by name; their store is not asked
5156            // until opened or entered.
5157            if self.browsing.is_none()
5158                && (self.catalog_dataset(&entry.path).is_some()
5159                    || self.bookmark(&entry.path).is_some())
5160            {
5161                continue;
5162            }
5163            // Asked and answered, or asked and still out.
5164            if self.cloud_kinds.contains_key(&entry.path)
5165                || self.peeking.contains(&entry.path)
5166                || self.peek_failed.contains(&entry.path)
5167            {
5168                continue;
5169            }
5170            if out.contains(&entry.path) {
5171                continue;
5172            }
5173            out.push(entry.path.clone());
5174            if out.len() >= limit {
5175                break;
5176            }
5177        }
5178        out
5179    }
5180
5181    /// Record that a probe could not read the root, and why when the service said.
5182    pub fn probe_failed(&mut self, root: PathBuf, why: Option<String>) {
5183        self.probes.insert(root, Probe::Unreachable(why));
5184    }
5185
5186    /// Measure a batch of rows on the calling thread, for tests and library callers with
5187    /// safe paths. The app never calls this: footer reads can block, so the UI thread
5188    /// only picks rows ([`HomeState::unmeasured_visible`]) and a worker reads.
5189    pub fn measure_now(&mut self, limit: usize) -> bool {
5190        let wanted = self.unmeasured_visible(limit);
5191        let more = self.unmeasured_visible(limit + 1).len() > wanted.len();
5192        for entry in wanted {
5193            let mut probe = entry.clone();
5194            stat_and_recall(&mut probe, &self.known);
5195            if probe.rows.is_none() && probe.columns.is_empty() {
5196                discover::enrich(&mut probe);
5197            }
5198            self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
5199        }
5200        self.apply_new_measurements();
5201        more
5202    }
5203
5204    /// Rows not yet measured, up to `limit`: the screen and a little either side of it
5205    /// (see [`HomeState::entries_on_screen`]), the highlighted row first. Nothing off
5206    /// screen is read for a keystroke, so a filter typed over thousands of files costs
5207    /// what it shows; a column name matches the rows whose columns are known, read here
5208    /// or remembered from an earlier run. Sorted by rows, the order needs every count, so
5209    /// then every row listed and those a cut hides: the sort asked for them.
5210    pub fn unmeasured_visible(&self, limit: usize) -> Vec<Entry> {
5211        let view = self.view();
5212        let mut out: Vec<Entry> = Vec::new();
5213        for entry in self.entries_on_screen(&view) {
5214            if self.wants_measuring(entry) && !out.iter().any(|e| e.path == entry.path) {
5215                out.push(entry.clone());
5216                if out.len() >= limit {
5217                    return out;
5218                }
5219            }
5220        }
5221        if self.sort == SortMode::Rows {
5222            self.sorted_rest(&view, limit, &mut out);
5223        }
5224        out
5225    }
5226
5227    /// Rows that need a stat and nothing read, up to `limit`: those on screen stat'ed
5228    /// before the last Ctrl+R, then, sorted by size or time, every row listed and those
5229    /// a cut hides. Each goes with its stat cleared, so it is taken again.
5230    pub fn unstated_for_sort(&self, limit: usize) -> Vec<Entry> {
5231        let view = self.view();
5232        let mut out: Vec<Entry> = Vec::new();
5233        for entry in self.entries_on_screen(&view) {
5234            let stale =
5235                (self.stated.get(&entry.path)).is_some_and(|stated| stated.epoch < self.stat_epoch);
5236            if stale && self.needs_stat(entry) && !out.iter().any(|e| e.path == entry.path) {
5237                out.push(entry.clone());
5238                if out.len() >= limit {
5239                    break;
5240                }
5241            }
5242        }
5243        if out.len() < limit && matches!(self.sort, SortMode::Size | SortMode::Modified) {
5244            self.sorted_rest(&view, limit, &mut out);
5245        }
5246        for entry in &mut out {
5247            entry.modified = None;
5248        }
5249        out
5250    }
5251
5252    /// The rows the sort wants and lacks, listed or behind a cut, into `out`.
5253    fn sorted_rest(&self, view: &View, limit: usize, out: &mut Vec<Entry>) {
5254        let mut take = |entry: &Entry| {
5255            if self.sort_wants(entry) && !out.iter().any(|e| e.path == entry.path) {
5256                out.push(entry.clone());
5257            }
5258            out.len() >= limit
5259        };
5260        for entry in view.slots.iter().filter_map(|slot| self.entry_of(slot)) {
5261            if take(entry) {
5262                return;
5263            }
5264        }
5265        for slot in &view.slots {
5266            let Slot::Plain(Row::More {
5267                section,
5268                measuring: true,
5269                ..
5270            }) = slot
5271            else {
5272                continue;
5273            };
5274            for entry in &self.sections[*section].rows {
5275                if take(entry) {
5276                    return;
5277                }
5278            }
5279        }
5280    }
5281
5282    /// Whether the sort orders by something `entry` lacks until measured: its count, or
5283    /// for size and time its stat alone (a directory's too, for time).
5284    fn sort_wants(&self, entry: &Entry) -> bool {
5285        match self.sort {
5286            SortMode::Natural => false,
5287            SortMode::Rows => self.wants_measuring(entry),
5288            SortMode::Size | SortMode::Modified => {
5289                (self.sort == SortMode::Modified || entry.kind != EntryKind::Unknown)
5290                    && self.needs_stat(entry)
5291            }
5292        }
5293    }
5294
5295    /// Whether a local row lacks a stat of this refresh: never stat'ed, or stat'ed before
5296    /// the last Ctrl+R.
5297    fn needs_stat(&self, entry: &Entry) -> bool {
5298        let due = match self.stated.get(&entry.path) {
5299            Some(stated) => stated.epoch < self.stat_epoch,
5300            None => entry.modified.is_none(),
5301        };
5302        due && discover::on_disk(entry) && !(self.network_check)(&entry.path)
5303    }
5304
5305    /// Whether `entry` is a local row with no count yet that measuring would give, or
5306    /// with no size and mtime yet, which a listing leaves to the rows shown.
5307    fn wants_measuring(&self, entry: &Entry) -> bool {
5308        if entry.measured || self.enriched.contains_key(&entry.path) {
5309            return false;
5310        }
5311        // A directory not yet looked into is the classification pass's, which stats it.
5312        if entry.kind != EntryKind::Unknown
5313            && discover::unstated(entry)
5314            && !self.stated.contains_key(&entry.path)
5315            && !(self.network_check)(&entry.path)
5316        {
5317            return true;
5318        }
5319        if entry.rows.is_some() {
5320            return false;
5321        }
5322        // The kind settles it before the mount table: this runs per row per frame, and the
5323        // cheap question first keeps thousands of rows free.
5324        if matches!(
5325            entry.kind,
5326            EntryKind::Directory | EntryKind::Unknown | EntryKind::Other
5327        ) || entry.kind.is_lake_table()
5328        {
5329            return false;
5330        }
5331        // Remote rows are measured by their root's probe; a second thread on a share that
5332        // may never answer is never reclaimed.
5333        !(self.network_check)(&entry.path)
5334    }
5335
5336    /// Look into a batch of rows on the calling thread; for tests and library callers,
5337    /// like [`HomeState::measure_now`].
5338    pub fn classify_now(&mut self, limit: usize) -> bool {
5339        let wanted = self.unclassified_visible(limit);
5340        let more = self.unclassified_visible(limit + 1).len() > wanted.len();
5341        for entry in wanted {
5342            let mut stated = entry.clone();
5343            stat_and_recall(&mut stated, &self.known);
5344            let probe = look_into_as(&stated, &Default::default());
5345            self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
5346        }
5347        self.apply_new_measurements();
5348        more
5349    }
5350
5351    /// Rows on or near the screen not yet looked into, up to `limit`. Unlike
5352    /// [`HomeState::unmeasured_visible`], which walks from the top, this asks the
5353    /// viewport plus a screen either side (thousands of partitions would take seconds to
5354    /// reach the cursor): the highlighted row first (about to be acted on), then the
5355    /// screen, the screen below, the screen above.
5356    pub fn unclassified_visible(&self, limit: usize) -> Vec<Entry> {
5357        self.unclassified_visible_where(limit, |_| true)
5358    }
5359
5360    /// [`HomeState::unclassified_visible`], of the rows `keep` takes.
5361    pub fn unclassified_visible_where(
5362        &self,
5363        limit: usize,
5364        keep: impl Fn(&Entry) -> bool,
5365    ) -> Vec<Entry> {
5366        if limit == 0 {
5367            return Vec::new();
5368        }
5369        let view = self.view();
5370        let mut out: Vec<Entry> = Vec::new();
5371        for entry in self.entries_near_cursor(&view, limit) {
5372            if entry.kind != EntryKind::Unknown
5373                || self.missing.contains(&entry.path)
5374                || !keep(entry)
5375            {
5376                continue;
5377            }
5378            // Already looked into, even if that settled nothing: re-asking would stat per frame.
5379            if self.enriched.contains_key(&entry.path) {
5380                continue;
5381            }
5382            // Object-store places are peeked by listing; see
5383            // [`HomeState::cloud_directories_to_peek`].
5384            if is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
5385                continue;
5386            }
5387            // The same dataset may be listed twice (directory and Recent); look once.
5388            if out.iter().any(|e| e.path == entry.path) {
5389                continue;
5390            }
5391            out.push(entry.clone());
5392            if out.len() >= limit {
5393                break;
5394            }
5395        }
5396        out
5397    }
5398
5399    /// The entry rows on screen: the highlighted one, the screen, then half a screen
5400    /// below and above it, so a step or a short scroll finds its rows already read.
5401    fn entries_on_screen<'a>(&'a self, view: &'a View) -> impl Iterator<Item = &'a Entry> + 'a {
5402        // Before the first frame there is no screen: the list from the top, as far as the
5403        // caller takes it.
5404        let height = if self.view_height == 0 {
5405            usize::MAX / 4
5406        } else {
5407            self.view_height
5408        };
5409        let rows = view.slots.len();
5410        let top = self.scroll.min(rows);
5411        let reach = height / 2;
5412        let bottom = top.saturating_add(height + reach).min(rows);
5413        let above = top.saturating_sub(reach);
5414        std::iter::once(self.selected)
5415            .chain(top..bottom)
5416            .chain(above..top)
5417            .filter_map(|i| self.entry_of(view.slots.get(i)?))
5418    }
5419
5420    /// The entry rows near the cursor: the highlighted one, the screen, the screen below,
5421    /// the screen above.
5422    fn entries_near_cursor<'a>(
5423        &'a self,
5424        view: &'a View,
5425        limit: usize,
5426    ) -> impl Iterator<Item = &'a Entry> + 'a {
5427        // Before the first frame there is no height: take the top of the list, a batch's
5428        // worth.
5429        let height = if self.view_height == 0 {
5430            limit
5431        } else {
5432            self.view_height
5433        };
5434        let rows = view.slots.len();
5435        let top = self.scroll.min(rows);
5436        let ahead = top.saturating_add(2 * height).min(rows);
5437        let behind = top.saturating_sub(height);
5438        std::iter::once(self.selected)
5439            .chain(top..ahead)
5440            .chain(behind..top)
5441            .filter_map(|i| self.entry_of(view.slots.get(i)?))
5442    }
5443
5444    /// The entry a row shows, when it is an entry row.
5445    fn entry_of(&self, slot: &Slot) -> Option<&Entry> {
5446        match slot {
5447            Slot::Entry { section, index, .. } => Some(&self.sections[*section].rows[*index]),
5448            _ => None,
5449        }
5450    }
5451
5452    /// Record what measuring `path` found, for [`HomeState::apply_new_measurements`] to
5453    /// fold into its rows.
5454    pub fn record_measurement(&mut self, path: PathBuf, measured: Measured) {
5455        self.unapplied.insert(path.clone());
5456        let before = self.stated.insert(
5457            path.clone(),
5458            Stated {
5459                size: measured.size,
5460                modified: measured.modified,
5461                epoch: self.stat_epoch,
5462            },
5463        );
5464        if !measured.stat_only {
5465            self.stale.remove(&path);
5466            self.enriched.insert(path, measured);
5467            return;
5468        }
5469        // A file changed since it was measured: its counts are dropped, and measured again
5470        // when shown.
5471        let changed = before.is_some_and(|before| {
5472            before.modified.is_some()
5473                && (before.size, before.modified) != (measured.size, measured.modified)
5474        });
5475        if changed && self.enriched.remove(&path).is_some() {
5476            self.stale.insert(path);
5477        }
5478    }
5479
5480    /// Record a size alone: a measurement that landed meanwhile keeps the rest.
5481    pub fn record_size(&mut self, path: PathBuf, measured: Measured) {
5482        match self.enriched.get_mut(&path) {
5483            Some(known) => {
5484                known.size = measured.size;
5485                self.unapplied.insert(path);
5486            }
5487            None => self.record_measurement(path, measured),
5488        }
5489    }
5490
5491    /// Fold every known measurement into the rows currently listed, as a new listing
5492    /// needs.
5493    pub fn apply_measurements(&mut self) {
5494        self.unapplied.clear();
5495        for section in &mut self.sections {
5496            // The door too: it reads its directory's slot on purpose, showing numbers already
5497            // measured upstairs; nothing writes the door's answer.
5498            for row in section.rows.iter_mut().chain(section.door.iter_mut()) {
5499                fold_known(row, &self.enriched, &self.stated, &self.stale);
5500            }
5501            // The door's name says what it opens, and a measurement can change that: the
5502            // footers turn a directory of files down as one table, or name its keys.
5503            if let Some(door) = section.door.as_mut() {
5504                door.name = door_name(door, &section.rows);
5505            }
5506        }
5507        self.changed();
5508        self.land_again();
5509    }
5510
5511    /// Fold the measurements recorded since the last fold into their rows. Only those
5512    /// rows change and are scored again: measurements land a file at a time while the
5513    /// user types, over listings of thousands.
5514    pub fn apply_new_measurements(&mut self) {
5515        if self.unapplied.is_empty() {
5516            return;
5517        }
5518        let unapplied = std::mem::take(&mut self.unapplied);
5519        // A row's name first: hashing a short name is cheaper than hashing a whole path
5520        // component by component, and few of thousands of rows are among those measured.
5521        // Equal paths have equal names, so no measured row is passed over.
5522        let names: std::collections::HashSet<&std::ffi::OsStr> = (unapplied.iter())
5523            .map(|path| path.file_name().unwrap_or(path.as_os_str()))
5524            .collect();
5525        let new = |path: &Path| {
5526            path.file_name().is_none_or(|name| names.contains(name)) && unapplied.contains(path)
5527        };
5528        let mut touched: Vec<(usize, usize)> = Vec::new();
5529        let mut any = false;
5530        let mut moved = false;
5531        let sort = self.sort;
5532        for (si, section) in self.sections.iter_mut().enumerate() {
5533            let mut here = false;
5534            for (i, row) in section.rows.iter_mut().enumerate() {
5535                if !new(&row.path) {
5536                    continue;
5537                }
5538                let before = Standing::of(row, sort);
5539                if fold_known(row, &self.enriched, &self.stated, &self.stale) {
5540                    moved |= Standing::of(row, sort) != before;
5541                    touched.push((si, i));
5542                    here = true;
5543                }
5544            }
5545            if let Some(door) = section.door.as_mut()
5546                && new(&door.path)
5547            {
5548                here |= fold_known(door, &self.enriched, &self.stated, &self.stale);
5549            }
5550            if here && let Some(door) = section.door.as_mut() {
5551                door.name = door_name(door, &section.rows);
5552            }
5553            any |= here;
5554        }
5555        if !any {
5556            return;
5557        }
5558        // The rows are drawn from the sections as they are now; the list is built again
5559        // only when a row moves, comes or goes, which is rare once the first answers are in.
5560        // Sorted by something measured, it is built again for each answer: the order and
5561        // what the `more` row says are made of them.
5562        if moved || sort != SortMode::Natural || !self.hits_hold(&touched) {
5563            self.rows_changed(&touched);
5564        }
5565        self.land_again();
5566    }
5567
5568    /// Whether the built list still answers the filter the same way for `touched` rows
5569    /// (section, index), so it stands as built. With no list built for the rows as they
5570    /// are, the hits kept for the next build are rescored instead.
5571    fn hits_hold(&self, touched: &[(usize, usize)]) -> bool {
5572        let built = self.rows_cache.built.borrow();
5573        let Some(view) = built.as_ref().filter(|view| view.key.matches(self)) else {
5574            return false;
5575        };
5576        let needle = Needle::new(&self.filter);
5577        touched.iter().all(|&(si, i)| {
5578            let was = view.hits.sections.get(si).and_then(|s| s.as_ref()?.get(i));
5579            was == Some(&needle.hit(&self.sections[si].rows[i]))
5580        })
5581    }
5582
5583    /// Landed on a door the footers have since turned down, and not moved: the cursor
5584    /// goes where it would have landed had they been read first.
5585    fn land_again(&mut self) {
5586        if self.landing
5587            && let Some(Row::Door { entry, .. }) = self.row_at(self.selected)
5588            && !door_lands(entry)
5589        {
5590            self.selected = self.landing_row();
5591            self.follow_selection();
5592        }
5593    }
5594
5595    /// Put the cursor on the first dataset rather than the first header, so the
5596    /// preview pane has something to show without a keypress.
5597    ///
5598    /// Inside a directory that is the door when the door opens the directory as one
5599    /// dataset (see [`door_lands`]), and the first thing in it otherwise.
5600    pub fn select_first_entry(&mut self) {
5601        self.returning = None;
5602        self.landing = true;
5603        // Narrowing picks the best match, as typing does.
5604        if !self.filter.is_empty() {
5605            self.resting = false;
5606        }
5607        self.selected = self.landing_row();
5608    }
5609
5610    /// Where [`HomeState::select_first_entry`] puts the cursor.
5611    fn landing_row(&self) -> usize {
5612        // Recent is ranked by frecency, and the last file opened is still one Enter away.
5613        if self.filter.is_empty()
5614            && let Some(newest) = self.newest_recent.as_ref()
5615            && let Some(at) = self.position(|r| {
5616                matches!(r, Row::Entry { section, entry, .. }
5617                    if entry.path == *newest
5618                        && self.sections[*section].title == Self::RECENT_SECTION)
5619            })
5620        {
5621            return at;
5622        }
5623        let first = self.position(|r| matches!(r, Row::Entry { .. } | Row::Door { .. }));
5624        let first = match first.and_then(|i| self.row_at(i)) {
5625            Some(Row::Door { entry, .. }) if !door_lands(entry) => self
5626                .position(|r| matches!(r, Row::Entry { .. } | Row::Hidden { .. }))
5627                .or(first),
5628            _ => first,
5629        };
5630        // A directory of files datui cannot open: the row that says so.
5631        first
5632            .or_else(|| self.position(|r| matches!(r, Row::Hidden { .. })))
5633            .unwrap_or(0)
5634    }
5635
5636    pub fn clamp_selection(&mut self) {
5637        let n = self.row_count();
5638        if n == 0 {
5639            self.selected = 0;
5640        } else if self.selected >= n {
5641            self.selected = n - 1;
5642        }
5643    }
5644
5645    /// Put the selection on row `index` of what is listed, as a click does.
5646    pub fn select(&mut self, index: usize) {
5647        if index < self.row_count() {
5648            self.resting = false;
5649            self.returning = None;
5650            self.landing = false;
5651            self.selected = index;
5652        }
5653    }
5654
5655    pub fn move_selection(&mut self, delta: isize) {
5656        self.resting = false;
5657        self.returning = None;
5658        self.landing = false;
5659        let n = self.row_count();
5660        if n == 0 {
5661            return;
5662        }
5663        let cur = self.selected as isize;
5664        let next = (cur + delta).rem_euclid(n as isize);
5665        self.selected = next as usize;
5666    }
5667
5668    /// Move the selection `delta` rows, stopping at the ends rather than wrapping (a
5669    /// wrapped page jump lands somewhere unexpected; single steps wrap).
5670    pub fn page_selection(&mut self, delta: isize) {
5671        self.resting = false;
5672        self.returning = None;
5673        self.landing = false;
5674        let n = self.row_count();
5675        if n == 0 {
5676            return;
5677        }
5678        // Saturating, so Home and End are a page of `isize::MIN` or `isize::MAX`.
5679        let next = (self.selected as isize)
5680            .saturating_add(delta)
5681            .clamp(0, n as isize - 1);
5682        self.selected = next as usize;
5683    }
5684}
5685
5686/// The row for a cloud source under `CLOUD`.
5687fn source_entry(source: &CloudSource) -> Entry {
5688    Entry {
5689        path: cloud_place(&source.id),
5690        kind: EntryKind::Directory,
5691        name: source.label.clone(),
5692        size: None,
5693        modified: source.listed_at,
5694        rows: None,
5695        cols: None,
5696        cols_sampled: false,
5697        columns: Vec::new(),
5698        cost: Default::default(),
5699        holds: Default::default(),
5700        opens_whole_directory: false,
5701        measured: false,
5702        format_spec: None,
5703        table: None,
5704    }
5705}
5706
5707/// The row for one bucket.
5708fn bucket_entry(url: &Path) -> Entry {
5709    let mut entry = Entry::directory(url);
5710    // The bucket name, without a source id, rather than the URL's last segment.
5711    let text = url.to_string_lossy();
5712    let (_, plain) = crate::cloud::source::split_source_id(&text);
5713    entry.name = plain
5714        .rsplit('/')
5715        .find(|part| !part.is_empty())
5716        .unwrap_or("")
5717        .to_string();
5718    entry
5719}
5720
5721/// Whether a remote path's name says it is a file: a data extension or any dot in its
5722/// last segment. A trailing slash is always a prefix.
5723pub fn names_a_file(path: &Path) -> bool {
5724    let named = path.to_string_lossy();
5725    // `file_name`, not a split on `/`, which on Windows took the whole path as the last
5726    // segment.
5727    let dotted = !named.ends_with('/')
5728        && path
5729            .file_name()
5730            .map(|last| last.to_string_lossy())
5731            .is_some_and(|last| last.trim_start_matches('.').contains('.'));
5732    discover::is_data_file(path) || dotted
5733}
5734
5735/// Build an entry for a path that is already known (a recent), classifying it.
5736fn entry_for_path(path: &Path, remote: bool) -> Entry {
5737    // A table inside a SQLite database, which nothing on disk is named.
5738    if !remote && let Some(table) = discover::table_row(path) {
5739        return table;
5740    }
5741    let mut holds = discover::Holds::default();
5742    // Classifying and stat'ing touch the filesystem, so a remote entry is listed by name
5743    // until its probe lands.
5744    let kind = if remote {
5745        // The name alone: an extension (readable or not) makes it a file, so → never enters
5746        // `data.dat` as a prefix; anything else stays Unknown, not a plain directory, which
5747        // would contradict its root's probe later. A trailing slash is a prefix whatever
5748        // the name (`exports/`, `2024.01.15/`).
5749        if names_a_file(path) {
5750            EntryKind::File
5751        } else {
5752            EntryKind::Unknown
5753        }
5754    } else if path.is_dir() {
5755        let (kind, found) = discover::look_at_directory(path);
5756        holds = found;
5757        kind
5758    } else {
5759        EntryKind::File
5760    };
5761    let mut entry = Entry {
5762        path: path.to_path_buf(),
5763        kind,
5764        name: path
5765            .file_name()
5766            .map(|n| n.to_string_lossy().into_owned())
5767            .unwrap_or_else(|| path.to_string_lossy().into_owned()),
5768        size: None,
5769        modified: None,
5770        rows: None,
5771        cols: None,
5772        cols_sampled: false,
5773        columns: Vec::new(),
5774        cost: Default::default(),
5775        holds,
5776        opens_whole_directory: false,
5777        measured: false,
5778        format_spec: None,
5779        table: None,
5780    };
5781    if !remote && let Ok(meta) = std::fs::metadata(path) {
5782        if meta.is_file() {
5783            entry.size = Some(meta.len());
5784        }
5785        entry.modified = meta.modified().ok();
5786    }
5787    entry
5788}
5789
5790/// Abbreviate a path with `~` for display.
5791pub fn display_path(path: &Path) -> String {
5792    if let Some(home) = dirs::home_dir()
5793        && let Ok(rest) = path.strip_prefix(&home)
5794    {
5795        if rest.as_os_str().is_empty() {
5796            return "~".to_string();
5797        }
5798        // The platform's separator, so Windows reads `~\data\a.csv`.
5799        return format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display());
5800    }
5801    path.display().to_string()
5802}
5803
5804/// Complete a partly typed path against its directory: the longest unambiguous
5805/// extension of `typed` and the candidate count. Reads a directory, so only on a
5806/// worker.
5807pub fn complete_path(typed: &str) -> (String, usize) {
5808    let expanded = expand_user_path(typed);
5809    // `\` is a separator on Windows too: `C:\data\` lists inside `data`.
5810    let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5811    let typed_ends_in_sep = typed.ends_with(is_separator);
5812
5813    let (dir, prefix) = if typed_ends_in_sep {
5814        (expanded.clone(), String::new())
5815    } else {
5816        match (expanded.parent(), expanded.file_name()) {
5817            (Some(parent), Some(name)) => {
5818                (parent.to_path_buf(), name.to_string_lossy().into_owned())
5819            }
5820            _ => (expanded.clone(), String::new()),
5821        }
5822    };
5823
5824    let Ok(entries) = std::fs::read_dir(&dir) else {
5825        return (typed.to_string(), 0);
5826    };
5827
5828    let mut names: Vec<String> = entries
5829        .flatten()
5830        .filter_map(|e| {
5831            let name = e.file_name().to_string_lossy().into_owned();
5832            // Dotfiles only when a dot is typed.
5833            if name.starts_with('.') && !prefix.starts_with('.') {
5834                return None;
5835            }
5836            name.starts_with(&prefix).then_some(name)
5837        })
5838        .collect();
5839    if names.is_empty() {
5840        return (typed.to_string(), 0);
5841    }
5842    names.sort();
5843
5844    // The candidates' common prefix: further would be guessing.
5845    let shared = names
5846        .iter()
5847        .skip(1)
5848        .fold(names[0].clone(), |acc, name| common_prefix(&acc, name));
5849
5850    let mut completed = typed.to_string();
5851    completed.truncate(typed.len() - prefix.len());
5852    completed.push_str(&shared);
5853
5854    // A single directory gets the separator being typed, so the next Tab descends
5855    // (`C:\Users\` stays `\`).
5856    if names.len() == 1 && dir.join(&shared).is_dir() && !completed.ends_with(is_separator) {
5857        let separator = typed
5858            .chars()
5859            .rev()
5860            .find(|c| is_separator(*c))
5861            .unwrap_or(std::path::MAIN_SEPARATOR);
5862        completed.push(separator);
5863    }
5864    (completed, names.len())
5865}
5866
5867/// One name in the directory the `~` prompt is typing.
5868#[derive(Debug, Clone, PartialEq, Eq)]
5869pub struct PathName {
5870    pub name: String,
5871    /// A directory, prefix or bucket: completed with a separator, and gone into.
5872    pub dir: bool,
5873}
5874
5875/// What the `~` prompt lists: the directory part of what is typed, and what is in it.
5876#[derive(Debug, Clone, Default, PartialEq, Eq)]
5877pub struct PathListing {
5878    /// The typed text up to and including its last separator, as typed.
5879    pub dir: String,
5880    pub names: Vec<PathName>,
5881    /// Reading the directory failed: there is nothing to list, and the prompt says so.
5882    pub failed: bool,
5883    /// The names the last typed segment matched, best first.
5884    pub matched: SegmentMatches,
5885}
5886
5887/// Which of a [`PathListing`]'s names a typed segment matches, kept for that segment:
5888/// each frame and key asks, and scoring thousands of names each time is what a
5889/// keystroke must not cost. A memo, so not part of the listing's value.
5890#[derive(Debug, Default)]
5891pub struct SegmentMatches(std::sync::Mutex<Option<(String, std::sync::Arc<[usize]>)>>);
5892
5893impl Clone for SegmentMatches {
5894    fn clone(&self) -> Self {
5895        Self::default()
5896    }
5897}
5898
5899impl PartialEq for SegmentMatches {
5900    fn eq(&self, _: &Self) -> bool {
5901        true
5902    }
5903}
5904
5905impl Eq for SegmentMatches {}
5906
5907/// Which of `names` the typed `segment` matches, best first: prefix matches, as a shell
5908/// completes, then fuzzy ones; hidden names only after a typed dot.
5909fn path_matches(names: &[PathName], segment: &str) -> std::sync::Arc<[usize]> {
5910    let mut matched: Vec<(usize, i32)> = (names.iter().enumerate())
5911        .filter(|(_, n)| !n.name.starts_with('.') || segment.starts_with('.'))
5912        .filter_map(|(i, n)| {
5913            if segment.is_empty() {
5914                return Some((i, 0));
5915            }
5916            let prefix = n.name.starts_with(segment) as i32 * 1_000_000;
5917            fuzzy_score(segment, &n.name).map(|score| (i, prefix + score))
5918        })
5919        .collect();
5920    matched
5921        .sort_by(|(a, sa), (b, sb)| sb.cmp(sa).then_with(|| names[*a].name.cmp(&names[*b].name)));
5922    matched.into_iter().map(|(i, _)| i).collect()
5923}
5924
5925/// The most names a typed directory lists: a prompt finds one name by typing.
5926const PATH_LISTING_MAX: usize = 5_000;
5927
5928/// The directory part of a typed path, through its last separator; for a URL at
5929/// least its scheme (`s3://`), so buckets list under it.
5930pub fn typed_dir(typed: &str) -> &str {
5931    let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5932    let floor = typed.find("://").map_or(0, |at| at + 3);
5933    match typed[floor..].rfind(is_separator) {
5934        Some(at) => &typed[..floor + at + 1],
5935        None => &typed[..floor],
5936    }
5937}
5938
5939/// The separator a directory completed under `dir` ends with: a URL's `/`, or the
5940/// one being typed.
5941fn separator_in(dir: &str) -> char {
5942    if typed_dir_is_url(dir) {
5943        return '/';
5944    }
5945    dir.chars()
5946        .rev()
5947        .find(|c| *c == '/' || (cfg!(windows) && *c == '\\'))
5948        .unwrap_or(std::path::MAIN_SEPARATOR)
5949}
5950
5951/// Whether a typed directory is a URL, listed from what datui knows rather than read.
5952pub fn typed_dir_is_url(dir: &str) -> bool {
5953    dir.contains("://")
5954}
5955
5956/// A local directory typed at `~`, for the prompt's list; reads it, so runs on a
5957/// worker. Nothing typed lists the working directory.
5958pub fn list_typed_dir(dir: &str) -> PathListing {
5959    let path = if dir.is_empty() {
5960        PathBuf::from(".")
5961    } else {
5962        expand_user_path(dir)
5963    };
5964    let Ok(entries) = std::fs::read_dir(&path) else {
5965        return PathListing {
5966            dir: dir.to_string(),
5967            names: Vec::new(),
5968            failed: true,
5969            matched: Default::default(),
5970        };
5971    };
5972    let mut names: Vec<PathName> = entries
5973        .flatten()
5974        .take(PATH_LISTING_MAX)
5975        .map(|e| {
5976            let name = e.file_name().to_string_lossy().into_owned();
5977            // A link to a directory is one to go into.
5978            let dir = e.file_type().is_ok_and(|t| t.is_dir())
5979                || (e.file_type().is_ok_and(|t| t.is_symlink()) && e.path().is_dir());
5980            PathName { name, dir }
5981        })
5982        .collect();
5983    names.sort_by(|a, b| a.name.cmp(&b.name));
5984    PathListing {
5985        dir: dir.to_string(),
5986        names,
5987        failed: false,
5988        matched: Default::default(),
5989    }
5990}
5991
5992/// The names one level below `dir` among `urls`: how `s3://`, `gs://` and `az://`
5993/// complete, from what was listed, opened or cataloged. Nothing is asked of the store.
5994pub fn names_under(dir: &str, urls: impl IntoIterator<Item = String>) -> PathListing {
5995    let mut names: Vec<PathName> = Vec::new();
5996    for url in urls {
5997        // An Azure URL is known in its full form; `az://container/` is how one is typed.
5998        let forms = match crate::cloud::source::azure_parts(&url) {
5999            Some((_, container, key)) => vec![url.clone(), format!("az://{container}/{key}")],
6000            None => vec![url],
6001        };
6002        for form in forms {
6003            let Some(rest) = form.strip_prefix(dir) else {
6004                continue;
6005            };
6006            let (name, more) = match rest.split_once('/') {
6007                Some((name, more)) => (name, Some(more)),
6008                None => (rest, None),
6009            };
6010            if name.is_empty() {
6011                continue;
6012            }
6013            // Something below it, a trailing slash, or no extension: a bucket or prefix, as for
6014            // a recent.
6015            let is_dir = more.is_some() || !names_a_file(Path::new(&form));
6016            match names.iter_mut().find(|n| n.name == name) {
6017                Some(known) => known.dir |= is_dir,
6018                None => names.push(PathName {
6019                    name: name.to_string(),
6020                    dir: is_dir,
6021                }),
6022            }
6023        }
6024    }
6025    names.sort_by(|a, b| a.name.cmp(&b.name));
6026    PathListing {
6027        dir: dir.to_string(),
6028        names,
6029        failed: false,
6030        matched: Default::default(),
6031    }
6032}
6033
6034fn common_prefix(a: &str, b: &str) -> String {
6035    a.chars()
6036        .zip(b.chars())
6037        .take_while(|(x, y)| x == y)
6038        .map(|(x, _)| x)
6039        .collect()
6040}
6041
6042/// Expand `~` and `$VAR` in a path the user typed.
6043pub fn expand_user_path(raw: &str) -> PathBuf {
6044    crate::config::expand_config_path(raw)
6045}
6046
6047#[cfg(test)]
6048mod holds_flow_tests {
6049    use super::*;
6050
6051    /// The list and the pane are told one thing: the curated word in both, and for a
6052    /// bucket directory, where looking into it is.
6053    #[test]
6054    fn a_rows_label_is_one_decision_for_the_list_and_the_pane() {
6055        let mut directory = Entry::for_test(Path::new("s3://bucket/warehouse"), "warehouse");
6056        directory.kind = EntryKind::Directory;
6057        let said = |look, place_kind| describe(&directory, place_kind, look, 0, None);
6058        let g = crate::glyphs::get();
6059
6060        let waiting = said(Some(CloudLook::Waiting), None);
6061        assert_eq!(
6062            (waiting.short.as_str(), waiting.words.as_str()),
6063            (g.ellipsis, "")
6064        );
6065        assert_eq!(said(Some(CloudLook::Looking), None).words, "");
6066        assert!(
6067            said(Some(CloudLook::Failed), None)
6068                .words
6069                .contains("listing failed")
6070        );
6071        assert_eq!(said(None, None).words, "directory");
6072        let curated = said(None, Some("dataset"));
6073        assert_eq!(
6074            (curated.short.as_str(), curated.words.as_str()),
6075            ("dataset", "dataset")
6076        );
6077        assert!(curated.curated);
6078
6079        directory.holds = crate::home::discover::Holds {
6080            formats: vec![("parquet".to_string(), 12)],
6081            ..Default::default()
6082        };
6083        let counted = describe(&directory, None, None, 0, None);
6084        assert_eq!(counted.short, "12 parquet");
6085        assert_eq!(
6086            counted.words, "directory",
6087            "the count is the pane's `contains` line"
6088        );
6089        let curated = describe(&directory, Some("dataset"), None, 0, None);
6090        assert_eq!(
6091            curated.short, "dataset",
6092            "the curated word wins over the count"
6093        );
6094
6095        directory.opens_whole_directory = true;
6096        assert_eq!(
6097            describe(&directory, Some("dataset"), None, 0, None),
6098            RowLabel::default()
6099        );
6100    }
6101
6102    /// A path under the home directory is written the way it is typed back: `~\` on
6103    /// Windows, and `~\` typed at the prompt expands.
6104    #[cfg(windows)]
6105    #[test]
6106    fn a_windows_home_path_is_shown_and_typed_with_backslashes() {
6107        let home = dirs::home_dir().unwrap();
6108        let path = home.join("data").join("a.csv");
6109        let shown = display_path(&path);
6110        assert_eq!(shown, r"~\data\a.csv");
6111        assert_eq!(expand_user_path(&shown), path);
6112    }
6113
6114    /// The last segment of a Windows path is after its last `\`, so a dot higher up
6115    /// does not make a directory a file.
6116    #[cfg(windows)]
6117    #[test]
6118    fn a_dot_above_a_windows_recent_does_not_make_it_a_file() {
6119        let path = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\orders");
6120        assert_eq!(entry_for_path(path, true).kind, EntryKind::Unknown);
6121        let file = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\a.parquet");
6122        assert_eq!(entry_for_path(file, true).kind, EntryKind::File);
6123    }
6124
6125    fn counted(n: usize) -> crate::home::discover::Holds {
6126        crate::home::discover::Holds {
6127            formats: vec![("parquet".to_string(), n)],
6128            ..Default::default()
6129        }
6130    }
6131
6132    /// The claim `peek_cloud_directories` stakes before its answers arrive, so a rebuild
6133    /// in the meantime does not ask the store again: a `Directory` that counted nothing.
6134    fn in_flight() -> (EntryKind, crate::home::discover::Holds) {
6135        (
6136            EntryKind::Directory,
6137            crate::home::discover::Holds::default(),
6138        )
6139    }
6140
6141    #[test]
6142    fn a_claim_staked_before_a_peek_lands_keeps_the_count_a_row_already_has() {
6143        let root = std::path::PathBuf::from("s3://bucket/warehouse");
6144        let path = root.join("orders");
6145        let mut row = Entry::for_test(&path, "orders");
6146        row.kind = EntryKind::Directory;
6147        // Restored from the facts cache on the way in, which is the only reason a
6148        // remote row has a count before anything peeked at it.
6149        row.holds = counted(15);
6150
6151        let mut home = HomeState::default();
6152        home.probe_ready(root.clone(), vec![row], false);
6153        home.cloud_kinds.insert(path, in_flight());
6154        home.apply_cloud_kinds(&root);
6155
6156        assert_eq!(
6157            home.probes.listed(&root).unwrap()[0].holds.label(),
6158            "15 parquet",
6159            "the placeholder erased a count the row already had"
6160        );
6161    }
6162
6163    #[test]
6164    fn a_cloud_directory_waits_then_looks_then_answers() {
6165        let path = std::path::PathBuf::from("gs://pitscope/seasons");
6166        let mut row = Entry::for_test(&path, "seasons");
6167        row.kind = EntryKind::Directory;
6168        let mut home = HomeState::default();
6169
6170        assert_eq!(
6171            home.cloud_look(&row),
6172            Some(CloudLook::Waiting),
6173            "not asked yet"
6174        );
6175        home.peeking.insert(path.clone());
6176        assert_eq!(
6177            home.cloud_look(&row),
6178            Some(CloudLook::Looking),
6179            "being looked into"
6180        );
6181        home.peeking.remove(&path);
6182
6183        // Answered with a count the drawn row does not carry yet: still looking, not
6184        // `dir`, which would claim there is no data inside.
6185        home.cloud_kinds
6186            .insert(path.clone(), (EntryKind::Directory, counted(12)));
6187        assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
6188        home.cloud_kinds
6189            .insert(path.clone(), (EntryKind::Hive, Default::default()));
6190        assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
6191
6192        // Answered with nothing to count: `dir` is the truth.
6193        home.cloud_kinds.insert(path.clone(), in_flight());
6194        assert_eq!(home.cloud_look(&row), None, "answered");
6195
6196        // A failed peek is not an answer, and is not asked again until Ctrl+R. The
6197        // picker reads the listing, so the row has to be on it.
6198        let mut home = HomeState {
6199            network_check: |_| true,
6200            ..Default::default()
6201        };
6202        let root = std::path::PathBuf::from("gs://pitscope");
6203        home.probe_ready(root.clone(), vec![row.clone()], false);
6204        home.browsing = Some(root);
6205        home.rebuild(&[]);
6206        assert_eq!(
6207            home.cloud_directories_to_peek(4),
6208            std::slice::from_ref(&path)
6209        );
6210        home.peek_failed.insert(path.clone());
6211        assert_eq!(home.cloud_look(&row), Some(CloudLook::Failed));
6212        assert!(home.cloud_directories_to_peek(4).is_empty());
6213
6214        // A row that already says what it holds, a bucket, and a local directory never
6215        // wait on a peek.
6216        let mut counted_row = Entry::for_test(&path.join("x"), "x");
6217        counted_row.kind = EntryKind::Directory;
6218        counted_row.holds = counted(3);
6219        assert_eq!(home.cloud_look(&counted_row), None);
6220        let mut bucket = Entry::for_test(std::path::Path::new("gs://pitscope"), "pitscope");
6221        bucket.kind = EntryKind::Directory;
6222        assert_eq!(home.cloud_look(&bucket), None);
6223        let mut local = Entry::for_test(std::path::Path::new("/data/seasons"), "seasons");
6224        local.kind = EntryKind::Directory;
6225        assert_eq!(home.cloud_look(&local), None);
6226    }
6227
6228    #[test]
6229    fn a_peeks_answer_replaces_the_count_a_row_had() {
6230        let root = std::path::PathBuf::from("s3://bucket/warehouse");
6231        let path = root.join("orders");
6232        let mut row = Entry::for_test(&path, "orders");
6233        row.kind = EntryKind::Directory;
6234        row.holds = counted(15);
6235
6236        let mut home = HomeState::default();
6237        home.probe_ready(root.clone(), vec![row], false);
6238        home.cloud_kinds
6239            .insert(path, (EntryKind::MultiFile, counted(40)));
6240        home.apply_cloud_kinds(&root);
6241
6242        assert_eq!(
6243            home.probes.listed(&root).unwrap()[0].holds.label(),
6244            "40 parquet"
6245        );
6246        assert_eq!(
6247            home.probes.listed(&root).unwrap()[0].kind,
6248            EntryKind::MultiFile
6249        );
6250    }
6251
6252    #[test]
6253    fn a_peek_answers_only_the_rows_that_asked() {
6254        let root = std::path::PathBuf::from("s3://bucket/warehouse");
6255        // A row the listing already settled. Its path is in `cloud_kinds` — a peek was
6256        // answered for it once — and it must not be read back over the top of a kind
6257        // the listing was surer of.
6258        let settled = root.join("sales");
6259        let mut row = Entry::for_test(&settled, "sales");
6260        row.kind = EntryKind::Hive;
6261        row.holds = counted(40);
6262
6263        let mut home = HomeState::default();
6264        home.probe_ready(root.clone(), vec![row], false);
6265        home.cloud_kinds
6266            .insert(settled, (EntryKind::Directory, counted(1)));
6267        home.apply_cloud_kinds(&root);
6268
6269        assert_eq!(home.probes.listed(&root).unwrap()[0].kind, EntryKind::Hive);
6270        assert_eq!(
6271            home.probes.listed(&root).unwrap()[0].holds.label(),
6272            "40 parquet"
6273        );
6274    }
6275
6276    /// A peek is a request, so it is spent on the row the cursor is on.
6277    ///
6278    /// The picker took the first forty-eight directories of each listing, once per
6279    /// session: a bucket of two hundred prefixes had forty-eight labelled and the rest
6280    /// reading `dir` however long you spent on them, and paging straight past those
6281    /// forty-eight spent every request on rows nobody saw.
6282    #[test]
6283    fn a_peek_goes_to_the_row_the_cursor_is_on_and_is_never_asked_twice() {
6284        let root = std::path::PathBuf::from("s3://bucket/warehouse");
6285        let mut home = HomeState {
6286            network_check: |_| true,
6287            ..Default::default()
6288        };
6289        let rows: Vec<Entry> = ["a", "b", "c", "d", "e"]
6290            .iter()
6291            .map(|n| {
6292                let mut row = Entry::for_test(&root.join(n), n);
6293                row.kind = EntryKind::Directory;
6294                row
6295            })
6296            .collect();
6297        home.probe_ready(root.clone(), rows, false);
6298        home.browsing = Some(root.clone());
6299        home.view_height = 10;
6300        home.rebuild(&[]);
6301        // One already answered, and one with a request already out.
6302        home.cloud_kinds
6303            .insert(root.join("b"), (EntryKind::MultiFile, counted(3)));
6304        home.peeking.insert(root.join("c"));
6305
6306        // The cursor on `e`, the last row: it is asked about first, though four rows
6307        // above it have never been looked into. That is the whole change.
6308        home.selected = home
6309            .visible()
6310            .iter()
6311            .position(|r| matches!(r, Row::Entry { entry, .. } if entry.name == "e"))
6312            .expect("the row is listed");
6313        let asked = home.cloud_directories_to_peek(3);
6314        assert_eq!(
6315            asked.first(),
6316            Some(&root.join("e")),
6317            "the highlighted row is the one about to be acted on"
6318        );
6319        assert_eq!(asked.len(), 3, "the budget is a budget");
6320        assert!(
6321            !asked.contains(&root.join("b")),
6322            "a directory already looked into is not asked again"
6323        );
6324        assert!(
6325            !asked.contains(&root.join("c")),
6326            "nor one with a request already out"
6327        );
6328    }
6329
6330    #[test]
6331    fn a_measurement_that_counted_nothing_keeps_the_count_a_row_already_has() {
6332        let path = std::path::PathBuf::from("/data/warehouse/orders");
6333        let mut row = Entry::for_test(&path, "orders");
6334        row.kind = EntryKind::Directory;
6335        row.holds = counted(15);
6336
6337        let mut home = HomeState::default();
6338        home.sections.push(Section::titled("Here", vec![row]));
6339        // A measurement of a file carries no `holds`, and the same struct measures both.
6340        home.enriched.insert(
6341            path,
6342            Measured {
6343                kind: Some(EntryKind::Directory),
6344                ..Default::default()
6345            },
6346        );
6347        home.apply_measurements();
6348
6349        assert_eq!(
6350            home.sections[0].rows[0].holds.label(),
6351            "15 parquet",
6352            "a measurement with nothing to say erased the label"
6353        );
6354    }
6355}
6356
6357#[cfg(test)]
6358mod look_into_batch_tests {
6359    use super::*;
6360    use polars::prelude::*;
6361
6362    /// Every row in a batch is labeled before any is measured: a kind is one directory
6363    /// read, and a count can be sixty-four footers.
6364    #[test]
6365    fn every_kind_is_sent_before_any_count() {
6366        let dir = tempfile::tempdir().unwrap();
6367        let cache_dir = tempfile::tempdir().unwrap();
6368        let cache = crate::cache::CacheManager::with_dir(cache_dir.path().to_path_buf());
6369        let mut rows = Vec::new();
6370        for name in ["a", "b"] {
6371            let partition = dir.path().join(name).join("year=2024");
6372            std::fs::create_dir_all(&partition).unwrap();
6373            let mut frame = df!("x" => [1i32, 2, 3]).unwrap();
6374            let file = std::fs::File::create(partition.join("part.parquet")).unwrap();
6375            ParquetWriter::new(file).finish(&mut frame).unwrap();
6376            rows.push(Entry::new(dir.path().join(name), EntryKind::Unknown));
6377        }
6378
6379        let mut sent = Vec::new();
6380        look_into_batch(
6381            rows,
6382            &cache,
6383            &Default::default(),
6384            Reads::Files,
6385            |path, m| {
6386                let name = path.file_name().unwrap().to_string_lossy().into_owned();
6387                sent.push((name, m.kind, m.rows));
6388            },
6389        );
6390
6391        let hive = Some(EntryKind::Hive);
6392        assert_eq!(
6393            sent,
6394            vec![
6395                ("a".to_string(), hive, None),
6396                ("b".to_string(), hive, None),
6397                ("a".to_string(), hive, Some(3)),
6398                ("b".to_string(), hive, Some(3)),
6399            ]
6400        );
6401    }
6402}
6403
6404#[cfg(test)]
6405mod known_facts_tests {
6406    use super::*;
6407    use crate::cache::DatasetFacts;
6408
6409    /// What a directory holds comes back with its kind, on both routes.
6410    ///
6411    /// A row given a kind from the cache is never looked into again — `look_into_as` only
6412    /// classifies an `Unknown`, and `unclassified_visible` skips anything else. So a
6413    /// count left behind is left behind for the session: the row says `dir` about a
6414    /// directory of fifteen Parquet files, and `enrich` goes on to describe it by
6415    /// whatever is in its subdirectories.
6416    #[test]
6417    fn what_a_directory_holds_is_restored_beside_its_kind() {
6418        let holds = crate::home::discover::Holds {
6419            formats: vec![("parquet".to_string(), 15)],
6420            ..Default::default()
6421        };
6422        for (path, remote) in [
6423            (
6424                std::path::PathBuf::from("s3://bucket/warehouse/orders"),
6425                true,
6426            ),
6427            (std::path::PathBuf::from("/data/warehouse/orders"), false),
6428        ] {
6429            let facts = DatasetFacts {
6430                mtime: 0,
6431                size: 4096,
6432                // A row count a directory's record has no business carrying, to prove
6433                // the gate below still turns it away.
6434                rows: Some(999),
6435                cols: Some(72),
6436                cols_sampled: false,
6437                columns: vec!["lat".to_string()],
6438                // A directory of separate tables: the kind the footers settled on, its
6439                // width, and no row count, because a sum over them is not a number.
6440                kind: Some(EntryKind::Directory),
6441                classified_by: crate::home::discover::CLASSIFIER_VERSION,
6442                holds: holds.clone(),
6443                cost: Default::default(),
6444            };
6445            let mut row = Entry::directory(&path);
6446            row.kind = EntryKind::Unknown;
6447            row.modified = Some(std::time::UNIX_EPOCH);
6448            // No size, which is what a listing gives a directory — and what makes the
6449            // byte fingerprint below unable to speak for one.
6450            assert_eq!(row.size, None);
6451            let index = std::collections::HashMap::from([(path.clone(), facts)]);
6452
6453            apply_known_facts(&mut row, &index, remote);
6454            assert_eq!(row.kind, EntryKind::Directory, "{path:?}");
6455            assert_eq!(row.label(), "15 parquet", "{path:?}");
6456            // And nothing the footers said. A directory's mtime moves when an entry
6457            // is added, removed or renamed; a file rewritten in place moves nothing,
6458            // and the width, the size and the column names all change with it. Only
6459            // locally — a remote row is never measured here at all, so the record is
6460            // all it will ever have and it takes the whole of it.
6461            if !remote {
6462                assert_eq!(row.rows, None, "{path:?}");
6463                assert_eq!(row.cols, None, "{path:?}");
6464                assert_eq!(row.size, None, "{path:?}");
6465                assert!(row.columns.is_empty(), "{path:?}");
6466            }
6467        }
6468    }
6469
6470    /// A dataset's own counts are not restored beside its kind. `unmeasured_visible`
6471    /// skips a row that already has a row count, so restoring one would stop a `hive`
6472    /// or a `multi` directory ever being measured again — and its size and its codec,
6473    /// which nothing else fills in, would be blank for the rest of the session.
6474    #[test]
6475    fn a_datasets_counts_are_measured_rather_than_restored() {
6476        let path = std::path::PathBuf::from("/data/warehouse/events");
6477        let facts = DatasetFacts {
6478            mtime: 0,
6479            size: 4096,
6480            rows: Some(1_200_000),
6481            cols: Some(58),
6482            cols_sampled: false,
6483            columns: vec!["ts".to_string()],
6484            kind: Some(EntryKind::MultiFile),
6485            classified_by: crate::home::discover::CLASSIFIER_VERSION,
6486            holds: crate::home::discover::Holds {
6487                formats: vec![("parquet".to_string(), 15)],
6488                ..Default::default()
6489            },
6490            cost: Default::default(),
6491        };
6492        let mut row = Entry::directory(&path);
6493        row.kind = EntryKind::Unknown;
6494        row.modified = Some(std::time::UNIX_EPOCH);
6495        let index = std::collections::HashMap::from([(path.clone(), facts)]);
6496
6497        apply_known_facts(&mut row, &index, false);
6498        assert_eq!(row.kind, EntryKind::MultiFile, "the kind comes back");
6499        assert_eq!(row.label(), "15 parquet", "and what it holds");
6500        assert_eq!(
6501            row.rows, None,
6502            "but not the count: the measuring pass skips a row that has one"
6503        );
6504        assert_eq!(row.cols, None);
6505    }
6506
6507    /// A kind recorded by a build that classified differently is not restored.
6508    ///
6509    /// A remote row was never stat'ed, so its cached kind is all it has and is restored
6510    /// rather than re-derived. That makes it a way for an answer this build would not
6511    /// give to come back: a Delta root measured before lake tables were recognized was
6512    /// recorded as `multifile`, and restoring that opens it as one table again — #237
6513    /// read back off disk. Everything else in the record is a measurement rather than a
6514    /// judgement, and survives.
6515    #[test]
6516    fn a_kind_from_an_older_classifier_is_not_restored() {
6517        let remote = std::path::PathBuf::from("s3://bucket/warehouse/orders");
6518        let facts = |classified_by| DatasetFacts {
6519            mtime: 0,
6520            size: 4096,
6521            rows: Some(1_000),
6522            cols: Some(7),
6523            cols_sampled: false,
6524            columns: vec!["id".into(), "amount".into()],
6525            kind: Some(EntryKind::MultiFile),
6526            classified_by,
6527            cost: Default::default(),
6528            holds: Default::default(),
6529        };
6530        let unprobed = || {
6531            let mut row = Entry::directory(&remote);
6532            row.kind = EntryKind::Unknown;
6533            row
6534        };
6535
6536        let index = |classified_by| {
6537            std::collections::HashMap::from([(remote.clone(), facts(classified_by))])
6538        };
6539
6540        let mut row = unprobed();
6541        apply_known_facts(
6542            &mut row,
6543            &index(crate::home::discover::CLASSIFIER_VERSION),
6544            true,
6545        );
6546        assert_eq!(
6547            row.kind,
6548            EntryKind::MultiFile,
6549            "this build's own answer comes back"
6550        );
6551
6552        let mut row = unprobed();
6553        apply_known_facts(&mut row, &index(0), true);
6554        assert_eq!(
6555            row.kind,
6556            EntryKind::Unknown,
6557            "an older build's does not: it may be a lake table this one would recognize"
6558        );
6559        assert_eq!(
6560            row.rows,
6561            Some(1_000),
6562            "but what it measured is still measured"
6563        );
6564        assert_eq!(row.columns, vec!["id".to_string(), "amount".to_string()]);
6565    }
6566}
6567
6568#[cfg(test)]
6569mod build_feature_tests {
6570    use super::*;
6571
6572    /// The built-in catalog lists only what this build can open; with neither `cloud`
6573    /// nor `http` the section is gone rather than a list of failures.
6574    #[test]
6575    fn the_builtin_catalog_lists_only_what_this_build_opens() {
6576        let urls: Vec<String> = catalogs(&crate::config::AppConfig::default())
6577            .into_iter()
6578            .filter(|c| c.origin == crate::home::catalog::Origin::Bundled)
6579            .flat_map(|c| c.datasets)
6580            .map(|d| d.location.to_string_lossy().into_owned())
6581            .collect();
6582        let web = urls.iter().filter(|u| u.starts_with("https://")).count();
6583        let stores = urls
6584            .iter()
6585            .filter(|u| is_object_store_url(Path::new(u)))
6586            .count();
6587        assert_eq!(web + stores, urls.len(), "{urls:?}");
6588        assert_eq!(web > 0, cfg!(feature = "http"), "{urls:?}");
6589        assert_eq!(stores > 0, cfg!(feature = "cloud"), "{urls:?}");
6590    }
6591
6592    /// An empty `examples.toml` of the user's replaces the Example datasets with
6593    /// nothing, and an empty catalog has no section: the section is gone.
6594    #[test]
6595    fn an_empty_examples_toml_hides_the_section() {
6596        let mut config = crate::config::AppConfig::default();
6597        // The examples are all HTTP or S3: a build that reads neither has none.
6598        assert_eq!(
6599            catalogs(&config)
6600                .iter()
6601                .any(|c| c.origin == crate::home::catalog::Origin::Bundled),
6602            cfg!(any(feature = "http", feature = "cloud"))
6603        );
6604        config.read_catalogs = vec![
6605            crate::home::catalog::parse(
6606                "label = \"Mine\"\n",
6607                crate::home::catalog::EXAMPLES,
6608                crate::home::catalog::Origin::Folder,
6609                None,
6610            )
6611            .unwrap(),
6612        ];
6613        assert!(catalogs(&config).is_empty(), "{:?}", catalogs(&config));
6614    }
6615
6616    /// A catalog of the user's stays whole whatever the build: the user named it, and
6617    /// opening a dataset it cannot read says why.
6618    #[test]
6619    fn a_users_catalog_is_shown_whole() {
6620        let mut config = crate::config::AppConfig::default();
6621        let mine = crate::home::catalog::parse(
6622            r#"
6623            [bucket]
6624            name = "Bucket"
6625            url = "s3://bucket/prefix/"
6626            [web]
6627            name = "Web"
6628            url = "https://example.com/data.csv"
6629            "#,
6630            crate::home::catalog::MINE,
6631            crate::home::catalog::Origin::Mine,
6632            None,
6633        )
6634        .unwrap();
6635        config.read_catalogs = vec![mine];
6636        let shown = catalogs(&config);
6637        let mine = shown.iter().find(|c| c.id == "mine").unwrap();
6638        assert_eq!(mine.datasets.len(), 2);
6639        assert_eq!(mine.label, crate::home::catalog::MINE_LABEL);
6640    }
6641}
6642
6643#[cfg(test)]
6644mod place_tests {
6645    use super::same_place;
6646    use std::path::Path;
6647
6648    #[test]
6649    fn local_paths_are_one_place_however_spelled() {
6650        assert!(same_place(
6651            Path::new("/data/./sales/"),
6652            Path::new("/data/sales")
6653        ));
6654        assert!(!same_place(
6655            Path::new("/data/sales"),
6656            Path::new("/data/sale")
6657        ));
6658        assert!(same_place(
6659            Path::new("s3://bucket/dir/"),
6660            Path::new("s3://bucket/dir")
6661        ));
6662        if cfg!(windows) {
6663            assert!(same_place(
6664                Path::new("c:/data/sales.csv"),
6665                Path::new(r"C:\data\sales.csv")
6666            ));
6667        }
6668    }
6669}