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