Skip to main content

datui_lib/home/
mod.rs

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