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