Skip to main content

datui_lib/
home.rs

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