1pub mod catalog;
15pub mod codebook;
16pub mod discover;
17pub mod fuzzy;
18pub(crate) mod home_app;
19pub(crate) mod home_keys;
20pub mod home_preview;
21pub mod locality;
22pub mod search;
23
24use crate::home::discover::{Entry, EntryKind};
25use std::path::{Path, PathBuf};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum RootOrigin {
30 Cwd,
31 Desktop,
33}
34
35impl RootOrigin {
36 pub fn note(self) -> &'static str {
37 match self {
38 RootOrigin::Cwd => "current directory",
39 RootOrigin::Desktop => "opened elsewhere",
40 }
41 }
42}
43
44pub fn desktop_recent_dirs() -> Vec<PathBuf> {
49 let Some(data_dir) = dirs::data_dir() else {
50 return Vec::new();
51 };
52 let path = data_dir.join("recently-used.xbel");
53 let Ok(contents) = std::fs::read_to_string(&path) else {
54 return Vec::new();
55 };
56 dirs_from_xbel(&contents)
57}
58
59pub fn dirs_from_xbel(contents: &str) -> Vec<PathBuf> {
62 const PREFIX: &str = "href=\"file://";
63 let mut dirs: Vec<PathBuf> = Vec::new();
64
65 for chunk in contents.split(PREFIX).skip(1) {
66 let Some(end) = chunk.find('"') else { continue };
67 let decoded = percent_decode(&chunk[..end]);
68 let file = PathBuf::from(decoded);
69 if !crate::home::discover::is_data_file(&file) || !file.is_file() {
71 continue;
72 }
73 let Some(parent) = file.parent() else {
74 continue;
75 };
76 if parent.as_os_str().is_empty() {
77 continue;
78 }
79 let parent = parent.to_path_buf();
80 if !dirs.contains(&parent) {
81 dirs.push(parent);
82 }
83 }
84
85 dirs
86}
87
88fn percent_decode(raw: &str) -> String {
90 let bytes = raw.as_bytes();
91 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
92 let mut i = 0;
93 while i < bytes.len() {
94 if bytes[i] == b'%' && i + 2 < bytes.len() {
95 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
96 if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
97 out.push(byte);
98 i += 3;
99 continue;
100 }
101 }
102 out.push(bytes[i]);
103 i += 1;
104 }
105 String::from_utf8_lossy(&out).into_owned()
106}
107
108pub fn object_place_label(path: &Path) -> Option<&'static str> {
112 if cloud_source_id(path).is_some() {
113 return Some("source");
114 }
115 if cloud_account(path).is_some() {
116 return Some("account");
117 }
118 let text = path.to_string_lossy();
119 if let Some((_, _, key)) = crate::cloud::source::azure_parts(&text) {
120 return key.trim_matches('/').is_empty().then_some("container");
121 }
122 let (scheme, rest) = text.split_once("://")?;
123 if !matches!(scheme, "s3" | "s3a" | "gs" | "gcs") {
124 return None;
125 }
126 let rest = rest.trim_end_matches('/');
127 if rest.is_empty() {
128 return None;
129 }
130 (!rest.contains('/')).then_some("bucket")
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum CloudLook {
136 Waiting,
138 Looking,
140 Failed,
142}
143
144#[derive(Debug, Default, PartialEq)]
146pub struct RowLabel {
147 pub short: String,
149 pub words: String,
151 pub curated: bool,
153 pub source: bool,
155 pub missing_source: bool,
157}
158
159pub fn describe(
163 entry: &Entry,
164 place_kind: Option<&'static str>,
165 look: Option<CloudLook>,
166 frame: usize,
167 known_sources: Option<&[crate::config::CloudConnectionConfig]>,
168) -> RowLabel {
169 if entry.opens_whole_directory {
172 return RowLabel::default();
173 }
174 let g = crate::glyphs::get();
175 let curated =
178 place_kind.filter(|_| matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown));
179 let look_glyph = look.map(|look| match look {
180 CloudLook::Waiting => g.ellipsis,
181 CloudLook::Looking => g.spinner[frame % g.spinner.len()],
182 CloudLook::Failed => "?",
183 });
184 let short = match curated {
185 Some(word) => word.to_string(),
186 None if entry.kind == EntryKind::Directory && entry.holds.formats.is_empty() => {
188 object_place_label(&entry.path)
189 .or(look_glyph)
190 .map(str::to_string)
191 .unwrap_or_else(|| entry.label().into_owned())
192 }
193 None => entry.label().into_owned(),
194 };
195 let path_text = entry.path.to_string_lossy();
198 let named = crate::cloud::source::split_source_id(&path_text).0;
199 let missing_source = named
200 .is_some_and(|id| known_sources.is_some_and(|known| !known.iter().any(|k| k.name == id)));
201 let (short, source) = match (named, known_sources) {
202 (Some(id), Some(_)) if short.is_empty() && missing_source => {
203 (format!("source not found: {id}"), true)
204 }
205 (Some(id), Some(_)) if short.is_empty() => (id.to_string(), true),
206 _ if short.is_empty() && entry.kind == EntryKind::Unknown => {
208 (g.ellipsis.to_string(), false)
209 }
210 _ => (short, false),
211 };
212 let words = match (&entry.table, entry.kind) {
213 (Some(table), _) => {
214 let of = match (&entry.format_spec, table.format) {
215 (Some(spec), _) => spec.clone(),
216 (None, Some(format)) => format.name().to_string(),
217 (None, None) => String::new(),
218 };
219 format!("{of} {}", table.kind).trim_start().to_string()
220 }
221 (None, _) if curated.is_some() => curated.unwrap_or_default().to_string(),
222 (None, EntryKind::File) => match (
223 &entry.format_spec,
224 crate::FileFormat::from_path(&entry.path),
225 ) {
226 (Some(spec), _) => format!("{spec} file"),
227 (None, Some(format)) => format!("{} file", format.name()),
228 (None, None) => "data file".to_string(),
230 },
231 (None, EntryKind::Hive) => "hive table".to_string(),
232 (None, EntryKind::MultiFile) => "multi-file table".to_string(),
233 (None, kind) if kind.is_lake_table() => {
234 format!(
235 "{} table",
236 kind.lake_name().unwrap_or_default().to_lowercase()
237 )
238 }
239 (None, EntryKind::Directory) => match look {
240 Some(CloudLook::Failed) => format!("? {} listing failed, Ctrl+R retries", g.middot),
241 Some(_) => String::new(),
243 None => object_place_label(&entry.path)
244 .unwrap_or("directory")
245 .to_string(),
246 },
247 _ => String::new(),
248 };
249 RowLabel {
250 short,
251 words,
252 curated: curated.is_some(),
253 source,
254 missing_source,
255 }
256}
257
258pub const CLOUD_PLACE: &str = "cloud://";
261
262const SCROLL_MARGIN: usize = 2;
264
265pub(crate) fn settle_top(top: usize, selected: usize, height: usize, total: usize) -> usize {
269 if height == 0 {
270 return top.min(selected);
271 }
272 let margin = SCROLL_MARGIN.min((height - 1) / 2);
274 let top = if selected < top + margin {
275 selected.saturating_sub(margin)
276 } else if selected + margin >= top + height {
277 selected + margin + 1 - height
278 } else {
279 top
280 };
281 top.min(total.saturating_sub(height))
282}
283
284pub fn cloud_place(id: &str) -> PathBuf {
286 PathBuf::from(format!("{CLOUD_PLACE}{id}"))
287}
288
289pub fn cloud_source_id(path: &Path) -> Option<String> {
291 let text = path.to_string_lossy();
292 let id = text.strip_prefix(CLOUD_PLACE)?.trim_end_matches('/');
293 (!id.is_empty() && !id.contains('/')).then(|| id.to_string())
294}
295
296pub fn cloud_account(path: &Path) -> Option<(String, String)> {
299 let text = path.to_string_lossy();
300 let rest = text.strip_prefix(CLOUD_PLACE)?.trim_end_matches('/');
301 let (id, account) = rest.split_once('/')?;
302 (!id.is_empty() && !account.is_empty() && !account.contains('/'))
303 .then(|| (id.to_string(), account.to_string()))
304}
305
306fn within(url: &str, root: &str) -> bool {
308 #[cfg(feature = "cloud")]
309 {
310 crate::cloud::cloud_sources::is_within(url, root)
311 }
312 #[cfg(not(feature = "cloud"))]
313 {
314 let (url, root) = (url.trim_end_matches('/'), root.trim_end_matches('/'));
315 url == root || url.strip_prefix(root).is_some_and(|r| r.starts_with('/'))
316 }
317}
318
319fn same_place(a: &Path, b: &Path) -> bool {
322 place_key(a) == place_key(b)
323}
324
325fn place_key(path: &Path) -> String {
327 let text = path.to_string_lossy();
328 if !text.contains("://") {
331 return crate::config::path_place(path).display().to_string();
332 }
333 #[cfg(feature = "cloud")]
334 {
335 crate::cloud::source::canonical_cloud_place(&text)
336 }
337 #[cfg(not(feature = "cloud"))]
338 {
339 text.trim_end_matches('/').to_string()
340 }
341}
342
343#[derive(Debug, Default)]
345pub struct CatalogPlaces {
346 datasets: std::collections::HashMap<String, (usize, usize)>,
348 bookmarks: std::collections::HashMap<String, (usize, usize, usize)>,
350 counted: (usize, usize),
353}
354
355impl CatalogPlaces {
356 fn of(catalogs: &[ShownCatalog]) -> Self {
357 let mut places = CatalogPlaces {
358 counted: Self::count(catalogs),
359 ..Default::default()
360 };
361 for (c, catalog) in catalogs.iter().enumerate() {
362 for (d, dataset) in catalog.datasets.iter().enumerate() {
363 places
364 .datasets
365 .entry(place_key(&dataset.location))
366 .or_insert((c, d));
367 for (b, (_, place)) in dataset.bookmarks.iter().enumerate() {
368 places
369 .bookmarks
370 .entry(place_key(place))
371 .or_insert((c, d, b));
372 }
373 }
374 }
375 places
376 }
377
378 fn count(catalogs: &[ShownCatalog]) -> (usize, usize) {
379 let datasets = catalogs.iter().flat_map(|c| c.datasets.iter());
380 (
381 datasets.clone().count(),
382 datasets.map(|d| d.bookmarks.len()).sum(),
383 )
384 }
385
386 fn indexes(&self, catalogs: &[ShownCatalog]) -> bool {
388 self.counted == Self::count(catalogs)
389 }
390}
391
392fn within_rest(url: &str, root: &str) -> String {
394 let canonical = |u: &str| match crate::cloud::source::azure_parts(u) {
395 Some((account, container, path)) => {
396 crate::cloud::source::azure_url(&account, &container, &path)
397 }
398 None => u.to_string(),
399 };
400 let (url, root) = (canonical(url), canonical(root));
401 url.strip_prefix(root.trim_end_matches('/'))
402 .unwrap_or("")
403 .to_string()
404}
405
406pub fn is_object_store_url(path: &Path) -> bool {
408 let text = path.to_string_lossy();
409 let scheme = text
410 .split_once("://")
411 .map(|(s, _)| s.to_ascii_lowercase())
412 .unwrap_or_default();
413 matches!(scheme.as_str(), "s3" | "s3a" | "gs" | "gcs")
414 || crate::cloud::source::azure_parts(&text).is_some()
415}
416
417pub fn directory_dataset_url(path: &Path) -> PathBuf {
420 let text = path.to_string_lossy();
421 if text.ends_with('/') {
422 path.to_path_buf()
423 } else {
424 PathBuf::from(format!("{text}/"))
425 }
426}
427
428fn whole_directory_row(dir: &Path, rows: &[Entry], remote: bool) -> Option<Entry> {
432 if cloud_account(dir).is_some() {
435 return None;
436 }
437 let directories: Vec<String> = rows
438 .iter()
439 .filter(|r| !matches!(r.kind, EntryKind::File | EntryKind::Other))
440 .map(|r| format!("{}/", r.name))
441 .collect();
442 let objects: Vec<(String, u64)> = rows
443 .iter()
444 .filter(|r| matches!(r.kind, EntryKind::File | EntryKind::Other))
445 .map(|r| (r.path.to_string_lossy().into_owned(), r.size.unwrap_or(1)))
446 .collect();
447 let (kind, holds) = if remote || is_object_store_url(dir) {
452 #[cfg(feature = "cloud")]
453 {
454 crate::cloud::cloud_browse::look_at_listing(
455 &dir.to_string_lossy(),
456 &directories,
457 &objects,
458 )
459 }
460 #[cfg(not(feature = "cloud"))]
464 {
465 let _ = (&directories, &objects);
466 (EntryKind::Unknown, Default::default())
467 }
468 } else {
469 crate::home::discover::look_at_directory(dir)
470 };
471 let openable_row = rows.iter().any(|r| r.kind != EntryKind::Other);
476 if !openable_row && holds_nothing_to_open(&holds) {
477 return None;
478 }
479 let mut entry = Entry::directory(&directory_dataset_url(dir));
480 entry.kind = kind;
481 entry.holds = holds;
484 entry.opens_whole_directory = true;
485 entry.name = door_name(&entry, rows);
486 Some(entry)
487}
488
489fn door_base_name(dir: &Path) -> String {
493 let text = dir.to_string_lossy();
494 if let Some((_, container, key)) = crate::cloud::source::azure_parts(&text) {
495 let leaf = key.trim_matches('/').rsplit('/').next().unwrap_or("");
496 if leaf.is_empty() {
497 container
498 } else {
499 leaf.to_string()
500 }
501 } else {
502 let (_, plain) = crate::cloud::source::split_source_id(&text);
503 std::path::Path::new(plain.as_ref())
504 .file_name()
505 .map(|n| n.to_string_lossy().into_owned())
506 .unwrap_or_else(|| plain.into_owned())
507 }
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub enum DoorKind {
513 Hive,
515 OneSchema,
517 SchemasDiffer,
519 Single,
521 Lake,
523 Mixed,
525 Unknown,
527}
528
529pub fn door_kind(door: &Entry) -> DoorKind {
533 let holds = &door.holds;
534 match door.kind {
535 EntryKind::Hive => DoorKind::Hive,
536 EntryKind::MultiFile => DoorKind::OneSchema,
537 k if k.is_lake_table() => DoorKind::Lake,
538 EntryKind::Unknown => DoorKind::Unknown,
539 _ => {
540 let Some(format) = holds.one_format() else {
541 return DoorKind::Mixed;
542 };
543 if holds.directories > 0 {
544 return DoorKind::Mixed;
545 }
546 let files = holds.data_files();
547 if files == 1 {
548 return DoorKind::Single;
549 }
550 let mostly_data = files * 2 >= files + holds.not_read + holds.unnamed;
551 let reads_many = crate::FileFormat::from_name(format)
552 .is_some_and(crate::FileFormat::reads_many_files);
553 if mostly_data && reads_many {
554 DoorKind::SchemasDiffer
555 } else {
556 DoorKind::Mixed
557 }
558 }
559 }
560}
561
562pub fn door_lands(door: &Entry) -> bool {
566 matches!(door_kind(door), DoorKind::Hive | DoorKind::OneSchema)
567}
568
569fn format_title(name: &str) -> String {
571 crate::FileFormat::from_name(name)
572 .map_or_else(|| name.to_ascii_uppercase(), |f| f.title().to_string())
573}
574
575fn door_keys(door: &Entry, rows: &[Entry]) -> Vec<String> {
578 if let Some(layout) = door.cost.partitions.as_ref()
579 && !layout.keys.is_empty()
580 {
581 return layout.keys.clone();
582 }
583 let mut keys: Vec<String> = Vec::new();
584 for row in rows {
585 if let Some((key, _)) = row.name.split_once('=')
586 && !key.is_empty()
587 && !keys.iter().any(|k| k == key)
588 {
589 keys.push(key.to_string());
590 }
591 }
592 keys
593}
594
595pub fn door_name(door: &Entry, rows: &[Entry]) -> String {
597 let name = door_base_name(&door.path);
598 let holds = &door.holds;
599 let more = if holds.truncated { "+" } else { "" };
600 let files = |format: &str| {
601 let count = holds.data_files();
602 let word = if count == 1 { "file" } else { "files" };
603 format!("{count}{more} {} {word}", format_title(format))
604 };
605 let what = match door_kind(door) {
606 DoorKind::Hive => {
607 let keys = door_keys(door, rows);
608 if keys.is_empty() {
609 "hive table".to_string()
610 } else {
611 format!("hive table: {}", keys.join(", "))
612 }
613 }
614 DoorKind::OneSchema => match (holds.model_weights(), holds.one_format()) {
615 (Some((format, count)), _) => {
616 let word = if count == 1 { "file" } else { "files" };
617 format!("model, {count}{more} {} {word}", format_title(format))
618 }
619 (None, Some(format)) => format!("{}, one schema", files(format)),
620 (None, None) => "one table".to_string(),
621 },
622 DoorKind::SchemasDiffer => {
623 format!(
624 "{}, schemas differ",
625 files(holds.one_format().unwrap_or(""))
626 )
627 }
628 DoorKind::Single => files(holds.one_format().unwrap_or("")),
629 DoorKind::Lake => format!(
630 "{} files, not the table",
631 door.kind.lake_name().unwrap_or_default()
632 ),
633 DoorKind::Mixed => "all files, mixed".to_string(),
634 DoorKind::Unknown => "all files".to_string(),
635 };
636 format!("{name} ({what})")
637}
638
639pub fn door_reads(door: &Entry) -> Option<(String, Option<String>)> {
644 if !matches!(door_kind(door), DoorKind::Mixed | DoorKind::Single) {
645 return None;
646 }
647 let holds = &door.holds;
648 let more = if holds.truncated { "+" } else { "" };
649 let plain = holds.directories.saturating_sub(holds.partitions);
650 let directories = |n: usize| {
651 let word = if n == 1 { "directory" } else { "directories" };
652 format!("{n}{more} {word}")
653 };
654 let Some((format, count)) = holds.formats.first() else {
655 return (holds.directories > 0).then(|| ("every Parquet file below".to_string(), None));
656 };
657 let remote = is_object_store_url(&door.path);
659 let below = remote || holds.partitions > 0 || (holds.formats.len() == 1 && format == "parquet");
660 let below = below && holds.directories > 0;
661 let reads = if below && remote {
662 format!("every {format} file below")
663 } else if below {
664 "every Parquet file below".to_string()
665 } else {
666 format!("{count}{more} {format}")
667 };
668 let mut skips: Vec<String> = holds
669 .formats
670 .iter()
671 .skip(1)
672 .map(|(name, n)| format!("{n}{more} {name}"))
673 .collect();
674 if !below && plain > 0 {
675 skips.push(directories(plain));
676 }
677 Some((reads, (!skips.is_empty()).then(|| skips.join(", "))))
678}
679
680pub fn name_by_spec(formats: &crate::formats::Registry, rows: &mut [Entry]) {
683 if formats.is_empty() {
684 return;
685 }
686 let mut named = false;
687 for row in rows
688 .iter_mut()
689 .filter(|r| r.kind == EntryKind::Other && r.format_spec.is_none())
690 {
691 if let Some(spec) = formats.by_glob(&row.path, false).first() {
692 discover::name_spec_file(row, spec);
693 named = true;
694 }
695 }
696 for row in rows.iter_mut().filter(|r| {
698 r.kind == EntryKind::File
699 && r.format_spec.is_none()
700 && discover::data_format(&r.path).is_some_and(|f| f.separator().is_some())
701 }) {
702 if let Some(spec) = formats
703 .by_glob(&row.path, false)
704 .into_iter()
705 .find(|s| s.is_delimited())
706 {
707 row.format_spec = Some(spec.name.clone());
708 }
709 }
710 if named {
712 discover::sort_entries(rows);
713 }
714}
715
716pub fn holds_nothing_to_open(holds: &discover::Holds) -> bool {
719 holds.formats.is_empty() && holds.directories == 0 && holds.unnamed == 0
720}
721
722pub fn is_cloud_place(path: &Path) -> bool {
724 path.to_string_lossy().starts_with(CLOUD_PLACE)
725}
726
727fn is_bucket_root(path: &Path) -> bool {
730 let text = path.to_string_lossy();
731 if let Some((_, _, key)) = crate::cloud::source::azure_parts(&text) {
732 return key.trim_matches('/').is_empty();
733 }
734 let Some((scheme, rest)) = text.split_once("://") else {
735 return false;
736 };
737 matches!(scheme, "s3" | "s3a" | "gs" | "gcs") && {
738 let rest = rest.trim_end_matches('/');
739 !rest.is_empty() && !rest.contains('/')
740 }
741}
742
743pub fn parent_location(path: &Path) -> Option<PathBuf> {
746 if !matches!(
747 crate::cloud::source::input_source(path),
748 crate::cloud::source::InputSource::Local(_)
749 ) {
750 let s = path.to_string_lossy();
751 let (scheme, rest) = s.split_once("://")?;
752 let (up, _) = rest.trim_end_matches('/').rsplit_once('/')?;
753 return Some(PathBuf::from(format!("{scheme}://{up}")));
754 }
755 path.parent()
756 .filter(|p| !p.as_os_str().is_empty() && *p != path)
757 .map(Path::to_path_buf)
758}
759
760pub fn is_remote_path(path: &Path) -> bool {
764 is_cloud_place(path)
765 || !matches!(
766 crate::cloud::source::input_source(path),
767 crate::cloud::source::InputSource::Local(_)
768 )
769 || is_network_path(path)
770}
771
772pub fn is_network_path(path: &Path) -> bool {
776 crate::home::locality::Mounts::cached().is_network(path)
777}
778
779pub fn could_block_path(path: &Path) -> bool {
783 crate::home::locality::Mounts::cached().could_block(path)
784}
785
786#[doc(hidden)]
789pub fn network_fs_for_test(mountinfo: &str, path: &Path) -> bool {
790 crate::home::locality::Mounts::parse(mountinfo).is_network(path)
791}
792
793#[derive(Debug, Clone)]
795pub struct Root {
796 pub path: PathBuf,
797 pub origin: RootOrigin,
798 pub network: bool,
800 pub available: bool,
803}
804
805#[derive(Debug, Clone, Default)]
807pub struct Section {
808 pub title: String,
809 pub subtitle: Option<String>,
812 pub origin: Option<&'static str>,
815 pub root: Option<PathBuf>,
818 pub rows: Vec<Entry>,
819 pub door: Option<Entry>,
824 pub unavailable: bool,
826 pub unavailable_note: Option<String>,
829 pub folded_by_default: bool,
832 pub remote_root: Option<PathBuf>,
834 pub waiting: bool,
836 pub grouped_by_place: bool,
839 pub place_labels: std::collections::HashMap<PathBuf, String>,
842}
843
844impl Section {
845 pub fn titled(title: impl Into<String>, rows: Vec<Entry>) -> Self {
847 Section {
848 title: title.into(),
849 rows,
850 ..Default::default()
851 }
852 }
853}
854
855#[derive(Debug, Clone, PartialEq, Eq, Default)]
857pub enum CloudStatus {
858 #[default]
860 Listing,
861 Unlisted,
864 Listed,
866 Failed { short: String, detail: String },
869}
870
871#[derive(Debug, Clone, Default, PartialEq, Eq)]
875pub struct CloudSource {
876 pub id: String,
878 pub label: String,
880 pub api: crate::cloud::source::ProviderKind,
882 pub note: String,
884 pub buckets: Vec<PathBuf>,
886 pub status: CloudStatus,
887 pub listed_at: Option<std::time::SystemTime>,
889 pub refreshing: bool,
891 pub asked: bool,
893 pub details: Vec<(String, String)>,
895 pub place_details: std::collections::HashMap<PathBuf, Vec<(String, String)>>,
898}
899
900impl CloudSource {
901 pub fn count_text(&self) -> String {
903 match &self.status {
904 CloudStatus::Failed { short, .. } if self.buckets.is_empty() => short.clone(),
905 CloudStatus::Listing if self.buckets.is_empty() => String::new(),
906 CloudStatus::Unlisted if self.buckets.is_empty() => "not listed".to_string(),
907 _ => {
908 let (one, many) = match self.api {
909 crate::cloud::source::ProviderKind::Azure => ("account", "accounts"),
910 crate::cloud::source::ProviderKind::Gcs => ("project", "projects"),
911 crate::cloud::source::ProviderKind::S3 => ("bucket", "buckets"),
912 };
913 match self.buckets.len() {
914 0 => format!("no {many}"),
915 1 => format!("1 {one}"),
916 n => format!("{n} {many}"),
917 }
918 }
919 }
920 }
921
922 pub fn begin_listing(&mut self) {
925 self.asked = true;
926 if self.status == CloudStatus::Unlisted && self.buckets.is_empty() {
927 self.status = CloudStatus::Listing;
928 } else {
929 self.refreshing = true;
930 }
931 }
932
933 pub fn busy(&self) -> bool {
935 self.refreshing || (self.status == CloudStatus::Listing && self.buckets.is_empty())
936 }
937
938 pub fn failed(&self) -> bool {
940 matches!(self.status, CloudStatus::Failed { .. })
941 }
942}
943
944#[derive(Debug, Clone, PartialEq)]
947pub struct ShownCatalog {
948 pub id: String,
950 pub label: String,
952 pub origin: crate::home::catalog::Origin,
954 pub description: String,
956 pub file: Option<PathBuf>,
958 pub datasets: Vec<ShownDataset>,
959 pub broken: Option<String>,
961}
962
963#[derive(Debug, Clone, Default, PartialEq)]
965pub struct ShownDataset {
966 pub name: String,
968 pub location: PathBuf,
970 pub details: Vec<(String, String)>,
972 pub size: Option<u64>,
974 pub codebook: Option<std::sync::Arc<crate::home::codebook::Codebook>>,
976 pub bookmarks: Vec<(String, PathBuf)>,
978 pub entry: std::sync::Arc<crate::home::catalog::Dataset>,
980}
981
982impl ShownCatalog {
983 pub fn from_catalog(catalog: &crate::home::catalog::Catalog) -> Self {
985 Self {
986 id: catalog.id.clone(),
987 label: catalog.label.clone(),
988 origin: catalog.origin,
989 description: catalog.description.clone(),
990 file: catalog.file.clone(),
991 datasets: catalog
992 .datasets
993 .iter()
994 .map(|dataset| {
995 let location = dataset.location();
996 let mut details: Vec<(String, String)> = [
997 ("about", &dataset.description),
998 ("publisher", &dataset.publisher),
999 ("license", &dataset.license),
1000 ("homepage", &dataset.homepage),
1001 ("documentation", &dataset.documentation),
1002 ]
1003 .into_iter()
1004 .filter(|(_, value)| !value.is_empty())
1005 .map(|(key, value)| (key.to_string(), value.clone()))
1006 .collect();
1007 match &dataset.url {
1008 None => details.push(("path".to_string(), display_path(&location))),
1009 Some(url) => {
1010 details.push(("url".to_string(), url.clone()));
1011 details.push(("login".to_string(), login_of(dataset)));
1012 }
1013 }
1014 ShownDataset {
1015 name: dataset.name.clone(),
1016 location,
1017 details,
1018 size: dataset.size,
1019 codebook: crate::home::codebook::Codebook::of(dataset)
1020 .map(std::sync::Arc::new),
1021 bookmarks: dataset
1022 .bookmarks
1023 .iter()
1024 .map(|(name, path)| (name.clone(), dataset.bookmark_location(path)))
1025 .collect(),
1026 entry: std::sync::Arc::new(dataset.clone()),
1027 }
1028 })
1029 .collect(),
1030 broken: None,
1031 }
1032 }
1033
1034 pub fn from_broken(broken: &crate::home::catalog::Broken) -> Self {
1036 Self {
1037 id: broken.id.clone(),
1038 label: broken.id.clone(),
1039 origin: broken.origin,
1040 description: String::new(),
1041 file: None,
1042 datasets: Vec::new(),
1043 broken: Some(broken.callout()),
1044 }
1045 }
1046
1047 pub fn origin_note(&self) -> &'static str {
1050 match self.origin {
1051 crate::home::catalog::Origin::Mine => "catalog.toml",
1052 crate::home::catalog::Origin::Listed | crate::home::catalog::Origin::Folder => {
1053 "catalog"
1054 }
1055 crate::home::catalog::Origin::Bundled => BUNDLED_ORIGIN,
1056 }
1057 }
1058}
1059
1060pub const BUNDLED_ORIGIN: &str = "comes with datui";
1062
1063pub const CATALOG_ORIGINS: [&str; 3] = ["catalog.toml", "catalog", BUNDLED_ORIGIN];
1065
1066pub fn is_catalog_origin(origin: &str) -> bool {
1068 CATALOG_ORIGINS.contains(&origin)
1069}
1070
1071pub fn login_of(dataset: &crate::home::catalog::Dataset) -> String {
1073 match dataset.object_store_auth() {
1074 Some(crate::config::DatasetAuth::Connection(connection)) => connection,
1075 Some(crate::config::DatasetAuth::Anonymous) | None => "none".to_string(),
1076 Some(crate::config::DatasetAuth::Auto) => "auto".to_string(),
1077 }
1078}
1079
1080pub fn catalogs(config: &crate::config::AppConfig) -> Vec<ShownCatalog> {
1084 let mut out: Vec<ShownCatalog> = config
1085 .shown_catalogs()
1086 .iter()
1087 .filter_map(|catalog| {
1088 let mut shown = ShownCatalog::from_catalog(catalog);
1089 if catalog.origin == crate::home::catalog::Origin::Bundled {
1090 shown
1091 .datasets
1092 .retain(|d| crate::cloud::source::opens_in_this_build(&d.location));
1093 }
1094 (!shown.datasets.is_empty()).then_some(shown)
1095 })
1096 .collect();
1097 let at = out
1099 .iter()
1100 .position(|c| c.origin == crate::home::catalog::Origin::Bundled)
1101 .unwrap_or(out.len());
1102 let broken: Vec<ShownCatalog> = config
1103 .broken_catalogs
1104 .iter()
1105 .filter(|b| !config.home.hide.contains(&b.id))
1106 .map(ShownCatalog::from_broken)
1107 .collect();
1108 out.splice(at..at, broken);
1109 out
1110}
1111
1112fn catalog_entry(
1115 dataset: &ShownDataset,
1116 network_check: fn(&Path) -> bool,
1117 missing: &mut std::collections::HashSet<PathBuf>,
1118) -> Entry {
1119 let path = &dataset.location;
1120 let local = matches!(
1121 crate::cloud::source::input_source(path),
1122 crate::cloud::source::InputSource::Local(_)
1123 );
1124 let mut entry = if is_object_store_url(path) {
1125 if names_a_file(path) {
1126 entry_for_path(path, true)
1127 } else {
1128 Entry::directory(path)
1129 }
1130 } else if !local || network_check(path) {
1131 entry_for_path(path, true)
1132 } else if path.exists() {
1133 entry_for_path(path, false)
1134 } else {
1135 missing.insert(path.clone());
1136 let mut entry = entry_for_path(path, true);
1137 entry.kind = EntryKind::Unknown;
1138 entry
1139 };
1140 entry.name = dataset.name.clone();
1141 entry
1142}
1143
1144pub fn codebook_for(
1147 catalogs: &[ShownCatalog],
1148 path: &Path,
1149) -> Option<std::sync::Arc<crate::home::codebook::Codebook>> {
1150 let text = path.to_string_lossy();
1151 catalogs
1152 .iter()
1153 .flat_map(|c| c.datasets.iter())
1154 .filter(|d| d.codebook.is_some())
1155 .filter(|d| d.location == path || within(&text, &d.location.to_string_lossy()))
1156 .max_by_key(|d| d.location.to_string_lossy().trim_end_matches('/').len())
1157 .and_then(|d| d.codebook.clone())
1158}
1159
1160pub fn catalog_entry_for(
1163 catalogs: &[ShownCatalog],
1164 path: &Path,
1165) -> Option<(String, std::sync::Arc<crate::home::catalog::Dataset>)> {
1166 let text = path.to_string_lossy();
1167 catalogs
1168 .iter()
1169 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
1170 .filter(|(_, d)| {
1171 d.location == path
1172 || same_place(&d.location, path)
1173 || within(&text, &d.location.to_string_lossy())
1174 })
1175 .rev()
1176 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
1177 .map(|(c, d)| (c.label.clone(), d.entry.clone()))
1178}
1179
1180fn bookmark_entry(name: &str, place: &Path, network_check: fn(&Path) -> bool) -> Entry {
1182 let local = matches!(
1183 crate::cloud::source::input_source(place),
1184 crate::cloud::source::InputSource::Local(_)
1185 );
1186 let mut entry = if is_object_store_url(place) && !names_a_file(place) {
1187 Entry::directory(place)
1188 } else {
1189 entry_for_path(place, !local || network_check(place) || !place.exists())
1190 };
1191 entry.name = name.to_string();
1192 entry
1193}
1194
1195fn catalog_section(
1197 catalog: &ShownCatalog,
1198 network_check: fn(&Path) -> bool,
1199 missing: &mut std::collections::HashSet<PathBuf>,
1200) -> Section {
1201 let rows = catalog
1203 .datasets
1204 .iter()
1205 .flat_map(|dataset| {
1206 std::iter::once(catalog_entry(dataset, network_check, missing)).chain(
1207 dataset
1208 .bookmarks
1209 .iter()
1210 .map(|(name, place)| bookmark_entry(name, place, network_check)),
1211 )
1212 })
1213 .collect();
1214 Section {
1215 origin: Some(catalog.origin_note()),
1216 unavailable: catalog.broken.is_some(),
1217 unavailable_note: catalog.broken.clone(),
1218 ..Section::titled(catalog.label.clone(), rows)
1219 }
1220}
1221
1222#[derive(Debug, Clone, Default, PartialEq)]
1225pub struct Measured {
1226 pub rows: Option<usize>,
1227 pub cols: Option<usize>,
1228 pub cols_sampled: bool,
1230 pub size: Option<u64>,
1231 pub modified: Option<std::time::SystemTime>,
1233 pub stat_only: bool,
1236 pub columns: Vec<String>,
1238 pub cost: crate::home::discover::Cost,
1241 pub kind: Option<crate::home::discover::EntryKind>,
1244 pub holds: crate::home::discover::Holds,
1247}
1248
1249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1251pub enum SortMode {
1252 #[default]
1254 Natural,
1255 Size,
1257 Modified,
1259 Rows,
1261}
1262
1263impl SortMode {
1264 pub fn label_in(self, section_is_recency_ordered: bool) -> &'static str {
1267 match self {
1268 SortMode::Natural if section_is_recency_ordered => "recent",
1269 SortMode::Natural => "name",
1270 SortMode::Size => "size",
1271 SortMode::Modified => "modified",
1272 SortMode::Rows => "rows",
1273 }
1274 }
1275
1276 pub fn next(self) -> Self {
1277 match self {
1278 SortMode::Natural => SortMode::Size,
1279 SortMode::Size => SortMode::Modified,
1280 SortMode::Modified => SortMode::Rows,
1281 SortMode::Rows => SortMode::Natural,
1282 }
1283 }
1284}
1285
1286#[derive(Debug, Clone)]
1290pub enum Row<'a> {
1291 Header {
1292 section: usize,
1293 matches: usize,
1295 collapsed: bool,
1296 },
1297 Entry {
1298 section: usize,
1299 entry: &'a Entry,
1300 nested: bool,
1302 hit: Hit,
1304 },
1305 Place {
1307 section: usize,
1308 path: PathBuf,
1309 label: Option<String>,
1312 source: Option<String>,
1314 held: usize,
1316 },
1317 Door { section: usize, entry: &'a Entry },
1320 More {
1323 section: usize,
1324 hidden: usize,
1325 places: usize,
1326 measuring: bool,
1329 },
1330 Hidden { section: usize, count: usize },
1334 Up { section: usize },
1337}
1338
1339impl Row<'_> {
1340 pub fn section(&self) -> usize {
1341 match self {
1342 Row::Header { section, .. }
1343 | Row::Entry { section, .. }
1344 | Row::Door { section, .. }
1345 | Row::Place { section, .. }
1346 | Row::More { section, .. }
1347 | Row::Hidden { section, .. }
1348 | Row::Up { section } => *section,
1349 }
1350 }
1351}
1352
1353#[derive(Debug, Clone, Copy, Default, PartialEq)]
1357pub struct Hit {
1358 pub score: i32,
1359 pub column: Option<usize>,
1362}
1363
1364impl Hit {
1365 pub fn column_of<'a>(&self, entry: &'a Entry) -> Option<&'a str> {
1367 self.column
1368 .and_then(|i| entry.columns.get(i))
1369 .map(String::as_str)
1370 }
1371
1372 pub fn positions(&self, filter: &str, entry: &Entry) -> Vec<usize> {
1375 match self.column_of(entry) {
1376 Some(column) => substring_positions(filter, column),
1377 None => fuzzy_positions(filter, &entry.name),
1378 }
1379 }
1380}
1381
1382#[derive(Debug, Default)]
1387pub struct RowsCache {
1388 built: std::cell::RefCell<Option<View>>,
1389 builds: std::cell::Cell<usize>,
1390 rescored: std::cell::RefCell<Option<Hits>>,
1393}
1394
1395#[derive(Debug, Clone)]
1398struct Hits {
1399 filter: String,
1400 sections: Vec<Option<Vec<Option<Hit>>>>,
1401}
1402
1403#[derive(Debug)]
1405struct View {
1406 key: ViewKey,
1407 slots: Vec<Slot>,
1408 headers: Vec<usize>,
1411 hits: Hits,
1412 has_dataset: bool,
1414}
1415
1416#[derive(Debug, PartialEq)]
1419struct ViewKey {
1420 filter: String,
1421 sort: SortMode,
1422 hide_unreadable: bool,
1423 recent_expanded: bool,
1424 shown_whole: std::collections::HashSet<PathBuf>,
1425 view_height: usize,
1426 browsing: Option<PathBuf>,
1427 folds: std::collections::HashMap<String, bool>,
1428 shape: Vec<(usize, bool)>,
1429}
1430
1431impl ViewKey {
1432 fn of(home: &HomeState) -> Self {
1433 ViewKey {
1434 filter: home.filter.clone(),
1435 sort: home.sort,
1436 hide_unreadable: home.hide_unreadable,
1437 recent_expanded: home.recent_expanded,
1438 shown_whole: home.shown_whole.clone(),
1439 view_height: home.view_height,
1440 browsing: home.browsing.clone(),
1441 folds: home.folds.clone(),
1442 shape: (home.sections.iter())
1443 .map(|s| (s.rows.len(), s.door.is_some()))
1444 .collect(),
1445 }
1446 }
1447
1448 fn matches(&self, home: &HomeState) -> bool {
1450 self.filter == home.filter
1451 && self.sort == home.sort
1452 && self.hide_unreadable == home.hide_unreadable
1453 && self.recent_expanded == home.recent_expanded
1454 && self.shown_whole == home.shown_whole
1455 && self.view_height == home.view_height
1456 && self.browsing == home.browsing
1457 && self.folds == home.folds
1458 && self.shape.iter().copied().eq(home
1459 .sections
1460 .iter()
1461 .map(|s| (s.rows.len(), s.door.is_some())))
1462 }
1463}
1464
1465#[derive(Debug)]
1467enum Slot {
1468 Plain(Row<'static>),
1470 Entry {
1471 section: usize,
1472 index: usize,
1473 nested: bool,
1474 hit: Hit,
1475 },
1476 Door {
1477 section: usize,
1478 },
1479}
1480
1481#[derive(Debug, Clone)]
1485pub struct ListLines {
1486 headers: Vec<usize>,
1487 rows: usize,
1488 spaced: bool,
1489}
1490
1491impl ListLines {
1492 pub fn rows(&self) -> usize {
1494 self.rows
1495 }
1496
1497 pub fn headers(&self) -> usize {
1499 self.headers.len()
1500 }
1501
1502 pub fn line_of(&self, row: usize) -> usize {
1504 if !self.spaced {
1505 return row;
1506 }
1507 let above = self.headers.partition_point(|&h| h <= row);
1509 let first_at_top = self.headers.first() == Some(&0);
1510 row + above.saturating_sub(usize::from(first_at_top))
1511 }
1512
1513 pub fn total(&self) -> usize {
1515 match self.rows {
1516 0 => 0,
1517 n => self.line_of(n - 1) + 1,
1518 }
1519 }
1520
1521 pub fn first_row_from(&self, line: usize) -> usize {
1523 let (mut lo, mut hi) = (0, self.rows);
1524 while lo < hi {
1525 let mid = lo + (hi - lo) / 2;
1526 if self.line_of(mid) < line {
1527 lo = mid + 1;
1528 } else {
1529 hi = mid;
1530 }
1531 }
1532 lo
1533 }
1534
1535 pub fn row_on(&self, line: usize) -> Option<usize> {
1537 let row = self.first_row_from(line);
1538 (row < self.rows && self.line_of(row) == line).then_some(row)
1539 }
1540}
1541
1542pub fn place_of(path: &Path) -> PathBuf {
1545 parent_location(path).unwrap_or_else(|| path.to_path_buf())
1546}
1547
1548pub fn place_is_browsable(path: &Path) -> bool {
1551 is_cloud_place(path)
1552 || is_object_store_url(path)
1553 || matches!(
1554 crate::cloud::source::input_source(path),
1555 crate::cloud::source::InputSource::Local(_)
1556 )
1557}
1558
1559#[derive(Debug, Clone, PartialEq, Eq)]
1563pub enum RowKey {
1564 Header(String),
1565 Entry(PathBuf),
1566 Door(PathBuf),
1569 Place(PathBuf),
1570 More(String),
1571 Hidden(String),
1572 Up(String),
1573}
1574
1575#[derive(Debug)]
1577pub struct HomeState {
1578 pub sections: Vec<Section>,
1579 pub filter: String,
1581 pub filter_selected: bool,
1584 pub search_limit: usize,
1586 pub hide_unreadable: bool,
1588 pub formats: std::sync::Arc<crate::formats::Registry>,
1591 pub lake_here: Option<(PathBuf, &'static str)>,
1594 pub selected: usize,
1596 pub scroll: usize,
1601 pub view_height: usize,
1603 pub path_input_active: bool,
1605 pub path_input: String,
1606 pub path_listing: Option<PathListing>,
1608 pub path_pick: Option<usize>,
1610 pub browsing: Option<PathBuf>,
1612 pub browse_start: Option<PathBuf>,
1615 pub status: Option<String>,
1617 pub network_check: fn(&Path) -> bool,
1620 pub visits: std::collections::HashMap<PathBuf, crate::cache::Visits>,
1623 pub newest_recent: Option<PathBuf>,
1625 pub probes: Probes,
1627 pub narrowed: Option<Narrowed>,
1629 pub cloud_kinds: std::collections::HashMap<PathBuf, (EntryKind, crate::home::discover::Holds)>,
1632 pub sort: SortMode,
1634 pub listing_in_flight: bool,
1636 pub measure_in_flight: bool,
1638 pub classifying: std::collections::HashSet<PathBuf>,
1642 pub peeking: std::collections::HashSet<PathBuf>,
1645 pub peek_failed: std::collections::HashSet<PathBuf>,
1648 pub enriched: std::collections::HashMap<PathBuf, Measured>,
1651 pub stated: std::collections::HashMap<PathBuf, Stated>,
1654 pub stat_epoch: u64,
1657 pub stale: std::collections::HashSet<PathBuf>,
1659 pub unapplied: std::collections::HashSet<PathBuf>,
1661 pub folds: std::collections::HashMap<String, bool>,
1665 pub folds_owed: bool,
1668 pub search: SearchState,
1670 pub known: Known,
1674 pub cloud: Vec<CloudSource>,
1677 pub catalogs: Vec<ShownCatalog>,
1680 pub sized: std::collections::HashSet<PathBuf>,
1682 pub web_gone: std::collections::HashMap<PathBuf, crate::error_display::HttpGone>,
1685 pub missing: std::collections::HashSet<PathBuf>,
1687 pub waiting_since: Option<std::time::Instant>,
1689 pub recent_expanded: bool,
1691 pub shown_whole: std::collections::HashSet<PathBuf>,
1694 pub trail: Vec<Mark>,
1697 pub returning: Option<RowKey>,
1700 pub returning_line: Option<usize>,
1702 pub landing: bool,
1705 pub resting: bool,
1709 pub rows_cache: RowsCache,
1711 pub catalog_places: CatalogPlaces,
1713}
1714
1715#[derive(Debug, Clone)]
1718pub struct Mark {
1719 pub place: Option<PathBuf>,
1721 pub key: Option<RowKey>,
1722 pub filter: String,
1724 pub search: Option<SearchState>,
1727 pub line: usize,
1729}
1730
1731#[derive(Debug, Clone, Default)]
1734pub struct SearchState {
1735 pub root: Option<PathBuf>,
1737 pub epoch: u64,
1739 pub results: Vec<std::sync::Arc<[Entry]>>,
1742 pub indexed: usize,
1744 pub matches: Option<crate::home::search::Matches>,
1747 pub scoring: bool,
1749 pub scanned: usize,
1751 pub running: bool,
1753 pub done: bool,
1755 pub limited: Option<String>,
1757}
1758
1759const SCORE_INLINE_MAX: usize = 2_000;
1762
1763const FRECENCY_LIFT: f64 = 3.0;
1766
1767#[derive(Debug, Clone)]
1769pub struct ScoreJob {
1770 pub epoch: u64,
1771 pub results: Vec<std::sync::Arc<[Entry]>>,
1772 pub query: String,
1773 pub base: Option<crate::home::search::Matches>,
1774 pub limit: usize,
1775}
1776
1777impl SearchState {
1778 pub fn reset(&mut self) {
1780 *self = Self::default();
1781 }
1782
1783 pub fn set_results(&mut self, results: Vec<Entry>) {
1785 self.indexed = results.len();
1786 self.results = vec![results.into()];
1787 self.matches = None;
1788 }
1789
1790 pub fn files(&self) -> impl Iterator<Item = &Entry> {
1792 self.results.iter().flat_map(|batch| batch.iter())
1793 }
1794
1795 fn scored_for(&self, query: &str) -> bool {
1797 self.matches
1798 .as_ref()
1799 .is_some_and(|m| m.query == query && m.upto == self.indexed)
1800 }
1801
1802 fn base_for(&self, query: &str) -> (Option<&crate::home::search::Matches>, usize) {
1804 match self.matches.as_ref() {
1805 Some(m) if m.narrows_to(query) && m.upto <= self.indexed => {
1806 (Some(m), m.ids.len() + self.indexed - m.upto)
1807 }
1808 _ => (None, self.indexed),
1809 }
1810 }
1811}
1812
1813impl Default for HomeState {
1814 fn default() -> Self {
1815 Self {
1816 sections: Vec::new(),
1817 cloud: Vec::new(),
1818 catalogs: Vec::new(),
1819 sized: std::collections::HashSet::new(),
1820 web_gone: Default::default(),
1821 missing: Default::default(),
1822 filter: String::new(),
1823 search_limit: crate::config::SearchConfig::default().max_results,
1824 rows_cache: RowsCache::default(),
1825 catalog_places: CatalogPlaces::default(),
1826 hide_unreadable: true,
1827 formats: Default::default(),
1828 lake_here: None,
1829 selected: 0,
1830 scroll: 0,
1831 view_height: 0,
1832 path_input_active: false,
1833 path_input: String::new(),
1834 path_listing: None,
1835 path_pick: None,
1836 filter_selected: false,
1837 browsing: None,
1838 browse_start: None,
1839 status: None,
1840 network_check: is_remote_path,
1841 visits: Default::default(),
1842 newest_recent: None,
1843 sort: SortMode::default(),
1844 listing_in_flight: false,
1845 measure_in_flight: false,
1846 classifying: Default::default(),
1847 peeking: std::collections::HashSet::new(),
1848 probes: Probes::default(),
1849 narrowed: None,
1850 cloud_kinds: std::collections::HashMap::new(),
1851 peek_failed: std::collections::HashSet::new(),
1852 waiting_since: None,
1853 enriched: std::collections::HashMap::new(),
1854 stated: Default::default(),
1855 stat_epoch: 0,
1856 stale: Default::default(),
1857 unapplied: Default::default(),
1858 folds: std::collections::HashMap::new(),
1859 folds_owed: false,
1860 search: SearchState::default(),
1861 known: Default::default(),
1862 recent_expanded: false,
1863 shown_whole: Default::default(),
1864 trail: Vec::new(),
1865 returning: None,
1866 returning_line: None,
1867 landing: false,
1868 resting: false,
1869 }
1870 }
1871}
1872
1873pub type Known = std::sync::Arc<std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>>;
1876
1877#[derive(Debug, Clone)]
1880pub struct ListingRequest {
1881 pub recents: Vec<PathBuf>,
1882 pub desktop_dirs: Vec<PathBuf>,
1883 pub browsing: Option<PathBuf>,
1884 pub probes: Probes,
1886 pub narrowed: Option<Narrowed>,
1888 pub network_check: fn(&Path) -> bool,
1889 pub cloud: Vec<CloudSource>,
1891 pub catalogs: Vec<ShownCatalog>,
1893 pub known: Known,
1896 pub formats: std::sync::Arc<crate::formats::Registry>,
1899}
1900
1901#[derive(Debug, Clone, Default)]
1903pub struct Listing {
1904 pub sections: Vec<Section>,
1905 pub missing: std::collections::HashSet<PathBuf>,
1907}
1908
1909impl Listing {
1910 pub fn learn(
1913 &mut self,
1914 learned: &[(PathBuf, crate::cache::DatasetFacts)],
1915 network_check: fn(&Path) -> bool,
1916 ) {
1917 if learned.is_empty() {
1918 return;
1919 }
1920 let learned: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts> =
1921 learned.iter().cloned().collect();
1922 for row in self.sections.iter_mut().flat_map(|s| s.rows.iter_mut()) {
1923 if known_facts(&learned, &row.path).is_some() {
1924 apply_known_facts(row, &learned, network_check(&row.path));
1925 }
1926 }
1927 }
1928
1929 pub fn alias_visits(
1935 &self,
1936 visits: &mut std::collections::HashMap<PathBuf, crate::cache::Visits>,
1937 ) {
1938 let names: std::collections::HashSet<std::ffi::OsString> = visits
1939 .keys()
1940 .filter_map(|p| p.file_name().map(|n| n.to_os_string()))
1941 .collect();
1942 let mut aliases = Vec::new();
1943 for row in self.sections.iter().flat_map(|s| &s.rows) {
1944 let path = &row.path;
1945 if visits.contains_key(path)
1946 || row.table.is_some()
1947 || !path.file_name().is_some_and(|n| names.contains(n))
1948 || is_network_path(path)
1949 {
1950 continue;
1951 }
1952 if let Some(v) = crate::canonical::canonicalize(path)
1953 .ok()
1954 .and_then(|canonical| visits.get(&canonical))
1955 {
1956 aliases.push((path.clone(), *v));
1957 }
1958 }
1959 visits.extend(aliases);
1960 }
1961}
1962
1963pub fn look_into_as(entry: &Entry, as_read: &crate::formats::schema_union::ReadAs) -> Entry {
1968 let mut probe = classify_row(entry);
1969 measure_row(&mut probe, entry, as_read, None);
1970 probe
1971}
1972
1973fn classify_row(entry: &Entry) -> Entry {
1975 let mut probe = entry.clone();
1976 if probe.kind == EntryKind::Unknown && probe.path.is_dir() {
1977 let (kind, holds) = discover::look_at_directory(&probe.path);
1978 probe.kind = kind;
1979 probe.holds = holds;
1980 }
1981 probe
1982}
1983
1984fn stat_and_recall(
1988 probe: &mut Entry,
1989 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
1990) -> bool {
1991 if discover::unstated(probe) && !discover::stat_row(probe) {
1992 return false;
1993 }
1994 if probe.rows.is_none() && probe.columns.is_empty() && discover::on_disk(probe) {
1997 apply_known_facts(probe, known, false);
1998 }
1999 true
2000}
2001
2002fn measure_row(
2005 probe: &mut Entry,
2006 entry: &Entry,
2007 as_read: &crate::formats::schema_union::ReadAs,
2008 remembered: Option<&crate::cache::CacheManager>,
2009) {
2010 discover::enrich_with(probe, as_read, remembered);
2011 probe.size = probe.size.or(entry.size);
2012 probe.modified = probe.modified.or(entry.modified);
2013}
2014
2015#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2017pub enum Reads {
2018 Files,
2020 StatOnly,
2022}
2023
2024pub fn look_into_batch(
2029 rows: Vec<Entry>,
2030 cache: &crate::cache::CacheManager,
2031 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2032 reads: Reads,
2033 mut each: impl FnMut(PathBuf, Measured),
2034) {
2035 let as_read = crate::formats::schema_union::ReadAs::default();
2036 let answer = |probe: &Entry, entry: &Entry| Measured {
2037 stat_only: reads == Reads::StatOnly,
2038 ..measured_from(probe, entry)
2039 };
2040 let classified: Vec<(Entry, Entry)> = rows
2041 .into_iter()
2042 .map(|entry| {
2043 let mut probe = entry.clone();
2044 if !stat_and_recall(&mut probe, known) {
2046 return (probe, entry);
2047 }
2048 let probe = match reads {
2050 Reads::Files => classify_row(&probe),
2051 Reads::StatOnly => probe,
2052 };
2053 if probe.kind != entry.kind || probe.modified != entry.modified {
2054 each(entry.path.clone(), answer(&probe, &entry));
2055 }
2056 (probe, entry)
2057 })
2058 .collect();
2059
2060 let mut facts = Vec::new();
2061 for (mut probe, entry) in classified {
2062 if reads == Reads::Files && probe.rows.is_none() && probe.columns.is_empty() {
2064 measure_row(&mut probe, &entry, &as_read, Some(cache));
2065 facts.extend(facts_for(&probe));
2066 }
2067 each(entry.path.clone(), answer(&probe, &entry));
2068 }
2069 cache.record_dataset_facts(&facts);
2072}
2073
2074pub fn measured_from(probe: &Entry, original: &Entry) -> Measured {
2076 Measured {
2077 rows: probe.rows,
2078 cols: probe.cols,
2079 cols_sampled: probe.cols_sampled,
2080 size: probe.size.or(original.size),
2081 modified: probe.modified.or(original.modified),
2082 stat_only: false,
2083 columns: probe.columns.clone(),
2084 kind: (probe.kind != original.kind).then_some(probe.kind),
2085 holds: probe.holds.clone(),
2086 cost: crate::home::discover::Cost {
2089 source: None,
2090 ..probe.cost.clone()
2091 },
2092 }
2093}
2094
2095#[derive(Debug, Clone)]
2098pub enum Probe {
2099 Listing(Vec<Entry>),
2101 Listed {
2103 rows: std::sync::Arc<[Entry]>,
2104 cut_short: bool,
2105 },
2106 Unreachable(Option<String>),
2108 Silent(Vec<Entry>),
2111}
2112
2113pub const NOT_ANSWERING: &str = "not answering · Ctrl+R retries";
2115
2116#[derive(Debug, Clone, Default)]
2118pub struct Probes(std::collections::HashMap<PathBuf, Probe>);
2119
2120impl Probes {
2121 pub fn listed(&self, place: &Path) -> Option<&[Entry]> {
2123 match self.0.get(place)? {
2124 Probe::Listed { rows, .. } => Some(rows),
2125 _ => None,
2126 }
2127 }
2128
2129 pub fn so_far(&self, place: &Path) -> Option<&[Entry]> {
2131 match self.0.get(place)? {
2132 Probe::Listing(rows) => Some(rows),
2133 _ => None,
2134 }
2135 }
2136
2137 pub fn settled(&self, place: &Path) -> bool {
2139 matches!(
2140 self.0.get(place),
2141 Some(Probe::Listed { .. } | Probe::Unreachable(_) | Probe::Silent(_))
2142 )
2143 }
2144
2145 pub fn silent(&self, place: &Path) -> bool {
2147 matches!(self.0.get(place), Some(Probe::Silent(_)))
2148 }
2149
2150 pub fn silent_places(&self) -> Vec<PathBuf> {
2152 (self.0.iter())
2153 .filter(|(_, probe)| matches!(probe, Probe::Silent(_)))
2154 .map(|(place, _)| place.clone())
2155 .collect()
2156 }
2157
2158 pub fn takes_pages(&self, place: &Path) -> bool {
2161 matches!(
2162 self.0.get(place),
2163 None | Some(Probe::Listing(_) | Probe::Silent(_))
2164 )
2165 }
2166
2167 pub fn go_silent(&mut self, place: &Path) {
2169 let rows = match self.0.remove(place) {
2170 Some(Probe::Listing(rows) | Probe::Silent(rows)) => rows,
2171 None => Vec::new(),
2172 Some(settled) => {
2173 self.0.insert(place.to_path_buf(), settled);
2174 return;
2175 }
2176 };
2177 self.0.insert(place.to_path_buf(), Probe::Silent(rows));
2178 }
2179
2180 pub fn cut_short(&self, place: &Path) -> bool {
2181 matches!(
2182 self.0.get(place),
2183 Some(Probe::Listed {
2184 cut_short: true,
2185 ..
2186 })
2187 )
2188 }
2189
2190 pub fn unreachable(&self, place: &Path) -> bool {
2191 matches!(self.0.get(place), Some(Probe::Unreachable(_)))
2192 }
2193
2194 pub fn error(&self, place: &Path) -> Option<&str> {
2196 match self.0.get(place)? {
2197 Probe::Unreachable(why) => why.as_deref(),
2198 _ => None,
2199 }
2200 }
2201
2202 fn rows(&self, place: &Path) -> Vec<Entry> {
2205 match self.0.get(place) {
2206 Some(Probe::Listed { rows, .. }) => rows.to_vec(),
2207 Some(Probe::Listing(rows) | Probe::Silent(rows)) => {
2208 let mut rows = rows.clone();
2209 if is_object_store_url(place) {
2210 rows.sort_by_key(|row| row.kind != EntryKind::Directory);
2211 } else {
2212 discover::sort_entries(&mut rows);
2213 }
2214 rows
2215 }
2216 _ => Vec::new(),
2217 }
2218 }
2219
2220 pub fn answered(&self) -> impl Iterator<Item = (&PathBuf, &[Entry])> {
2222 self.0.iter().filter_map(|(place, probe)| match probe {
2223 Probe::Listed { rows, .. } => Some((place, &rows[..])),
2224 _ => None,
2225 })
2226 }
2227
2228 fn entry(&self, path: &Path) -> Option<Entry> {
2230 self.answered()
2231 .flat_map(|(_, rows)| rows.iter())
2232 .find(|e| e.path == path)
2233 .cloned()
2234 }
2235
2236 pub fn read(&mut self, place: &Path, rows: &[Entry]) {
2238 let probe = (self.0)
2239 .entry(place.to_path_buf())
2240 .or_insert(Probe::Listing(Vec::new()));
2241 if let Probe::Silent(so_far) = probe {
2243 *probe = Probe::Listing(std::mem::take(so_far));
2244 }
2245 if let Probe::Listing(so_far) = probe {
2246 so_far.extend_from_slice(rows);
2247 }
2248 }
2249
2250 pub fn insert(&mut self, place: PathBuf, probe: Probe) {
2251 self.0.insert(place, probe);
2252 }
2253
2254 pub fn forget(&mut self, place: &Path) {
2256 self.0.remove(place);
2257 }
2258
2259 pub fn stopped(&mut self, place: &Path) {
2261 if let Some(Probe::Listing(_) | Probe::Silent(_)) = self.0.get(place) {
2262 self.0.remove(place);
2263 }
2264 }
2265
2266 fn listed_mut(&mut self, place: &Path) -> Option<&mut std::sync::Arc<[Entry]>> {
2267 match self.0.get_mut(place)? {
2268 Probe::Listed { rows, .. } => Some(rows),
2269 _ => None,
2270 }
2271 }
2272}
2273
2274#[derive(Debug, Clone)]
2277pub struct Narrowed {
2278 pub dir: PathBuf,
2279 pub prefix: String,
2281 pub rows: Vec<Entry>,
2282 pub truncated: bool,
2284}
2285
2286pub fn build_listing(request: &ListingRequest) -> Listing {
2290 let ListingRequest {
2291 recents,
2292 desktop_dirs,
2293 browsing,
2294 probes,
2295 narrowed,
2296 network_check,
2297 cloud,
2298 catalogs,
2299 known,
2300 formats,
2301 } = request;
2302 let network_check = *network_check;
2303 let mounts = crate::home::locality::Mounts::current();
2306 let mut sections: Vec<Section> = Vec::new();
2307
2308 if let Some(id) = browsing.as_deref().and_then(cloud_source_id) {
2310 sections.push(source_section(&id, cloud));
2311 annotate(&mut sections, known, network_check, &mounts);
2312 return Listing {
2313 sections,
2314 ..Default::default()
2315 };
2316 }
2317
2318 if let Some(dir) = browsing.clone() {
2320 let remote = network_check(&dir);
2321 sections.push(browsed_section(
2322 &dir,
2323 remote,
2324 &Listed {
2325 probes,
2326 narrowed: narrowed.as_ref(),
2327 catalogs,
2328 known,
2329 formats,
2330 },
2331 ));
2332 annotate(&mut sections, known, network_check, &mounts);
2333 return Listing {
2334 sections,
2335 ..Default::default()
2336 };
2337 }
2338
2339 let recent_rows: Vec<Entry> = recents
2341 .iter()
2342 .filter(|p| {
2345 network_check(p)
2346 || p.exists()
2347 || crate::formats::members::split(p).is_some()
2348 || crate::formats::members::split_variant(p, formats).is_some()
2349 || crate::formats::hf_splits::split_place(p).is_some()
2350 })
2351 .map(|p| {
2353 if let Some(known) = probes.entry(p) {
2356 return known;
2357 }
2358 if let Some(variant) = discover::variant_row(p, formats) {
2359 return variant;
2360 }
2361 if !network_check(p)
2362 && let Some(split) = discover::split_row(p)
2363 {
2364 return split;
2365 }
2366 let mut entry = entry_for_path(p, network_check(p));
2367 if !network_check(p) {
2368 discover::name_unlisted_file(&mut entry, formats);
2369 }
2370 if let Some(dataset) = catalogs
2373 .iter()
2374 .flat_map(|c| &c.datasets)
2375 .find(|d| d.location == *p)
2376 {
2377 entry.name = dataset.name.clone();
2378 }
2379 entry
2380 })
2381 .collect();
2382
2383 let mut elsewhere: Vec<Entry> = Vec::new();
2385
2386 let roots = HomeState::roots_with(desktop_dirs, network_check);
2387 let mut root_sections: Vec<(RootOrigin, Section)> = Vec::new();
2388 let mut cwd_listing: Option<(PathBuf, std::collections::HashSet<std::ffi::OsString>)> = None;
2391 for root in roots {
2392 if root.origin == RootOrigin::Desktop {
2395 if root.available {
2396 let mut entry = Entry::directory(&root.path);
2397 entry.name = display_path(&root.path);
2399 elsewhere.push(entry);
2400 }
2401 continue;
2402 }
2403
2404 let mut truncated = false;
2407 let rows = if root.network {
2408 truncated = probes.cut_short(&root.path);
2409 probes.rows(&root.path)
2410 } else if root.available {
2411 let scan = discover::scan_dir_specs(&root.path, formats);
2412 truncated = scan.truncated;
2413 scan.entries
2414 } else {
2415 Vec::new()
2416 };
2417 if root.origin == RootOrigin::Cwd {
2418 let key = if root.network {
2421 root.path.clone()
2422 } else {
2423 crate::canonical::canonicalize(&root.path).unwrap_or_else(|_| root.path.clone())
2424 };
2425 let names = rows
2426 .iter()
2427 .filter_map(|row| row.path.file_name().map(|n| n.to_os_string()))
2428 .collect();
2429 cwd_listing = Some((key, names));
2430 }
2431 let unreachable = root.network && probes.unreachable(&root.path);
2433 let silent = root.network && probes.silent(&root.path);
2434 let waiting = root.network && !probes.settled(&root.path);
2435 let described = mounts.describe(&root.path);
2439 let fstype = if described.network() {
2440 described.fstype
2441 } else {
2442 "network".to_string()
2443 };
2444 let mut state: Vec<String> = Vec::new();
2447 if truncated {
2448 state.push(format!(
2449 "first {}",
2450 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2451 ));
2452 }
2453 if root.network {
2454 state.push(fstype);
2455 }
2456 root_sections.push((
2457 root.origin,
2458 Section {
2459 subtitle: (!state.is_empty()).then(|| crate::glyphs::dotted(&state.join(" · "))),
2460 origin: Some(root.origin.note()),
2461 root: Some(root.path.clone()),
2462 unavailable: !root.available || unreachable || silent,
2463 unavailable_note: silent.then(|| NOT_ANSWERING.to_string()),
2464 remote_root: root.network.then(|| root.path.clone()),
2465 waiting,
2466 ..Section::titled(display_path(&root.path), rows)
2467 },
2468 ));
2469 }
2470
2471 let recent_rows: Vec<Entry> = match &cwd_listing {
2476 None => recent_rows,
2477 Some((cwd, names)) => recent_rows
2478 .into_iter()
2479 .filter(|row| {
2480 let place = place_of(&row.path);
2481 let place = if network_check(&place) {
2484 place
2485 } else {
2486 crate::canonical::canonicalize(&place).unwrap_or(place)
2487 };
2488 place != *cwd || !row.path.file_name().is_some_and(|n| names.contains(n))
2489 })
2490 .collect(),
2491 };
2492 if !recent_rows.is_empty() {
2493 let place_labels = place_labels(&recent_rows, known, network_check);
2494 sections.push(Section {
2495 grouped_by_place: true,
2497 place_labels,
2498 ..Section::titled(HomeState::RECENT_SECTION, recent_rows)
2499 });
2500 }
2501
2502 sections.extend(root_sections.into_iter().map(|(_, s)| s));
2506
2507 sections.extend(cloud_section(cloud));
2510
2511 let mut missing = std::collections::HashSet::new();
2513 for catalog in catalogs {
2514 let mut section = catalog_section(catalog, network_check, &mut missing);
2515 for row in section.rows.iter_mut().filter(|r| {
2517 r.kind == EntryKind::File
2518 && r.format_spec.is_none()
2519 && r.table.is_none()
2520 && !network_check(&r.path)
2521 && !discover::is_data_file(&r.path)
2522 }) {
2523 if let Some(spec) = formats.by_glob(&row.path, false).first() {
2524 discover::name_spec_file(row, spec);
2525 }
2526 }
2527 sections.push(section);
2528 }
2529
2530 if !elsewhere.is_empty() {
2531 sections.push(Section {
2532 folded_by_default: true,
2534 ..Section::titled("Elsewhere", elsewhere)
2535 });
2536 }
2537
2538 annotate(&mut sections, known, network_check, &mounts);
2541
2542 Listing { sections, missing }
2543}
2544
2545fn source_section(id: &str, cloud: &[CloudSource]) -> Section {
2548 let source = cloud.iter().find(|s| s.id == id);
2549 let rows = source
2550 .map(|s| s.buckets.iter().map(|b| bucket_entry(b)).collect())
2551 .unwrap_or_default();
2552 let failure = source.and_then(|s| match &s.status {
2553 CloudStatus::Failed { short, .. } => Some(short.clone()),
2554 _ => None,
2555 });
2556 Section {
2557 subtitle: source.map(|s| s.note.clone()).filter(|n| !n.is_empty()),
2558 unavailable: source.is_none() || failure.is_some(),
2559 unavailable_note: if source.is_none() {
2560 Some("source not found".to_string())
2561 } else {
2562 failure
2563 },
2564 waiting: source.is_some_and(|s| s.busy()),
2565 ..Section::titled(
2566 source.map_or_else(|| id.to_string(), |s| s.label.clone()),
2567 rows,
2568 )
2569 }
2570}
2571
2572fn cloud_section(cloud: &[CloudSource]) -> Option<Section> {
2574 (!cloud.is_empty()).then(|| {
2575 Section::titled(
2576 HomeState::CLOUD_SECTION.to_string(),
2577 cloud.iter().map(source_entry).collect(),
2578 )
2579 })
2580}
2581
2582pub(crate) struct Listed<'a> {
2585 pub probes: &'a Probes,
2586 pub narrowed: Option<&'a Narrowed>,
2587 pub catalogs: &'a [ShownCatalog],
2588 pub known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2589 pub formats: &'a crate::formats::Registry,
2590}
2591
2592fn browsed_section(dir: &Path, remote: bool, listed: &Listed) -> Section {
2595 let Listed {
2596 probes,
2597 narrowed,
2598 catalogs,
2599 known,
2600 formats,
2601 } = *listed;
2602 let dir = dir.to_path_buf();
2603
2604 let so_far = remote && probes.so_far(&dir).is_some();
2608 let database = !remote && dir.is_file();
2610 let (mut rows, truncated) = if remote {
2611 (probes.rows(&dir), probes.cut_short(&dir))
2612 } else if database {
2613 let tables = discover::database_rows(&dir);
2614 let rows = if tables.is_empty() {
2615 discover::variant_rows(&dir, formats)
2616 } else {
2617 tables
2618 };
2619 (rows, false)
2620 } else {
2621 let scan = discover::scan_dir_specs(&dir, formats);
2622 let mut rows = discover::split_rows(&dir);
2624 rows.extend(scan.entries);
2625 (rows, scan.truncated)
2626 };
2627 let narrowed = narrowed.filter(|n| remote && truncated && n.dir == dir);
2630 if let Some(narrowed) = narrowed {
2631 let listed: std::collections::HashSet<PathBuf> =
2632 rows.iter().map(|row| row.path.clone()).collect();
2633 rows.extend(
2634 narrowed
2635 .rows
2636 .iter()
2637 .filter(|row| !listed.contains(&row.path))
2638 .cloned(),
2639 );
2640 }
2641 let subtitle = if so_far {
2643 Some(format!(
2644 "{} so far",
2645 crate::numfmt::group_chrome(rows.len())
2646 ))
2647 } else if truncated {
2648 let first = format!(
2649 "first {}",
2650 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2651 );
2652 Some(match narrowed {
2653 Some(n) => format!(
2654 "{first} + {}{} {}*",
2655 crate::numfmt::group_chrome(n.rows.len()),
2656 if n.truncated { "+" } else { "" },
2657 n.prefix
2658 ),
2659 None => first,
2660 })
2661 } else {
2662 None
2663 };
2664 let unavailable = remote && (probes.unreachable(&dir) || probes.silent(&dir));
2665 let mut door = (!database)
2668 .then(|| whole_directory_row(&dir, &rows, remote))
2669 .flatten();
2670 if !remote
2674 && let Some(door) = door.as_mut()
2675 && let Ok(meta) = std::fs::metadata(&dir)
2676 {
2677 door.modified = meta.modified().ok();
2678 apply_known_facts(door, known, false);
2679 door.modified = None;
2680 door.name = door_name(door, &rows);
2681 }
2682 let title = {
2685 let text = dir.to_string_lossy();
2686 if let Some(dataset) = catalogs
2687 .iter()
2688 .flat_map(|c| c.datasets.iter())
2689 .find(|d| is_object_store_url(&d.location) && same_place(&d.location, &dir))
2690 {
2691 dataset.name.clone()
2692 } else if let Some((_, account)) = cloud_account(&dir) {
2693 account
2694 } else if let Some((_, container, key)) = crate::cloud::source::azure_parts(&text) {
2695 format!("{container}/{}", key.trim_matches('/'))
2696 .trim_end_matches('/')
2697 .to_string()
2698 } else {
2699 match crate::cloud::source::split_source_id(&text) {
2700 (Some(_), plain) => plain.into_owned(),
2701 (None, _) => display_path(&dir),
2702 }
2703 }
2704 };
2705 Section {
2706 subtitle,
2707 root: Some(dir.clone()),
2708 unavailable,
2709 unavailable_note: probes
2712 .error(&dir)
2713 .map(str::to_string)
2714 .or_else(|| probes.silent(&dir).then(|| NOT_ANSWERING.to_string())),
2715 waiting: so_far,
2718 door,
2719 ..Section::titled(title, rows)
2720 }
2721}
2722
2723pub fn index_key(path: &Path) -> PathBuf {
2727 let text = path.to_string_lossy();
2728 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
2729 return PathBuf::from(crate::cloud::source::azure_url(&account, &container, &key));
2730 }
2731 match crate::cloud::source::split_source_id(&text) {
2732 (Some(_), plain) => PathBuf::from(plain.into_owned()),
2733 (None, _) => path.to_path_buf(),
2734 }
2735}
2736
2737fn known_facts<'a>(
2739 known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2740 path: &Path,
2741) -> Option<&'a crate::cache::DatasetFacts> {
2742 known.get(path).or_else(|| known.get(&index_key(path)))
2743}
2744
2745fn place_labels(
2750 rows: &[Entry],
2751 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2752 network_check: fn(&Path) -> bool,
2753) -> std::collections::HashMap<PathBuf, String> {
2754 let mut labels = std::collections::HashMap::new();
2755 for row in rows {
2756 let place = place_of(&row.path);
2757 if labels.contains_key(&place) {
2758 continue;
2759 }
2760 let Some(facts) = known_facts(known, &place) else {
2761 continue;
2762 };
2763 if facts.classified_by != crate::home::discover::CLASSIFIER_VERSION {
2764 continue;
2765 }
2766 if !network_check(&place) {
2767 let same_mtime = std::fs::metadata(&place)
2768 .and_then(|m| m.modified())
2769 .ok()
2770 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2771 .is_some_and(|d| d.as_secs() == facts.mtime);
2772 if !same_mtime {
2773 continue;
2774 }
2775 }
2776 let Some(kind) = facts.kind else {
2777 continue;
2778 };
2779 let mut probe = Entry::directory(&place);
2780 probe.kind = kind;
2781 probe.holds = facts.holds.clone();
2782 let label = probe.label();
2783 if !label.is_empty() && !label.starts_with("dir") {
2784 labels.insert(place, label.into_owned());
2785 }
2786 }
2787 labels
2788}
2789
2790fn annotate(
2793 sections: &mut [Section],
2794 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2795 network_check: fn(&Path) -> bool,
2796 mounts: &crate::home::locality::Mounts,
2797) {
2798 for section in sections {
2799 for row in &mut section.rows {
2800 if is_cloud_place(&row.path) {
2801 row.cost.source = Some("cloud".to_string());
2802 continue;
2803 }
2804 let remote = network_check(&row.path);
2805 if !remote && discover::unstated(row) && known_facts(known, &row.path).is_some() {
2809 discover::stat_row(row);
2810 }
2811 apply_known_facts(row, known, remote);
2812 row.cost.source = Some(mounts.describe(&row.path).fstype);
2813 }
2814 if let Some(door) = section.door.as_mut()
2816 && !is_cloud_place(&door.path)
2817 {
2818 door.cost.source = Some(mounts.describe(&door.path).fstype);
2819 }
2820 }
2821}
2822
2823#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2825pub struct Stated {
2826 pub size: Option<u64>,
2827 pub modified: Option<std::time::SystemTime>,
2828 pub epoch: u64,
2830}
2831
2832fn fold_known(
2835 row: &mut Entry,
2836 enriched: &std::collections::HashMap<PathBuf, Measured>,
2837 stated: &std::collections::HashMap<PathBuf, Stated>,
2838 stale: &std::collections::HashSet<PathBuf>,
2839) -> bool {
2840 let mut any = false;
2841 if stale.contains(&row.path) {
2842 forget_counts(row);
2843 any = true;
2844 }
2845 if let Some(m) = enriched.get(&row.path) {
2846 fold_measured(row, m);
2847 any = true;
2848 }
2849 if let Some(stat) = stated.get(&row.path) {
2850 if stat.size.is_some() {
2851 row.size = stat.size;
2852 }
2853 if stat.modified.is_some() {
2854 row.modified = stat.modified;
2855 }
2856 any = true;
2857 }
2858 any
2859}
2860
2861fn forget_counts(row: &mut Entry) {
2864 row.measured = false;
2865 row.rows = None;
2866 row.cols = None;
2867 row.cols_sampled = false;
2868 row.columns.clear();
2869 take_cost(row, &discover::Cost::default());
2870}
2871
2872#[derive(PartialEq)]
2875struct Standing {
2876 hidden: bool,
2877 dataset: bool,
2878 sorted_by: Option<u64>,
2880}
2881
2882impl Standing {
2883 fn of(row: &Entry, sort: SortMode) -> Self {
2884 Standing {
2885 hidden: row.hidden_by_default(),
2886 dataset: row.kind.is_dataset() || row.kind.is_lake_table(),
2887 sorted_by: match sort {
2888 SortMode::Size => Some(row.size.unwrap_or(0)),
2889 SortMode::Rows => Some(row.rows.unwrap_or(0) as u64),
2890 SortMode::Modified => Some(
2891 (row.modified)
2892 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2893 .map_or(0, |d| d.as_secs()),
2894 ),
2895 SortMode::Natural => None,
2896 },
2897 }
2898 }
2899}
2900
2901fn fold_measured(row: &mut Entry, m: &Measured) {
2903 row.measured = true;
2904 row.rows = m.rows;
2905 row.cols = m.cols;
2906 row.cols_sampled = m.cols_sampled;
2907 if let Some(kind) = m.kind {
2908 row.kind = kind;
2909 }
2910 if m.size.is_some() {
2911 row.size = m.size;
2912 }
2913 if m.modified.is_some() {
2914 row.modified = m.modified;
2915 }
2916 if !m.columns.is_empty() && row.columns != m.columns {
2917 row.columns.clone_from(&m.columns);
2918 }
2919 if !m.holds.is_empty() && row.holds != m.holds {
2920 row.holds.clone_from(&m.holds);
2921 }
2922 take_cost(row, &m.cost);
2924}
2925
2926fn take_cost(row: &mut Entry, cost: &discover::Cost) {
2930 let source = row.cost.source.take();
2931 let variants = row.cost.tables.filter(|_| row.format_spec.is_some());
2932 row.cost = cost.clone();
2933 row.cost.source = source;
2934 if variants.is_some() {
2935 row.cost.tables = variants;
2936 }
2937}
2938
2939fn apply_known_facts(
2943 row: &mut Entry,
2944 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2945 remote: bool,
2946) {
2947 let Some(facts) = known_facts(known, &row.path) else {
2948 return;
2949 };
2950
2951 if !remote
2958 && matches!(row.kind, EntryKind::Unknown | EntryKind::MultiFile)
2959 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
2960 && let Some(kind) = facts.kind
2961 {
2962 let same_mtime = row
2963 .modified
2964 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2965 .is_some_and(|d| d.as_secs() == facts.mtime);
2966 if same_mtime {
2967 row.kind = kind;
2968 if row.holds.is_empty() {
2971 row.holds = facts.holds.clone();
2972 }
2973 }
2977 }
2978
2979 if !remote {
2980 let same_bytes = row.size.map(|s| s == facts.size).unwrap_or(false)
2981 && row
2982 .modified
2983 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2984 .map(|d| d.as_secs() == facts.mtime)
2985 .unwrap_or(false);
2986 if !same_bytes {
2987 return;
2988 }
2989 }
2990
2991 row.rows = facts.rows;
2992 row.cols = facts.cols;
2993 row.cols_sampled = facts.cols_sampled;
2994 if !facts.columns.is_empty() {
2995 row.columns = facts.columns.clone();
2996 }
2997 take_cost(row, &facts.cost);
3000 if remote {
3001 if facts.size > 0 {
3004 row.size = row.size.or(Some(facts.size));
3005 }
3006 if row.kind == EntryKind::Unknown
3010 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
3011 && let Some(kind) = facts.kind
3012 {
3013 row.kind = kind;
3014 if row.holds.is_empty() {
3016 row.holds = facts.holds.clone();
3017 }
3018 }
3019 }
3020}
3021
3022pub fn facts_for(entry: &Entry) -> Option<(PathBuf, crate::cache::DatasetFacts)> {
3024 let size = entry.size?;
3025 let mtime = entry
3026 .modified?
3027 .duration_since(std::time::UNIX_EPOCH)
3028 .ok()?
3029 .as_secs();
3030 let cost = crate::home::discover::Cost {
3032 source: None,
3033 ..entry.cost.clone()
3034 };
3035 if entry.rows.is_none() && entry.columns.is_empty() && cost == Default::default() {
3036 return None; }
3038 Some((
3039 entry.path.clone(),
3040 crate::cache::DatasetFacts {
3041 mtime,
3042 size,
3043 rows: entry.rows,
3044 cols: entry.cols,
3045 cols_sampled: entry.cols_sampled,
3046 columns: entry.columns.clone(),
3047 kind: Some(entry.kind),
3048 holds: entry.holds.clone(),
3049 classified_by: crate::home::discover::CLASSIFIER_VERSION,
3050 cost: crate::home::discover::Cost {
3052 source: None,
3053 ..entry.cost.clone()
3054 },
3055 },
3056 ))
3057}
3058
3059pub fn match_score(filter: &str, entry: &Entry) -> Option<i32> {
3063 match crate::home::fuzzy::best_match(filter, &entry.name) {
3064 Some(m) => Some(m.score),
3065 None => matching_column(filter, entry).map(|_| -COLUMN_MATCH_PENALTY),
3068 }
3069}
3070
3071pub fn match_hit(filter: &str, entry: &Entry) -> Option<Hit> {
3073 Needle::new(filter).hit(entry)
3074}
3075
3076struct Needle<'a> {
3079 filter: &'a str,
3080 lower: String,
3081}
3082
3083impl<'a> Needle<'a> {
3084 fn new(filter: &'a str) -> Self {
3085 Needle {
3086 filter,
3087 lower: filter.to_lowercase(),
3088 }
3089 }
3090
3091 fn hit(&self, entry: &Entry) -> Option<Hit> {
3092 let named = crate::home::fuzzy::best_match_with(self.filter, &entry.name, |score, _| Hit {
3093 score,
3094 column: None,
3095 });
3096 if named.is_some() {
3097 return named;
3098 }
3099 Some(Hit {
3100 score: -COLUMN_MATCH_PENALTY,
3101 column: Some(self.column(entry)?),
3102 })
3103 }
3104
3105 fn column(&self, entry: &Entry) -> Option<usize> {
3107 if self.filter.is_empty() {
3108 return None;
3109 }
3110 (entry.columns.iter()).position(|c| contains_folded(c, &self.lower))
3111 }
3112}
3113
3114fn contains_folded(haystack: &str, lower: &str) -> bool {
3117 if haystack.is_ascii() && lower.is_ascii() {
3118 let (hay, needle) = (haystack.as_bytes(), lower.as_bytes());
3119 return needle.is_empty()
3120 || hay
3121 .windows(needle.len())
3122 .any(|w| w.eq_ignore_ascii_case(needle));
3123 }
3124 haystack.to_lowercase().contains(lower)
3125}
3126
3127const COLUMN_MATCH_PENALTY: i32 = 1_000_000;
3130
3131pub fn matching_column<'a>(filter: &str, entry: &'a Entry) -> Option<&'a str> {
3134 (Needle::new(filter).column(entry)).map(|i| entry.columns[i].as_str())
3135}
3136
3137pub fn fuzzy_positions(needle: &str, haystack: &str) -> Vec<usize> {
3140 crate::home::fuzzy::best_match(needle, haystack)
3141 .map(|m| m.positions)
3142 .unwrap_or_default()
3143}
3144
3145pub fn substring_positions(needle: &str, haystack: &str) -> Vec<usize> {
3148 if needle.is_empty() {
3149 return Vec::new();
3150 }
3151 let hay: Vec<char> = haystack.to_lowercase().chars().collect();
3152 let need: Vec<char> = needle.to_lowercase().chars().collect();
3153 if need.len() > hay.len() {
3154 return Vec::new();
3155 }
3156 for start in 0..=(hay.len() - need.len()) {
3157 if hay[start..start + need.len()] == need[..] {
3158 return (start..start + need.len()).collect();
3159 }
3160 }
3161 Vec::new()
3162}
3163
3164pub fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
3167 crate::home::fuzzy::best_match(needle, haystack).map(|m| m.score)
3168}
3169
3170impl HomeState {
3171 pub fn roots(desktop_dirs: &[PathBuf]) -> Vec<Root> {
3174 Self::roots_with(desktop_dirs, is_remote_path)
3175 }
3176
3177 pub fn roots_with(desktop_dirs: &[PathBuf], is_network: fn(&Path) -> bool) -> Vec<Root> {
3179 let mut roots: Vec<Root> = Vec::new();
3180 let mut seen: Vec<PathBuf> = Vec::new();
3181
3182 let push =
3183 |path: PathBuf, origin: RootOrigin, roots: &mut Vec<Root>, seen: &mut Vec<PathBuf>| {
3184 let network = is_network(&path);
3186
3187 let key = if network {
3191 path.clone()
3192 } else {
3193 crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone())
3194 };
3195 if seen.contains(&key) {
3196 return;
3197 }
3198 seen.push(key);
3199
3200 let available = if network {
3201 true } else {
3203 std::fs::read_dir(&path).is_ok()
3204 };
3205 roots.push(Root {
3206 path,
3207 origin,
3208 available,
3209 network,
3210 });
3211 };
3212
3213 if let Ok(cwd) = std::env::current_dir() {
3217 push(cwd, RootOrigin::Cwd, &mut roots, &mut seen);
3218 }
3219
3220 for dir in desktop_dirs {
3223 push(dir.clone(), RootOrigin::Desktop, &mut roots, &mut seen);
3224 }
3225
3226 roots
3227 }
3228
3229 pub fn rebuild(&mut self, recents: &[PathBuf]) {
3232 self.rebuild_with(recents, &[])
3233 }
3234
3235 pub fn rebuild_with(&mut self, recents: &[PathBuf], desktop_dirs: &[PathBuf]) {
3238 let request = ListingRequest {
3239 recents: recents.to_vec(),
3240 desktop_dirs: desktop_dirs.to_vec(),
3241 browsing: self.browsing.clone(),
3242 probes: self.probes.clone(),
3243 narrowed: self.narrowed.clone(),
3244 network_check: self.network_check,
3245 cloud: self.cloud.clone(),
3246 catalogs: self.catalogs.clone(),
3247 known: Default::default(),
3250 formats: self.formats.clone(),
3251 };
3252 let listing = build_listing(&request);
3253 self.apply_listing(listing);
3254 }
3255
3256 pub fn apply_listing(&mut self, listing: Listing) {
3258 let mut listing = listing;
3259 for section in &mut listing.sections {
3260 name_by_spec(&self.formats, &mut section.rows);
3261 }
3262 self.missing = listing.missing;
3263 self.replace_sections(|home| home.sections = listing.sections);
3264 }
3265
3266 pub fn relist_remote(&mut self, root: &Path) {
3270 let browsed = self.browsing.as_deref() == Some(root)
3271 && (self.network_check)(root)
3272 && cloud_source_id(root).is_none();
3273 let at = (self.sections.iter()).position(|s| {
3274 s.root.as_deref() == Some(root) && (browsed || s.remote_root.as_deref() == Some(root))
3275 });
3276 let Some(at) = at else {
3277 return;
3278 };
3279 let mut section = if browsed {
3280 browsed_section(
3281 root,
3282 true,
3283 &Listed {
3284 probes: &self.probes,
3285 narrowed: self.narrowed.as_ref(),
3286 catalogs: &self.catalogs,
3287 known: &self.known,
3288 formats: &self.formats,
3289 },
3290 )
3291 } else {
3292 Section::titled("", self.probes.rows(root))
3293 };
3294 let mounts = crate::home::locality::Mounts::cached();
3295 annotate(
3296 std::slice::from_mut(&mut section),
3297 &self.known,
3298 self.network_check,
3299 &mounts,
3300 );
3301 name_by_spec(&self.formats, &mut section.rows);
3302 let silent = self.probes.silent(root);
3303 let unreachable = self.probes.unreachable(root);
3304 let waiting = !self.probes.settled(root);
3305 self.replace_sections(|home| {
3306 if browsed {
3307 home.sections[at] = section;
3308 return;
3309 }
3310 let shown = &mut home.sections[at];
3313 shown.rows = section.rows;
3314 shown.waiting = waiting;
3315 shown.unavailable = silent || unreachable;
3316 shown.unavailable_note = silent.then(|| NOT_ANSWERING.to_string());
3317 });
3318 }
3319
3320 pub fn set_cloud(&mut self, cloud: Vec<CloudSource>) {
3323 self.cloud = cloud;
3324 let mounts = crate::home::locality::Mounts::cached();
3325 let annotated = |home: &Self, mut section: Section| {
3326 annotate(
3327 std::slice::from_mut(&mut section),
3328 &home.known,
3329 home.network_check,
3330 &mounts,
3331 );
3332 section
3333 };
3334 match self.browsing.as_deref().map(cloud_source_id) {
3335 Some(Some(id)) => {
3336 let section = annotated(self, source_section(&id, &self.cloud));
3337 self.replace_sections(|home| home.sections = vec![section]);
3338 }
3339 Some(None) => {}
3340 None => {
3341 let section = cloud_section(&self.cloud).map(|s| annotated(self, s));
3342 self.replace_sections(|home| {
3343 let at = (home.sections.iter()).position(|s| s.title == Self::CLOUD_SECTION);
3344 match (at, section) {
3345 (Some(at), Some(section)) => home.sections[at] = section,
3346 (Some(at), None) => {
3347 home.sections.remove(at);
3348 }
3349 (None, Some(section)) => {
3351 let at = (home.sections.iter())
3352 .position(|s| {
3353 s.origin.is_some_and(is_catalog_origin)
3354 || s.title == "Elsewhere"
3355 || s.title == Self::SEARCH_SECTION
3356 })
3357 .unwrap_or(home.sections.len());
3358 home.sections.insert(at, section);
3359 }
3360 (None, None) => {}
3361 }
3362 });
3363 }
3364 }
3365 }
3366
3367 fn replace_sections(&mut self, change: impl FnOnce(&mut Self)) {
3370 let returning = self.returning.take();
3371 let previous = returning.clone().or_else(|| self.selected_key());
3372 let line = self.selected.saturating_sub(self.scroll);
3374 change(self);
3375 self.changed();
3376 if let (Some(browsing), Some((dir, format))) = (&self.browsing, &self.lake_here)
3378 && browsing == dir
3379 && let Some(section) = self.sections.first_mut()
3380 {
3381 let note = crate::glyphs::dotted(&format!(
3382 "{} · not read as a table",
3383 format.to_ascii_lowercase()
3384 ));
3385 section.subtitle = Some(match section.subtitle.take() {
3386 Some(state) => crate::glyphs::dotted(&format!("{note} · {state}")),
3387 None => note,
3388 });
3389 }
3390 self.sync_search_section();
3392 self.apply_measurements();
3395
3396 let placed = self.reselect(previous);
3398 if placed && returning.is_some() {
3401 self.landing = false;
3402 }
3403 if !placed {
3404 self.select_first_entry();
3405 if self.rows_still_arriving() {
3408 self.returning = returning;
3409 }
3410 } else if returning.is_some() {
3411 self.scroll_to_returning_line();
3412 } else {
3413 self.scroll = self.selected.saturating_sub(line);
3414 }
3415 self.follow_selection();
3416 }
3417
3418 pub fn leave_mark(&mut self) {
3421 let mark = Mark {
3422 place: self.browsing.clone(),
3423 key: self.selected_key(),
3424 filter: self.filter.clone(),
3425 search: (!self.search.running).then(|| SearchState {
3428 scoring: false,
3429 ..self.search.clone()
3430 }),
3431 line: self.selected.saturating_sub(self.scroll),
3432 };
3433 self.trail.retain(|m| m.place != mark.place);
3436 self.trail.push(mark);
3437 }
3438
3439 pub fn come_back(&mut self, from: Option<PathBuf>) {
3444 let to = self.browsing.clone();
3445 let mark = self
3446 .trail
3447 .iter()
3448 .rposition(|m| m.place == to)
3449 .map(|at| self.trail.split_off(at).remove(0));
3450 match mark {
3451 Some(mark) => {
3452 self.filter = mark.filter;
3453 self.search = mark.search.unwrap_or_default();
3454 self.returning = mark.key;
3455 self.returning_line = Some(mark.line);
3456 }
3457 None => {
3458 self.filter.clear();
3459 self.search.reset();
3460 self.returning = from.map(RowKey::Entry);
3461 self.returning_line = None;
3462 }
3463 }
3464 }
3465
3466 fn rows_still_arriving(&self) -> bool {
3469 self.listing_in_flight
3470 || self.sections_waiting()
3471 || self.awaiting_listing().is_some()
3472 || self.search.running
3473 }
3474
3475 fn settle_return(&mut self) {
3477 let Some(key) = self.returning.clone() else {
3478 return;
3479 };
3480 if let Some(idx) = self.place_key(&key) {
3481 self.selected = idx;
3482 self.returning = None;
3483 self.landing = false;
3484 self.scroll_to_returning_line();
3485 self.follow_selection();
3486 } else if !self.rows_still_arriving() {
3487 self.returning = None;
3488 }
3489 }
3490
3491 fn scroll_to_returning_line(&mut self) {
3493 if let Some(line) = self.returning_line.take() {
3494 self.scroll = self.selected.saturating_sub(line);
3495 }
3496 }
3497
3498 pub fn selected_key(&self) -> Option<RowKey> {
3501 let title = |section: usize| self.sections.get(section).map(|s| s.title.clone());
3502 Some(match self.cursor_row()? {
3503 Row::Header { section, .. } => RowKey::Header(title(section)?),
3504 Row::More { section, .. } => RowKey::More(title(section)?),
3505 Row::Hidden { section, .. } => RowKey::Hidden(title(section)?),
3506 Row::Up { section } => RowKey::Up(title(section)?),
3507 Row::Entry { entry, .. } => RowKey::Entry(entry.path.clone()),
3508 Row::Door { entry, .. } => RowKey::Door(entry.path.clone()),
3509 Row::Place { path, .. } => RowKey::Place(path),
3510 })
3511 }
3512
3513 pub fn reselect(&mut self, key: Option<RowKey>) -> bool {
3517 let Some(key) = key else {
3518 self.clamp_selection();
3519 return false;
3520 };
3521 match self.place_key(&key) {
3522 Some(idx) => {
3523 self.selected = idx;
3524 true
3525 }
3526 None => {
3527 self.clamp_selection();
3528 false
3529 }
3530 }
3531 }
3532
3533 fn place_key(&mut self, key: &RowKey) -> Option<usize> {
3536 if let Some(found) = self.listed(key) {
3537 return Some(found);
3538 }
3539 if let RowKey::Entry(path) = key
3540 && let Some(root) = self
3541 .sections
3542 .iter()
3543 .find(|s| !s.grouped_by_place && s.rows.iter().any(|r| r.path == *path))
3544 .and_then(|s| s.root.clone())
3545 && self.shown_whole.insert(root.clone())
3546 {
3547 match self.listed(key) {
3548 Some(found) => return Some(found),
3549 None => {
3550 self.shown_whole.remove(&root);
3551 }
3552 }
3553 }
3554 self.behind_recent_cap(key)
3555 }
3556
3557 fn listed(&self, key: &RowKey) -> Option<usize> {
3559 self.position(|row| match (row, key) {
3560 (Row::Entry { entry, .. }, RowKey::Entry(path)) => entry.path == *path,
3561 (Row::Door { entry, .. }, RowKey::Door(path)) => entry.path == *path,
3562 (Row::Place { path, .. }, RowKey::Place(wanted)) => path == wanted,
3563 (Row::Header { section, .. }, RowKey::Header(title))
3564 | (Row::More { section, .. }, RowKey::More(title))
3565 | (Row::Hidden { section, .. }, RowKey::Hidden(title))
3566 | (Row::Up { section }, RowKey::Up(title)) => self
3567 .sections
3568 .get(*section)
3569 .is_some_and(|s| s.title == *title),
3570 _ => false,
3571 })
3572 }
3573
3574 fn behind_recent_cap(&self, key: &RowKey) -> Option<usize> {
3576 let (RowKey::Entry(path) | RowKey::Place(path)) = key else {
3577 return None;
3578 };
3579 self.position(|row| {
3580 matches!(row, Row::More { section, .. }
3581 if self.sections.get(*section).is_some_and(|s| {
3582 s.grouped_by_place
3583 && s.rows
3584 .iter()
3585 .any(|r| r.path == *path || place_of(&r.path) == *path)
3586 }))
3587 })
3588 }
3589
3590 pub fn set_view_height(&mut self, height: usize) {
3593 if height == self.view_height {
3594 return;
3595 }
3596 let key = self.selected_key();
3597 self.view_height = height;
3598 self.reselect(key);
3599 }
3600
3601 fn follow_selection(&mut self) {
3605 let rows = self.row_count();
3606 self.scroll = settle_top(self.scroll, self.selected, self.view_height, rows);
3607 }
3608
3609 fn section_folded(&self, section: &Section) -> bool {
3613 if self.browsing.is_some() {
3614 return false;
3615 }
3616 self.folds
3617 .get(§ion.title)
3618 .copied()
3619 .unwrap_or(section.folded_by_default)
3620 }
3621
3622 pub fn is_collapsed(&self, section: usize) -> bool {
3624 self.sections
3625 .get(section)
3626 .is_some_and(|s| self.section_folded(s))
3627 }
3628
3629 pub fn toggle_collapsed(&mut self, section: usize) {
3631 let folded = self.is_collapsed(section);
3632 self.set_collapsed(section, !folded);
3633 }
3634
3635 pub fn set_collapsed(&mut self, section: usize, collapsed: bool) {
3639 if self.browsing.is_some() {
3640 return;
3641 }
3642 let Some(title) = self.sections.get(section).map(|s| s.title.clone()) else {
3643 return;
3644 };
3645 self.folds.insert(title, collapsed);
3646 }
3647
3648 pub fn jump_section(&mut self, delta: isize) {
3650 self.resting = false;
3651 self.returning = None;
3652 self.landing = false;
3653 let headers = self.view().headers.clone();
3654 if headers.is_empty() {
3655 return;
3656 }
3657 let current = self.selected;
3658 self.selected = if delta > 0 {
3659 headers
3660 .iter()
3661 .copied()
3662 .find(|&h| h > current)
3663 .unwrap_or(headers[0])
3664 } else {
3665 headers
3666 .iter()
3667 .rev()
3668 .copied()
3669 .find(|&h| h < current)
3670 .unwrap_or(*headers.last().unwrap())
3671 };
3672 }
3673
3674 pub fn has_any_dataset(&self) -> bool {
3678 self.view().has_dataset
3679 }
3680
3681 pub const SEARCH_SECTION: &'static str = "Found";
3683
3684 pub const CLOUD_SECTION: &'static str = "Cloud";
3686
3687 pub const RECENT_SECTION: &'static str = "Recent";
3689
3690 pub fn cloud_source_of(&self, path: &Path) -> Option<&CloudSource> {
3693 if let Some(id) = cloud_source_id(path) {
3694 return self.cloud.iter().find(|s| s.id == id);
3695 }
3696 if let Some((id, _)) = cloud_account(path) {
3697 return self.cloud.iter().find(|s| s.id == id);
3698 }
3699 let text = path.to_string_lossy();
3700 if let Some((account, _, _)) = crate::cloud::source::azure_parts(&text) {
3701 return self.azure_account_place(&account).and_then(|place| {
3702 cloud_account(&place).and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id))
3703 });
3704 }
3705 if let (Some(id), _) = crate::cloud::source::split_source_id(&text) {
3706 return self.cloud.iter().find(|s| s.id == id);
3707 }
3708 if let Some(project) =
3709 Self::google_bucket_root(path).and_then(|b| self.project_of_bucket(&b))
3710 {
3711 return cloud_account(&project)
3712 .and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id));
3713 }
3714 let (_, plain) = crate::cloud::source::split_source_id(&text);
3715 let (scheme, rest) = plain.split_once("://")?;
3716 let bucket = rest.split('/').next()?;
3717 let root = PathBuf::from(format!("{scheme}://{bucket}"));
3718 self.cloud.iter().find(|s| s.buckets.contains(&root))
3719 }
3720
3721 pub fn parent_of(&self, path: &Path) -> Option<PathBuf> {
3724 if cloud_source_id(path).is_some() {
3725 return None;
3726 }
3727 if let Some((_, dataset)) = self.remote_dataset_of(path) {
3730 let place = &dataset.location;
3731 if same_place(path, place) {
3732 return None;
3733 }
3734 let up = self.parent_within(path)?;
3735 return Some(if same_place(&up, place) {
3737 place.clone()
3738 } else {
3739 up
3740 });
3741 }
3742 if let Some((id, _)) = cloud_account(path) {
3743 return Some(cloud_place(&id));
3744 }
3745 let text = path.to_string_lossy();
3746 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3747 let key = key.trim_matches('/');
3748 if key.is_empty() {
3749 return self.azure_account_place(&account);
3750 }
3751 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3752 let up = if up.is_empty() {
3753 String::new()
3754 } else {
3755 format!("{up}/")
3756 };
3757 return Some(PathBuf::from(crate::cloud::source::azure_url(
3758 &account, &container, &up,
3759 )));
3760 }
3761 if is_bucket_root(path) {
3762 if let Some(project) = self.project_of_bucket(path) {
3764 return Some(project);
3765 }
3766 return self.cloud_source_of(path).map(|s| cloud_place(&s.id));
3767 }
3768 parent_location(path)
3769 }
3770
3771 fn parent_within(&self, path: &Path) -> Option<PathBuf> {
3773 let text = path.to_string_lossy();
3774 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3775 let key = key.trim_matches('/');
3776 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3777 let up = if up.is_empty() {
3778 String::new()
3779 } else {
3780 format!("{up}/")
3781 };
3782 return Some(PathBuf::from(crate::cloud::source::azure_url(
3783 &account, &container, &up,
3784 )));
3785 }
3786 parent_location(path)
3787 }
3788
3789 fn remote_dataset_of(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3792 if !is_object_store_url(path) {
3793 return None;
3794 }
3795 let text = path.to_string_lossy();
3796 self.catalogs
3797 .iter()
3798 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3799 .filter(|(_, d)| {
3800 is_object_store_url(&d.location) && within(&text, &d.location.to_string_lossy())
3801 })
3802 .rev()
3803 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
3804 }
3805
3806 pub fn catalog_dataset(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3808 let places = &self.catalog_places;
3809 if places.indexes(&self.catalogs) {
3810 let key = place_key(path);
3811 let &(c, d) = places.datasets.get(&key)?;
3812 let catalog = self.catalogs.get(c)?;
3813 if let Some(dataset) = catalog.datasets.get(d)
3814 && place_key(&dataset.location) == key
3815 {
3816 return Some((catalog, dataset));
3817 }
3818 }
3819 self.catalogs
3820 .iter()
3821 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3822 .find(|(_, d)| same_place(&d.location, path))
3823 }
3824
3825 pub fn bookmark(&self, path: &Path) -> Option<(&ShownDataset, &str)> {
3827 let places = &self.catalog_places;
3828 if places.indexes(&self.catalogs) {
3829 let key = place_key(path);
3830 let &(c, d, b) = places.bookmarks.get(&key)?;
3831 if let Some(dataset) = self.catalogs.get(c).and_then(|c| c.datasets.get(d))
3832 && let Some((name, place)) = dataset.bookmarks.get(b)
3833 && place_key(place) == key
3834 {
3835 return Some((dataset, name.as_str()));
3836 }
3837 }
3838 self.catalogs
3839 .iter()
3840 .flat_map(|c| c.datasets.iter())
3841 .find_map(|d| {
3842 d.bookmarks
3843 .iter()
3844 .find(|(_, place)| same_place(place, path))
3845 .map(|(name, _)| (d, name.as_str()))
3846 })
3847 }
3848
3849 pub fn size_hint(&self, path: &Path) -> Option<u64> {
3851 self.catalog_dataset(path).and_then(|(_, d)| d.size)
3852 }
3853
3854 fn azure_account_place(&self, account: &str) -> Option<PathBuf> {
3856 self.cloud
3857 .iter()
3858 .flat_map(|s| s.buckets.iter())
3859 .find(|place| cloud_account(place).is_some_and(|(_, a)| a == account))
3860 .cloned()
3861 }
3862
3863 pub fn cloud_look(&self, entry: &Entry) -> Option<CloudLook> {
3866 if entry.kind != EntryKind::Directory
3867 || !entry.holds.is_empty()
3868 || !is_object_store_url(&entry.path)
3869 || is_cloud_place(&entry.path)
3870 || object_place_label(&entry.path).is_some()
3871 {
3872 return None;
3873 }
3874 if self.peeking.contains(&entry.path) {
3875 return Some(CloudLook::Looking);
3876 }
3877 if self.peek_failed.contains(&entry.path) {
3878 return Some(CloudLook::Failed);
3879 }
3880 match self.cloud_kinds.get(&entry.path) {
3881 None => Some(CloudLook::Waiting),
3882 Some((kind, holds)) if *kind != EntryKind::Directory || !holds.is_empty() => {
3885 Some(CloudLook::Looking)
3886 }
3887 Some(_) => None,
3888 }
3889 }
3890
3891 pub fn place_kind(&self, path: &Path) -> Option<&'static str> {
3894 if self.missing.contains(path) {
3895 return Some("missing");
3896 }
3897 if let Some((id, _)) = cloud_account(path) {
3898 return self
3899 .cloud
3900 .iter()
3901 .any(|s| s.id == id && s.api == crate::cloud::source::ProviderKind::Gcs)
3902 .then_some("project");
3903 }
3904 (is_object_store_url(path)
3906 && (self
3907 .catalog_dataset(path)
3908 .is_some_and(|(_, d)| is_object_store_url(&d.location))
3909 || (self.browsing.is_none() && self.bookmark(path).is_some())))
3910 .then_some("dataset")
3911 }
3912
3913 fn project_of_bucket(&self, bucket_root: &Path) -> Option<PathBuf> {
3915 let root = bucket_root.to_string_lossy();
3916 let root = root.trim_end_matches('/');
3917 self.probes
3918 .answered()
3919 .filter(|(place, _)| cloud_account(place).is_some())
3920 .find(|(_, rows)| {
3921 rows.iter()
3922 .any(|row| row.path.to_string_lossy().trim_end_matches('/') == root)
3923 })
3924 .map(|(place, _)| place.clone())
3925 }
3926
3927 fn google_bucket_root(path: &Path) -> Option<PathBuf> {
3929 let text = path.to_string_lossy();
3930 let rest = text
3931 .strip_prefix("gs://")
3932 .or_else(|| text.strip_prefix("gcs://"))?;
3933 let bucket = rest.split('/').next().filter(|b| !b.is_empty())?;
3934 Some(PathBuf::from(format!("gs://{bucket}")))
3935 }
3936
3937 pub fn place_details(&self, path: &Path) -> Option<&[(String, String)]> {
3940 self.cloud
3941 .iter()
3942 .find_map(|s| s.place_details.get(path))
3943 .or_else(|| self.catalog_dataset(path).map(|(_, d)| &d.details))
3944 .or_else(|| self.bookmark(path).map(|(d, _)| &d.details))
3945 .map(Vec::as_slice)
3946 }
3947
3948 pub fn location_label(&self, path: &Path) -> String {
3951 let sep = crate::glyphs::get().trail;
3952 if let Some((catalog, dataset)) = self.remote_dataset_of(path) {
3955 let text = path.to_string_lossy();
3956 let rest = within_rest(&text, &dataset.location.to_string_lossy());
3957 let mut parts = vec![catalog.label.clone(), dataset.name.clone()];
3958 parts.extend(
3959 rest.split('/')
3960 .filter(|p| !p.is_empty())
3961 .map(str::to_string),
3962 );
3963 return parts.join(&format!(" {sep} "));
3964 }
3965 if let Some(source) = self.cloud_source_of(path) {
3966 let mut parts = vec!["cloud".to_string(), source.label.clone()];
3967 let text = path.to_string_lossy();
3968 if let Some((_, account)) = cloud_account(path) {
3969 parts.push(account);
3970 } else if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text)
3971 {
3972 parts.push(account);
3973 parts.push(container);
3974 parts.extend(key.split('/').filter(|p| !p.is_empty()).map(str::to_string));
3975 } else if cloud_source_id(path).is_none() {
3976 if let Some((_, project)) = Self::google_bucket_root(path)
3977 .and_then(|b| self.project_of_bucket(&b))
3978 .as_deref()
3979 .and_then(cloud_account)
3980 {
3981 parts.push(project);
3982 }
3983 let (_, plain) = crate::cloud::source::split_source_id(&text);
3984 if let Some((_, rest)) = plain.split_once("://") {
3985 parts.extend(
3986 rest.split('/')
3987 .filter(|p| !p.is_empty())
3988 .map(str::to_string),
3989 );
3990 }
3991 }
3992 return parts.join(&format!(" {sep} "));
3993 }
3994 display_path(path)
3995 }
3996
3997 pub fn sync_search_section(&mut self) {
4000 let found = (self.sections.iter()).position(|s| s.title == Self::SEARCH_SECTION);
4002 let mut kept = self.take_hits();
4003 self.sections.retain(|s| s.title != Self::SEARCH_SECTION);
4004 self.changed();
4005 if let (Some(hits), Some(at)) = (kept.as_mut(), found)
4006 && at < hits.sections.len()
4007 {
4008 hits.sections.remove(at);
4009 }
4010 *self.rows_cache.rescored.get_mut() = kept;
4011
4012 if self.filter.is_empty() {
4013 return;
4014 }
4015 let cloud_rows: Vec<Entry> = if self.browsing.is_none() {
4017 self.cloud
4018 .iter()
4019 .flat_map(|source| {
4020 source.buckets.iter().map(move |bucket| {
4021 let mut entry = bucket_entry(bucket);
4022 entry.name = format!(
4023 "{} {} {}",
4024 source.label,
4025 crate::glyphs::get().trail,
4026 entry.name
4027 );
4028 entry.cost.source = Some(source.api.name().to_string());
4029 entry
4030 })
4031 })
4032 .filter(|e| match_score(&self.filter, e).is_some())
4033 .collect()
4034 } else {
4035 Vec::new()
4036 };
4037 self.score_search_inline();
4038 let local = self.search.root.is_some()
4039 && (self.search.indexed > 0 || self.search.running || self.search.limited.is_some());
4040 if !local {
4041 if !cloud_rows.is_empty() {
4042 let subtitle =
4043 crate::glyphs::dotted(&format!("cloud · {} names", cloud_rows.len()));
4044 self.sections.push(Section {
4045 subtitle: Some(subtitle),
4046 ..Section::titled(Self::SEARCH_SECTION, cloud_rows)
4047 });
4048 }
4049 return;
4050 }
4051
4052 let matches = self.search.matches.as_ref();
4055 let names: std::collections::HashSet<&std::ffi::OsStr> = (matches.iter())
4059 .flat_map(|m| m.top.iter())
4060 .map(|e| e.path.file_name().unwrap_or(e.path.as_os_str()))
4061 .collect();
4062 let listed: std::collections::HashSet<&PathBuf> = (self.sections.iter())
4063 .flat_map(|s| s.rows.iter().map(|r| &r.path))
4064 .filter(|path| path.file_name().is_none_or(|name| names.contains(name)))
4065 .collect();
4066 let kept: Vec<(&Entry, i32)> = matches
4067 .map(|m| {
4068 let fresh = m.query == self.filter;
4069 m.top
4070 .iter()
4071 .zip(m.scores.iter().copied())
4072 .filter(|(e, _)| !listed.contains(&e.path))
4073 .filter_map(|(e, score)| {
4074 if fresh {
4075 Some((e, score))
4076 } else {
4077 match_score(&self.filter, e).map(|s| (e, s))
4078 }
4079 })
4080 .collect()
4081 })
4082 .unwrap_or_default();
4083 let mut rows: Vec<Entry> = kept.into_iter().map(|(e, _)| e.clone()).collect();
4084 rows.extend(cloud_rows);
4085 for row in &mut rows {
4088 fold_known(row, &self.enriched, &self.stated, &self.stale);
4089 }
4090
4091 let partial = self.search.limited.is_some();
4093 let scored = self.search.scored_for(&self.filter);
4094 if rows.is_empty() && !self.search.running && !partial && scored {
4095 return;
4096 }
4097
4098 let subtitle = self.found_subtitle(rows.is_empty());
4099
4100 self.sections.push(Section {
4101 subtitle: Some(subtitle),
4102 ..Section::titled(Self::SEARCH_SECTION, rows)
4103 });
4104 }
4105
4106 fn found_subtitle(&self, empty: bool) -> String {
4108 let root = self.search.root.clone().unwrap_or_default();
4109 let dot = crate::glyphs::get().middot;
4110 let files = crate::numfmt::group_chrome(self.search.indexed);
4111 let files = if self.search.indexed == 1 {
4112 format!("{files} file")
4113 } else {
4114 format!("{files} files")
4115 };
4116 let subtitle = display_path(&root);
4117 let counted = self
4120 .search
4121 .matches
4122 .as_ref()
4123 .filter(|m| m.query == self.filter && m.ids.len() > m.top.len())
4124 .map(|m| {
4125 format!(
4126 " {dot} {} of {} matches",
4127 crate::numfmt::group_chrome(m.top.len()),
4128 crate::numfmt::group_chrome(m.ids.len())
4129 )
4130 })
4131 .unwrap_or_default();
4132 if self.search.running {
4133 format!(
4134 "{subtitle} {dot} searching {}{counted}",
4135 crate::numfmt::group_chrome(self.search.scanned)
4136 )
4137 } else if !self.search.scored_for(&self.filter) {
4138 format!("{subtitle} {dot} matching {files}")
4139 } else if empty {
4140 match &self.search.limited {
4141 Some(limit) => format!("{subtitle} {dot} no match in {files} {dot} {limit}"),
4142 None => format!("{subtitle} {dot} no match in {files}"),
4143 }
4144 } else {
4145 let searched = crate::numfmt::group_chrome(self.search.scanned);
4146 match &self.search.limited {
4147 Some(limit) => {
4148 format!("{subtitle} {dot} {limit} {dot} {searched} searched{counted}")
4149 }
4150 None => format!("{subtitle} {dot} {searched} searched{counted}"),
4151 }
4152 }
4153 }
4154
4155 pub fn search_batch(&mut self, root: &Path, mut found: Vec<Entry>, scanned: usize) {
4157 if self.search.root.as_deref() != Some(root) {
4159 return;
4160 }
4161 for row in &mut found {
4164 apply_known_facts(row, &self.known, false);
4165 }
4166 self.search.scanned = scanned;
4167 let start = self.search.indexed;
4168 let batch: std::sync::Arc<[Entry]> = found.into();
4169 if !batch.is_empty() {
4170 self.search.indexed += batch.len();
4171 self.search.results.push(batch.clone());
4172 }
4173 let limit = self.search_limit;
4176 let changed = match self.search.matches.as_mut() {
4177 Some(m) if m.query == self.filter && m.upto == start => m.extend(&batch, start, limit),
4178 _ => {
4179 let before = self.search.matches.as_ref().map(|m| m.upto);
4180 self.score_search_inline();
4181 self.search.matches.as_ref().map(|m| m.upto) != before
4182 }
4183 };
4184 let found_listed = self
4186 .sections
4187 .iter()
4188 .position(|s| s.title == Self::SEARCH_SECTION);
4189 match found_listed {
4190 Some(at) if !changed => {
4191 let empty = self.sections[at].rows.is_empty();
4192 self.sections[at].subtitle = Some(self.found_subtitle(empty));
4193 }
4194 _ => self.sync_search_section(),
4195 }
4196 self.settle_return();
4197 }
4198
4199 fn score_search_inline(&mut self) {
4201 if self.filter.is_empty() || self.search.scored_for(&self.filter) {
4202 return;
4203 }
4204 let (base, looks_at) = self.search.base_for(&self.filter);
4205 if looks_at > SCORE_INLINE_MAX {
4206 return;
4207 }
4208 let scored =
4209 crate::home::search::score(&self.search.results, &self.filter, base, self.search_limit);
4210 self.search.matches = Some(scored);
4211 }
4212
4213 pub fn score_job(&mut self) -> Option<ScoreJob> {
4216 if self.filter.is_empty() || self.search.scoring || self.search.scored_for(&self.filter) {
4217 return None;
4218 }
4219 let base = self.search.base_for(&self.filter).0.cloned();
4220 self.search.scoring = true;
4221 Some(ScoreJob {
4222 epoch: self.search.epoch,
4223 results: self.search.results.clone(),
4224 query: self.filter.clone(),
4225 base,
4226 limit: self.search_limit,
4227 })
4228 }
4229
4230 pub fn search_scored(&mut self, epoch: u64, scored: crate::home::search::Matches) {
4232 if epoch != self.search.epoch {
4233 return;
4234 }
4235 self.search.scoring = false;
4236 if self
4238 .search
4239 .matches
4240 .as_ref()
4241 .is_some_and(|m| m.query == scored.query && m.upto >= scored.upto)
4242 {
4243 return;
4244 }
4245 self.search.matches = Some(scored);
4246 self.sync_search_section();
4247 if !matches!(self.selected_row(), Some(Row::Entry { .. })) {
4249 self.select_first_entry();
4250 }
4251 self.settle_return();
4252 }
4253
4254 pub fn search_finished(&mut self, root: &Path, scanned: usize, limited: Option<String>) {
4256 if self.search.root.as_deref() != Some(root) {
4257 return;
4258 }
4259 self.search.running = false;
4260 self.search.done = true;
4261 self.search.scanned = self.search.scanned.max(scanned);
4263 self.search.limited = limited;
4264 self.sync_search_section();
4265 self.settle_return();
4266 }
4267
4268 pub fn visible(&self) -> Vec<Row<'_>> {
4271 let view = self.view();
4272 view.slots.iter().map(|slot| self.row(slot)).collect()
4273 }
4274
4275 pub fn list_lines(&self, spaced: bool) -> ListLines {
4279 let view = self.view();
4280 ListLines {
4281 headers: view.headers.clone(),
4282 rows: view.slots.len(),
4283 spaced,
4284 }
4285 }
4286
4287 pub fn matched(&self) -> usize {
4289 let view = self.view();
4290 (view.headers.iter())
4291 .map(|&i| match &view.slots[i] {
4292 Slot::Plain(Row::Header { matches, .. }) => *matches,
4293 _ => 0,
4294 })
4295 .sum()
4296 }
4297
4298 pub fn position(&self, mut wanted: impl FnMut(&Row<'_>) -> bool) -> Option<usize> {
4301 let view = self.view();
4302 view.slots.iter().position(|slot| wanted(&self.row(slot)))
4303 }
4304
4305 pub fn row_count(&self) -> usize {
4307 self.view().slots.len()
4308 }
4309
4310 pub fn row_at(&self, index: usize) -> Option<Row<'_>> {
4312 self.view().slots.get(index).map(|slot| self.row(slot))
4313 }
4314
4315 pub fn rows_built(&self) -> usize {
4318 self.rows_cache.builds.get()
4319 }
4320
4321 fn changed(&mut self) {
4323 *self.rows_cache.built.get_mut() = None;
4324 *self.rows_cache.rescored.get_mut() = None;
4325 }
4326
4327 fn take_hits(&mut self) -> Option<Hits> {
4330 let built = self.rows_cache.built.get_mut().take();
4331 match self.rows_cache.rescored.get_mut().take() {
4332 Some(hits) => Some(hits),
4333 None => built
4334 .filter(|view| view.key.matches(self))
4335 .map(|view| view.hits),
4336 }
4337 .filter(|hits| hits.filter == self.filter)
4338 }
4339
4340 fn rows_changed(&mut self, touched: &[(usize, usize)]) {
4344 let Some(mut hits) = self.take_hits() else {
4345 return;
4346 };
4347 let needle = Needle::new(&self.filter);
4348 for &(si, i) in touched {
4349 if let Some(Some(section)) = hits.sections.get_mut(si)
4350 && let Some(hit) = section.get_mut(i)
4351 {
4352 *hit = needle.hit(&self.sections[si].rows[i]);
4353 }
4354 }
4355 *self.rows_cache.rescored.get_mut() = Some(hits);
4356 }
4357
4358 pub fn set_catalogs(&mut self, catalogs: Vec<ShownCatalog>) {
4360 self.catalog_places = CatalogPlaces::of(&catalogs);
4361 self.catalogs = catalogs;
4362 self.changed();
4363 }
4364
4365 pub fn set_visits(&mut self, visits: std::collections::HashMap<PathBuf, crate::cache::Visits>) {
4367 self.visits = visits;
4368 self.changed();
4369 }
4370
4371 pub fn show_all(&mut self, section: usize) {
4373 match self.sections.get(section) {
4374 Some(s) if s.grouped_by_place => self.recent_expanded = true,
4375 Some(Section {
4376 root: Some(root), ..
4377 }) => {
4378 self.shown_whole.insert(root.clone());
4379 }
4380 _ => {}
4381 }
4382 }
4383
4384 pub fn cut_again(&mut self, section: usize) -> bool {
4387 let Some(key) = self.selected_key() else {
4388 return false;
4389 };
4390 let Some(s) = self.sections.get(section) else {
4391 return false;
4392 };
4393 let title = s.title.clone();
4394 let whole = if s.grouped_by_place {
4395 None
4396 } else {
4397 match s.root.clone() {
4398 Some(root) => Some(root),
4399 None => return false,
4400 }
4401 };
4402 let was_whole = match &whole {
4403 None => std::mem::replace(&mut self.recent_expanded, false),
4404 Some(root) => self.shown_whole.remove(root),
4405 };
4406 if !was_whole {
4407 return false;
4408 }
4409 if self.listed(&key).is_some() {
4410 match whole {
4411 None => self.recent_expanded = true,
4412 Some(root) => {
4413 self.shown_whole.insert(root);
4414 }
4415 }
4416 return false;
4417 }
4418 self.reselect(Some(RowKey::More(title)));
4419 true
4420 }
4421
4422 pub fn sections_mut(&mut self) -> &mut Vec<Section> {
4424 self.changed();
4425 &mut self.sections
4426 }
4427
4428 fn view(&self) -> std::cell::Ref<'_, View> {
4429 let fresh = self
4430 .rows_cache
4431 .built
4432 .borrow()
4433 .as_ref()
4434 .is_some_and(|view| view.key.matches(self));
4435 if !fresh {
4436 let view = self.build_view();
4437 self.rows_cache.builds.set(self.rows_cache.builds.get() + 1);
4438 *self.rows_cache.built.borrow_mut() = Some(view);
4439 }
4440 std::cell::Ref::map(self.rows_cache.built.borrow(), |view| {
4441 view.as_ref().expect("built above")
4442 })
4443 }
4444
4445 fn row<'a>(&'a self, slot: &Slot) -> Row<'a> {
4446 match slot {
4447 Slot::Plain(row) => row.clone(),
4448 Slot::Entry {
4449 section,
4450 index,
4451 nested,
4452 hit,
4453 } => Row::Entry {
4454 section: *section,
4455 entry: &self.sections[*section].rows[*index],
4456 nested: *nested,
4457 hit: *hit,
4458 },
4459 Slot::Door { section } => Row::Door {
4460 section: *section,
4461 entry: self.sections[*section]
4462 .door
4463 .as_ref()
4464 .expect("the shape says it has a door"),
4465 },
4466 }
4467 }
4468
4469 fn build_view(&self) -> View {
4470 let kept = (self.rows_cache.rescored.take()).filter(|hits| {
4472 hits.filter == self.filter && hits.sections.len() <= self.sections.len()
4473 });
4474 let hits = self.score_rows(kept);
4475 let slots = self.slots(&hits);
4476 View {
4477 key: ViewKey::of(self),
4478 headers: (slots.iter().enumerate())
4479 .filter(|(_, slot)| matches!(slot, Slot::Plain(Row::Header { .. })))
4480 .map(|(i, _)| i)
4481 .collect(),
4482 slots,
4483 hits,
4484 has_dataset: self
4485 .sections
4486 .iter()
4487 .flat_map(|s| s.rows.iter())
4488 .any(|e| e.kind.is_dataset() || e.kind.is_lake_table()),
4489 }
4490 }
4491
4492 fn score_rows(&self, kept: Option<Hits>) -> Hits {
4495 let needle = Needle::new(&self.filter);
4496 let mut kept = kept.map(|hits| hits.sections).unwrap_or_default();
4497 let sections =
4498 (self.sections.iter().enumerate())
4499 .map(|(si, section)| {
4500 let reused = (kept.get_mut(si).and_then(Option::take))
4501 .filter(|hits| hits.len() == section.rows.len());
4502 Some(reused.unwrap_or_else(|| {
4503 (section.rows.iter()).map(|row| needle.hit(row)).collect()
4504 }))
4505 })
4506 .collect();
4507 Hits {
4508 filter: self.filter.clone(),
4509 sections,
4510 }
4511 }
4512
4513 fn slots(&self, hits: &Hits) -> Vec<Slot> {
4514 let mut out: Vec<Slot> = Vec::new();
4515 for (si, section) in self.sections.iter().enumerate() {
4516 let mut matched: Vec<(usize, Hit)> = (section.rows.iter())
4517 .zip(hits.sections[si].iter().flatten())
4518 .enumerate()
4519 .filter(|(_, (row, _))| !(self.hide_unreadable && row.hidden_by_default()))
4520 .filter_map(|(i, (_, hit))| hit.map(|hit| (i, hit)))
4521 .collect();
4522
4523 let keep_empty = section.unavailable || section.waiting || section.origin.is_some();
4527 let has_door = section.door.is_some() && self.filter.is_empty();
4528 let hidden =
4531 if self.browsing.is_some() && self.hide_unreadable && self.filter.is_empty() {
4532 section
4533 .rows
4534 .iter()
4535 .filter(|row| row.hidden_by_default())
4536 .count()
4537 } else {
4538 0
4539 };
4540 let says_why = section.title == Self::SEARCH_SECTION;
4542 if matched.is_empty()
4543 && !has_door
4544 && hidden == 0
4545 && !says_why
4546 && !(keep_empty && self.filter.is_empty())
4547 {
4548 continue;
4549 }
4550
4551 let entry = |i: usize| §ion.rows[i];
4555 if !self.filter.is_empty() {
4556 let now = std::time::SystemTime::now()
4557 .duration_since(std::time::UNIX_EPOCH)
4558 .map(|d| d.as_secs())
4559 .unwrap_or_default();
4560 let lifted = |i: usize, score: i32| {
4561 let frecency = self
4562 .visits
4563 .get(&entry(i).path)
4564 .map_or(0.0, |v| v.frecency(now));
4565 score.saturating_add((frecency.min(10.0) * FRECENCY_LIFT) as i32)
4566 };
4567 matched.sort_by_cached_key(|(i, hit)| {
4568 (
4569 std::cmp::Reverse(lifted(*i, hit.score)),
4570 entry(*i).name.len(),
4571 )
4572 });
4573 }
4574
4575 match self.sort {
4578 SortMode::Natural => {}
4579 SortMode::Size => {
4580 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).size.unwrap_or(0)));
4581 }
4582 SortMode::Rows => {
4583 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).rows.unwrap_or(0)));
4584 }
4585 SortMode::Modified => {
4586 matched.sort_by_key(|(i, _)| {
4587 std::cmp::Reverse(
4588 entry(*i)
4589 .modified
4590 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
4591 .map(|d| d.as_secs())
4592 .unwrap_or(0),
4593 )
4594 });
4595 }
4596 }
4597
4598 let collapsed = self.section_folded(section);
4599 out.push(Slot::Plain(Row::Header {
4600 section: si,
4601 matches: matched.len(),
4603 collapsed,
4604 }));
4605 if collapsed {
4606 continue;
4607 }
4608 let root = section.root.as_deref();
4610 if self.filter.is_empty()
4611 && root
4612 .is_some_and(|root| self.browsing.is_some() || self.parent_of(root).is_some())
4613 {
4614 out.push(Slot::Plain(Row::Up { section: si }));
4615 }
4616 if has_door {
4619 out.push(Slot::Door { section: si });
4620 }
4621 let shown = match root {
4625 Some(_)
4626 if self.browsing.is_none()
4627 && self.filter.is_empty()
4628 && self.view_height > 0
4629 && !root.is_some_and(|root| self.shown_whole.contains(root)) =>
4630 {
4631 (self.view_height * 2 / 5).max(8)
4632 }
4633 _ => usize::MAX,
4634 };
4635 let rest = if matched.len() > shown.saturating_add(1) {
4636 matched.split_off(shown)
4637 } else {
4638 Vec::new()
4639 };
4640 let measuring = rest.iter().any(|(i, _)| self.sort_wants(entry(*i)));
4643 if section.grouped_by_place {
4644 self.slots_by_place(si, section, &matched, &mut out);
4645 } else {
4646 let in_order = self.sort == SortMode::Natural
4648 && self.filter.is_empty()
4649 && section.origin.is_some_and(is_catalog_origin)
4650 && section.root.is_none();
4651 out.extend(matched.into_iter().map(|(index, hit)| Slot::Entry {
4652 section: si,
4653 index,
4654 nested: in_order && self.bookmark(&entry(index).path).is_some(),
4655 hit,
4656 }));
4657 }
4658 if !rest.is_empty() {
4659 out.push(Slot::Plain(Row::More {
4660 section: si,
4661 hidden: rest.len(),
4662 places: 0,
4663 measuring,
4664 }));
4665 }
4666 if hidden > 0 {
4667 out.push(Slot::Plain(Row::Hidden {
4668 section: si,
4669 count: hidden,
4670 }));
4671 }
4672 }
4673 out
4674 }
4675
4676 fn slots_by_place(
4682 &self,
4683 si: usize,
4684 section: &Section,
4685 matched: &[(usize, Hit)],
4686 out: &mut Vec<Slot>,
4687 ) {
4688 let places: Vec<PathBuf> = section.rows.iter().map(|row| place_of(&row.path)).collect();
4689 let mut order: Vec<&PathBuf> = Vec::new();
4690 for place in &places {
4691 if !order.contains(&place) {
4692 order.push(place);
4693 }
4694 }
4695 let groups: Vec<(&PathBuf, Vec<&(usize, Hit)>)> = order
4696 .into_iter()
4697 .filter_map(|place| {
4698 let rows: Vec<&(usize, Hit)> = matched
4699 .iter()
4700 .filter(|(i, _)| places[*i] == *place)
4701 .collect();
4702 (!rows.is_empty()).then_some((place, rows))
4703 })
4704 .collect();
4705
4706 let capped = !self.recent_expanded && self.filter.is_empty() && self.view_height > 0;
4708 let budget = self.view_height / 3;
4709 let mut used = 0usize;
4710 let mut shown = 0usize;
4711 for (place, rows) in &groups {
4712 let cost = 1 + rows.len();
4713 if capped && shown > 0 && used + cost > budget {
4714 break;
4715 }
4716 out.push(Slot::Plain(Row::Place {
4717 section: si,
4718 path: (*place).clone(),
4719 label: section.place_labels.get(*place).cloned(),
4720 source: section.rows[rows[0].0].cost.source.clone(),
4722 held: places.iter().filter(|p| p == place).count(),
4723 }));
4724 out.extend(rows.iter().map(|(index, hit)| Slot::Entry {
4725 section: si,
4726 index: *index,
4727 nested: true,
4728 hit: *hit,
4729 }));
4730 used += cost;
4731 shown += 1;
4732 }
4733 if shown < groups.len() {
4734 out.push(Slot::Plain(Row::More {
4735 section: si,
4736 hidden: groups[shown..].iter().map(|(_, rows)| rows.len()).sum(),
4737 places: groups.len() - shown,
4738 measuring: false,
4739 }));
4740 }
4741 }
4742
4743 pub fn path_candidates(&self) -> Vec<&PathName> {
4746 let Some(listing) = self
4747 .path_listing
4748 .as_ref()
4749 .filter(|l| l.dir == typed_dir(&self.path_input))
4750 else {
4751 return Vec::new();
4752 };
4753 let segment = &self.path_input[listing.dir.len()..];
4754 let mut memo = (listing.matched.0.lock()).unwrap_or_else(|e| e.into_inner());
4755 let at = match memo.as_ref() {
4756 Some((typed, at)) if typed == segment => at.clone(),
4757 _ => {
4758 let at = path_matches(&listing.names, segment);
4759 *memo = Some((segment.to_string(), at.clone()));
4760 at
4761 }
4762 };
4763 drop(memo);
4764 at.iter().map(|&i| &listing.names[i]).collect()
4765 }
4766
4767 pub fn pick_first_path(&mut self) {
4770 self.path_pick = (!self.path_candidates().is_empty()).then_some(0);
4771 }
4772
4773 pub fn picked_path(&self) -> Option<String> {
4775 let pick = self.path_pick?;
4776 let name = *self.path_candidates().get(pick)?;
4777 let dir = typed_dir(&self.path_input);
4778 let mut path = format!("{dir}{}", name.name);
4779 if name.dir {
4780 path.push(separator_in(dir));
4781 }
4782 Some(path)
4783 }
4784
4785 pub fn path_completion(&self) -> Option<String> {
4788 let dir = typed_dir(&self.path_input);
4789 let segment = &self.path_input[dir.len()..];
4790 let candidates = self.path_candidates();
4791 match candidates.as_slice() {
4792 [] => None,
4793 [one] => {
4794 let mut path = format!("{dir}{}", one.name);
4795 if one.dir {
4796 path.push(separator_in(dir));
4797 }
4798 Some(path)
4799 }
4800 many => {
4801 let starting: Vec<&str> = many
4802 .iter()
4803 .map(|n| n.name.as_str())
4804 .filter(|n| n.starts_with(segment))
4805 .collect();
4806 let first = starting.first()?;
4807 let shared = starting
4808 .iter()
4809 .skip(1)
4810 .fold(first.to_string(), |acc, n| common_prefix(&acc, n));
4811 (shared.len() > segment.len()).then(|| format!("{dir}{shared}"))
4812 }
4813 }
4814 }
4815
4816 pub fn known_urls(&self) -> Vec<String> {
4819 let mut urls: Vec<String> = Vec::new();
4820 let mut add = |path: &Path| {
4821 let text = path.to_string_lossy();
4822 if text.contains("://") && !is_cloud_place(path) {
4823 urls.push(text.into_owned());
4824 }
4825 };
4826 for catalog in &self.catalogs {
4827 for dataset in &catalog.datasets {
4828 add(&dataset.location);
4829 }
4830 }
4831 for source in &self.cloud {
4832 for bucket in &source.buckets {
4833 add(bucket);
4834 }
4835 }
4836 for (root, rows) in self.probes.answered() {
4837 add(root);
4838 for row in rows.iter() {
4839 add(&row.path);
4840 }
4841 }
4842 for path in self.known.keys() {
4843 add(path);
4844 }
4845 for section in &self.sections {
4846 for row in §ion.rows {
4847 add(&row.path);
4848 }
4849 }
4850 urls
4851 }
4852
4853 pub fn selected_row(&self) -> Option<Row<'_>> {
4855 if self.resting {
4856 return None;
4857 }
4858 self.row_at(self.selected)
4859 }
4860
4861 pub fn cursor_row(&self) -> Option<Row<'_>> {
4863 self.row_at(self.selected)
4864 }
4865
4866 pub fn selected_entry(&self) -> Option<&Entry> {
4869 match self.selected_row()? {
4870 Row::Entry { entry, .. } | Row::Door { entry, .. } => Some(entry),
4871 _ => None,
4872 }
4873 }
4874
4875 pub fn selection_is_the_door(&self) -> bool {
4877 matches!(self.selected_row(), Some(Row::Door { .. }))
4878 }
4879
4880 pub fn recents_in(&self, place: &Path) -> Vec<PathBuf> {
4882 self.sections
4883 .iter()
4884 .filter(|s| s.grouped_by_place)
4885 .flat_map(|s| s.rows.iter())
4886 .filter(|row| place_of(&row.path) == place)
4887 .map(|row| row.path.clone())
4888 .collect()
4889 }
4890
4891 pub fn selected_section(&self) -> Option<usize> {
4893 self.selected_row().map(|r| r.section())
4894 }
4895
4896 pub fn selected_catalog(&self) -> Option<&ShownCatalog> {
4898 if !self.selection_is_header() {
4899 return None;
4900 }
4901 let section = self.sections.get(self.selected_section()?)?;
4902 let origin = section.origin?;
4903 self.catalogs
4904 .iter()
4905 .find(|c| c.label == section.title && c.origin_note() == origin)
4906 }
4907
4908 pub fn selection_is_header(&self) -> bool {
4910 matches!(self.selected_row(), Some(Row::Header { .. }))
4911 }
4912
4913 pub fn pending_probes(&self) -> Vec<PathBuf> {
4916 let check = self.network_check;
4917 let mut out = Vec::new();
4918 for root in self.sections.iter().filter_map(|s| s.remote_root.as_ref()) {
4920 if !self.probes.settled(root) && !out.contains(root) {
4921 out.push(root.clone());
4922 }
4923 }
4924 if let Some(dir) = &self.browsing
4927 && check(dir)
4928 && cloud_source_id(dir).is_none()
4929 && !self.probes.settled(dir)
4930 && !out.contains(dir)
4931 {
4932 out.push(dir.clone());
4933 }
4934 out
4935 }
4936
4937 pub fn below_browse_start(&self) -> bool {
4940 let (Some(dir), Some(start)) = (&self.browsing, &self.browse_start) else {
4941 return false;
4942 };
4943 if dir == start {
4944 return false;
4945 }
4946 let mut current = self.parent_of(dir);
4948 let mut steps = 0;
4949 while let Some(place) = current {
4950 if &place == start {
4951 return true;
4952 }
4953 steps += 1;
4954 if steps > 64 {
4955 break;
4956 }
4957 current = self.parent_of(&place);
4958 }
4959 false
4960 }
4961
4962 pub fn sections_waiting(&self) -> bool {
4964 self.sections.iter().any(|s| s.waiting)
4965 }
4966
4967 pub fn awaiting_listing(&self) -> Option<&Path> {
4969 let dir = self.browsing.as_deref()?;
4970 if cloud_source_id(dir).is_some() {
4971 return self
4972 .cloud_source_of(dir)
4973 .is_some_and(|s| s.status == CloudStatus::Listing && s.buckets.is_empty())
4974 .then_some(dir);
4975 }
4976 ((self.network_check)(dir) && !self.probes.settled(dir)).then_some(dir)
4977 }
4978
4979 pub fn probe_ready(&mut self, root: PathBuf, rows: Vec<Entry>, cut_short: bool) {
4981 self.probes.insert(
4982 root.clone(),
4983 Probe::Listed {
4984 rows: rows.into(),
4985 cut_short,
4986 },
4987 );
4988 self.apply_cloud_kinds(&root);
4989 }
4990
4991 pub fn take_cloud_kinds(&mut self) {
4995 let roots: Vec<PathBuf> = (self.probes.answered())
4996 .map(|(root, _)| root.clone())
4997 .collect();
4998 for root in &roots {
4999 self.apply_cloud_kinds(root);
5000 }
5001 let mut labeled = false;
5002 for section in &mut self.sections {
5003 for row in &mut section.rows {
5004 if row.kind == EntryKind::Directory
5005 && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
5006 {
5007 row.kind = *kind;
5008 if !holds.is_empty() {
5009 row.holds = holds.clone();
5010 }
5011 labeled = true;
5012 }
5013 }
5014 }
5015 if labeled {
5016 self.changed();
5017 }
5018 if let Some(dir) = self.browsing.clone()
5020 && roots.contains(&dir)
5021 {
5022 self.relist_remote(&dir);
5023 }
5024 }
5025
5026 pub fn apply_cloud_kinds(&mut self, root: &Path) {
5028 let Some(rows) = self.probes.listed_mut(root) else {
5029 return;
5030 };
5031 for row in std::sync::Arc::make_mut(rows).iter_mut() {
5033 if row.kind == EntryKind::Directory
5034 && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
5035 {
5036 row.kind = *kind;
5037 if !holds.is_empty() {
5040 row.holds = holds.clone();
5041 }
5042 }
5043 }
5044 }
5045
5046 pub fn cloud_directories_to_peek(&self, limit: usize) -> Vec<PathBuf> {
5050 if limit == 0 {
5051 return Vec::new();
5052 }
5053 let view = self.view();
5054 let mut out: Vec<PathBuf> = Vec::new();
5055 for entry in self.entries_near_cursor(&view, limit) {
5056 if !is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
5059 continue;
5060 }
5061 if !matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown) {
5062 continue;
5063 }
5064 if self.browsing.is_none()
5067 && (self.catalog_dataset(&entry.path).is_some()
5068 || self.bookmark(&entry.path).is_some())
5069 {
5070 continue;
5071 }
5072 if self.cloud_kinds.contains_key(&entry.path)
5074 || self.peeking.contains(&entry.path)
5075 || self.peek_failed.contains(&entry.path)
5076 {
5077 continue;
5078 }
5079 if out.contains(&entry.path) {
5080 continue;
5081 }
5082 out.push(entry.path.clone());
5083 if out.len() >= limit {
5084 break;
5085 }
5086 }
5087 out
5088 }
5089
5090 pub fn probe_failed(&mut self, root: PathBuf, why: Option<String>) {
5092 self.probes.insert(root, Probe::Unreachable(why));
5093 }
5094
5095 pub fn measure_now(&mut self, limit: usize) -> bool {
5099 let wanted = self.unmeasured_visible(limit);
5100 let more = self.unmeasured_visible(limit + 1).len() > wanted.len();
5101 for entry in wanted {
5102 let mut probe = entry.clone();
5103 stat_and_recall(&mut probe, &self.known);
5104 if probe.rows.is_none() && probe.columns.is_empty() {
5105 discover::enrich(&mut probe);
5106 }
5107 self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
5108 }
5109 self.apply_new_measurements();
5110 more
5111 }
5112
5113 pub fn unmeasured_visible(&self, limit: usize) -> Vec<Entry> {
5120 let view = self.view();
5121 let mut out: Vec<Entry> = Vec::new();
5122 for entry in self.entries_on_screen(&view) {
5123 if self.wants_measuring(entry) && !out.iter().any(|e| e.path == entry.path) {
5124 out.push(entry.clone());
5125 if out.len() >= limit {
5126 return out;
5127 }
5128 }
5129 }
5130 if self.sort == SortMode::Rows {
5131 self.sorted_rest(&view, limit, &mut out);
5132 }
5133 out
5134 }
5135
5136 pub fn unstated_for_sort(&self, limit: usize) -> Vec<Entry> {
5140 let view = self.view();
5141 let mut out: Vec<Entry> = Vec::new();
5142 for entry in self.entries_on_screen(&view) {
5143 let stale =
5144 (self.stated.get(&entry.path)).is_some_and(|stated| stated.epoch < self.stat_epoch);
5145 if stale && self.needs_stat(entry) && !out.iter().any(|e| e.path == entry.path) {
5146 out.push(entry.clone());
5147 if out.len() >= limit {
5148 break;
5149 }
5150 }
5151 }
5152 if out.len() < limit && matches!(self.sort, SortMode::Size | SortMode::Modified) {
5153 self.sorted_rest(&view, limit, &mut out);
5154 }
5155 for entry in &mut out {
5156 entry.modified = None;
5157 }
5158 out
5159 }
5160
5161 fn sorted_rest(&self, view: &View, limit: usize, out: &mut Vec<Entry>) {
5163 let mut take = |entry: &Entry| {
5164 if self.sort_wants(entry) && !out.iter().any(|e| e.path == entry.path) {
5165 out.push(entry.clone());
5166 }
5167 out.len() >= limit
5168 };
5169 for entry in view.slots.iter().filter_map(|slot| self.entry_of(slot)) {
5170 if take(entry) {
5171 return;
5172 }
5173 }
5174 for slot in &view.slots {
5175 let Slot::Plain(Row::More {
5176 section,
5177 measuring: true,
5178 ..
5179 }) = slot
5180 else {
5181 continue;
5182 };
5183 for entry in &self.sections[*section].rows {
5184 if take(entry) {
5185 return;
5186 }
5187 }
5188 }
5189 }
5190
5191 fn sort_wants(&self, entry: &Entry) -> bool {
5194 match self.sort {
5195 SortMode::Natural => false,
5196 SortMode::Rows => self.wants_measuring(entry),
5197 SortMode::Size | SortMode::Modified => {
5198 (self.sort == SortMode::Modified || entry.kind != EntryKind::Unknown)
5199 && self.needs_stat(entry)
5200 }
5201 }
5202 }
5203
5204 fn needs_stat(&self, entry: &Entry) -> bool {
5207 let due = match self.stated.get(&entry.path) {
5208 Some(stated) => stated.epoch < self.stat_epoch,
5209 None => entry.modified.is_none(),
5210 };
5211 due && discover::on_disk(entry) && !(self.network_check)(&entry.path)
5212 }
5213
5214 fn wants_measuring(&self, entry: &Entry) -> bool {
5217 if entry.measured || self.enriched.contains_key(&entry.path) {
5218 return false;
5219 }
5220 if entry.kind != EntryKind::Unknown
5222 && discover::unstated(entry)
5223 && !self.stated.contains_key(&entry.path)
5224 && !(self.network_check)(&entry.path)
5225 {
5226 return true;
5227 }
5228 if entry.rows.is_some() {
5229 return false;
5230 }
5231 if matches!(
5234 entry.kind,
5235 EntryKind::Directory | EntryKind::Unknown | EntryKind::Other
5236 ) || entry.kind.is_lake_table()
5237 {
5238 return false;
5239 }
5240 !(self.network_check)(&entry.path)
5243 }
5244
5245 pub fn classify_now(&mut self, limit: usize) -> bool {
5248 let wanted = self.unclassified_visible(limit);
5249 let more = self.unclassified_visible(limit + 1).len() > wanted.len();
5250 for entry in wanted {
5251 let mut stated = entry.clone();
5252 stat_and_recall(&mut stated, &self.known);
5253 let probe = look_into_as(&stated, &Default::default());
5254 self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
5255 }
5256 self.apply_new_measurements();
5257 more
5258 }
5259
5260 pub fn unclassified_visible(&self, limit: usize) -> Vec<Entry> {
5266 self.unclassified_visible_where(limit, |_| true)
5267 }
5268
5269 pub fn unclassified_visible_where(
5271 &self,
5272 limit: usize,
5273 keep: impl Fn(&Entry) -> bool,
5274 ) -> Vec<Entry> {
5275 if limit == 0 {
5276 return Vec::new();
5277 }
5278 let view = self.view();
5279 let mut out: Vec<Entry> = Vec::new();
5280 for entry in self.entries_near_cursor(&view, limit) {
5281 if entry.kind != EntryKind::Unknown
5282 || self.missing.contains(&entry.path)
5283 || !keep(entry)
5284 {
5285 continue;
5286 }
5287 if self.enriched.contains_key(&entry.path) {
5289 continue;
5290 }
5291 if is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
5294 continue;
5295 }
5296 if out.iter().any(|e| e.path == entry.path) {
5298 continue;
5299 }
5300 out.push(entry.clone());
5301 if out.len() >= limit {
5302 break;
5303 }
5304 }
5305 out
5306 }
5307
5308 fn entries_on_screen<'a>(&'a self, view: &'a View) -> impl Iterator<Item = &'a Entry> + 'a {
5311 let height = if self.view_height == 0 {
5314 usize::MAX / 4
5315 } else {
5316 self.view_height
5317 };
5318 let rows = view.slots.len();
5319 let top = self.scroll.min(rows);
5320 let reach = height / 2;
5321 let bottom = top.saturating_add(height + reach).min(rows);
5322 let above = top.saturating_sub(reach);
5323 std::iter::once(self.selected)
5324 .chain(top..bottom)
5325 .chain(above..top)
5326 .filter_map(|i| self.entry_of(view.slots.get(i)?))
5327 }
5328
5329 fn entries_near_cursor<'a>(
5332 &'a self,
5333 view: &'a View,
5334 limit: usize,
5335 ) -> impl Iterator<Item = &'a Entry> + 'a {
5336 let height = if self.view_height == 0 {
5339 limit
5340 } else {
5341 self.view_height
5342 };
5343 let rows = view.slots.len();
5344 let top = self.scroll.min(rows);
5345 let ahead = top.saturating_add(2 * height).min(rows);
5346 let behind = top.saturating_sub(height);
5347 std::iter::once(self.selected)
5348 .chain(top..ahead)
5349 .chain(behind..top)
5350 .filter_map(|i| self.entry_of(view.slots.get(i)?))
5351 }
5352
5353 fn entry_of(&self, slot: &Slot) -> Option<&Entry> {
5355 match slot {
5356 Slot::Entry { section, index, .. } => Some(&self.sections[*section].rows[*index]),
5357 _ => None,
5358 }
5359 }
5360
5361 pub fn record_measurement(&mut self, path: PathBuf, measured: Measured) {
5364 self.unapplied.insert(path.clone());
5365 let before = self.stated.insert(
5366 path.clone(),
5367 Stated {
5368 size: measured.size,
5369 modified: measured.modified,
5370 epoch: self.stat_epoch,
5371 },
5372 );
5373 if !measured.stat_only {
5374 self.stale.remove(&path);
5375 self.enriched.insert(path, measured);
5376 return;
5377 }
5378 let changed = before.is_some_and(|before| {
5381 before.modified.is_some()
5382 && (before.size, before.modified) != (measured.size, measured.modified)
5383 });
5384 if changed && self.enriched.remove(&path).is_some() {
5385 self.stale.insert(path);
5386 }
5387 }
5388
5389 pub fn record_size(&mut self, path: PathBuf, measured: Measured) {
5391 match self.enriched.get_mut(&path) {
5392 Some(known) => {
5393 known.size = measured.size;
5394 self.unapplied.insert(path);
5395 }
5396 None => self.record_measurement(path, measured),
5397 }
5398 }
5399
5400 pub fn apply_measurements(&mut self) {
5403 self.unapplied.clear();
5404 for section in &mut self.sections {
5405 for row in section.rows.iter_mut().chain(section.door.iter_mut()) {
5408 fold_known(row, &self.enriched, &self.stated, &self.stale);
5409 }
5410 if let Some(door) = section.door.as_mut() {
5413 door.name = door_name(door, §ion.rows);
5414 }
5415 }
5416 self.changed();
5417 self.land_again();
5418 }
5419
5420 pub fn apply_new_measurements(&mut self) {
5424 if self.unapplied.is_empty() {
5425 return;
5426 }
5427 let unapplied = std::mem::take(&mut self.unapplied);
5428 let names: std::collections::HashSet<&std::ffi::OsStr> = (unapplied.iter())
5432 .map(|path| path.file_name().unwrap_or(path.as_os_str()))
5433 .collect();
5434 let new = |path: &Path| {
5435 path.file_name().is_none_or(|name| names.contains(name)) && unapplied.contains(path)
5436 };
5437 let mut touched: Vec<(usize, usize)> = Vec::new();
5438 let mut any = false;
5439 let mut moved = false;
5440 let sort = self.sort;
5441 for (si, section) in self.sections.iter_mut().enumerate() {
5442 let mut here = false;
5443 for (i, row) in section.rows.iter_mut().enumerate() {
5444 if !new(&row.path) {
5445 continue;
5446 }
5447 let before = Standing::of(row, sort);
5448 if fold_known(row, &self.enriched, &self.stated, &self.stale) {
5449 moved |= Standing::of(row, sort) != before;
5450 touched.push((si, i));
5451 here = true;
5452 }
5453 }
5454 if let Some(door) = section.door.as_mut()
5455 && new(&door.path)
5456 {
5457 here |= fold_known(door, &self.enriched, &self.stated, &self.stale);
5458 }
5459 if here && let Some(door) = section.door.as_mut() {
5460 door.name = door_name(door, §ion.rows);
5461 }
5462 any |= here;
5463 }
5464 if !any {
5465 return;
5466 }
5467 if moved || sort != SortMode::Natural || !self.hits_hold(&touched) {
5472 self.rows_changed(&touched);
5473 }
5474 self.land_again();
5475 }
5476
5477 fn hits_hold(&self, touched: &[(usize, usize)]) -> bool {
5481 let built = self.rows_cache.built.borrow();
5482 let Some(view) = built.as_ref().filter(|view| view.key.matches(self)) else {
5483 return false;
5484 };
5485 let needle = Needle::new(&self.filter);
5486 touched.iter().all(|&(si, i)| {
5487 let was = view.hits.sections.get(si).and_then(|s| s.as_ref()?.get(i));
5488 was == Some(&needle.hit(&self.sections[si].rows[i]))
5489 })
5490 }
5491
5492 fn land_again(&mut self) {
5495 if self.landing
5496 && let Some(Row::Door { entry, .. }) = self.row_at(self.selected)
5497 && !door_lands(entry)
5498 {
5499 self.selected = self.landing_row();
5500 self.follow_selection();
5501 }
5502 }
5503
5504 pub fn select_first_entry(&mut self) {
5510 self.returning = None;
5511 self.landing = true;
5512 if !self.filter.is_empty() {
5514 self.resting = false;
5515 }
5516 self.selected = self.landing_row();
5517 }
5518
5519 fn landing_row(&self) -> usize {
5521 if self.filter.is_empty()
5523 && let Some(newest) = self.newest_recent.as_ref()
5524 && let Some(at) = self.position(|r| {
5525 matches!(r, Row::Entry { section, entry, .. }
5526 if entry.path == *newest
5527 && self.sections[*section].title == Self::RECENT_SECTION)
5528 })
5529 {
5530 return at;
5531 }
5532 let first = self.position(|r| matches!(r, Row::Entry { .. } | Row::Door { .. }));
5533 let first = match first.and_then(|i| self.row_at(i)) {
5534 Some(Row::Door { entry, .. }) if !door_lands(entry) => self
5535 .position(|r| matches!(r, Row::Entry { .. } | Row::Hidden { .. }))
5536 .or(first),
5537 _ => first,
5538 };
5539 first
5541 .or_else(|| self.position(|r| matches!(r, Row::Hidden { .. })))
5542 .unwrap_or(0)
5543 }
5544
5545 pub fn clamp_selection(&mut self) {
5546 let n = self.row_count();
5547 if n == 0 {
5548 self.selected = 0;
5549 } else if self.selected >= n {
5550 self.selected = n - 1;
5551 }
5552 }
5553
5554 pub fn select(&mut self, index: usize) {
5556 if index < self.row_count() {
5557 self.resting = false;
5558 self.returning = None;
5559 self.landing = false;
5560 self.selected = index;
5561 }
5562 }
5563
5564 pub fn move_selection(&mut self, delta: isize) {
5565 self.resting = false;
5566 self.returning = None;
5567 self.landing = false;
5568 let n = self.row_count();
5569 if n == 0 {
5570 return;
5571 }
5572 let cur = self.selected as isize;
5573 let next = (cur + delta).rem_euclid(n as isize);
5574 self.selected = next as usize;
5575 }
5576
5577 pub fn page_selection(&mut self, delta: isize) {
5580 self.resting = false;
5581 self.returning = None;
5582 self.landing = false;
5583 let n = self.row_count();
5584 if n == 0 {
5585 return;
5586 }
5587 let next = (self.selected as isize)
5589 .saturating_add(delta)
5590 .clamp(0, n as isize - 1);
5591 self.selected = next as usize;
5592 }
5593}
5594
5595fn source_entry(source: &CloudSource) -> Entry {
5597 Entry {
5598 path: cloud_place(&source.id),
5599 kind: EntryKind::Directory,
5600 name: source.label.clone(),
5601 size: None,
5602 modified: source.listed_at,
5603 rows: None,
5604 cols: None,
5605 cols_sampled: false,
5606 columns: Vec::new(),
5607 cost: Default::default(),
5608 holds: Default::default(),
5609 opens_whole_directory: false,
5610 measured: false,
5611 format_spec: None,
5612 table: None,
5613 }
5614}
5615
5616fn bucket_entry(url: &Path) -> Entry {
5618 let mut entry = Entry::directory(url);
5619 let text = url.to_string_lossy();
5621 let (_, plain) = crate::cloud::source::split_source_id(&text);
5622 entry.name = plain
5623 .rsplit('/')
5624 .find(|part| !part.is_empty())
5625 .unwrap_or("")
5626 .to_string();
5627 entry
5628}
5629
5630pub fn names_a_file(path: &Path) -> bool {
5633 let named = path.to_string_lossy();
5634 let dotted = !named.ends_with('/')
5637 && path
5638 .file_name()
5639 .map(|last| last.to_string_lossy())
5640 .is_some_and(|last| last.trim_start_matches('.').contains('.'));
5641 discover::is_data_file(path) || dotted
5642}
5643
5644fn entry_for_path(path: &Path, remote: bool) -> Entry {
5646 if !remote && let Some(table) = discover::table_row(path) {
5648 return table;
5649 }
5650 let mut holds = discover::Holds::default();
5651 let kind = if remote {
5654 if names_a_file(path) {
5659 EntryKind::File
5660 } else {
5661 EntryKind::Unknown
5662 }
5663 } else if path.is_dir() {
5664 let (kind, found) = discover::look_at_directory(path);
5665 holds = found;
5666 kind
5667 } else {
5668 EntryKind::File
5669 };
5670 let mut entry = Entry {
5671 path: path.to_path_buf(),
5672 kind,
5673 name: path
5674 .file_name()
5675 .map(|n| n.to_string_lossy().into_owned())
5676 .unwrap_or_else(|| path.to_string_lossy().into_owned()),
5677 size: None,
5678 modified: None,
5679 rows: None,
5680 cols: None,
5681 cols_sampled: false,
5682 columns: Vec::new(),
5683 cost: Default::default(),
5684 holds,
5685 opens_whole_directory: false,
5686 measured: false,
5687 format_spec: None,
5688 table: None,
5689 };
5690 if !remote && let Ok(meta) = std::fs::metadata(path) {
5691 if meta.is_file() {
5692 entry.size = Some(meta.len());
5693 }
5694 entry.modified = meta.modified().ok();
5695 }
5696 entry
5697}
5698
5699pub fn display_path(path: &Path) -> String {
5701 if let Some(home) = dirs::home_dir()
5702 && let Ok(rest) = path.strip_prefix(&home)
5703 {
5704 if rest.as_os_str().is_empty() {
5705 return "~".to_string();
5706 }
5707 return format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display());
5709 }
5710 path.display().to_string()
5711}
5712
5713pub fn complete_path(typed: &str) -> (String, usize) {
5717 let expanded = expand_user_path(typed);
5718 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5720 let typed_ends_in_sep = typed.ends_with(is_separator);
5721
5722 let (dir, prefix) = if typed_ends_in_sep {
5723 (expanded.clone(), String::new())
5724 } else {
5725 match (expanded.parent(), expanded.file_name()) {
5726 (Some(parent), Some(name)) => {
5727 (parent.to_path_buf(), name.to_string_lossy().into_owned())
5728 }
5729 _ => (expanded.clone(), String::new()),
5730 }
5731 };
5732
5733 let Ok(entries) = std::fs::read_dir(&dir) else {
5734 return (typed.to_string(), 0);
5735 };
5736
5737 let mut names: Vec<String> = entries
5738 .flatten()
5739 .filter_map(|e| {
5740 let name = e.file_name().to_string_lossy().into_owned();
5741 if name.starts_with('.') && !prefix.starts_with('.') {
5743 return None;
5744 }
5745 name.starts_with(&prefix).then_some(name)
5746 })
5747 .collect();
5748 if names.is_empty() {
5749 return (typed.to_string(), 0);
5750 }
5751 names.sort();
5752
5753 let shared = names
5755 .iter()
5756 .skip(1)
5757 .fold(names[0].clone(), |acc, name| common_prefix(&acc, name));
5758
5759 let mut completed = typed.to_string();
5760 completed.truncate(typed.len() - prefix.len());
5761 completed.push_str(&shared);
5762
5763 if names.len() == 1 && dir.join(&shared).is_dir() && !completed.ends_with(is_separator) {
5766 let separator = typed
5767 .chars()
5768 .rev()
5769 .find(|c| is_separator(*c))
5770 .unwrap_or(std::path::MAIN_SEPARATOR);
5771 completed.push(separator);
5772 }
5773 (completed, names.len())
5774}
5775
5776#[derive(Debug, Clone, PartialEq, Eq)]
5778pub struct PathName {
5779 pub name: String,
5780 pub dir: bool,
5782}
5783
5784#[derive(Debug, Clone, Default, PartialEq, Eq)]
5786pub struct PathListing {
5787 pub dir: String,
5789 pub names: Vec<PathName>,
5790 pub failed: bool,
5792 pub matched: SegmentMatches,
5794}
5795
5796#[derive(Debug, Default)]
5800pub struct SegmentMatches(std::sync::Mutex<Option<(String, std::sync::Arc<[usize]>)>>);
5801
5802impl Clone for SegmentMatches {
5803 fn clone(&self) -> Self {
5804 Self::default()
5805 }
5806}
5807
5808impl PartialEq for SegmentMatches {
5809 fn eq(&self, _: &Self) -> bool {
5810 true
5811 }
5812}
5813
5814impl Eq for SegmentMatches {}
5815
5816fn path_matches(names: &[PathName], segment: &str) -> std::sync::Arc<[usize]> {
5819 let mut matched: Vec<(usize, i32)> = (names.iter().enumerate())
5820 .filter(|(_, n)| !n.name.starts_with('.') || segment.starts_with('.'))
5821 .filter_map(|(i, n)| {
5822 if segment.is_empty() {
5823 return Some((i, 0));
5824 }
5825 let prefix = n.name.starts_with(segment) as i32 * 1_000_000;
5826 fuzzy_score(segment, &n.name).map(|score| (i, prefix + score))
5827 })
5828 .collect();
5829 matched
5830 .sort_by(|(a, sa), (b, sb)| sb.cmp(sa).then_with(|| names[*a].name.cmp(&names[*b].name)));
5831 matched.into_iter().map(|(i, _)| i).collect()
5832}
5833
5834const PATH_LISTING_MAX: usize = 5_000;
5836
5837pub fn typed_dir(typed: &str) -> &str {
5840 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5841 let floor = typed.find("://").map_or(0, |at| at + 3);
5842 match typed[floor..].rfind(is_separator) {
5843 Some(at) => &typed[..floor + at + 1],
5844 None => &typed[..floor],
5845 }
5846}
5847
5848fn separator_in(dir: &str) -> char {
5851 if typed_dir_is_url(dir) {
5852 return '/';
5853 }
5854 dir.chars()
5855 .rev()
5856 .find(|c| *c == '/' || (cfg!(windows) && *c == '\\'))
5857 .unwrap_or(std::path::MAIN_SEPARATOR)
5858}
5859
5860pub fn typed_dir_is_url(dir: &str) -> bool {
5862 dir.contains("://")
5863}
5864
5865pub fn list_typed_dir(dir: &str) -> PathListing {
5868 let path = if dir.is_empty() {
5869 PathBuf::from(".")
5870 } else {
5871 expand_user_path(dir)
5872 };
5873 let Ok(entries) = std::fs::read_dir(&path) else {
5874 return PathListing {
5875 dir: dir.to_string(),
5876 names: Vec::new(),
5877 failed: true,
5878 matched: Default::default(),
5879 };
5880 };
5881 let mut names: Vec<PathName> = entries
5882 .flatten()
5883 .take(PATH_LISTING_MAX)
5884 .map(|e| {
5885 let name = e.file_name().to_string_lossy().into_owned();
5886 let dir = e.file_type().is_ok_and(|t| t.is_dir())
5888 || (e.file_type().is_ok_and(|t| t.is_symlink()) && e.path().is_dir());
5889 PathName { name, dir }
5890 })
5891 .collect();
5892 names.sort_by(|a, b| a.name.cmp(&b.name));
5893 PathListing {
5894 dir: dir.to_string(),
5895 names,
5896 failed: false,
5897 matched: Default::default(),
5898 }
5899}
5900
5901pub fn names_under(dir: &str, urls: impl IntoIterator<Item = String>) -> PathListing {
5904 let mut names: Vec<PathName> = Vec::new();
5905 for url in urls {
5906 let forms = match crate::cloud::source::azure_parts(&url) {
5908 Some((_, container, key)) => vec![url.clone(), format!("az://{container}/{key}")],
5909 None => vec![url],
5910 };
5911 for form in forms {
5912 let Some(rest) = form.strip_prefix(dir) else {
5913 continue;
5914 };
5915 let (name, more) = match rest.split_once('/') {
5916 Some((name, more)) => (name, Some(more)),
5917 None => (rest, None),
5918 };
5919 if name.is_empty() {
5920 continue;
5921 }
5922 let is_dir = more.is_some() || !names_a_file(Path::new(&form));
5925 match names.iter_mut().find(|n| n.name == name) {
5926 Some(known) => known.dir |= is_dir,
5927 None => names.push(PathName {
5928 name: name.to_string(),
5929 dir: is_dir,
5930 }),
5931 }
5932 }
5933 }
5934 names.sort_by(|a, b| a.name.cmp(&b.name));
5935 PathListing {
5936 dir: dir.to_string(),
5937 names,
5938 failed: false,
5939 matched: Default::default(),
5940 }
5941}
5942
5943fn common_prefix(a: &str, b: &str) -> String {
5944 a.chars()
5945 .zip(b.chars())
5946 .take_while(|(x, y)| x == y)
5947 .map(|(x, _)| x)
5948 .collect()
5949}
5950
5951pub fn expand_user_path(raw: &str) -> PathBuf {
5953 crate::config::expand_config_path(raw)
5954}
5955
5956#[cfg(test)]
5957mod holds_flow_tests {
5958 use super::*;
5959
5960 #[test]
5963 fn a_rows_label_is_one_decision_for_the_list_and_the_pane() {
5964 let mut directory = Entry::for_test(Path::new("s3://bucket/warehouse"), "warehouse");
5965 directory.kind = EntryKind::Directory;
5966 let said = |look, place_kind| describe(&directory, place_kind, look, 0, None);
5967 let g = crate::glyphs::get();
5968
5969 let waiting = said(Some(CloudLook::Waiting), None);
5970 assert_eq!(
5971 (waiting.short.as_str(), waiting.words.as_str()),
5972 (g.ellipsis, "")
5973 );
5974 assert_eq!(said(Some(CloudLook::Looking), None).words, "");
5975 assert!(
5976 said(Some(CloudLook::Failed), None)
5977 .words
5978 .contains("listing failed")
5979 );
5980 assert_eq!(said(None, None).words, "directory");
5981 let curated = said(None, Some("dataset"));
5982 assert_eq!(
5983 (curated.short.as_str(), curated.words.as_str()),
5984 ("dataset", "dataset")
5985 );
5986 assert!(curated.curated);
5987
5988 directory.holds = crate::home::discover::Holds {
5989 formats: vec![("parquet".to_string(), 12)],
5990 ..Default::default()
5991 };
5992 let counted = describe(&directory, None, None, 0, None);
5993 assert_eq!(counted.short, "12 parquet");
5994 assert_eq!(
5995 counted.words, "directory",
5996 "the count is the pane's `contains` line"
5997 );
5998 let curated = describe(&directory, Some("dataset"), None, 0, None);
5999 assert_eq!(
6000 curated.short, "dataset",
6001 "the curated word wins over the count"
6002 );
6003
6004 directory.opens_whole_directory = true;
6005 assert_eq!(
6006 describe(&directory, Some("dataset"), None, 0, None),
6007 RowLabel::default()
6008 );
6009 }
6010
6011 #[cfg(windows)]
6014 #[test]
6015 fn a_windows_home_path_is_shown_and_typed_with_backslashes() {
6016 let home = dirs::home_dir().unwrap();
6017 let path = home.join("data").join("a.csv");
6018 let shown = display_path(&path);
6019 assert_eq!(shown, r"~\data\a.csv");
6020 assert_eq!(expand_user_path(&shown), path);
6021 }
6022
6023 #[cfg(windows)]
6026 #[test]
6027 fn a_dot_above_a_windows_recent_does_not_make_it_a_file() {
6028 let path = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\orders");
6029 assert_eq!(entry_for_path(path, true).kind, EntryKind::Unknown);
6030 let file = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\a.parquet");
6031 assert_eq!(entry_for_path(file, true).kind, EntryKind::File);
6032 }
6033
6034 fn counted(n: usize) -> crate::home::discover::Holds {
6035 crate::home::discover::Holds {
6036 formats: vec![("parquet".to_string(), n)],
6037 ..Default::default()
6038 }
6039 }
6040
6041 fn in_flight() -> (EntryKind, crate::home::discover::Holds) {
6044 (
6045 EntryKind::Directory,
6046 crate::home::discover::Holds::default(),
6047 )
6048 }
6049
6050 #[test]
6051 fn a_claim_staked_before_a_peek_lands_keeps_the_count_a_row_already_has() {
6052 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6053 let path = root.join("orders");
6054 let mut row = Entry::for_test(&path, "orders");
6055 row.kind = EntryKind::Directory;
6056 row.holds = counted(15);
6059
6060 let mut home = HomeState::default();
6061 home.probe_ready(root.clone(), vec![row], false);
6062 home.cloud_kinds.insert(path, in_flight());
6063 home.apply_cloud_kinds(&root);
6064
6065 assert_eq!(
6066 home.probes.listed(&root).unwrap()[0].holds.label(),
6067 "15 parquet",
6068 "the placeholder erased a count the row already had"
6069 );
6070 }
6071
6072 #[test]
6073 fn a_cloud_directory_waits_then_looks_then_answers() {
6074 let path = std::path::PathBuf::from("gs://pitscope/seasons");
6075 let mut row = Entry::for_test(&path, "seasons");
6076 row.kind = EntryKind::Directory;
6077 let mut home = HomeState::default();
6078
6079 assert_eq!(
6080 home.cloud_look(&row),
6081 Some(CloudLook::Waiting),
6082 "not asked yet"
6083 );
6084 home.peeking.insert(path.clone());
6085 assert_eq!(
6086 home.cloud_look(&row),
6087 Some(CloudLook::Looking),
6088 "being looked into"
6089 );
6090 home.peeking.remove(&path);
6091
6092 home.cloud_kinds
6095 .insert(path.clone(), (EntryKind::Directory, counted(12)));
6096 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
6097 home.cloud_kinds
6098 .insert(path.clone(), (EntryKind::Hive, Default::default()));
6099 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
6100
6101 home.cloud_kinds.insert(path.clone(), in_flight());
6103 assert_eq!(home.cloud_look(&row), None, "answered");
6104
6105 let mut home = HomeState {
6108 network_check: |_| true,
6109 ..Default::default()
6110 };
6111 let root = std::path::PathBuf::from("gs://pitscope");
6112 home.probe_ready(root.clone(), vec![row.clone()], false);
6113 home.browsing = Some(root);
6114 home.rebuild(&[]);
6115 assert_eq!(
6116 home.cloud_directories_to_peek(4),
6117 std::slice::from_ref(&path)
6118 );
6119 home.peek_failed.insert(path.clone());
6120 assert_eq!(home.cloud_look(&row), Some(CloudLook::Failed));
6121 assert!(home.cloud_directories_to_peek(4).is_empty());
6122
6123 let mut counted_row = Entry::for_test(&path.join("x"), "x");
6126 counted_row.kind = EntryKind::Directory;
6127 counted_row.holds = counted(3);
6128 assert_eq!(home.cloud_look(&counted_row), None);
6129 let mut bucket = Entry::for_test(std::path::Path::new("gs://pitscope"), "pitscope");
6130 bucket.kind = EntryKind::Directory;
6131 assert_eq!(home.cloud_look(&bucket), None);
6132 let mut local = Entry::for_test(std::path::Path::new("/data/seasons"), "seasons");
6133 local.kind = EntryKind::Directory;
6134 assert_eq!(home.cloud_look(&local), None);
6135 }
6136
6137 #[test]
6138 fn a_peeks_answer_replaces_the_count_a_row_had() {
6139 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6140 let path = root.join("orders");
6141 let mut row = Entry::for_test(&path, "orders");
6142 row.kind = EntryKind::Directory;
6143 row.holds = counted(15);
6144
6145 let mut home = HomeState::default();
6146 home.probe_ready(root.clone(), vec![row], false);
6147 home.cloud_kinds
6148 .insert(path, (EntryKind::MultiFile, counted(40)));
6149 home.apply_cloud_kinds(&root);
6150
6151 assert_eq!(
6152 home.probes.listed(&root).unwrap()[0].holds.label(),
6153 "40 parquet"
6154 );
6155 assert_eq!(
6156 home.probes.listed(&root).unwrap()[0].kind,
6157 EntryKind::MultiFile
6158 );
6159 }
6160
6161 #[test]
6162 fn a_peek_answers_only_the_rows_that_asked() {
6163 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6164 let settled = root.join("sales");
6168 let mut row = Entry::for_test(&settled, "sales");
6169 row.kind = EntryKind::Hive;
6170 row.holds = counted(40);
6171
6172 let mut home = HomeState::default();
6173 home.probe_ready(root.clone(), vec![row], false);
6174 home.cloud_kinds
6175 .insert(settled, (EntryKind::Directory, counted(1)));
6176 home.apply_cloud_kinds(&root);
6177
6178 assert_eq!(home.probes.listed(&root).unwrap()[0].kind, EntryKind::Hive);
6179 assert_eq!(
6180 home.probes.listed(&root).unwrap()[0].holds.label(),
6181 "40 parquet"
6182 );
6183 }
6184
6185 #[test]
6192 fn a_peek_goes_to_the_row_the_cursor_is_on_and_is_never_asked_twice() {
6193 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6194 let mut home = HomeState {
6195 network_check: |_| true,
6196 ..Default::default()
6197 };
6198 let rows: Vec<Entry> = ["a", "b", "c", "d", "e"]
6199 .iter()
6200 .map(|n| {
6201 let mut row = Entry::for_test(&root.join(n), n);
6202 row.kind = EntryKind::Directory;
6203 row
6204 })
6205 .collect();
6206 home.probe_ready(root.clone(), rows, false);
6207 home.browsing = Some(root.clone());
6208 home.view_height = 10;
6209 home.rebuild(&[]);
6210 home.cloud_kinds
6212 .insert(root.join("b"), (EntryKind::MultiFile, counted(3)));
6213 home.peeking.insert(root.join("c"));
6214
6215 home.selected = home
6218 .visible()
6219 .iter()
6220 .position(|r| matches!(r, Row::Entry { entry, .. } if entry.name == "e"))
6221 .expect("the row is listed");
6222 let asked = home.cloud_directories_to_peek(3);
6223 assert_eq!(
6224 asked.first(),
6225 Some(&root.join("e")),
6226 "the highlighted row is the one about to be acted on"
6227 );
6228 assert_eq!(asked.len(), 3, "the budget is a budget");
6229 assert!(
6230 !asked.contains(&root.join("b")),
6231 "a directory already looked into is not asked again"
6232 );
6233 assert!(
6234 !asked.contains(&root.join("c")),
6235 "nor one with a request already out"
6236 );
6237 }
6238
6239 #[test]
6240 fn a_measurement_that_counted_nothing_keeps_the_count_a_row_already_has() {
6241 let path = std::path::PathBuf::from("/data/warehouse/orders");
6242 let mut row = Entry::for_test(&path, "orders");
6243 row.kind = EntryKind::Directory;
6244 row.holds = counted(15);
6245
6246 let mut home = HomeState::default();
6247 home.sections.push(Section::titled("Here", vec![row]));
6248 home.enriched.insert(
6250 path,
6251 Measured {
6252 kind: Some(EntryKind::Directory),
6253 ..Default::default()
6254 },
6255 );
6256 home.apply_measurements();
6257
6258 assert_eq!(
6259 home.sections[0].rows[0].holds.label(),
6260 "15 parquet",
6261 "a measurement with nothing to say erased the label"
6262 );
6263 }
6264}
6265
6266#[cfg(test)]
6267mod look_into_batch_tests {
6268 use super::*;
6269 use polars::prelude::*;
6270
6271 #[test]
6274 fn every_kind_is_sent_before_any_count() {
6275 let dir = tempfile::tempdir().unwrap();
6276 let cache_dir = tempfile::tempdir().unwrap();
6277 let cache = crate::cache::CacheManager::with_dir(cache_dir.path().to_path_buf());
6278 let mut rows = Vec::new();
6279 for name in ["a", "b"] {
6280 let partition = dir.path().join(name).join("year=2024");
6281 std::fs::create_dir_all(&partition).unwrap();
6282 let mut frame = df!("x" => [1i32, 2, 3]).unwrap();
6283 let file = std::fs::File::create(partition.join("part.parquet")).unwrap();
6284 ParquetWriter::new(file).finish(&mut frame).unwrap();
6285 rows.push(Entry::new(dir.path().join(name), EntryKind::Unknown));
6286 }
6287
6288 let mut sent = Vec::new();
6289 look_into_batch(
6290 rows,
6291 &cache,
6292 &Default::default(),
6293 Reads::Files,
6294 |path, m| {
6295 let name = path.file_name().unwrap().to_string_lossy().into_owned();
6296 sent.push((name, m.kind, m.rows));
6297 },
6298 );
6299
6300 let hive = Some(EntryKind::Hive);
6301 assert_eq!(
6302 sent,
6303 vec![
6304 ("a".to_string(), hive, None),
6305 ("b".to_string(), hive, None),
6306 ("a".to_string(), hive, Some(3)),
6307 ("b".to_string(), hive, Some(3)),
6308 ]
6309 );
6310 }
6311}
6312
6313#[cfg(test)]
6314mod known_facts_tests {
6315 use super::*;
6316 use crate::cache::DatasetFacts;
6317
6318 #[test]
6326 fn what_a_directory_holds_is_restored_beside_its_kind() {
6327 let holds = crate::home::discover::Holds {
6328 formats: vec![("parquet".to_string(), 15)],
6329 ..Default::default()
6330 };
6331 for (path, remote) in [
6332 (
6333 std::path::PathBuf::from("s3://bucket/warehouse/orders"),
6334 true,
6335 ),
6336 (std::path::PathBuf::from("/data/warehouse/orders"), false),
6337 ] {
6338 let facts = DatasetFacts {
6339 mtime: 0,
6340 size: 4096,
6341 rows: Some(999),
6344 cols: Some(72),
6345 cols_sampled: false,
6346 columns: vec!["lat".to_string()],
6347 kind: Some(EntryKind::Directory),
6350 classified_by: crate::home::discover::CLASSIFIER_VERSION,
6351 holds: holds.clone(),
6352 cost: Default::default(),
6353 };
6354 let mut row = Entry::directory(&path);
6355 row.kind = EntryKind::Unknown;
6356 row.modified = Some(std::time::UNIX_EPOCH);
6357 assert_eq!(row.size, None);
6360 let index = std::collections::HashMap::from([(path.clone(), facts)]);
6361
6362 apply_known_facts(&mut row, &index, remote);
6363 assert_eq!(row.kind, EntryKind::Directory, "{path:?}");
6364 assert_eq!(row.label(), "15 parquet", "{path:?}");
6365 if !remote {
6371 assert_eq!(row.rows, None, "{path:?}");
6372 assert_eq!(row.cols, None, "{path:?}");
6373 assert_eq!(row.size, None, "{path:?}");
6374 assert!(row.columns.is_empty(), "{path:?}");
6375 }
6376 }
6377 }
6378
6379 #[test]
6384 fn a_datasets_counts_are_measured_rather_than_restored() {
6385 let path = std::path::PathBuf::from("/data/warehouse/events");
6386 let facts = DatasetFacts {
6387 mtime: 0,
6388 size: 4096,
6389 rows: Some(1_200_000),
6390 cols: Some(58),
6391 cols_sampled: false,
6392 columns: vec!["ts".to_string()],
6393 kind: Some(EntryKind::MultiFile),
6394 classified_by: crate::home::discover::CLASSIFIER_VERSION,
6395 holds: crate::home::discover::Holds {
6396 formats: vec![("parquet".to_string(), 15)],
6397 ..Default::default()
6398 },
6399 cost: Default::default(),
6400 };
6401 let mut row = Entry::directory(&path);
6402 row.kind = EntryKind::Unknown;
6403 row.modified = Some(std::time::UNIX_EPOCH);
6404 let index = std::collections::HashMap::from([(path.clone(), facts)]);
6405
6406 apply_known_facts(&mut row, &index, false);
6407 assert_eq!(row.kind, EntryKind::MultiFile, "the kind comes back");
6408 assert_eq!(row.label(), "15 parquet", "and what it holds");
6409 assert_eq!(
6410 row.rows, None,
6411 "but not the count: the measuring pass skips a row that has one"
6412 );
6413 assert_eq!(row.cols, None);
6414 }
6415
6416 #[test]
6425 fn a_kind_from_an_older_classifier_is_not_restored() {
6426 let remote = std::path::PathBuf::from("s3://bucket/warehouse/orders");
6427 let facts = |classified_by| DatasetFacts {
6428 mtime: 0,
6429 size: 4096,
6430 rows: Some(1_000),
6431 cols: Some(7),
6432 cols_sampled: false,
6433 columns: vec!["id".into(), "amount".into()],
6434 kind: Some(EntryKind::MultiFile),
6435 classified_by,
6436 cost: Default::default(),
6437 holds: Default::default(),
6438 };
6439 let unprobed = || {
6440 let mut row = Entry::directory(&remote);
6441 row.kind = EntryKind::Unknown;
6442 row
6443 };
6444
6445 let index = |classified_by| {
6446 std::collections::HashMap::from([(remote.clone(), facts(classified_by))])
6447 };
6448
6449 let mut row = unprobed();
6450 apply_known_facts(
6451 &mut row,
6452 &index(crate::home::discover::CLASSIFIER_VERSION),
6453 true,
6454 );
6455 assert_eq!(
6456 row.kind,
6457 EntryKind::MultiFile,
6458 "this build's own answer comes back"
6459 );
6460
6461 let mut row = unprobed();
6462 apply_known_facts(&mut row, &index(0), true);
6463 assert_eq!(
6464 row.kind,
6465 EntryKind::Unknown,
6466 "an older build's does not: it may be a lake table this one would recognize"
6467 );
6468 assert_eq!(
6469 row.rows,
6470 Some(1_000),
6471 "but what it measured is still measured"
6472 );
6473 assert_eq!(row.columns, vec!["id".to_string(), "amount".to_string()]);
6474 }
6475}
6476
6477#[cfg(test)]
6478mod build_feature_tests {
6479 use super::*;
6480
6481 #[test]
6484 fn the_builtin_catalog_lists_only_what_this_build_opens() {
6485 let urls: Vec<String> = catalogs(&crate::config::AppConfig::default())
6486 .into_iter()
6487 .filter(|c| c.origin == crate::home::catalog::Origin::Bundled)
6488 .flat_map(|c| c.datasets)
6489 .map(|d| d.location.to_string_lossy().into_owned())
6490 .collect();
6491 let web = urls.iter().filter(|u| u.starts_with("https://")).count();
6492 let stores = urls
6493 .iter()
6494 .filter(|u| is_object_store_url(Path::new(u)))
6495 .count();
6496 assert_eq!(web + stores, urls.len(), "{urls:?}");
6497 assert_eq!(web > 0, cfg!(feature = "http"), "{urls:?}");
6498 assert_eq!(stores > 0, cfg!(feature = "cloud"), "{urls:?}");
6499 }
6500
6501 #[test]
6504 fn an_empty_examples_toml_hides_the_section() {
6505 let mut config = crate::config::AppConfig::default();
6506 assert_eq!(
6508 catalogs(&config)
6509 .iter()
6510 .any(|c| c.origin == crate::home::catalog::Origin::Bundled),
6511 cfg!(any(feature = "http", feature = "cloud"))
6512 );
6513 config.read_catalogs = vec![
6514 crate::home::catalog::parse(
6515 "label = \"Mine\"\n",
6516 crate::home::catalog::EXAMPLES,
6517 crate::home::catalog::Origin::Folder,
6518 None,
6519 )
6520 .unwrap(),
6521 ];
6522 assert!(catalogs(&config).is_empty(), "{:?}", catalogs(&config));
6523 }
6524
6525 #[test]
6528 fn a_users_catalog_is_shown_whole() {
6529 let mut config = crate::config::AppConfig::default();
6530 let mine = crate::home::catalog::parse(
6531 r#"
6532 [bucket]
6533 name = "Bucket"
6534 url = "s3://bucket/prefix/"
6535 [web]
6536 name = "Web"
6537 url = "https://example.com/data.csv"
6538 "#,
6539 crate::home::catalog::MINE,
6540 crate::home::catalog::Origin::Mine,
6541 None,
6542 )
6543 .unwrap();
6544 config.read_catalogs = vec![mine];
6545 let shown = catalogs(&config);
6546 let mine = shown.iter().find(|c| c.id == "mine").unwrap();
6547 assert_eq!(mine.datasets.len(), 2);
6548 assert_eq!(mine.label, crate::home::catalog::MINE_LABEL);
6549 }
6550}
6551
6552#[cfg(test)]
6553mod place_tests {
6554 use super::same_place;
6555 use std::path::Path;
6556
6557 #[test]
6558 fn local_paths_are_one_place_however_spelled() {
6559 assert!(same_place(
6560 Path::new("/data/./sales/"),
6561 Path::new("/data/sales")
6562 ));
6563 assert!(!same_place(
6564 Path::new("/data/sales"),
6565 Path::new("/data/sale")
6566 ));
6567 assert!(same_place(
6568 Path::new("s3://bucket/dir/"),
6569 Path::new("s3://bucket/dir")
6570 ));
6571 if cfg!(windows) {
6572 assert!(same_place(
6573 Path::new("c:/data/sales.csv"),
6574 Path::new(r"C:\data\sales.csv")
6575 ));
6576 }
6577 }
6578}