Skip to main content

datui_lib/home/
mod.rs

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