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(§ion.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 §ion.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 §ion.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, §ion.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}