Skip to main content

datui_lib/home/
mod.rs

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