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
779#[doc(hidden)]
782pub fn network_fs_for_test(mountinfo: &str, path: &Path) -> bool {
783 crate::home::locality::Mounts::parse(mountinfo).is_network(path)
784}
785
786#[derive(Debug, Clone)]
788pub struct Root {
789 pub path: PathBuf,
790 pub origin: RootOrigin,
791 pub network: bool,
793 pub available: bool,
796}
797
798#[derive(Debug, Clone, Default)]
800pub struct Section {
801 pub title: String,
802 pub subtitle: Option<String>,
805 pub origin: Option<&'static str>,
808 pub root: Option<PathBuf>,
811 pub rows: Vec<Entry>,
812 pub door: Option<Entry>,
817 pub unavailable: bool,
819 pub unavailable_note: Option<String>,
822 pub folded_by_default: bool,
825 pub remote_root: Option<PathBuf>,
827 pub waiting: bool,
829 pub grouped_by_place: bool,
832 pub place_labels: std::collections::HashMap<PathBuf, String>,
835}
836
837impl Section {
838 pub fn titled(title: impl Into<String>, rows: Vec<Entry>) -> Self {
840 Section {
841 title: title.into(),
842 rows,
843 ..Default::default()
844 }
845 }
846}
847
848#[derive(Debug, Clone, PartialEq, Eq, Default)]
850pub enum CloudStatus {
851 #[default]
853 Listing,
854 Unlisted,
857 Listed,
859 Failed { short: String, detail: String },
862}
863
864#[derive(Debug, Clone, Default, PartialEq, Eq)]
868pub struct CloudSource {
869 pub id: String,
871 pub label: String,
873 pub api: crate::cloud::source::ProviderKind,
875 pub note: String,
877 pub buckets: Vec<PathBuf>,
879 pub status: CloudStatus,
880 pub listed_at: Option<std::time::SystemTime>,
882 pub refreshing: bool,
884 pub asked: bool,
886 pub details: Vec<(String, String)>,
888 pub place_details: std::collections::HashMap<PathBuf, Vec<(String, String)>>,
891}
892
893impl CloudSource {
894 pub fn count_text(&self) -> String {
896 match &self.status {
897 CloudStatus::Failed { short, .. } if self.buckets.is_empty() => short.clone(),
898 CloudStatus::Listing if self.buckets.is_empty() => String::new(),
899 CloudStatus::Unlisted if self.buckets.is_empty() => "not listed".to_string(),
900 _ => {
901 let (one, many) = match self.api {
902 crate::cloud::source::ProviderKind::Azure => ("account", "accounts"),
903 crate::cloud::source::ProviderKind::Gcs => ("project", "projects"),
904 crate::cloud::source::ProviderKind::S3 => ("bucket", "buckets"),
905 };
906 match self.buckets.len() {
907 0 => format!("no {many}"),
908 1 => format!("1 {one}"),
909 n => format!("{n} {many}"),
910 }
911 }
912 }
913 }
914
915 pub fn begin_listing(&mut self) {
918 self.asked = true;
919 if self.status == CloudStatus::Unlisted && self.buckets.is_empty() {
920 self.status = CloudStatus::Listing;
921 } else {
922 self.refreshing = true;
923 }
924 }
925
926 pub fn busy(&self) -> bool {
928 self.refreshing || (self.status == CloudStatus::Listing && self.buckets.is_empty())
929 }
930
931 pub fn failed(&self) -> bool {
933 matches!(self.status, CloudStatus::Failed { .. })
934 }
935}
936
937#[derive(Debug, Clone, PartialEq)]
940pub struct ShownCatalog {
941 pub id: String,
943 pub label: String,
945 pub origin: crate::home::catalog::Origin,
947 pub description: String,
949 pub file: Option<PathBuf>,
951 pub datasets: Vec<ShownDataset>,
952 pub broken: Option<String>,
954}
955
956#[derive(Debug, Clone, Default, PartialEq)]
958pub struct ShownDataset {
959 pub name: String,
961 pub location: PathBuf,
963 pub details: Vec<(String, String)>,
965 pub size: Option<u64>,
967 pub codebook: Option<std::sync::Arc<crate::home::codebook::Codebook>>,
969 pub bookmarks: Vec<(String, PathBuf)>,
971 pub entry: std::sync::Arc<crate::home::catalog::Dataset>,
973}
974
975impl ShownCatalog {
976 pub fn from_catalog(catalog: &crate::home::catalog::Catalog) -> Self {
978 Self {
979 id: catalog.id.clone(),
980 label: catalog.label.clone(),
981 origin: catalog.origin,
982 description: catalog.description.clone(),
983 file: catalog.file.clone(),
984 datasets: catalog
985 .datasets
986 .iter()
987 .map(|dataset| {
988 let location = dataset.location();
989 let mut details: Vec<(String, String)> = [
990 ("about", &dataset.description),
991 ("publisher", &dataset.publisher),
992 ("license", &dataset.license),
993 ("homepage", &dataset.homepage),
994 ("documentation", &dataset.documentation),
995 ]
996 .into_iter()
997 .filter(|(_, value)| !value.is_empty())
998 .map(|(key, value)| (key.to_string(), value.clone()))
999 .collect();
1000 match &dataset.url {
1001 None => details.push(("path".to_string(), display_path(&location))),
1002 Some(url) => {
1003 details.push(("url".to_string(), url.clone()));
1004 details.push(("login".to_string(), login_of(dataset)));
1005 }
1006 }
1007 ShownDataset {
1008 name: dataset.name.clone(),
1009 location,
1010 details,
1011 size: dataset.size,
1012 codebook: crate::home::codebook::Codebook::of(dataset)
1013 .map(std::sync::Arc::new),
1014 bookmarks: dataset
1015 .bookmarks
1016 .iter()
1017 .map(|(name, path)| (name.clone(), dataset.bookmark_location(path)))
1018 .collect(),
1019 entry: std::sync::Arc::new(dataset.clone()),
1020 }
1021 })
1022 .collect(),
1023 broken: None,
1024 }
1025 }
1026
1027 pub fn from_broken(broken: &crate::home::catalog::Broken) -> Self {
1029 Self {
1030 id: broken.id.clone(),
1031 label: broken.id.clone(),
1032 origin: broken.origin,
1033 description: String::new(),
1034 file: None,
1035 datasets: Vec::new(),
1036 broken: Some(broken.callout()),
1037 }
1038 }
1039
1040 pub fn origin_note(&self) -> &'static str {
1043 match self.origin {
1044 crate::home::catalog::Origin::Mine => "catalog.toml",
1045 crate::home::catalog::Origin::Listed | crate::home::catalog::Origin::Folder => {
1046 "catalog"
1047 }
1048 crate::home::catalog::Origin::Bundled => BUNDLED_ORIGIN,
1049 }
1050 }
1051}
1052
1053pub const BUNDLED_ORIGIN: &str = "comes with datui";
1055
1056pub const CATALOG_ORIGINS: [&str; 3] = ["catalog.toml", "catalog", BUNDLED_ORIGIN];
1058
1059pub fn is_catalog_origin(origin: &str) -> bool {
1061 CATALOG_ORIGINS.contains(&origin)
1062}
1063
1064pub fn login_of(dataset: &crate::home::catalog::Dataset) -> String {
1066 match dataset.object_store_auth() {
1067 Some(crate::config::DatasetAuth::Connection(connection)) => connection,
1068 Some(crate::config::DatasetAuth::Anonymous) | None => "none".to_string(),
1069 Some(crate::config::DatasetAuth::Auto) => "auto".to_string(),
1070 }
1071}
1072
1073pub fn catalogs(config: &crate::config::AppConfig) -> Vec<ShownCatalog> {
1077 let mut out: Vec<ShownCatalog> = config
1078 .shown_catalogs()
1079 .iter()
1080 .filter_map(|catalog| {
1081 let mut shown = ShownCatalog::from_catalog(catalog);
1082 if catalog.origin == crate::home::catalog::Origin::Bundled {
1083 shown
1084 .datasets
1085 .retain(|d| crate::cloud::source::opens_in_this_build(&d.location));
1086 }
1087 (!shown.datasets.is_empty()).then_some(shown)
1088 })
1089 .collect();
1090 let at = out
1092 .iter()
1093 .position(|c| c.origin == crate::home::catalog::Origin::Bundled)
1094 .unwrap_or(out.len());
1095 let broken: Vec<ShownCatalog> = config
1096 .broken_catalogs
1097 .iter()
1098 .filter(|b| !config.home.hide.contains(&b.id))
1099 .map(ShownCatalog::from_broken)
1100 .collect();
1101 out.splice(at..at, broken);
1102 out
1103}
1104
1105fn catalog_entry(
1108 dataset: &ShownDataset,
1109 network_check: fn(&Path) -> bool,
1110 missing: &mut std::collections::HashSet<PathBuf>,
1111) -> Entry {
1112 let path = &dataset.location;
1113 let local = matches!(
1114 crate::cloud::source::input_source(path),
1115 crate::cloud::source::InputSource::Local(_)
1116 );
1117 let mut entry = if is_object_store_url(path) {
1118 if names_a_file(path) {
1119 entry_for_path(path, true)
1120 } else {
1121 Entry::directory(path)
1122 }
1123 } else if !local || network_check(path) {
1124 entry_for_path(path, true)
1125 } else if path.exists() {
1126 entry_for_path(path, false)
1127 } else {
1128 missing.insert(path.clone());
1129 let mut entry = entry_for_path(path, true);
1130 entry.kind = EntryKind::Unknown;
1131 entry
1132 };
1133 entry.name = dataset.name.clone();
1134 entry
1135}
1136
1137pub fn codebook_for(
1140 catalogs: &[ShownCatalog],
1141 path: &Path,
1142) -> Option<std::sync::Arc<crate::home::codebook::Codebook>> {
1143 let text = path.to_string_lossy();
1144 catalogs
1145 .iter()
1146 .flat_map(|c| c.datasets.iter())
1147 .filter(|d| d.codebook.is_some())
1148 .filter(|d| d.location == path || within(&text, &d.location.to_string_lossy()))
1149 .max_by_key(|d| d.location.to_string_lossy().trim_end_matches('/').len())
1150 .and_then(|d| d.codebook.clone())
1151}
1152
1153pub fn catalog_entry_for(
1156 catalogs: &[ShownCatalog],
1157 path: &Path,
1158) -> Option<(String, std::sync::Arc<crate::home::catalog::Dataset>)> {
1159 let text = path.to_string_lossy();
1160 catalogs
1161 .iter()
1162 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
1163 .filter(|(_, d)| {
1164 d.location == path
1165 || same_place(&d.location, path)
1166 || within(&text, &d.location.to_string_lossy())
1167 })
1168 .rev()
1169 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
1170 .map(|(c, d)| (c.label.clone(), d.entry.clone()))
1171}
1172
1173fn bookmark_entry(name: &str, place: &Path, network_check: fn(&Path) -> bool) -> Entry {
1175 let local = matches!(
1176 crate::cloud::source::input_source(place),
1177 crate::cloud::source::InputSource::Local(_)
1178 );
1179 let mut entry = if is_object_store_url(place) && !names_a_file(place) {
1180 Entry::directory(place)
1181 } else {
1182 entry_for_path(place, !local || network_check(place) || !place.exists())
1183 };
1184 entry.name = name.to_string();
1185 entry
1186}
1187
1188fn catalog_section(
1190 catalog: &ShownCatalog,
1191 network_check: fn(&Path) -> bool,
1192 missing: &mut std::collections::HashSet<PathBuf>,
1193) -> Section {
1194 let rows = catalog
1196 .datasets
1197 .iter()
1198 .flat_map(|dataset| {
1199 std::iter::once(catalog_entry(dataset, network_check, missing)).chain(
1200 dataset
1201 .bookmarks
1202 .iter()
1203 .map(|(name, place)| bookmark_entry(name, place, network_check)),
1204 )
1205 })
1206 .collect();
1207 Section {
1208 origin: Some(catalog.origin_note()),
1209 unavailable: catalog.broken.is_some(),
1210 unavailable_note: catalog.broken.clone(),
1211 ..Section::titled(catalog.label.clone(), rows)
1212 }
1213}
1214
1215#[derive(Debug, Clone, Default, PartialEq)]
1218pub struct Measured {
1219 pub rows: Option<usize>,
1220 pub cols: Option<usize>,
1221 pub cols_sampled: bool,
1223 pub size: Option<u64>,
1224 pub columns: Vec<String>,
1226 pub cost: crate::home::discover::Cost,
1229 pub kind: Option<crate::home::discover::EntryKind>,
1232 pub holds: crate::home::discover::Holds,
1235}
1236
1237#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1239pub enum SortMode {
1240 #[default]
1242 Natural,
1243 Size,
1245 Modified,
1247 Rows,
1249}
1250
1251impl SortMode {
1252 pub fn label_in(self, section_is_recency_ordered: bool) -> &'static str {
1255 match self {
1256 SortMode::Natural if section_is_recency_ordered => "recent",
1257 SortMode::Natural => "name",
1258 SortMode::Size => "size",
1259 SortMode::Modified => "modified",
1260 SortMode::Rows => "rows",
1261 }
1262 }
1263
1264 pub fn next(self) -> Self {
1265 match self {
1266 SortMode::Natural => SortMode::Size,
1267 SortMode::Size => SortMode::Modified,
1268 SortMode::Modified => SortMode::Rows,
1269 SortMode::Rows => SortMode::Natural,
1270 }
1271 }
1272}
1273
1274#[derive(Debug, Clone)]
1278pub enum Row<'a> {
1279 Header {
1280 section: usize,
1281 matches: usize,
1283 collapsed: bool,
1284 },
1285 Entry {
1286 section: usize,
1287 entry: &'a Entry,
1288 nested: bool,
1290 hit: Hit,
1292 },
1293 Place {
1295 section: usize,
1296 path: PathBuf,
1297 label: Option<String>,
1300 source: Option<String>,
1302 held: usize,
1304 },
1305 Door { section: usize, entry: &'a Entry },
1308 More {
1311 section: usize,
1312 hidden: usize,
1313 places: usize,
1314 measuring: bool,
1317 },
1318 Hidden { section: usize, count: usize },
1322 Up { section: usize },
1325}
1326
1327impl Row<'_> {
1328 pub fn section(&self) -> usize {
1329 match self {
1330 Row::Header { section, .. }
1331 | Row::Entry { section, .. }
1332 | Row::Door { section, .. }
1333 | Row::Place { section, .. }
1334 | Row::More { section, .. }
1335 | Row::Hidden { section, .. }
1336 | Row::Up { section } => *section,
1337 }
1338 }
1339}
1340
1341#[derive(Debug, Clone, Default, PartialEq)]
1344pub struct Hit {
1345 pub score: i32,
1346 pub positions: std::sync::Arc<[usize]>,
1348 pub column: Option<usize>,
1351}
1352
1353impl Hit {
1354 pub fn column_of<'a>(&self, entry: &'a Entry) -> Option<&'a str> {
1356 self.column
1357 .and_then(|i| entry.columns.get(i))
1358 .map(String::as_str)
1359 }
1360}
1361
1362#[derive(Debug, Default)]
1367pub struct RowsCache {
1368 built: std::cell::RefCell<Option<View>>,
1369 builds: std::cell::Cell<usize>,
1370}
1371
1372#[derive(Debug)]
1374struct View {
1375 key: ViewKey,
1376 slots: Vec<Slot>,
1377 has_dataset: bool,
1379}
1380
1381#[derive(Debug, PartialEq)]
1384struct ViewKey {
1385 filter: String,
1386 sort: SortMode,
1387 hide_unreadable: bool,
1388 recent_expanded: bool,
1389 shown_whole: std::collections::HashSet<PathBuf>,
1390 view_height: usize,
1391 browsing: Option<PathBuf>,
1392 folds: std::collections::HashMap<String, bool>,
1393 shape: Vec<(usize, bool)>,
1394}
1395
1396impl ViewKey {
1397 fn of(home: &HomeState) -> Self {
1398 ViewKey {
1399 filter: home.filter.clone(),
1400 sort: home.sort,
1401 hide_unreadable: home.hide_unreadable,
1402 recent_expanded: home.recent_expanded,
1403 shown_whole: home.shown_whole.clone(),
1404 view_height: home.view_height,
1405 browsing: home.browsing.clone(),
1406 folds: home.folds.clone(),
1407 shape: (home.sections.iter())
1408 .map(|s| (s.rows.len(), s.door.is_some()))
1409 .collect(),
1410 }
1411 }
1412
1413 fn matches(&self, home: &HomeState) -> bool {
1415 self.filter == home.filter
1416 && self.sort == home.sort
1417 && self.hide_unreadable == home.hide_unreadable
1418 && self.recent_expanded == home.recent_expanded
1419 && self.shown_whole == home.shown_whole
1420 && self.view_height == home.view_height
1421 && self.browsing == home.browsing
1422 && self.folds == home.folds
1423 && self.shape.iter().copied().eq(home
1424 .sections
1425 .iter()
1426 .map(|s| (s.rows.len(), s.door.is_some())))
1427 }
1428}
1429
1430#[derive(Debug)]
1432enum Slot {
1433 Plain(Row<'static>),
1435 Entry {
1436 section: usize,
1437 index: usize,
1438 nested: bool,
1439 hit: Hit,
1440 },
1441 Door {
1442 section: usize,
1443 },
1444}
1445
1446pub fn place_of(path: &Path) -> PathBuf {
1449 parent_location(path).unwrap_or_else(|| path.to_path_buf())
1450}
1451
1452pub fn place_is_browsable(path: &Path) -> bool {
1455 is_cloud_place(path)
1456 || is_object_store_url(path)
1457 || matches!(
1458 crate::cloud::source::input_source(path),
1459 crate::cloud::source::InputSource::Local(_)
1460 )
1461}
1462
1463#[derive(Debug, Clone, PartialEq, Eq)]
1467pub enum RowKey {
1468 Header(String),
1469 Entry(PathBuf),
1470 Door(PathBuf),
1473 Place(PathBuf),
1474 More(String),
1475 Hidden(String),
1476 Up(String),
1477}
1478
1479#[derive(Debug)]
1481pub struct HomeState {
1482 pub sections: Vec<Section>,
1483 pub filter: String,
1485 pub filter_selected: bool,
1488 pub search_limit: usize,
1490 pub hide_unreadable: bool,
1492 pub formats: std::sync::Arc<crate::formats::Registry>,
1495 pub lake_here: Option<(PathBuf, &'static str)>,
1498 pub selected: usize,
1500 pub scroll: usize,
1505 pub view_height: usize,
1507 pub path_input_active: bool,
1509 pub path_input: String,
1510 pub path_listing: Option<PathListing>,
1512 pub path_pick: Option<usize>,
1514 pub browsing: Option<PathBuf>,
1516 pub browse_start: Option<PathBuf>,
1519 pub status: Option<String>,
1521 pub network_check: fn(&Path) -> bool,
1524 pub visits: std::collections::HashMap<PathBuf, crate::cache::Visits>,
1527 pub newest_recent: Option<PathBuf>,
1529 pub probes: Probes,
1531 pub narrowed: Option<Narrowed>,
1533 pub cloud_kinds: std::collections::HashMap<PathBuf, (EntryKind, crate::home::discover::Holds)>,
1536 pub sort: SortMode,
1538 pub listing_in_flight: bool,
1540 pub measure_in_flight: bool,
1542 pub classify_in_flight: bool,
1545 pub peeking: std::collections::HashSet<PathBuf>,
1548 pub peek_failed: std::collections::HashSet<PathBuf>,
1551 pub enriched: std::collections::HashMap<PathBuf, Measured>,
1554 pub folds: std::collections::HashMap<String, bool>,
1558 pub folds_owed: bool,
1561 pub search: SearchState,
1563 pub known: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
1566 pub cloud: Vec<CloudSource>,
1569 pub catalogs: Vec<ShownCatalog>,
1572 pub sized: std::collections::HashSet<PathBuf>,
1574 pub web_gone: std::collections::HashMap<PathBuf, crate::error_display::HttpGone>,
1577 pub missing: std::collections::HashSet<PathBuf>,
1579 pub waiting_since: Option<std::time::Instant>,
1581 pub recent_expanded: bool,
1583 pub shown_whole: std::collections::HashSet<PathBuf>,
1586 pub trail: Vec<Mark>,
1589 pub returning: Option<RowKey>,
1592 pub returning_line: Option<usize>,
1594 pub landing: bool,
1597 pub rows_cache: RowsCache,
1599 pub catalog_places: CatalogPlaces,
1601}
1602
1603#[derive(Debug, Clone)]
1606pub struct Mark {
1607 pub place: Option<PathBuf>,
1609 pub key: Option<RowKey>,
1610 pub filter: String,
1612 pub search: Option<SearchState>,
1615 pub line: usize,
1617}
1618
1619#[derive(Debug, Clone, Default)]
1622pub struct SearchState {
1623 pub root: Option<PathBuf>,
1625 pub epoch: u64,
1627 pub results: Vec<std::sync::Arc<[Entry]>>,
1630 pub indexed: usize,
1632 pub matches: Option<crate::home::search::Matches>,
1635 pub scoring: bool,
1637 pub scanned: usize,
1639 pub running: bool,
1641 pub done: bool,
1643 pub limited: Option<String>,
1645}
1646
1647const SCORE_INLINE_MAX: usize = 2_000;
1650
1651const FRECENCY_LIFT: f64 = 3.0;
1654
1655#[derive(Debug, Clone)]
1657pub struct ScoreJob {
1658 pub epoch: u64,
1659 pub results: Vec<std::sync::Arc<[Entry]>>,
1660 pub query: String,
1661 pub base: Option<crate::home::search::Matches>,
1662 pub limit: usize,
1663}
1664
1665impl SearchState {
1666 pub fn reset(&mut self) {
1668 *self = Self::default();
1669 }
1670
1671 pub fn set_results(&mut self, results: Vec<Entry>) {
1673 self.indexed = results.len();
1674 self.results = vec![results.into()];
1675 self.matches = None;
1676 }
1677
1678 pub fn files(&self) -> impl Iterator<Item = &Entry> {
1680 self.results.iter().flat_map(|batch| batch.iter())
1681 }
1682
1683 fn scored_for(&self, query: &str) -> bool {
1685 self.matches
1686 .as_ref()
1687 .is_some_and(|m| m.query == query && m.upto == self.indexed)
1688 }
1689
1690 fn base_for(&self, query: &str) -> (Option<&crate::home::search::Matches>, usize) {
1692 match self.matches.as_ref() {
1693 Some(m) if m.narrows_to(query) && m.upto <= self.indexed => {
1694 (Some(m), m.ids.len() + self.indexed - m.upto)
1695 }
1696 _ => (None, self.indexed),
1697 }
1698 }
1699}
1700
1701impl Default for HomeState {
1702 fn default() -> Self {
1703 Self {
1704 sections: Vec::new(),
1705 cloud: Vec::new(),
1706 catalogs: Vec::new(),
1707 sized: std::collections::HashSet::new(),
1708 web_gone: Default::default(),
1709 missing: Default::default(),
1710 filter: String::new(),
1711 search_limit: crate::config::SearchConfig::default().max_results,
1712 rows_cache: RowsCache::default(),
1713 catalog_places: CatalogPlaces::default(),
1714 hide_unreadable: true,
1715 formats: Default::default(),
1716 lake_here: None,
1717 selected: 0,
1718 scroll: 0,
1719 view_height: 0,
1720 path_input_active: false,
1721 path_input: String::new(),
1722 path_listing: None,
1723 path_pick: None,
1724 filter_selected: false,
1725 browsing: None,
1726 browse_start: None,
1727 status: None,
1728 network_check: is_remote_path,
1729 visits: Default::default(),
1730 newest_recent: None,
1731 sort: SortMode::default(),
1732 listing_in_flight: false,
1733 measure_in_flight: false,
1734 classify_in_flight: false,
1735 peeking: std::collections::HashSet::new(),
1736 probes: Probes::default(),
1737 narrowed: None,
1738 cloud_kinds: std::collections::HashMap::new(),
1739 peek_failed: std::collections::HashSet::new(),
1740 waiting_since: None,
1741 enriched: std::collections::HashMap::new(),
1742 folds: std::collections::HashMap::new(),
1743 folds_owed: false,
1744 search: SearchState::default(),
1745 known: Default::default(),
1746 recent_expanded: false,
1747 shown_whole: Default::default(),
1748 trail: Vec::new(),
1749 returning: None,
1750 returning_line: None,
1751 landing: false,
1752 }
1753 }
1754}
1755
1756#[derive(Debug, Clone)]
1759pub struct ListingRequest {
1760 pub recents: Vec<PathBuf>,
1761 pub desktop_dirs: Vec<PathBuf>,
1762 pub browsing: Option<PathBuf>,
1763 pub probes: Probes,
1765 pub narrowed: Option<Narrowed>,
1767 pub network_check: fn(&Path) -> bool,
1768 pub cloud: Vec<CloudSource>,
1770 pub catalogs: Vec<ShownCatalog>,
1772 pub known: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
1775 pub formats: std::sync::Arc<crate::formats::Registry>,
1778}
1779
1780#[derive(Debug, Clone, Default)]
1782pub struct Listing {
1783 pub sections: Vec<Section>,
1784 pub missing: std::collections::HashSet<PathBuf>,
1786}
1787
1788impl Listing {
1789 pub fn alias_visits(
1795 &self,
1796 visits: &mut std::collections::HashMap<PathBuf, crate::cache::Visits>,
1797 ) {
1798 let names: std::collections::HashSet<std::ffi::OsString> = visits
1799 .keys()
1800 .filter_map(|p| p.file_name().map(|n| n.to_os_string()))
1801 .collect();
1802 let mut aliases = Vec::new();
1803 for row in self.sections.iter().flat_map(|s| &s.rows) {
1804 let path = &row.path;
1805 if visits.contains_key(path)
1806 || row.table.is_some()
1807 || !path.file_name().is_some_and(|n| names.contains(n))
1808 || is_network_path(path)
1809 {
1810 continue;
1811 }
1812 if let Some(v) = crate::canonical::canonicalize(path)
1813 .ok()
1814 .and_then(|canonical| visits.get(&canonical))
1815 {
1816 aliases.push((path.clone(), *v));
1817 }
1818 }
1819 visits.extend(aliases);
1820 }
1821}
1822
1823pub fn look_into_as(entry: &Entry, as_read: &crate::formats::schema_union::ReadAs) -> Entry {
1828 let mut probe = classify_row(entry);
1829 measure_row(&mut probe, entry, as_read, None);
1830 probe
1831}
1832
1833fn classify_row(entry: &Entry) -> Entry {
1835 let mut probe = entry.clone();
1836 if probe.kind == EntryKind::Unknown && probe.path.is_dir() {
1837 let (kind, holds) = discover::look_at_directory(&probe.path);
1838 probe.kind = kind;
1839 probe.holds = holds;
1840 }
1841 probe
1842}
1843
1844fn measure_row(
1847 probe: &mut Entry,
1848 entry: &Entry,
1849 as_read: &crate::formats::schema_union::ReadAs,
1850 remembered: Option<&crate::cache::CacheManager>,
1851) {
1852 discover::enrich_with(probe, as_read, remembered);
1853 probe.size = probe.size.or(entry.size);
1854 probe.modified = probe.modified.or(entry.modified);
1855}
1856
1857pub fn look_into_batch(
1862 rows: Vec<Entry>,
1863 cache: &crate::cache::CacheManager,
1864 mut each: impl FnMut(PathBuf, Measured),
1865) {
1866 let as_read = crate::formats::schema_union::ReadAs::default();
1867 let classified: Vec<(Entry, Entry)> = rows
1868 .into_iter()
1869 .map(|entry| {
1870 let probe = classify_row(&entry);
1871 if probe.kind != entry.kind {
1872 each(entry.path.clone(), measured_from(&probe, &entry));
1873 }
1874 (probe, entry)
1875 })
1876 .collect();
1877
1878 let mut facts = Vec::new();
1879 for (mut probe, entry) in classified {
1880 measure_row(&mut probe, &entry, &as_read, Some(cache));
1881 facts.extend(facts_for(&probe));
1882 each(entry.path.clone(), measured_from(&probe, &entry));
1883 }
1884 cache.record_dataset_facts(&facts);
1887}
1888
1889pub fn measured_from(probe: &Entry, original: &Entry) -> Measured {
1891 Measured {
1892 rows: probe.rows,
1893 cols: probe.cols,
1894 cols_sampled: probe.cols_sampled,
1895 size: probe.size.or(original.size),
1896 columns: probe.columns.clone(),
1897 kind: (probe.kind != original.kind).then_some(probe.kind),
1898 holds: probe.holds.clone(),
1899 cost: crate::home::discover::Cost {
1902 source: None,
1903 ..probe.cost.clone()
1904 },
1905 }
1906}
1907
1908#[derive(Debug, Clone)]
1911pub enum Probe {
1912 Listing(Vec<Entry>),
1914 Listed {
1916 rows: std::sync::Arc<[Entry]>,
1917 cut_short: bool,
1918 },
1919 Unreachable(Option<String>),
1921}
1922
1923#[derive(Debug, Clone, Default)]
1925pub struct Probes(std::collections::HashMap<PathBuf, Probe>);
1926
1927impl Probes {
1928 pub fn listed(&self, place: &Path) -> Option<&[Entry]> {
1930 match self.0.get(place)? {
1931 Probe::Listed { rows, .. } => Some(rows),
1932 _ => None,
1933 }
1934 }
1935
1936 pub fn so_far(&self, place: &Path) -> Option<&[Entry]> {
1938 match self.0.get(place)? {
1939 Probe::Listing(rows) => Some(rows),
1940 _ => None,
1941 }
1942 }
1943
1944 pub fn settled(&self, place: &Path) -> bool {
1946 matches!(
1947 self.0.get(place),
1948 Some(Probe::Listed { .. } | Probe::Unreachable(_))
1949 )
1950 }
1951
1952 pub fn cut_short(&self, place: &Path) -> bool {
1953 matches!(
1954 self.0.get(place),
1955 Some(Probe::Listed {
1956 cut_short: true,
1957 ..
1958 })
1959 )
1960 }
1961
1962 pub fn unreachable(&self, place: &Path) -> bool {
1963 matches!(self.0.get(place), Some(Probe::Unreachable(_)))
1964 }
1965
1966 pub fn error(&self, place: &Path) -> Option<&str> {
1968 match self.0.get(place)? {
1969 Probe::Unreachable(why) => why.as_deref(),
1970 _ => None,
1971 }
1972 }
1973
1974 fn rows(&self, place: &Path) -> Vec<Entry> {
1977 match self.0.get(place) {
1978 Some(Probe::Listed { rows, .. }) => rows.to_vec(),
1979 Some(Probe::Listing(rows)) => {
1980 let mut rows = rows.clone();
1981 if is_object_store_url(place) {
1982 rows.sort_by_key(|row| row.kind != EntryKind::Directory);
1983 } else {
1984 discover::sort_entries(&mut rows);
1985 }
1986 rows
1987 }
1988 _ => Vec::new(),
1989 }
1990 }
1991
1992 pub fn answered(&self) -> impl Iterator<Item = (&PathBuf, &[Entry])> {
1994 self.0.iter().filter_map(|(place, probe)| match probe {
1995 Probe::Listed { rows, .. } => Some((place, &rows[..])),
1996 _ => None,
1997 })
1998 }
1999
2000 fn entry(&self, path: &Path) -> Option<Entry> {
2002 self.answered()
2003 .flat_map(|(_, rows)| rows.iter())
2004 .find(|e| e.path == path)
2005 .cloned()
2006 }
2007
2008 pub fn read(&mut self, place: &Path, rows: &[Entry]) {
2010 if let Probe::Listing(so_far) = self
2011 .0
2012 .entry(place.to_path_buf())
2013 .or_insert(Probe::Listing(Vec::new()))
2014 {
2015 so_far.extend_from_slice(rows);
2016 }
2017 }
2018
2019 pub fn insert(&mut self, place: PathBuf, probe: Probe) {
2020 self.0.insert(place, probe);
2021 }
2022
2023 pub fn forget(&mut self, place: &Path) {
2025 self.0.remove(place);
2026 }
2027
2028 pub fn stopped(&mut self, place: &Path) {
2030 if let Some(Probe::Listing(_)) = self.0.get(place) {
2031 self.0.remove(place);
2032 }
2033 }
2034
2035 fn listed_mut(&mut self, place: &Path) -> Option<&mut std::sync::Arc<[Entry]>> {
2036 match self.0.get_mut(place)? {
2037 Probe::Listed { rows, .. } => Some(rows),
2038 _ => None,
2039 }
2040 }
2041}
2042
2043#[derive(Debug, Clone)]
2046pub struct Narrowed {
2047 pub dir: PathBuf,
2048 pub prefix: String,
2050 pub rows: Vec<Entry>,
2051 pub truncated: bool,
2053}
2054
2055pub fn build_listing(request: &ListingRequest) -> Listing {
2059 let ListingRequest {
2060 recents,
2061 desktop_dirs,
2062 browsing,
2063 probes,
2064 narrowed,
2065 network_check,
2066 cloud,
2067 catalogs,
2068 known,
2069 formats,
2070 } = request;
2071 let network_check = *network_check;
2072 let mounts = crate::home::locality::Mounts::current();
2075 let mut sections: Vec<Section> = Vec::new();
2076
2077 if let Some(id) = browsing.as_deref().and_then(cloud_source_id) {
2079 let source = cloud.iter().find(|s| s.id == id);
2080 let rows = source
2081 .map(|s| s.buckets.iter().map(|b| bucket_entry(b)).collect())
2082 .unwrap_or_default();
2083 let failure = source.and_then(|s| match &s.status {
2084 CloudStatus::Failed { short, .. } => Some(short.clone()),
2085 _ => None,
2086 });
2087 sections.push(Section {
2088 subtitle: source.map(|s| s.note.clone()).filter(|n| !n.is_empty()),
2089 unavailable: source.is_none() || failure.is_some(),
2090 unavailable_note: if source.is_none() {
2091 Some("source not found".to_string())
2092 } else {
2093 failure
2094 },
2095 waiting: source.is_some_and(|s| s.busy()),
2096 ..Section::titled(source.map(|s| s.label.clone()).unwrap_or(id), rows)
2097 });
2098 annotate(&mut sections, known, network_check, &mounts);
2099 return Listing {
2100 sections,
2101 ..Default::default()
2102 };
2103 }
2104
2105 if let Some(dir) = browsing.clone() {
2107 let remote = network_check(&dir);
2110 let so_far = remote && probes.so_far(&dir).is_some();
2112 let database = !remote && dir.is_file();
2114 let (mut rows, truncated) = if remote {
2115 (probes.rows(&dir), probes.cut_short(&dir))
2116 } else if database {
2117 let tables = discover::database_rows(&dir);
2118 let rows = if tables.is_empty() {
2119 discover::variant_rows(&dir, formats)
2120 } else {
2121 tables
2122 };
2123 (rows, false)
2124 } else {
2125 let scan = discover::scan_dir_specs(&dir, formats);
2126 let mut rows = discover::split_rows(&dir);
2128 rows.extend(scan.entries);
2129 (rows, scan.truncated)
2130 };
2131 let narrowed = narrowed
2134 .as_ref()
2135 .filter(|n| remote && truncated && n.dir == dir);
2136 if let Some(narrowed) = narrowed {
2137 let listed: std::collections::HashSet<PathBuf> =
2138 rows.iter().map(|row| row.path.clone()).collect();
2139 rows.extend(
2140 narrowed
2141 .rows
2142 .iter()
2143 .filter(|row| !listed.contains(&row.path))
2144 .cloned(),
2145 );
2146 }
2147 let subtitle = if so_far {
2149 Some(format!(
2150 "{} so far",
2151 crate::numfmt::group_chrome(rows.len())
2152 ))
2153 } else if truncated {
2154 let first = format!(
2155 "first {}",
2156 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2157 );
2158 Some(match narrowed {
2159 Some(n) => format!(
2160 "{first} + {}{} {}*",
2161 crate::numfmt::group_chrome(n.rows.len()),
2162 if n.truncated { "+" } else { "" },
2163 n.prefix
2164 ),
2165 None => first,
2166 })
2167 } else {
2168 None
2169 };
2170 let unavailable = remote && probes.unreachable(&dir);
2171 let mut door = (!database)
2174 .then(|| whole_directory_row(&dir, &rows, remote))
2175 .flatten();
2176 if !remote
2180 && let Some(door) = door.as_mut()
2181 && let Ok(meta) = std::fs::metadata(&dir)
2182 {
2183 door.modified = meta.modified().ok();
2184 apply_known_facts(door, known, false);
2185 door.modified = None;
2186 door.name = door_name(door, &rows);
2187 }
2188 let title = {
2191 let text = dir.to_string_lossy();
2192 if let Some(dataset) = catalogs
2193 .iter()
2194 .flat_map(|c| c.datasets.iter())
2195 .find(|d| is_object_store_url(&d.location) && same_place(&d.location, &dir))
2196 {
2197 dataset.name.clone()
2198 } else if let Some((_, account)) = cloud_account(&dir) {
2199 account
2200 } else if let Some((_, container, key)) = crate::cloud::source::azure_parts(&text) {
2201 format!("{container}/{}", key.trim_matches('/'))
2202 .trim_end_matches('/')
2203 .to_string()
2204 } else {
2205 match crate::cloud::source::split_source_id(&text) {
2206 (Some(_), plain) => plain.into_owned(),
2207 (None, _) => display_path(&dir),
2208 }
2209 }
2210 };
2211 sections.push(Section {
2212 subtitle,
2213 root: Some(dir.clone()),
2214 unavailable,
2215 unavailable_note: probes.error(&dir).map(str::to_string),
2218 waiting: so_far,
2221 door,
2222 ..Section::titled(title, rows)
2223 });
2224 annotate(&mut sections, known, network_check, &mounts);
2225 return Listing {
2226 sections,
2227 ..Default::default()
2228 };
2229 }
2230
2231 let recent_rows: Vec<Entry> = recents
2233 .iter()
2234 .filter(|p| {
2237 network_check(p)
2238 || p.exists()
2239 || crate::formats::members::split(p).is_some()
2240 || crate::formats::members::split_variant(p, formats).is_some()
2241 || crate::formats::hf_splits::split_place(p).is_some()
2242 })
2243 .map(|p| {
2245 if let Some(known) = probes.entry(p) {
2248 return known;
2249 }
2250 if let Some(variant) = discover::variant_row(p, formats) {
2251 return variant;
2252 }
2253 if !network_check(p)
2254 && let Some(split) = discover::split_row(p)
2255 {
2256 return split;
2257 }
2258 let mut entry = entry_for_path(p, network_check(p));
2259 if !network_check(p) {
2260 discover::name_unlisted_file(&mut entry, formats);
2261 }
2262 if let Some(dataset) = catalogs
2265 .iter()
2266 .flat_map(|c| &c.datasets)
2267 .find(|d| d.location == *p)
2268 {
2269 entry.name = dataset.name.clone();
2270 }
2271 entry
2272 })
2273 .collect();
2274
2275 let mut elsewhere: Vec<Entry> = Vec::new();
2277
2278 let roots = HomeState::roots_with(desktop_dirs, network_check);
2279 let mut root_sections: Vec<(RootOrigin, Section)> = Vec::new();
2280 let mut cwd_listing: Option<(PathBuf, std::collections::HashSet<std::ffi::OsString>)> = None;
2283 for root in roots {
2284 if root.origin == RootOrigin::Desktop {
2287 if root.available {
2288 let mut entry = Entry::directory(&root.path);
2289 entry.name = display_path(&root.path);
2291 elsewhere.push(entry);
2292 }
2293 continue;
2294 }
2295
2296 let mut truncated = false;
2299 let rows = if root.network {
2300 truncated = probes.cut_short(&root.path);
2301 probes.rows(&root.path)
2302 } else if root.available {
2303 let scan = discover::scan_dir_specs(&root.path, formats);
2304 truncated = scan.truncated;
2305 scan.entries
2306 } else {
2307 Vec::new()
2308 };
2309 if root.origin == RootOrigin::Cwd {
2310 let key = if root.network {
2313 root.path.clone()
2314 } else {
2315 crate::canonical::canonicalize(&root.path).unwrap_or_else(|_| root.path.clone())
2316 };
2317 let names = rows
2318 .iter()
2319 .filter_map(|row| row.path.file_name().map(|n| n.to_os_string()))
2320 .collect();
2321 cwd_listing = Some((key, names));
2322 }
2323 let unreachable = root.network && probes.unreachable(&root.path);
2325 let waiting = root.network && !unreachable && probes.listed(&root.path).is_none();
2326 let described = mounts.describe(&root.path);
2330 let fstype = if described.network() {
2331 described.fstype
2332 } else {
2333 "network".to_string()
2334 };
2335 let mut state: Vec<String> = Vec::new();
2338 if truncated {
2339 state.push(format!(
2340 "first {}",
2341 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2342 ));
2343 }
2344 if root.network {
2345 state.push(fstype);
2346 }
2347 root_sections.push((
2348 root.origin,
2349 Section {
2350 subtitle: (!state.is_empty()).then(|| crate::glyphs::dotted(&state.join(" · "))),
2351 origin: Some(root.origin.note()),
2352 root: Some(root.path.clone()),
2353 unavailable: !root.available || unreachable,
2354 remote_root: root.network.then(|| root.path.clone()),
2355 waiting,
2356 ..Section::titled(display_path(&root.path), rows)
2357 },
2358 ));
2359 }
2360
2361 let recent_rows: Vec<Entry> = match &cwd_listing {
2366 None => recent_rows,
2367 Some((cwd, names)) => recent_rows
2368 .into_iter()
2369 .filter(|row| {
2370 let place = place_of(&row.path);
2371 let place = if network_check(&place) {
2374 place
2375 } else {
2376 crate::canonical::canonicalize(&place).unwrap_or(place)
2377 };
2378 place != *cwd || !row.path.file_name().is_some_and(|n| names.contains(n))
2379 })
2380 .collect(),
2381 };
2382 if !recent_rows.is_empty() {
2383 let place_labels = place_labels(&recent_rows, known, network_check);
2384 sections.push(Section {
2385 grouped_by_place: true,
2387 place_labels,
2388 ..Section::titled(HomeState::RECENT_SECTION, recent_rows)
2389 });
2390 }
2391
2392 sections.extend(root_sections.into_iter().map(|(_, s)| s));
2396
2397 if !cloud.is_empty() {
2400 sections.push(Section::titled(
2401 HomeState::CLOUD_SECTION.to_string(),
2402 cloud.iter().map(source_entry).collect(),
2403 ));
2404 }
2405
2406 let mut missing = std::collections::HashSet::new();
2408 for catalog in catalogs {
2409 let mut section = catalog_section(catalog, network_check, &mut missing);
2410 for row in section.rows.iter_mut().filter(|r| {
2412 r.kind == EntryKind::File
2413 && r.format_spec.is_none()
2414 && r.table.is_none()
2415 && !network_check(&r.path)
2416 && !discover::is_data_file(&r.path)
2417 }) {
2418 if let Some(spec) = formats.by_glob(&row.path, false).first() {
2419 discover::name_spec_file(row, spec);
2420 }
2421 }
2422 sections.push(section);
2423 }
2424
2425 if !elsewhere.is_empty() {
2426 sections.push(Section {
2427 folded_by_default: true,
2429 ..Section::titled("Elsewhere", elsewhere)
2430 });
2431 }
2432
2433 annotate(&mut sections, known, network_check, &mounts);
2436
2437 Listing { sections, missing }
2438}
2439
2440pub fn index_key(path: &Path) -> PathBuf {
2444 let text = path.to_string_lossy();
2445 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
2446 return PathBuf::from(crate::cloud::source::azure_url(&account, &container, &key));
2447 }
2448 match crate::cloud::source::split_source_id(&text) {
2449 (Some(_), plain) => PathBuf::from(plain.into_owned()),
2450 (None, _) => path.to_path_buf(),
2451 }
2452}
2453
2454fn known_facts<'a>(
2456 known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2457 path: &Path,
2458) -> Option<&'a crate::cache::DatasetFacts> {
2459 known.get(path).or_else(|| known.get(&index_key(path)))
2460}
2461
2462fn place_labels(
2467 rows: &[Entry],
2468 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2469 network_check: fn(&Path) -> bool,
2470) -> std::collections::HashMap<PathBuf, String> {
2471 let mut labels = std::collections::HashMap::new();
2472 for row in rows {
2473 let place = place_of(&row.path);
2474 if labels.contains_key(&place) {
2475 continue;
2476 }
2477 let Some(facts) = known_facts(known, &place) else {
2478 continue;
2479 };
2480 if facts.classified_by != crate::home::discover::CLASSIFIER_VERSION {
2481 continue;
2482 }
2483 if !network_check(&place) {
2484 let same_mtime = std::fs::metadata(&place)
2485 .and_then(|m| m.modified())
2486 .ok()
2487 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2488 .is_some_and(|d| d.as_secs() == facts.mtime);
2489 if !same_mtime {
2490 continue;
2491 }
2492 }
2493 let Some(kind) = facts.kind else {
2494 continue;
2495 };
2496 let mut probe = Entry::directory(&place);
2497 probe.kind = kind;
2498 probe.holds = facts.holds.clone();
2499 let label = probe.label();
2500 if !label.is_empty() && !label.starts_with("dir") {
2501 labels.insert(place, label.into_owned());
2502 }
2503 }
2504 labels
2505}
2506
2507fn annotate(
2510 sections: &mut [Section],
2511 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2512 network_check: fn(&Path) -> bool,
2513 mounts: &crate::home::locality::Mounts,
2514) {
2515 for section in sections {
2516 for row in &mut section.rows {
2517 if is_cloud_place(&row.path) {
2518 row.cost.source = Some("cloud".to_string());
2519 continue;
2520 }
2521 apply_known_facts(row, known, network_check(&row.path));
2522 row.cost.source = Some(mounts.describe(&row.path).fstype);
2523 }
2524 if let Some(door) = section.door.as_mut()
2526 && !is_cloud_place(&door.path)
2527 {
2528 door.cost.source = Some(mounts.describe(&door.path).fstype);
2529 }
2530 }
2531}
2532
2533fn take_cost(row: &mut Entry, cost: &discover::Cost) {
2537 let source = row.cost.source.take();
2538 let variants = row.cost.tables.filter(|_| row.format_spec.is_some());
2539 row.cost = cost.clone();
2540 row.cost.source = source;
2541 if variants.is_some() {
2542 row.cost.tables = variants;
2543 }
2544}
2545
2546fn apply_known_facts(
2550 row: &mut Entry,
2551 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2552 remote: bool,
2553) {
2554 let Some(facts) = known_facts(known, &row.path) else {
2555 return;
2556 };
2557
2558 if !remote
2565 && matches!(row.kind, EntryKind::Unknown | EntryKind::MultiFile)
2566 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
2567 && let Some(kind) = facts.kind
2568 {
2569 let same_mtime = row
2570 .modified
2571 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2572 .is_some_and(|d| d.as_secs() == facts.mtime);
2573 if same_mtime {
2574 row.kind = kind;
2575 if row.holds.is_empty() {
2578 row.holds = facts.holds.clone();
2579 }
2580 }
2584 }
2585
2586 if !remote {
2587 let same_bytes = row.size.map(|s| s == facts.size).unwrap_or(false)
2588 && row
2589 .modified
2590 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2591 .map(|d| d.as_secs() == facts.mtime)
2592 .unwrap_or(false);
2593 if !same_bytes {
2594 return;
2595 }
2596 }
2597
2598 row.rows = facts.rows;
2599 row.cols = facts.cols;
2600 row.cols_sampled = facts.cols_sampled;
2601 if !facts.columns.is_empty() {
2602 row.columns = facts.columns.clone();
2603 }
2604 take_cost(row, &facts.cost);
2607 if remote {
2608 if facts.size > 0 {
2611 row.size = row.size.or(Some(facts.size));
2612 }
2613 if row.kind == EntryKind::Unknown
2617 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
2618 && let Some(kind) = facts.kind
2619 {
2620 row.kind = kind;
2621 if row.holds.is_empty() {
2623 row.holds = facts.holds.clone();
2624 }
2625 }
2626 }
2627}
2628
2629pub fn facts_for(entry: &Entry) -> Option<(PathBuf, crate::cache::DatasetFacts)> {
2631 let size = entry.size?;
2632 let mtime = entry
2633 .modified?
2634 .duration_since(std::time::UNIX_EPOCH)
2635 .ok()?
2636 .as_secs();
2637 if entry.rows.is_none() && entry.columns.is_empty() && entry.cost == Default::default() {
2638 return None; }
2640 Some((
2641 entry.path.clone(),
2642 crate::cache::DatasetFacts {
2643 mtime,
2644 size,
2645 rows: entry.rows,
2646 cols: entry.cols,
2647 cols_sampled: entry.cols_sampled,
2648 columns: entry.columns.clone(),
2649 kind: Some(entry.kind),
2650 holds: entry.holds.clone(),
2651 classified_by: crate::home::discover::CLASSIFIER_VERSION,
2652 cost: crate::home::discover::Cost {
2654 source: None,
2655 ..entry.cost.clone()
2656 },
2657 },
2658 ))
2659}
2660
2661pub fn match_score(filter: &str, entry: &Entry) -> Option<i32> {
2665 match crate::home::fuzzy::best_match(filter, &entry.name) {
2666 Some(m) => Some(m.score),
2667 None => matching_column(filter, entry).map(|_| -COLUMN_MATCH_PENALTY),
2670 }
2671}
2672
2673pub fn match_hit(filter: &str, entry: &Entry) -> Option<Hit> {
2675 if let Some(m) = crate::home::fuzzy::best_match(filter, &entry.name) {
2676 return Some(Hit {
2677 score: m.score,
2678 positions: m.positions.into(),
2679 column: None,
2680 });
2681 }
2682 let index = matching_column_index(filter, entry)?;
2683 Some(Hit {
2684 score: -COLUMN_MATCH_PENALTY,
2685 positions: substring_positions(filter, &entry.columns[index]).into(),
2686 column: Some(index),
2687 })
2688}
2689
2690const COLUMN_MATCH_PENALTY: i32 = 1_000_000;
2693
2694pub fn matching_column<'a>(filter: &str, entry: &'a Entry) -> Option<&'a str> {
2697 matching_column_index(filter, entry).map(|i| entry.columns[i].as_str())
2698}
2699
2700fn matching_column_index(filter: &str, entry: &Entry) -> Option<usize> {
2701 if filter.is_empty() {
2702 return None;
2703 }
2704 let needle = filter.to_lowercase();
2705 entry
2706 .columns
2707 .iter()
2708 .position(|c| c.to_lowercase().contains(&needle))
2709}
2710
2711pub fn fuzzy_positions(needle: &str, haystack: &str) -> Vec<usize> {
2714 crate::home::fuzzy::best_match(needle, haystack)
2715 .map(|m| m.positions)
2716 .unwrap_or_default()
2717}
2718
2719pub fn substring_positions(needle: &str, haystack: &str) -> Vec<usize> {
2722 if needle.is_empty() {
2723 return Vec::new();
2724 }
2725 let hay: Vec<char> = haystack.to_lowercase().chars().collect();
2726 let need: Vec<char> = needle.to_lowercase().chars().collect();
2727 if need.len() > hay.len() {
2728 return Vec::new();
2729 }
2730 for start in 0..=(hay.len() - need.len()) {
2731 if hay[start..start + need.len()] == need[..] {
2732 return (start..start + need.len()).collect();
2733 }
2734 }
2735 Vec::new()
2736}
2737
2738pub fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
2741 crate::home::fuzzy::best_match(needle, haystack).map(|m| m.score)
2742}
2743
2744impl HomeState {
2745 pub fn roots(desktop_dirs: &[PathBuf]) -> Vec<Root> {
2748 Self::roots_with(desktop_dirs, is_remote_path)
2749 }
2750
2751 pub fn roots_with(desktop_dirs: &[PathBuf], is_network: fn(&Path) -> bool) -> Vec<Root> {
2753 let mut roots: Vec<Root> = Vec::new();
2754 let mut seen: Vec<PathBuf> = Vec::new();
2755
2756 let push =
2757 |path: PathBuf, origin: RootOrigin, roots: &mut Vec<Root>, seen: &mut Vec<PathBuf>| {
2758 let network = is_network(&path);
2760
2761 let key = if network {
2765 path.clone()
2766 } else {
2767 crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone())
2768 };
2769 if seen.contains(&key) {
2770 return;
2771 }
2772 seen.push(key);
2773
2774 let available = if network {
2775 true } else {
2777 std::fs::read_dir(&path).is_ok()
2778 };
2779 roots.push(Root {
2780 path,
2781 origin,
2782 available,
2783 network,
2784 });
2785 };
2786
2787 if let Ok(cwd) = std::env::current_dir() {
2791 push(cwd, RootOrigin::Cwd, &mut roots, &mut seen);
2792 }
2793
2794 for dir in desktop_dirs {
2797 push(dir.clone(), RootOrigin::Desktop, &mut roots, &mut seen);
2798 }
2799
2800 roots
2801 }
2802
2803 pub fn rebuild(&mut self, recents: &[PathBuf]) {
2806 self.rebuild_with(recents, &[])
2807 }
2808
2809 pub fn rebuild_with(&mut self, recents: &[PathBuf], desktop_dirs: &[PathBuf]) {
2812 let request = ListingRequest {
2813 recents: recents.to_vec(),
2814 desktop_dirs: desktop_dirs.to_vec(),
2815 browsing: self.browsing.clone(),
2816 probes: self.probes.clone(),
2817 narrowed: self.narrowed.clone(),
2818 network_check: self.network_check,
2819 cloud: self.cloud.clone(),
2820 catalogs: self.catalogs.clone(),
2821 known: Default::default(),
2824 formats: self.formats.clone(),
2825 };
2826 let listing = build_listing(&request);
2827 self.apply_listing(listing);
2828 }
2829
2830 pub fn apply_listing(&mut self, listing: Listing) {
2832 let returning = self.returning.take();
2833 let previous = returning.clone().or_else(|| self.selected_key());
2834 let line = self.selected.saturating_sub(self.scroll);
2836 let mut listing = listing;
2837 for section in &mut listing.sections {
2838 name_by_spec(&self.formats, &mut section.rows);
2839 }
2840 self.sections = listing.sections;
2841 self.changed();
2842 self.missing = listing.missing;
2843 if let (Some(browsing), Some((dir, format))) = (&self.browsing, &self.lake_here)
2845 && browsing == dir
2846 && let Some(section) = self.sections.first_mut()
2847 {
2848 let note = crate::glyphs::dotted(&format!(
2849 "{} · not read as a table",
2850 format.to_ascii_lowercase()
2851 ));
2852 section.subtitle = Some(match section.subtitle.take() {
2853 Some(state) => crate::glyphs::dotted(&format!("{note} · {state}")),
2854 None => note,
2855 });
2856 }
2857 self.sync_search_section();
2859 self.apply_measurements();
2862
2863 let placed = self.reselect(previous);
2865 if placed && returning.is_some() {
2868 self.landing = false;
2869 }
2870 if !placed {
2871 self.select_first_entry();
2872 if self.rows_still_arriving() {
2875 self.returning = returning;
2876 }
2877 } else if returning.is_some() {
2878 self.scroll_to_returning_line();
2879 } else {
2880 self.scroll = self.selected.saturating_sub(line);
2881 }
2882 self.follow_selection();
2883 }
2884
2885 pub fn leave_mark(&mut self) {
2888 let mark = Mark {
2889 place: self.browsing.clone(),
2890 key: self.selected_key(),
2891 filter: self.filter.clone(),
2892 search: (!self.search.running).then(|| SearchState {
2895 scoring: false,
2896 ..self.search.clone()
2897 }),
2898 line: self.selected.saturating_sub(self.scroll),
2899 };
2900 self.trail.retain(|m| m.place != mark.place);
2903 self.trail.push(mark);
2904 }
2905
2906 pub fn come_back(&mut self, from: Option<PathBuf>) {
2911 let to = self.browsing.clone();
2912 let mark = self
2913 .trail
2914 .iter()
2915 .rposition(|m| m.place == to)
2916 .map(|at| self.trail.split_off(at).remove(0));
2917 match mark {
2918 Some(mark) => {
2919 self.filter = mark.filter;
2920 self.search = mark.search.unwrap_or_default();
2921 self.returning = mark.key;
2922 self.returning_line = Some(mark.line);
2923 }
2924 None => {
2925 self.filter.clear();
2926 self.search.reset();
2927 self.returning = from.map(RowKey::Entry);
2928 self.returning_line = None;
2929 }
2930 }
2931 }
2932
2933 fn rows_still_arriving(&self) -> bool {
2936 self.listing_in_flight
2937 || self.sections_waiting()
2938 || self.awaiting_listing().is_some()
2939 || self.search.running
2940 }
2941
2942 fn settle_return(&mut self) {
2944 let Some(key) = self.returning.clone() else {
2945 return;
2946 };
2947 if let Some(idx) = self.place_key(&key) {
2948 self.selected = idx;
2949 self.returning = None;
2950 self.landing = false;
2951 self.scroll_to_returning_line();
2952 self.follow_selection();
2953 } else if !self.rows_still_arriving() {
2954 self.returning = None;
2955 }
2956 }
2957
2958 fn scroll_to_returning_line(&mut self) {
2960 if let Some(line) = self.returning_line.take() {
2961 self.scroll = self.selected.saturating_sub(line);
2962 }
2963 }
2964
2965 pub fn selected_key(&self) -> Option<RowKey> {
2967 let title = |section: usize| self.sections.get(section).map(|s| s.title.clone());
2968 Some(match self.selected_row()? {
2969 Row::Header { section, .. } => RowKey::Header(title(section)?),
2970 Row::More { section, .. } => RowKey::More(title(section)?),
2971 Row::Hidden { section, .. } => RowKey::Hidden(title(section)?),
2972 Row::Up { section } => RowKey::Up(title(section)?),
2973 Row::Entry { entry, .. } => RowKey::Entry(entry.path.clone()),
2974 Row::Door { entry, .. } => RowKey::Door(entry.path.clone()),
2975 Row::Place { path, .. } => RowKey::Place(path),
2976 })
2977 }
2978
2979 pub fn reselect(&mut self, key: Option<RowKey>) -> bool {
2983 let Some(key) = key else {
2984 self.clamp_selection();
2985 return false;
2986 };
2987 match self.place_key(&key) {
2988 Some(idx) => {
2989 self.selected = idx;
2990 true
2991 }
2992 None => {
2993 self.clamp_selection();
2994 false
2995 }
2996 }
2997 }
2998
2999 fn place_key(&mut self, key: &RowKey) -> Option<usize> {
3002 if let Some(found) = self.listed(key) {
3003 return Some(found);
3004 }
3005 if let RowKey::Entry(path) = key
3006 && let Some(root) = self
3007 .sections
3008 .iter()
3009 .find(|s| !s.grouped_by_place && s.rows.iter().any(|r| r.path == *path))
3010 .and_then(|s| s.root.clone())
3011 && self.shown_whole.insert(root.clone())
3012 {
3013 match self.listed(key) {
3014 Some(found) => return Some(found),
3015 None => {
3016 self.shown_whole.remove(&root);
3017 }
3018 }
3019 }
3020 self.behind_recent_cap(key)
3021 }
3022
3023 fn listed(&self, key: &RowKey) -> Option<usize> {
3025 self.visible().iter().position(|row| match (row, key) {
3026 (Row::Entry { entry, .. }, RowKey::Entry(path)) => entry.path == *path,
3027 (Row::Door { entry, .. }, RowKey::Door(path)) => entry.path == *path,
3028 (Row::Place { path, .. }, RowKey::Place(wanted)) => path == wanted,
3029 (Row::Header { section, .. }, RowKey::Header(title))
3030 | (Row::More { section, .. }, RowKey::More(title))
3031 | (Row::Hidden { section, .. }, RowKey::Hidden(title))
3032 | (Row::Up { section }, RowKey::Up(title)) => self
3033 .sections
3034 .get(*section)
3035 .is_some_and(|s| s.title == *title),
3036 _ => false,
3037 })
3038 }
3039
3040 fn behind_recent_cap(&self, key: &RowKey) -> Option<usize> {
3042 let (RowKey::Entry(path) | RowKey::Place(path)) = key else {
3043 return None;
3044 };
3045 self.visible().iter().position(|row| {
3046 matches!(row, Row::More { section, .. }
3047 if self.sections.get(*section).is_some_and(|s| {
3048 s.grouped_by_place
3049 && s.rows
3050 .iter()
3051 .any(|r| r.path == *path || place_of(&r.path) == *path)
3052 }))
3053 })
3054 }
3055
3056 pub fn set_view_height(&mut self, height: usize) {
3059 if height == self.view_height {
3060 return;
3061 }
3062 let key = self.selected_key();
3063 self.view_height = height;
3064 self.reselect(key);
3065 }
3066
3067 fn follow_selection(&mut self) {
3071 let rows = self.row_count();
3072 self.scroll = settle_top(self.scroll, self.selected, self.view_height, rows);
3073 }
3074
3075 fn section_folded(&self, section: &Section) -> bool {
3079 if self.browsing.is_some() {
3080 return false;
3081 }
3082 self.folds
3083 .get(§ion.title)
3084 .copied()
3085 .unwrap_or(section.folded_by_default)
3086 }
3087
3088 pub fn is_collapsed(&self, section: usize) -> bool {
3090 self.sections
3091 .get(section)
3092 .is_some_and(|s| self.section_folded(s))
3093 }
3094
3095 pub fn toggle_collapsed(&mut self, section: usize) {
3097 let folded = self.is_collapsed(section);
3098 self.set_collapsed(section, !folded);
3099 }
3100
3101 pub fn set_collapsed(&mut self, section: usize, collapsed: bool) {
3105 if self.browsing.is_some() {
3106 return;
3107 }
3108 let Some(title) = self.sections.get(section).map(|s| s.title.clone()) else {
3109 return;
3110 };
3111 self.folds.insert(title, collapsed);
3112 }
3113
3114 pub fn jump_section(&mut self, delta: isize) {
3116 self.returning = None;
3117 self.landing = false;
3118 let rows = self.visible();
3119 let headers: Vec<usize> = rows
3120 .iter()
3121 .enumerate()
3122 .filter(|(_, r)| matches!(r, Row::Header { .. }))
3123 .map(|(i, _)| i)
3124 .collect();
3125 if headers.is_empty() {
3126 return;
3127 }
3128 let current = self.selected;
3129 self.selected = if delta > 0 {
3130 headers
3131 .iter()
3132 .copied()
3133 .find(|&h| h > current)
3134 .unwrap_or(headers[0])
3135 } else {
3136 headers
3137 .iter()
3138 .rev()
3139 .copied()
3140 .find(|&h| h < current)
3141 .unwrap_or(*headers.last().unwrap())
3142 };
3143 }
3144
3145 pub fn has_any_dataset(&self) -> bool {
3149 self.view().has_dataset
3150 }
3151
3152 pub const SEARCH_SECTION: &'static str = "Found";
3154
3155 pub const CLOUD_SECTION: &'static str = "Cloud";
3157
3158 pub const RECENT_SECTION: &'static str = "Recent";
3160
3161 pub fn cloud_source_of(&self, path: &Path) -> Option<&CloudSource> {
3164 if let Some(id) = cloud_source_id(path) {
3165 return self.cloud.iter().find(|s| s.id == id);
3166 }
3167 if let Some((id, _)) = cloud_account(path) {
3168 return self.cloud.iter().find(|s| s.id == id);
3169 }
3170 let text = path.to_string_lossy();
3171 if let Some((account, _, _)) = crate::cloud::source::azure_parts(&text) {
3172 return self.azure_account_place(&account).and_then(|place| {
3173 cloud_account(&place).and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id))
3174 });
3175 }
3176 if let (Some(id), _) = crate::cloud::source::split_source_id(&text) {
3177 return self.cloud.iter().find(|s| s.id == id);
3178 }
3179 if let Some(project) =
3180 Self::google_bucket_root(path).and_then(|b| self.project_of_bucket(&b))
3181 {
3182 return cloud_account(&project)
3183 .and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id));
3184 }
3185 let (_, plain) = crate::cloud::source::split_source_id(&text);
3186 let (scheme, rest) = plain.split_once("://")?;
3187 let bucket = rest.split('/').next()?;
3188 let root = PathBuf::from(format!("{scheme}://{bucket}"));
3189 self.cloud.iter().find(|s| s.buckets.contains(&root))
3190 }
3191
3192 pub fn parent_of(&self, path: &Path) -> Option<PathBuf> {
3195 if cloud_source_id(path).is_some() {
3196 return None;
3197 }
3198 if let Some((_, dataset)) = self.remote_dataset_of(path) {
3201 let place = &dataset.location;
3202 if same_place(path, place) {
3203 return None;
3204 }
3205 let up = self.parent_within(path)?;
3206 return Some(if same_place(&up, place) {
3208 place.clone()
3209 } else {
3210 up
3211 });
3212 }
3213 if let Some((id, _)) = cloud_account(path) {
3214 return Some(cloud_place(&id));
3215 }
3216 let text = path.to_string_lossy();
3217 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3218 let key = key.trim_matches('/');
3219 if key.is_empty() {
3220 return self.azure_account_place(&account);
3221 }
3222 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3223 let up = if up.is_empty() {
3224 String::new()
3225 } else {
3226 format!("{up}/")
3227 };
3228 return Some(PathBuf::from(crate::cloud::source::azure_url(
3229 &account, &container, &up,
3230 )));
3231 }
3232 if is_bucket_root(path) {
3233 if let Some(project) = self.project_of_bucket(path) {
3235 return Some(project);
3236 }
3237 return self.cloud_source_of(path).map(|s| cloud_place(&s.id));
3238 }
3239 parent_location(path)
3240 }
3241
3242 fn parent_within(&self, path: &Path) -> Option<PathBuf> {
3244 let text = path.to_string_lossy();
3245 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3246 let key = key.trim_matches('/');
3247 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3248 let up = if up.is_empty() {
3249 String::new()
3250 } else {
3251 format!("{up}/")
3252 };
3253 return Some(PathBuf::from(crate::cloud::source::azure_url(
3254 &account, &container, &up,
3255 )));
3256 }
3257 parent_location(path)
3258 }
3259
3260 fn remote_dataset_of(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3263 if !is_object_store_url(path) {
3264 return None;
3265 }
3266 let text = path.to_string_lossy();
3267 self.catalogs
3268 .iter()
3269 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3270 .filter(|(_, d)| {
3271 is_object_store_url(&d.location) && within(&text, &d.location.to_string_lossy())
3272 })
3273 .rev()
3274 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
3275 }
3276
3277 pub fn catalog_dataset(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3279 let places = &self.catalog_places;
3280 if places.indexes(&self.catalogs) {
3281 let key = place_key(path);
3282 let &(c, d) = places.datasets.get(&key)?;
3283 let catalog = self.catalogs.get(c)?;
3284 if let Some(dataset) = catalog.datasets.get(d)
3285 && place_key(&dataset.location) == key
3286 {
3287 return Some((catalog, dataset));
3288 }
3289 }
3290 self.catalogs
3291 .iter()
3292 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3293 .find(|(_, d)| same_place(&d.location, path))
3294 }
3295
3296 pub fn bookmark(&self, path: &Path) -> Option<(&ShownDataset, &str)> {
3298 let places = &self.catalog_places;
3299 if places.indexes(&self.catalogs) {
3300 let key = place_key(path);
3301 let &(c, d, b) = places.bookmarks.get(&key)?;
3302 if let Some(dataset) = self.catalogs.get(c).and_then(|c| c.datasets.get(d))
3303 && let Some((name, place)) = dataset.bookmarks.get(b)
3304 && place_key(place) == key
3305 {
3306 return Some((dataset, name.as_str()));
3307 }
3308 }
3309 self.catalogs
3310 .iter()
3311 .flat_map(|c| c.datasets.iter())
3312 .find_map(|d| {
3313 d.bookmarks
3314 .iter()
3315 .find(|(_, place)| same_place(place, path))
3316 .map(|(name, _)| (d, name.as_str()))
3317 })
3318 }
3319
3320 pub fn size_hint(&self, path: &Path) -> Option<u64> {
3322 self.catalog_dataset(path).and_then(|(_, d)| d.size)
3323 }
3324
3325 fn azure_account_place(&self, account: &str) -> Option<PathBuf> {
3327 self.cloud
3328 .iter()
3329 .flat_map(|s| s.buckets.iter())
3330 .find(|place| cloud_account(place).is_some_and(|(_, a)| a == account))
3331 .cloned()
3332 }
3333
3334 pub fn cloud_look(&self, entry: &Entry) -> Option<CloudLook> {
3337 if entry.kind != EntryKind::Directory
3338 || !entry.holds.is_empty()
3339 || !is_object_store_url(&entry.path)
3340 || is_cloud_place(&entry.path)
3341 || object_place_label(&entry.path).is_some()
3342 {
3343 return None;
3344 }
3345 if self.peeking.contains(&entry.path) {
3346 return Some(CloudLook::Looking);
3347 }
3348 if self.peek_failed.contains(&entry.path) {
3349 return Some(CloudLook::Failed);
3350 }
3351 match self.cloud_kinds.get(&entry.path) {
3352 None => Some(CloudLook::Waiting),
3353 Some((kind, holds)) if *kind != EntryKind::Directory || !holds.is_empty() => {
3356 Some(CloudLook::Looking)
3357 }
3358 Some(_) => None,
3359 }
3360 }
3361
3362 pub fn place_kind(&self, path: &Path) -> Option<&'static str> {
3365 if self.missing.contains(path) {
3366 return Some("missing");
3367 }
3368 if let Some((id, _)) = cloud_account(path) {
3369 return self
3370 .cloud
3371 .iter()
3372 .any(|s| s.id == id && s.api == crate::cloud::source::ProviderKind::Gcs)
3373 .then_some("project");
3374 }
3375 (is_object_store_url(path)
3377 && (self
3378 .catalog_dataset(path)
3379 .is_some_and(|(_, d)| is_object_store_url(&d.location))
3380 || (self.browsing.is_none() && self.bookmark(path).is_some())))
3381 .then_some("dataset")
3382 }
3383
3384 fn project_of_bucket(&self, bucket_root: &Path) -> Option<PathBuf> {
3386 let root = bucket_root.to_string_lossy();
3387 let root = root.trim_end_matches('/');
3388 self.probes
3389 .answered()
3390 .filter(|(place, _)| cloud_account(place).is_some())
3391 .find(|(_, rows)| {
3392 rows.iter()
3393 .any(|row| row.path.to_string_lossy().trim_end_matches('/') == root)
3394 })
3395 .map(|(place, _)| place.clone())
3396 }
3397
3398 fn google_bucket_root(path: &Path) -> Option<PathBuf> {
3400 let text = path.to_string_lossy();
3401 let rest = text
3402 .strip_prefix("gs://")
3403 .or_else(|| text.strip_prefix("gcs://"))?;
3404 let bucket = rest.split('/').next().filter(|b| !b.is_empty())?;
3405 Some(PathBuf::from(format!("gs://{bucket}")))
3406 }
3407
3408 pub fn place_details(&self, path: &Path) -> Option<&[(String, String)]> {
3411 self.cloud
3412 .iter()
3413 .find_map(|s| s.place_details.get(path))
3414 .or_else(|| self.catalog_dataset(path).map(|(_, d)| &d.details))
3415 .or_else(|| self.bookmark(path).map(|(d, _)| &d.details))
3416 .map(Vec::as_slice)
3417 }
3418
3419 pub fn location_label(&self, path: &Path) -> String {
3422 let sep = crate::glyphs::get().trail;
3423 if let Some((catalog, dataset)) = self.remote_dataset_of(path) {
3426 let text = path.to_string_lossy();
3427 let rest = within_rest(&text, &dataset.location.to_string_lossy());
3428 let mut parts = vec![catalog.label.clone(), dataset.name.clone()];
3429 parts.extend(
3430 rest.split('/')
3431 .filter(|p| !p.is_empty())
3432 .map(str::to_string),
3433 );
3434 return parts.join(&format!(" {sep} "));
3435 }
3436 if let Some(source) = self.cloud_source_of(path) {
3437 let mut parts = vec!["cloud".to_string(), source.label.clone()];
3438 let text = path.to_string_lossy();
3439 if let Some((_, account)) = cloud_account(path) {
3440 parts.push(account);
3441 } else if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text)
3442 {
3443 parts.push(account);
3444 parts.push(container);
3445 parts.extend(key.split('/').filter(|p| !p.is_empty()).map(str::to_string));
3446 } else if cloud_source_id(path).is_none() {
3447 if let Some((_, project)) = Self::google_bucket_root(path)
3448 .and_then(|b| self.project_of_bucket(&b))
3449 .as_deref()
3450 .and_then(cloud_account)
3451 {
3452 parts.push(project);
3453 }
3454 let (_, plain) = crate::cloud::source::split_source_id(&text);
3455 if let Some((_, rest)) = plain.split_once("://") {
3456 parts.extend(
3457 rest.split('/')
3458 .filter(|p| !p.is_empty())
3459 .map(str::to_string),
3460 );
3461 }
3462 }
3463 return parts.join(&format!(" {sep} "));
3464 }
3465 display_path(path)
3466 }
3467
3468 pub fn sync_search_section(&mut self) {
3471 self.sections.retain(|s| s.title != Self::SEARCH_SECTION);
3472 self.changed();
3473
3474 if self.filter.is_empty() {
3475 return;
3476 }
3477 let cloud_rows: Vec<Entry> = if self.browsing.is_none() {
3479 self.cloud
3480 .iter()
3481 .flat_map(|source| {
3482 source.buckets.iter().map(move |bucket| {
3483 let mut entry = bucket_entry(bucket);
3484 entry.name = format!(
3485 "{} {} {}",
3486 source.label,
3487 crate::glyphs::get().trail,
3488 entry.name
3489 );
3490 entry.cost.source = Some(source.api.name().to_string());
3491 entry
3492 })
3493 })
3494 .filter(|e| match_score(&self.filter, e).is_some())
3495 .collect()
3496 } else {
3497 Vec::new()
3498 };
3499 self.score_search_inline();
3500 let local = self.search.root.is_some()
3501 && (self.search.indexed > 0 || self.search.running || self.search.limited.is_some());
3502 if !local {
3503 if !cloud_rows.is_empty() {
3504 let subtitle =
3505 crate::glyphs::dotted(&format!("cloud · {} names", cloud_rows.len()));
3506 self.sections.push(Section {
3507 subtitle: Some(subtitle),
3508 ..Section::titled(Self::SEARCH_SECTION, cloud_rows)
3509 });
3510 }
3511 return;
3512 }
3513
3514 let listed: std::collections::HashSet<&PathBuf> = self
3517 .sections
3518 .iter()
3519 .flat_map(|s| s.rows.iter().map(|r| &r.path))
3520 .collect();
3521
3522 let matches = self.search.matches.as_ref();
3525 let kept: Vec<(&Entry, i32)> = matches
3526 .map(|m| {
3527 let fresh = m.query == self.filter;
3528 m.top
3529 .iter()
3530 .zip(m.scores.iter().copied())
3531 .filter(|(e, _)| !listed.contains(&e.path))
3532 .filter_map(|(e, score)| {
3533 if fresh {
3534 Some((e, score))
3535 } else {
3536 match_score(&self.filter, e).map(|s| (e, s))
3537 }
3538 })
3539 .collect()
3540 })
3541 .unwrap_or_default();
3542 let mut rows: Vec<Entry> = kept.into_iter().map(|(e, _)| e.clone()).collect();
3543 rows.extend(cloud_rows);
3544
3545 let partial = self.search.limited.is_some();
3547 let scored = self.search.scored_for(&self.filter);
3548 if rows.is_empty() && !self.search.running && !partial && scored {
3549 return;
3550 }
3551
3552 let subtitle = self.found_subtitle(rows.is_empty());
3553
3554 self.sections.push(Section {
3555 subtitle: Some(subtitle),
3556 ..Section::titled(Self::SEARCH_SECTION, rows)
3557 });
3558 }
3559
3560 fn found_subtitle(&self, empty: bool) -> String {
3562 let root = self.search.root.clone().unwrap_or_default();
3563 let dot = crate::glyphs::get().middot;
3564 let files = crate::numfmt::group_chrome(self.search.indexed);
3565 let files = if self.search.indexed == 1 {
3566 format!("{files} file")
3567 } else {
3568 format!("{files} files")
3569 };
3570 let subtitle = display_path(&root);
3571 let counted = self
3574 .search
3575 .matches
3576 .as_ref()
3577 .filter(|m| m.query == self.filter && m.ids.len() > m.top.len())
3578 .map(|m| {
3579 format!(
3580 " {dot} {} of {} matches",
3581 crate::numfmt::group_chrome(m.top.len()),
3582 crate::numfmt::group_chrome(m.ids.len())
3583 )
3584 })
3585 .unwrap_or_default();
3586 if self.search.running {
3587 format!(
3588 "{subtitle} {dot} searching {}{counted}",
3589 crate::numfmt::group_chrome(self.search.scanned)
3590 )
3591 } else if !self.search.scored_for(&self.filter) {
3592 format!("{subtitle} {dot} matching {files}")
3593 } else if empty {
3594 match &self.search.limited {
3595 Some(limit) => format!("{subtitle} {dot} no match in {files} {dot} {limit}"),
3596 None => format!("{subtitle} {dot} no match in {files}"),
3597 }
3598 } else {
3599 let searched = crate::numfmt::group_chrome(self.search.scanned);
3600 match &self.search.limited {
3601 Some(limit) => {
3602 format!("{subtitle} {dot} {limit} {dot} {searched} searched{counted}")
3603 }
3604 None => format!("{subtitle} {dot} {searched} searched{counted}"),
3605 }
3606 }
3607 }
3608
3609 pub fn search_batch(&mut self, root: &Path, mut found: Vec<Entry>, scanned: usize) {
3611 if self.search.root.as_deref() != Some(root) {
3613 return;
3614 }
3615 for row in &mut found {
3618 apply_known_facts(row, &self.known, false);
3619 }
3620 self.search.scanned = scanned;
3621 let start = self.search.indexed;
3622 let batch: std::sync::Arc<[Entry]> = found.into();
3623 if !batch.is_empty() {
3624 self.search.indexed += batch.len();
3625 self.search.results.push(batch.clone());
3626 }
3627 let limit = self.search_limit;
3630 let changed = match self.search.matches.as_mut() {
3631 Some(m) if m.query == self.filter && m.upto == start => m.extend(&batch, start, limit),
3632 _ => {
3633 let before = self.search.matches.as_ref().map(|m| m.upto);
3634 self.score_search_inline();
3635 self.search.matches.as_ref().map(|m| m.upto) != before
3636 }
3637 };
3638 let found_listed = self
3640 .sections
3641 .iter()
3642 .position(|s| s.title == Self::SEARCH_SECTION);
3643 match found_listed {
3644 Some(at) if !changed => {
3645 let empty = self.sections[at].rows.is_empty();
3646 self.sections[at].subtitle = Some(self.found_subtitle(empty));
3647 }
3648 _ => self.sync_search_section(),
3649 }
3650 self.settle_return();
3651 }
3652
3653 fn score_search_inline(&mut self) {
3655 if self.filter.is_empty() || self.search.scored_for(&self.filter) {
3656 return;
3657 }
3658 let (base, looks_at) = self.search.base_for(&self.filter);
3659 if looks_at > SCORE_INLINE_MAX {
3660 return;
3661 }
3662 let scored =
3663 crate::home::search::score(&self.search.results, &self.filter, base, self.search_limit);
3664 self.search.matches = Some(scored);
3665 }
3666
3667 pub fn score_job(&mut self) -> Option<ScoreJob> {
3670 if self.filter.is_empty() || self.search.scoring || self.search.scored_for(&self.filter) {
3671 return None;
3672 }
3673 let base = self.search.base_for(&self.filter).0.cloned();
3674 self.search.scoring = true;
3675 Some(ScoreJob {
3676 epoch: self.search.epoch,
3677 results: self.search.results.clone(),
3678 query: self.filter.clone(),
3679 base,
3680 limit: self.search_limit,
3681 })
3682 }
3683
3684 pub fn search_scored(&mut self, epoch: u64, scored: crate::home::search::Matches) {
3686 if epoch != self.search.epoch {
3687 return;
3688 }
3689 self.search.scoring = false;
3690 if self
3692 .search
3693 .matches
3694 .as_ref()
3695 .is_some_and(|m| m.query == scored.query && m.upto >= scored.upto)
3696 {
3697 return;
3698 }
3699 self.search.matches = Some(scored);
3700 self.sync_search_section();
3701 if !matches!(self.selected_row(), Some(Row::Entry { .. })) {
3703 self.select_first_entry();
3704 }
3705 self.settle_return();
3706 }
3707
3708 pub fn search_finished(&mut self, root: &Path, scanned: usize, limited: Option<String>) {
3710 if self.search.root.as_deref() != Some(root) {
3711 return;
3712 }
3713 self.search.running = false;
3714 self.search.done = true;
3715 self.search.scanned = self.search.scanned.max(scanned);
3717 self.search.limited = limited;
3718 self.sync_search_section();
3719 self.settle_return();
3720 }
3721
3722 pub fn visible(&self) -> Vec<Row<'_>> {
3725 let view = self.view();
3726 view.slots.iter().map(|slot| self.row(slot)).collect()
3727 }
3728
3729 pub fn header_rows(&self) -> Vec<bool> {
3731 (self.view().slots.iter())
3732 .map(|slot| matches!(slot, Slot::Plain(Row::Header { .. })))
3733 .collect()
3734 }
3735
3736 pub fn matched(&self) -> usize {
3738 (self.view().slots.iter())
3739 .map(|slot| match slot {
3740 Slot::Plain(Row::Header { matches, .. }) => *matches,
3741 _ => 0,
3742 })
3743 .sum()
3744 }
3745
3746 pub fn row_count(&self) -> usize {
3748 self.view().slots.len()
3749 }
3750
3751 pub fn row_at(&self, index: usize) -> Option<Row<'_>> {
3753 self.view().slots.get(index).map(|slot| self.row(slot))
3754 }
3755
3756 pub fn rows_built(&self) -> usize {
3759 self.rows_cache.builds.get()
3760 }
3761
3762 fn changed(&mut self) {
3764 *self.rows_cache.built.get_mut() = None;
3765 }
3766
3767 pub fn set_catalogs(&mut self, catalogs: Vec<ShownCatalog>) {
3769 self.catalog_places = CatalogPlaces::of(&catalogs);
3770 self.catalogs = catalogs;
3771 self.changed();
3772 }
3773
3774 pub fn set_visits(&mut self, visits: std::collections::HashMap<PathBuf, crate::cache::Visits>) {
3776 self.visits = visits;
3777 self.changed();
3778 }
3779
3780 pub fn show_all(&mut self, section: usize) {
3782 match self.sections.get(section) {
3783 Some(s) if s.grouped_by_place => self.recent_expanded = true,
3784 Some(Section {
3785 root: Some(root), ..
3786 }) => {
3787 self.shown_whole.insert(root.clone());
3788 }
3789 _ => {}
3790 }
3791 }
3792
3793 pub fn cut_again(&mut self, section: usize) -> bool {
3796 let Some(key) = self.selected_key() else {
3797 return false;
3798 };
3799 let Some(s) = self.sections.get(section) else {
3800 return false;
3801 };
3802 let title = s.title.clone();
3803 let whole = if s.grouped_by_place {
3804 None
3805 } else {
3806 match s.root.clone() {
3807 Some(root) => Some(root),
3808 None => return false,
3809 }
3810 };
3811 let was_whole = match &whole {
3812 None => std::mem::replace(&mut self.recent_expanded, false),
3813 Some(root) => self.shown_whole.remove(root),
3814 };
3815 if !was_whole {
3816 return false;
3817 }
3818 if self.listed(&key).is_some() {
3819 match whole {
3820 None => self.recent_expanded = true,
3821 Some(root) => {
3822 self.shown_whole.insert(root);
3823 }
3824 }
3825 return false;
3826 }
3827 self.reselect(Some(RowKey::More(title)));
3828 true
3829 }
3830
3831 pub fn sections_mut(&mut self) -> &mut Vec<Section> {
3833 self.changed();
3834 &mut self.sections
3835 }
3836
3837 fn view(&self) -> std::cell::Ref<'_, View> {
3838 let fresh = self
3839 .rows_cache
3840 .built
3841 .borrow()
3842 .as_ref()
3843 .is_some_and(|view| view.key.matches(self));
3844 if !fresh {
3845 let view = self.build_view();
3846 self.rows_cache.builds.set(self.rows_cache.builds.get() + 1);
3847 *self.rows_cache.built.borrow_mut() = Some(view);
3848 }
3849 std::cell::Ref::map(self.rows_cache.built.borrow(), |view| {
3850 view.as_ref().expect("built above")
3851 })
3852 }
3853
3854 fn row<'a>(&'a self, slot: &Slot) -> Row<'a> {
3855 match slot {
3856 Slot::Plain(row) => row.clone(),
3857 Slot::Entry {
3858 section,
3859 index,
3860 nested,
3861 hit,
3862 } => Row::Entry {
3863 section: *section,
3864 entry: &self.sections[*section].rows[*index],
3865 nested: *nested,
3866 hit: hit.clone(),
3867 },
3868 Slot::Door { section } => Row::Door {
3869 section: *section,
3870 entry: self.sections[*section]
3871 .door
3872 .as_ref()
3873 .expect("the shape says it has a door"),
3874 },
3875 }
3876 }
3877
3878 fn build_view(&self) -> View {
3879 View {
3880 key: ViewKey::of(self),
3881 slots: self.slots(),
3882 has_dataset: self
3883 .sections
3884 .iter()
3885 .flat_map(|s| s.rows.iter())
3886 .any(|e| e.kind.is_dataset() || e.kind.is_lake_table()),
3887 }
3888 }
3889
3890 fn slots(&self) -> Vec<Slot> {
3891 let mut out: Vec<Slot> = Vec::new();
3892 for (si, section) in self.sections.iter().enumerate() {
3893 let mut matched: Vec<(usize, Hit)> = section
3894 .rows
3895 .iter()
3896 .enumerate()
3897 .filter(|(_, row)| !(self.hide_unreadable && row.hidden_by_default()))
3898 .filter_map(|(i, row)| match_hit(&self.filter, row).map(|hit| (i, hit)))
3899 .collect();
3900
3901 let keep_empty = section.unavailable || section.waiting || section.origin.is_some();
3905 let has_door = section.door.is_some() && self.filter.is_empty();
3906 let hidden =
3909 if self.browsing.is_some() && self.hide_unreadable && self.filter.is_empty() {
3910 section
3911 .rows
3912 .iter()
3913 .filter(|row| row.hidden_by_default())
3914 .count()
3915 } else {
3916 0
3917 };
3918 let says_why = section.title == Self::SEARCH_SECTION;
3920 if matched.is_empty()
3921 && !has_door
3922 && hidden == 0
3923 && !says_why
3924 && !(keep_empty && self.filter.is_empty())
3925 {
3926 continue;
3927 }
3928
3929 let entry = |i: usize| §ion.rows[i];
3933 if !self.filter.is_empty() {
3934 let now = std::time::SystemTime::now()
3935 .duration_since(std::time::UNIX_EPOCH)
3936 .map(|d| d.as_secs())
3937 .unwrap_or_default();
3938 let lifted = |i: usize, score: i32| {
3939 let frecency = self
3940 .visits
3941 .get(&entry(i).path)
3942 .map_or(0.0, |v| v.frecency(now));
3943 score.saturating_add((frecency.min(10.0) * FRECENCY_LIFT) as i32)
3944 };
3945 matched.sort_by_cached_key(|(i, hit)| {
3946 (
3947 std::cmp::Reverse(lifted(*i, hit.score)),
3948 entry(*i).name.len(),
3949 )
3950 });
3951 }
3952
3953 match self.sort {
3956 SortMode::Natural => {}
3957 SortMode::Size => {
3958 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).size.unwrap_or(0)));
3959 }
3960 SortMode::Rows => {
3961 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).rows.unwrap_or(0)));
3962 }
3963 SortMode::Modified => {
3964 matched.sort_by_key(|(i, _)| {
3965 std::cmp::Reverse(
3966 entry(*i)
3967 .modified
3968 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
3969 .map(|d| d.as_secs())
3970 .unwrap_or(0),
3971 )
3972 });
3973 }
3974 }
3975
3976 let collapsed = self.section_folded(section);
3977 out.push(Slot::Plain(Row::Header {
3978 section: si,
3979 matches: matched.len(),
3981 collapsed,
3982 }));
3983 if collapsed {
3984 continue;
3985 }
3986 let root = section.root.as_deref();
3988 if self.filter.is_empty()
3989 && root
3990 .is_some_and(|root| self.browsing.is_some() || self.parent_of(root).is_some())
3991 {
3992 out.push(Slot::Plain(Row::Up { section: si }));
3993 }
3994 if has_door {
3997 out.push(Slot::Door { section: si });
3998 }
3999 let shown = match root {
4003 Some(_)
4004 if self.browsing.is_none()
4005 && self.filter.is_empty()
4006 && self.view_height > 0
4007 && !root.is_some_and(|root| self.shown_whole.contains(root)) =>
4008 {
4009 (self.view_height * 2 / 5).max(8)
4010 }
4011 _ => usize::MAX,
4012 };
4013 let rest = if matched.len() > shown.saturating_add(1) {
4014 matched.split_off(shown)
4015 } else {
4016 Vec::new()
4017 };
4018 let measuring = self.sort == SortMode::Rows
4021 && rest.iter().any(|(i, _)| self.wants_measuring(entry(*i)));
4022 if section.grouped_by_place {
4023 self.slots_by_place(si, section, &matched, &mut out);
4024 } else {
4025 let in_order = self.sort == SortMode::Natural
4027 && self.filter.is_empty()
4028 && section.origin.is_some_and(is_catalog_origin)
4029 && section.root.is_none();
4030 out.extend(matched.into_iter().map(|(index, hit)| Slot::Entry {
4031 section: si,
4032 index,
4033 nested: in_order && self.bookmark(&entry(index).path).is_some(),
4034 hit,
4035 }));
4036 }
4037 if !rest.is_empty() {
4038 out.push(Slot::Plain(Row::More {
4039 section: si,
4040 hidden: rest.len(),
4041 places: 0,
4042 measuring,
4043 }));
4044 }
4045 if hidden > 0 {
4046 out.push(Slot::Plain(Row::Hidden {
4047 section: si,
4048 count: hidden,
4049 }));
4050 }
4051 }
4052 out
4053 }
4054
4055 fn slots_by_place(
4061 &self,
4062 si: usize,
4063 section: &Section,
4064 matched: &[(usize, Hit)],
4065 out: &mut Vec<Slot>,
4066 ) {
4067 let places: Vec<PathBuf> = section.rows.iter().map(|row| place_of(&row.path)).collect();
4068 let mut order: Vec<&PathBuf> = Vec::new();
4069 for place in &places {
4070 if !order.contains(&place) {
4071 order.push(place);
4072 }
4073 }
4074 let groups: Vec<(&PathBuf, Vec<&(usize, Hit)>)> = order
4075 .into_iter()
4076 .filter_map(|place| {
4077 let rows: Vec<&(usize, Hit)> = matched
4078 .iter()
4079 .filter(|(i, _)| places[*i] == *place)
4080 .collect();
4081 (!rows.is_empty()).then_some((place, rows))
4082 })
4083 .collect();
4084
4085 let capped = !self.recent_expanded && self.filter.is_empty() && self.view_height > 0;
4087 let budget = self.view_height / 3;
4088 let mut used = 0usize;
4089 let mut shown = 0usize;
4090 for (place, rows) in &groups {
4091 let cost = 1 + rows.len();
4092 if capped && shown > 0 && used + cost > budget {
4093 break;
4094 }
4095 out.push(Slot::Plain(Row::Place {
4096 section: si,
4097 path: (*place).clone(),
4098 label: section.place_labels.get(*place).cloned(),
4099 source: section.rows[rows[0].0].cost.source.clone(),
4101 held: places.iter().filter(|p| p == place).count(),
4102 }));
4103 out.extend(rows.iter().map(|(index, hit)| Slot::Entry {
4104 section: si,
4105 index: *index,
4106 nested: true,
4107 hit: hit.clone(),
4108 }));
4109 used += cost;
4110 shown += 1;
4111 }
4112 if shown < groups.len() {
4113 out.push(Slot::Plain(Row::More {
4114 section: si,
4115 hidden: groups[shown..].iter().map(|(_, rows)| rows.len()).sum(),
4116 places: groups.len() - shown,
4117 measuring: false,
4118 }));
4119 }
4120 }
4121
4122 pub fn path_candidates(&self) -> Vec<&PathName> {
4125 let Some(listing) = self
4126 .path_listing
4127 .as_ref()
4128 .filter(|l| l.dir == typed_dir(&self.path_input))
4129 else {
4130 return Vec::new();
4131 };
4132 let segment = &self.path_input[listing.dir.len()..];
4133 let mut matched: Vec<(&PathName, i32)> = listing
4134 .names
4135 .iter()
4136 .filter(|n| !n.name.starts_with('.') || segment.starts_with('.'))
4137 .filter_map(|n| {
4138 if segment.is_empty() {
4139 return Some((n, 0));
4140 }
4141 let prefix = n.name.starts_with(segment) as i32 * 1_000_000;
4143 fuzzy_score(segment, &n.name).map(|score| (n, prefix + score))
4144 })
4145 .collect();
4146 matched.sort_by(|(a, sa), (b, sb)| sb.cmp(sa).then_with(|| a.name.cmp(&b.name)));
4147 matched.into_iter().map(|(n, _)| n).collect()
4148 }
4149
4150 pub fn pick_first_path(&mut self) {
4153 self.path_pick = (!self.path_candidates().is_empty()).then_some(0);
4154 }
4155
4156 pub fn picked_path(&self) -> Option<String> {
4158 let pick = self.path_pick?;
4159 let name = *self.path_candidates().get(pick)?;
4160 let dir = typed_dir(&self.path_input);
4161 let mut path = format!("{dir}{}", name.name);
4162 if name.dir {
4163 path.push(separator_in(dir));
4164 }
4165 Some(path)
4166 }
4167
4168 pub fn path_completion(&self) -> Option<String> {
4171 let dir = typed_dir(&self.path_input);
4172 let segment = &self.path_input[dir.len()..];
4173 let candidates = self.path_candidates();
4174 match candidates.as_slice() {
4175 [] => None,
4176 [one] => {
4177 let mut path = format!("{dir}{}", one.name);
4178 if one.dir {
4179 path.push(separator_in(dir));
4180 }
4181 Some(path)
4182 }
4183 many => {
4184 let starting: Vec<&str> = many
4185 .iter()
4186 .map(|n| n.name.as_str())
4187 .filter(|n| n.starts_with(segment))
4188 .collect();
4189 let first = starting.first()?;
4190 let shared = starting
4191 .iter()
4192 .skip(1)
4193 .fold(first.to_string(), |acc, n| common_prefix(&acc, n));
4194 (shared.len() > segment.len()).then(|| format!("{dir}{shared}"))
4195 }
4196 }
4197 }
4198
4199 pub fn known_urls(&self) -> Vec<String> {
4202 let mut urls: Vec<String> = Vec::new();
4203 let mut add = |path: &Path| {
4204 let text = path.to_string_lossy();
4205 if text.contains("://") && !is_cloud_place(path) {
4206 urls.push(text.into_owned());
4207 }
4208 };
4209 for catalog in &self.catalogs {
4210 for dataset in &catalog.datasets {
4211 add(&dataset.location);
4212 }
4213 }
4214 for source in &self.cloud {
4215 for bucket in &source.buckets {
4216 add(bucket);
4217 }
4218 }
4219 for (root, rows) in self.probes.answered() {
4220 add(root);
4221 for row in rows.iter() {
4222 add(&row.path);
4223 }
4224 }
4225 for path in self.known.keys() {
4226 add(path);
4227 }
4228 for section in &self.sections {
4229 for row in §ion.rows {
4230 add(&row.path);
4231 }
4232 }
4233 urls
4234 }
4235
4236 pub fn selected_row(&self) -> Option<Row<'_>> {
4238 self.row_at(self.selected)
4239 }
4240
4241 pub fn selected_entry(&self) -> Option<&Entry> {
4244 match self.selected_row()? {
4245 Row::Entry { entry, .. } | Row::Door { entry, .. } => Some(entry),
4246 _ => None,
4247 }
4248 }
4249
4250 pub fn selection_is_the_door(&self) -> bool {
4252 matches!(self.selected_row(), Some(Row::Door { .. }))
4253 }
4254
4255 pub fn recents_in(&self, place: &Path) -> Vec<PathBuf> {
4257 self.sections
4258 .iter()
4259 .filter(|s| s.grouped_by_place)
4260 .flat_map(|s| s.rows.iter())
4261 .filter(|row| place_of(&row.path) == place)
4262 .map(|row| row.path.clone())
4263 .collect()
4264 }
4265
4266 pub fn selected_section(&self) -> Option<usize> {
4268 self.selected_row().map(|r| r.section())
4269 }
4270
4271 pub fn selected_catalog(&self) -> Option<&ShownCatalog> {
4273 if !self.selection_is_header() {
4274 return None;
4275 }
4276 let section = self.sections.get(self.selected_section()?)?;
4277 let origin = section.origin?;
4278 self.catalogs
4279 .iter()
4280 .find(|c| c.label == section.title && c.origin_note() == origin)
4281 }
4282
4283 pub fn selection_is_header(&self) -> bool {
4285 matches!(self.selected_row(), Some(Row::Header { .. }))
4286 }
4287
4288 pub fn pending_probes(&self) -> Vec<PathBuf> {
4291 let check = self.network_check;
4292 let mut out = Vec::new();
4293 for root in self.sections.iter().filter_map(|s| s.remote_root.as_ref()) {
4295 if !self.probes.settled(root) && !out.contains(root) {
4296 out.push(root.clone());
4297 }
4298 }
4299 if let Some(dir) = &self.browsing
4302 && check(dir)
4303 && cloud_source_id(dir).is_none()
4304 && !self.probes.settled(dir)
4305 && !out.contains(dir)
4306 {
4307 out.push(dir.clone());
4308 }
4309 out
4310 }
4311
4312 pub fn below_browse_start(&self) -> bool {
4315 let (Some(dir), Some(start)) = (&self.browsing, &self.browse_start) else {
4316 return false;
4317 };
4318 if dir == start {
4319 return false;
4320 }
4321 let mut current = self.parent_of(dir);
4323 let mut steps = 0;
4324 while let Some(place) = current {
4325 if &place == start {
4326 return true;
4327 }
4328 steps += 1;
4329 if steps > 64 {
4330 break;
4331 }
4332 current = self.parent_of(&place);
4333 }
4334 false
4335 }
4336
4337 pub fn sections_waiting(&self) -> bool {
4339 self.sections.iter().any(|s| s.waiting)
4340 }
4341
4342 pub fn awaiting_listing(&self) -> Option<&Path> {
4344 let dir = self.browsing.as_deref()?;
4345 if cloud_source_id(dir).is_some() {
4346 return self
4347 .cloud_source_of(dir)
4348 .is_some_and(|s| s.status == CloudStatus::Listing && s.buckets.is_empty())
4349 .then_some(dir);
4350 }
4351 ((self.network_check)(dir) && !self.probes.settled(dir)).then_some(dir)
4352 }
4353
4354 pub fn probe_ready(&mut self, root: PathBuf, rows: Vec<Entry>, cut_short: bool) {
4356 self.probes.insert(
4357 root.clone(),
4358 Probe::Listed {
4359 rows: rows.into(),
4360 cut_short,
4361 },
4362 );
4363 self.apply_cloud_kinds(&root);
4364 }
4365
4366 pub fn apply_cloud_kinds(&mut self, root: &Path) {
4368 let Some(rows) = self.probes.listed_mut(root) else {
4369 return;
4370 };
4371 for row in std::sync::Arc::make_mut(rows).iter_mut() {
4373 if row.kind == EntryKind::Directory
4374 && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
4375 {
4376 row.kind = *kind;
4377 if !holds.is_empty() {
4380 row.holds = holds.clone();
4381 }
4382 }
4383 }
4384 }
4385
4386 pub fn cloud_directories_to_peek(&self, limit: usize) -> Vec<PathBuf> {
4390 if limit == 0 {
4391 return Vec::new();
4392 }
4393 let view = self.view();
4394 let mut out: Vec<PathBuf> = Vec::new();
4395 for entry in self.entries_near_cursor(&view, limit) {
4396 if !is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
4399 continue;
4400 }
4401 if !matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown) {
4402 continue;
4403 }
4404 if self.browsing.is_none()
4407 && (self.catalog_dataset(&entry.path).is_some()
4408 || self.bookmark(&entry.path).is_some())
4409 {
4410 continue;
4411 }
4412 if self.cloud_kinds.contains_key(&entry.path)
4414 || self.peeking.contains(&entry.path)
4415 || self.peek_failed.contains(&entry.path)
4416 {
4417 continue;
4418 }
4419 if out.contains(&entry.path) {
4420 continue;
4421 }
4422 out.push(entry.path.clone());
4423 if out.len() >= limit {
4424 break;
4425 }
4426 }
4427 out
4428 }
4429
4430 pub fn probe_failed(&mut self, root: PathBuf, why: Option<String>) {
4432 self.probes.insert(root, Probe::Unreachable(why));
4433 }
4434
4435 pub fn measure_now(&mut self, limit: usize) -> bool {
4439 let wanted = self.unmeasured_visible(limit);
4440 let more = self.unmeasured_visible(limit + 1).len() > wanted.len();
4441 for entry in wanted {
4442 let mut probe = entry.clone();
4443 discover::enrich(&mut probe);
4444 self.enriched
4445 .insert(entry.path.clone(), measured_from(&probe, &entry));
4446 }
4447 self.apply_measurements();
4448 more
4449 }
4450
4451 pub fn unmeasured_visible(&self, limit: usize) -> Vec<Entry> {
4454 let view = self.view();
4455 let mut out: Vec<Entry> = Vec::new();
4456 for entry in view.slots.iter().filter_map(|slot| self.entry_of(slot)) {
4457 if self.wants_measuring(entry) {
4458 out.push(entry.clone());
4459 if out.len() >= limit {
4460 return out;
4461 }
4462 }
4463 }
4464 for slot in &view.slots {
4467 let Slot::Plain(Row::More {
4468 section,
4469 measuring: true,
4470 ..
4471 }) = slot
4472 else {
4473 continue;
4474 };
4475 for entry in &self.sections[*section].rows {
4476 if self.wants_measuring(entry) && !out.iter().any(|e| e.path == entry.path) {
4477 out.push(entry.clone());
4478 if out.len() >= limit {
4479 return out;
4480 }
4481 }
4482 }
4483 }
4484 out
4485 }
4486
4487 fn wants_measuring(&self, entry: &Entry) -> bool {
4489 if entry.rows.is_some() || self.enriched.contains_key(&entry.path) {
4490 return false;
4491 }
4492 if matches!(
4495 entry.kind,
4496 EntryKind::Directory | EntryKind::Unknown | EntryKind::Other
4497 ) || entry.kind.is_lake_table()
4498 {
4499 return false;
4500 }
4501 !(self.network_check)(&entry.path)
4504 }
4505
4506 pub fn classify_now(&mut self, limit: usize) -> bool {
4509 let wanted = self.unclassified_visible(limit);
4510 let more = self.unclassified_visible(limit + 1).len() > wanted.len();
4511 for entry in wanted {
4512 let probe = look_into_as(&entry, &Default::default());
4513 self.enriched
4514 .insert(entry.path.clone(), measured_from(&probe, &entry));
4515 }
4516 self.apply_measurements();
4517 more
4518 }
4519
4520 pub fn unclassified_visible(&self, limit: usize) -> Vec<Entry> {
4526 if limit == 0 {
4527 return Vec::new();
4528 }
4529 let view = self.view();
4530 let mut out: Vec<Entry> = Vec::new();
4531 for entry in self.entries_near_cursor(&view, limit) {
4532 if entry.kind != EntryKind::Unknown || self.missing.contains(&entry.path) {
4533 continue;
4534 }
4535 if self.enriched.contains_key(&entry.path) {
4537 continue;
4538 }
4539 if is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
4542 continue;
4543 }
4544 if out.iter().any(|e| e.path == entry.path) {
4546 continue;
4547 }
4548 out.push(entry.clone());
4549 if out.len() >= limit {
4550 break;
4551 }
4552 }
4553 out
4554 }
4555
4556 fn entries_near_cursor<'a>(
4559 &'a self,
4560 view: &'a View,
4561 limit: usize,
4562 ) -> impl Iterator<Item = &'a Entry> + 'a {
4563 let height = if self.view_height == 0 {
4566 limit
4567 } else {
4568 self.view_height
4569 };
4570 let rows = view.slots.len();
4571 let top = self.scroll.min(rows);
4572 let ahead = top.saturating_add(2 * height).min(rows);
4573 let behind = top.saturating_sub(height);
4574 std::iter::once(self.selected)
4575 .chain(top..ahead)
4576 .chain(behind..top)
4577 .filter_map(|i| self.entry_of(view.slots.get(i)?))
4578 }
4579
4580 fn entry_of(&self, slot: &Slot) -> Option<&Entry> {
4582 match slot {
4583 Slot::Entry { section, index, .. } => Some(&self.sections[*section].rows[*index]),
4584 _ => None,
4585 }
4586 }
4587
4588 pub fn apply_measurements(&mut self) {
4590 for section in &mut self.sections {
4591 for row in section.rows.iter_mut().chain(section.door.iter_mut()) {
4594 if let Some(m) = self.enriched.get(&row.path) {
4595 row.rows = m.rows;
4596 row.cols = m.cols;
4597 row.cols_sampled = m.cols_sampled;
4598 if let Some(kind) = m.kind {
4599 row.kind = kind;
4600 }
4601 if m.size.is_some() {
4602 row.size = m.size;
4603 }
4604 if !m.columns.is_empty() {
4605 row.columns = m.columns.clone();
4606 }
4607 if !m.holds.is_empty() {
4608 row.holds = m.holds.clone();
4609 }
4610 take_cost(row, &m.cost);
4612 }
4613 }
4614 if let Some(door) = section.door.as_mut() {
4617 door.name = door_name(door, §ion.rows);
4618 }
4619 }
4620 self.changed();
4621 if self.landing
4624 && let Some(Row::Door { entry, .. }) = self.row_at(self.selected)
4625 && !door_lands(entry)
4626 {
4627 self.selected = self.landing_row();
4628 self.follow_selection();
4629 }
4630 }
4631
4632 pub fn select_first_entry(&mut self) {
4638 self.returning = None;
4639 self.landing = true;
4640 self.selected = self.landing_row();
4641 }
4642
4643 fn landing_row(&self) -> usize {
4645 let rows = self.visible();
4646 if self.filter.is_empty()
4648 && let Some(newest) = self.newest_recent.as_ref()
4649 && let Some(at) = rows.iter().position(|r| {
4650 matches!(r, Row::Entry { section, entry, .. }
4651 if entry.path == *newest
4652 && self.sections[*section].title == Self::RECENT_SECTION)
4653 })
4654 {
4655 return at;
4656 }
4657 let first = rows
4658 .iter()
4659 .position(|r| matches!(r, Row::Entry { .. } | Row::Door { .. }));
4660 let first = match first.and_then(|i| rows.get(i)) {
4661 Some(Row::Door { entry, .. }) if !door_lands(entry) => rows
4662 .iter()
4663 .position(|r| matches!(r, Row::Entry { .. } | Row::Hidden { .. }))
4664 .or(first),
4665 _ => first,
4666 };
4667 first
4669 .or_else(|| rows.iter().position(|r| matches!(r, Row::Hidden { .. })))
4670 .unwrap_or(0)
4671 }
4672
4673 pub fn clamp_selection(&mut self) {
4674 let n = self.row_count();
4675 if n == 0 {
4676 self.selected = 0;
4677 } else if self.selected >= n {
4678 self.selected = n - 1;
4679 }
4680 }
4681
4682 pub fn select(&mut self, index: usize) {
4684 if index < self.row_count() {
4685 self.returning = None;
4686 self.landing = false;
4687 self.selected = index;
4688 }
4689 }
4690
4691 pub fn move_selection(&mut self, delta: isize) {
4692 self.returning = None;
4693 self.landing = false;
4694 let n = self.row_count();
4695 if n == 0 {
4696 return;
4697 }
4698 let cur = self.selected as isize;
4699 let next = (cur + delta).rem_euclid(n as isize);
4700 self.selected = next as usize;
4701 }
4702
4703 pub fn page_selection(&mut self, delta: isize) {
4706 self.returning = None;
4707 self.landing = false;
4708 let n = self.row_count();
4709 if n == 0 {
4710 return;
4711 }
4712 let next = (self.selected as isize)
4714 .saturating_add(delta)
4715 .clamp(0, n as isize - 1);
4716 self.selected = next as usize;
4717 }
4718}
4719
4720fn source_entry(source: &CloudSource) -> Entry {
4722 Entry {
4723 path: cloud_place(&source.id),
4724 kind: EntryKind::Directory,
4725 name: source.label.clone(),
4726 size: None,
4727 modified: source.listed_at,
4728 rows: None,
4729 cols: None,
4730 cols_sampled: false,
4731 columns: Vec::new(),
4732 cost: Default::default(),
4733 holds: Default::default(),
4734 opens_whole_directory: false,
4735 format_spec: None,
4736 table: None,
4737 }
4738}
4739
4740fn bucket_entry(url: &Path) -> Entry {
4742 let mut entry = Entry::directory(url);
4743 let text = url.to_string_lossy();
4745 let (_, plain) = crate::cloud::source::split_source_id(&text);
4746 entry.name = plain
4747 .rsplit('/')
4748 .find(|part| !part.is_empty())
4749 .unwrap_or("")
4750 .to_string();
4751 entry
4752}
4753
4754pub fn names_a_file(path: &Path) -> bool {
4757 let named = path.to_string_lossy();
4758 let dotted = !named.ends_with('/')
4761 && path
4762 .file_name()
4763 .map(|last| last.to_string_lossy())
4764 .is_some_and(|last| last.trim_start_matches('.').contains('.'));
4765 discover::is_data_file(path) || dotted
4766}
4767
4768fn entry_for_path(path: &Path, remote: bool) -> Entry {
4770 if !remote && let Some(table) = discover::table_row(path) {
4772 return table;
4773 }
4774 let mut holds = discover::Holds::default();
4775 let kind = if remote {
4778 if names_a_file(path) {
4783 EntryKind::File
4784 } else {
4785 EntryKind::Unknown
4786 }
4787 } else if path.is_dir() {
4788 let (kind, found) = discover::look_at_directory(path);
4789 holds = found;
4790 kind
4791 } else {
4792 EntryKind::File
4793 };
4794 let mut entry = Entry {
4795 path: path.to_path_buf(),
4796 kind,
4797 name: path
4798 .file_name()
4799 .map(|n| n.to_string_lossy().into_owned())
4800 .unwrap_or_else(|| path.to_string_lossy().into_owned()),
4801 size: None,
4802 modified: None,
4803 rows: None,
4804 cols: None,
4805 cols_sampled: false,
4806 columns: Vec::new(),
4807 cost: Default::default(),
4808 holds,
4809 opens_whole_directory: false,
4810 format_spec: None,
4811 table: None,
4812 };
4813 if !remote && let Ok(meta) = std::fs::metadata(path) {
4814 if meta.is_file() {
4815 entry.size = Some(meta.len());
4816 }
4817 entry.modified = meta.modified().ok();
4818 }
4819 entry
4820}
4821
4822pub fn display_path(path: &Path) -> String {
4824 if let Some(home) = dirs::home_dir()
4825 && let Ok(rest) = path.strip_prefix(&home)
4826 {
4827 if rest.as_os_str().is_empty() {
4828 return "~".to_string();
4829 }
4830 return format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display());
4832 }
4833 path.display().to_string()
4834}
4835
4836pub fn complete_path(typed: &str) -> (String, usize) {
4840 let expanded = expand_user_path(typed);
4841 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
4843 let typed_ends_in_sep = typed.ends_with(is_separator);
4844
4845 let (dir, prefix) = if typed_ends_in_sep {
4846 (expanded.clone(), String::new())
4847 } else {
4848 match (expanded.parent(), expanded.file_name()) {
4849 (Some(parent), Some(name)) => {
4850 (parent.to_path_buf(), name.to_string_lossy().into_owned())
4851 }
4852 _ => (expanded.clone(), String::new()),
4853 }
4854 };
4855
4856 let Ok(entries) = std::fs::read_dir(&dir) else {
4857 return (typed.to_string(), 0);
4858 };
4859
4860 let mut names: Vec<String> = entries
4861 .flatten()
4862 .filter_map(|e| {
4863 let name = e.file_name().to_string_lossy().into_owned();
4864 if name.starts_with('.') && !prefix.starts_with('.') {
4866 return None;
4867 }
4868 name.starts_with(&prefix).then_some(name)
4869 })
4870 .collect();
4871 if names.is_empty() {
4872 return (typed.to_string(), 0);
4873 }
4874 names.sort();
4875
4876 let shared = names
4878 .iter()
4879 .skip(1)
4880 .fold(names[0].clone(), |acc, name| common_prefix(&acc, name));
4881
4882 let mut completed = typed.to_string();
4883 completed.truncate(typed.len() - prefix.len());
4884 completed.push_str(&shared);
4885
4886 if names.len() == 1 && dir.join(&shared).is_dir() && !completed.ends_with(is_separator) {
4889 let separator = typed
4890 .chars()
4891 .rev()
4892 .find(|c| is_separator(*c))
4893 .unwrap_or(std::path::MAIN_SEPARATOR);
4894 completed.push(separator);
4895 }
4896 (completed, names.len())
4897}
4898
4899#[derive(Debug, Clone, PartialEq, Eq)]
4901pub struct PathName {
4902 pub name: String,
4903 pub dir: bool,
4905}
4906
4907#[derive(Debug, Clone, Default, PartialEq, Eq)]
4909pub struct PathListing {
4910 pub dir: String,
4912 pub names: Vec<PathName>,
4913 pub failed: bool,
4915}
4916
4917const PATH_LISTING_MAX: usize = 5_000;
4919
4920pub fn typed_dir(typed: &str) -> &str {
4923 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
4924 let floor = typed.find("://").map_or(0, |at| at + 3);
4925 match typed[floor..].rfind(is_separator) {
4926 Some(at) => &typed[..floor + at + 1],
4927 None => &typed[..floor],
4928 }
4929}
4930
4931fn separator_in(dir: &str) -> char {
4934 if typed_dir_is_url(dir) {
4935 return '/';
4936 }
4937 dir.chars()
4938 .rev()
4939 .find(|c| *c == '/' || (cfg!(windows) && *c == '\\'))
4940 .unwrap_or(std::path::MAIN_SEPARATOR)
4941}
4942
4943pub fn typed_dir_is_url(dir: &str) -> bool {
4945 dir.contains("://")
4946}
4947
4948pub fn list_typed_dir(dir: &str) -> PathListing {
4951 let path = if dir.is_empty() {
4952 PathBuf::from(".")
4953 } else {
4954 expand_user_path(dir)
4955 };
4956 let Ok(entries) = std::fs::read_dir(&path) else {
4957 return PathListing {
4958 dir: dir.to_string(),
4959 names: Vec::new(),
4960 failed: true,
4961 };
4962 };
4963 let mut names: Vec<PathName> = entries
4964 .flatten()
4965 .take(PATH_LISTING_MAX)
4966 .map(|e| {
4967 let name = e.file_name().to_string_lossy().into_owned();
4968 let dir = e.file_type().is_ok_and(|t| t.is_dir())
4970 || (e.file_type().is_ok_and(|t| t.is_symlink()) && e.path().is_dir());
4971 PathName { name, dir }
4972 })
4973 .collect();
4974 names.sort_by(|a, b| a.name.cmp(&b.name));
4975 PathListing {
4976 dir: dir.to_string(),
4977 names,
4978 failed: false,
4979 }
4980}
4981
4982pub fn names_under(dir: &str, urls: impl IntoIterator<Item = String>) -> PathListing {
4985 let mut names: Vec<PathName> = Vec::new();
4986 for url in urls {
4987 let forms = match crate::cloud::source::azure_parts(&url) {
4989 Some((_, container, key)) => vec![url.clone(), format!("az://{container}/{key}")],
4990 None => vec![url],
4991 };
4992 for form in forms {
4993 let Some(rest) = form.strip_prefix(dir) else {
4994 continue;
4995 };
4996 let (name, more) = match rest.split_once('/') {
4997 Some((name, more)) => (name, Some(more)),
4998 None => (rest, None),
4999 };
5000 if name.is_empty() {
5001 continue;
5002 }
5003 let is_dir = more.is_some() || !names_a_file(Path::new(&form));
5006 match names.iter_mut().find(|n| n.name == name) {
5007 Some(known) => known.dir |= is_dir,
5008 None => names.push(PathName {
5009 name: name.to_string(),
5010 dir: is_dir,
5011 }),
5012 }
5013 }
5014 }
5015 names.sort_by(|a, b| a.name.cmp(&b.name));
5016 PathListing {
5017 dir: dir.to_string(),
5018 names,
5019 failed: false,
5020 }
5021}
5022
5023fn common_prefix(a: &str, b: &str) -> String {
5024 a.chars()
5025 .zip(b.chars())
5026 .take_while(|(x, y)| x == y)
5027 .map(|(x, _)| x)
5028 .collect()
5029}
5030
5031pub fn expand_user_path(raw: &str) -> PathBuf {
5033 crate::config::expand_config_path(raw)
5034}
5035
5036#[cfg(test)]
5037mod holds_flow_tests {
5038 use super::*;
5039
5040 #[test]
5043 fn a_rows_label_is_one_decision_for_the_list_and_the_pane() {
5044 let mut directory = Entry::for_test(Path::new("s3://bucket/warehouse"), "warehouse");
5045 directory.kind = EntryKind::Directory;
5046 let said = |look, place_kind| describe(&directory, place_kind, look, 0, None);
5047 let g = crate::glyphs::get();
5048
5049 let waiting = said(Some(CloudLook::Waiting), None);
5050 assert_eq!(
5051 (waiting.short.as_str(), waiting.words.as_str()),
5052 (g.ellipsis, "")
5053 );
5054 assert_eq!(said(Some(CloudLook::Looking), None).words, "");
5055 assert!(
5056 said(Some(CloudLook::Failed), None)
5057 .words
5058 .contains("listing failed")
5059 );
5060 assert_eq!(said(None, None).words, "directory");
5061 let curated = said(None, Some("dataset"));
5062 assert_eq!(
5063 (curated.short.as_str(), curated.words.as_str()),
5064 ("dataset", "dataset")
5065 );
5066 assert!(curated.curated);
5067
5068 directory.holds = crate::home::discover::Holds {
5069 formats: vec![("parquet".to_string(), 12)],
5070 ..Default::default()
5071 };
5072 let counted = describe(&directory, None, None, 0, None);
5073 assert_eq!(counted.short, "12 parquet");
5074 assert_eq!(
5075 counted.words, "directory",
5076 "the count is the pane's `contains` line"
5077 );
5078 let curated = describe(&directory, Some("dataset"), None, 0, None);
5079 assert_eq!(
5080 curated.short, "dataset",
5081 "the curated word wins over the count"
5082 );
5083
5084 directory.opens_whole_directory = true;
5085 assert_eq!(
5086 describe(&directory, Some("dataset"), None, 0, None),
5087 RowLabel::default()
5088 );
5089 }
5090
5091 #[cfg(windows)]
5094 #[test]
5095 fn a_windows_home_path_is_shown_and_typed_with_backslashes() {
5096 let home = dirs::home_dir().unwrap();
5097 let path = home.join("data").join("a.csv");
5098 let shown = display_path(&path);
5099 assert_eq!(shown, r"~\data\a.csv");
5100 assert_eq!(expand_user_path(&shown), path);
5101 }
5102
5103 #[cfg(windows)]
5106 #[test]
5107 fn a_dot_above_a_windows_recent_does_not_make_it_a_file() {
5108 let path = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\orders");
5109 assert_eq!(entry_for_path(path, true).kind, EntryKind::Unknown);
5110 let file = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\a.parquet");
5111 assert_eq!(entry_for_path(file, true).kind, EntryKind::File);
5112 }
5113
5114 fn counted(n: usize) -> crate::home::discover::Holds {
5115 crate::home::discover::Holds {
5116 formats: vec![("parquet".to_string(), n)],
5117 ..Default::default()
5118 }
5119 }
5120
5121 fn in_flight() -> (EntryKind, crate::home::discover::Holds) {
5124 (
5125 EntryKind::Directory,
5126 crate::home::discover::Holds::default(),
5127 )
5128 }
5129
5130 #[test]
5131 fn a_claim_staked_before_a_peek_lands_keeps_the_count_a_row_already_has() {
5132 let root = std::path::PathBuf::from("s3://bucket/warehouse");
5133 let path = root.join("orders");
5134 let mut row = Entry::for_test(&path, "orders");
5135 row.kind = EntryKind::Directory;
5136 row.holds = counted(15);
5139
5140 let mut home = HomeState::default();
5141 home.probe_ready(root.clone(), vec![row], false);
5142 home.cloud_kinds.insert(path, in_flight());
5143 home.apply_cloud_kinds(&root);
5144
5145 assert_eq!(
5146 home.probes.listed(&root).unwrap()[0].holds.label(),
5147 "15 parquet",
5148 "the placeholder erased a count the row already had"
5149 );
5150 }
5151
5152 #[test]
5153 fn a_cloud_directory_waits_then_looks_then_answers() {
5154 let path = std::path::PathBuf::from("gs://pitscope/seasons");
5155 let mut row = Entry::for_test(&path, "seasons");
5156 row.kind = EntryKind::Directory;
5157 let mut home = HomeState::default();
5158
5159 assert_eq!(
5160 home.cloud_look(&row),
5161 Some(CloudLook::Waiting),
5162 "not asked yet"
5163 );
5164 home.peeking.insert(path.clone());
5165 assert_eq!(
5166 home.cloud_look(&row),
5167 Some(CloudLook::Looking),
5168 "being looked into"
5169 );
5170 home.peeking.remove(&path);
5171
5172 home.cloud_kinds
5175 .insert(path.clone(), (EntryKind::Directory, counted(12)));
5176 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
5177 home.cloud_kinds
5178 .insert(path.clone(), (EntryKind::Hive, Default::default()));
5179 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
5180
5181 home.cloud_kinds.insert(path.clone(), in_flight());
5183 assert_eq!(home.cloud_look(&row), None, "answered");
5184
5185 let mut home = HomeState {
5188 network_check: |_| true,
5189 ..Default::default()
5190 };
5191 let root = std::path::PathBuf::from("gs://pitscope");
5192 home.probe_ready(root.clone(), vec![row.clone()], false);
5193 home.browsing = Some(root);
5194 home.rebuild(&[]);
5195 assert_eq!(
5196 home.cloud_directories_to_peek(4),
5197 std::slice::from_ref(&path)
5198 );
5199 home.peek_failed.insert(path.clone());
5200 assert_eq!(home.cloud_look(&row), Some(CloudLook::Failed));
5201 assert!(home.cloud_directories_to_peek(4).is_empty());
5202
5203 let mut counted_row = Entry::for_test(&path.join("x"), "x");
5206 counted_row.kind = EntryKind::Directory;
5207 counted_row.holds = counted(3);
5208 assert_eq!(home.cloud_look(&counted_row), None);
5209 let mut bucket = Entry::for_test(std::path::Path::new("gs://pitscope"), "pitscope");
5210 bucket.kind = EntryKind::Directory;
5211 assert_eq!(home.cloud_look(&bucket), None);
5212 let mut local = Entry::for_test(std::path::Path::new("/data/seasons"), "seasons");
5213 local.kind = EntryKind::Directory;
5214 assert_eq!(home.cloud_look(&local), None);
5215 }
5216
5217 #[test]
5218 fn a_peeks_answer_replaces_the_count_a_row_had() {
5219 let root = std::path::PathBuf::from("s3://bucket/warehouse");
5220 let path = root.join("orders");
5221 let mut row = Entry::for_test(&path, "orders");
5222 row.kind = EntryKind::Directory;
5223 row.holds = counted(15);
5224
5225 let mut home = HomeState::default();
5226 home.probe_ready(root.clone(), vec![row], false);
5227 home.cloud_kinds
5228 .insert(path, (EntryKind::MultiFile, counted(40)));
5229 home.apply_cloud_kinds(&root);
5230
5231 assert_eq!(
5232 home.probes.listed(&root).unwrap()[0].holds.label(),
5233 "40 parquet"
5234 );
5235 assert_eq!(
5236 home.probes.listed(&root).unwrap()[0].kind,
5237 EntryKind::MultiFile
5238 );
5239 }
5240
5241 #[test]
5242 fn a_peek_answers_only_the_rows_that_asked() {
5243 let root = std::path::PathBuf::from("s3://bucket/warehouse");
5244 let settled = root.join("sales");
5248 let mut row = Entry::for_test(&settled, "sales");
5249 row.kind = EntryKind::Hive;
5250 row.holds = counted(40);
5251
5252 let mut home = HomeState::default();
5253 home.probe_ready(root.clone(), vec![row], false);
5254 home.cloud_kinds
5255 .insert(settled, (EntryKind::Directory, counted(1)));
5256 home.apply_cloud_kinds(&root);
5257
5258 assert_eq!(home.probes.listed(&root).unwrap()[0].kind, EntryKind::Hive);
5259 assert_eq!(
5260 home.probes.listed(&root).unwrap()[0].holds.label(),
5261 "40 parquet"
5262 );
5263 }
5264
5265 #[test]
5272 fn a_peek_goes_to_the_row_the_cursor_is_on_and_is_never_asked_twice() {
5273 let root = std::path::PathBuf::from("s3://bucket/warehouse");
5274 let mut home = HomeState {
5275 network_check: |_| true,
5276 ..Default::default()
5277 };
5278 let rows: Vec<Entry> = ["a", "b", "c", "d", "e"]
5279 .iter()
5280 .map(|n| {
5281 let mut row = Entry::for_test(&root.join(n), n);
5282 row.kind = EntryKind::Directory;
5283 row
5284 })
5285 .collect();
5286 home.probe_ready(root.clone(), rows, false);
5287 home.browsing = Some(root.clone());
5288 home.view_height = 10;
5289 home.rebuild(&[]);
5290 home.cloud_kinds
5292 .insert(root.join("b"), (EntryKind::MultiFile, counted(3)));
5293 home.peeking.insert(root.join("c"));
5294
5295 home.selected = home
5298 .visible()
5299 .iter()
5300 .position(|r| matches!(r, Row::Entry { entry, .. } if entry.name == "e"))
5301 .expect("the row is listed");
5302 let asked = home.cloud_directories_to_peek(3);
5303 assert_eq!(
5304 asked.first(),
5305 Some(&root.join("e")),
5306 "the highlighted row is the one about to be acted on"
5307 );
5308 assert_eq!(asked.len(), 3, "the budget is a budget");
5309 assert!(
5310 !asked.contains(&root.join("b")),
5311 "a directory already looked into is not asked again"
5312 );
5313 assert!(
5314 !asked.contains(&root.join("c")),
5315 "nor one with a request already out"
5316 );
5317 }
5318
5319 #[test]
5320 fn a_measurement_that_counted_nothing_keeps_the_count_a_row_already_has() {
5321 let path = std::path::PathBuf::from("/data/warehouse/orders");
5322 let mut row = Entry::for_test(&path, "orders");
5323 row.kind = EntryKind::Directory;
5324 row.holds = counted(15);
5325
5326 let mut home = HomeState::default();
5327 home.sections.push(Section::titled("Here", vec![row]));
5328 home.enriched.insert(
5330 path,
5331 Measured {
5332 kind: Some(EntryKind::Directory),
5333 ..Default::default()
5334 },
5335 );
5336 home.apply_measurements();
5337
5338 assert_eq!(
5339 home.sections[0].rows[0].holds.label(),
5340 "15 parquet",
5341 "a measurement with nothing to say erased the label"
5342 );
5343 }
5344}
5345
5346#[cfg(test)]
5347mod look_into_batch_tests {
5348 use super::*;
5349 use polars::prelude::*;
5350
5351 #[test]
5354 fn every_kind_is_sent_before_any_count() {
5355 let dir = tempfile::tempdir().unwrap();
5356 let cache_dir = tempfile::tempdir().unwrap();
5357 let cache = crate::cache::CacheManager::with_dir(cache_dir.path().to_path_buf());
5358 let mut rows = Vec::new();
5359 for name in ["a", "b"] {
5360 let partition = dir.path().join(name).join("year=2024");
5361 std::fs::create_dir_all(&partition).unwrap();
5362 let mut frame = df!("x" => [1i32, 2, 3]).unwrap();
5363 let file = std::fs::File::create(partition.join("part.parquet")).unwrap();
5364 ParquetWriter::new(file).finish(&mut frame).unwrap();
5365 rows.push(Entry::new(dir.path().join(name), EntryKind::Unknown));
5366 }
5367
5368 let mut sent = Vec::new();
5369 look_into_batch(rows, &cache, |path, m| {
5370 let name = path.file_name().unwrap().to_string_lossy().into_owned();
5371 sent.push((name, m.kind, m.rows));
5372 });
5373
5374 let hive = Some(EntryKind::Hive);
5375 assert_eq!(
5376 sent,
5377 vec![
5378 ("a".to_string(), hive, None),
5379 ("b".to_string(), hive, None),
5380 ("a".to_string(), hive, Some(3)),
5381 ("b".to_string(), hive, Some(3)),
5382 ]
5383 );
5384 }
5385}
5386
5387#[cfg(test)]
5388mod known_facts_tests {
5389 use super::*;
5390 use crate::cache::DatasetFacts;
5391
5392 #[test]
5400 fn what_a_directory_holds_is_restored_beside_its_kind() {
5401 let holds = crate::home::discover::Holds {
5402 formats: vec![("parquet".to_string(), 15)],
5403 ..Default::default()
5404 };
5405 for (path, remote) in [
5406 (
5407 std::path::PathBuf::from("s3://bucket/warehouse/orders"),
5408 true,
5409 ),
5410 (std::path::PathBuf::from("/data/warehouse/orders"), false),
5411 ] {
5412 let facts = DatasetFacts {
5413 mtime: 0,
5414 size: 4096,
5415 rows: Some(999),
5418 cols: Some(72),
5419 cols_sampled: false,
5420 columns: vec!["lat".to_string()],
5421 kind: Some(EntryKind::Directory),
5424 classified_by: crate::home::discover::CLASSIFIER_VERSION,
5425 holds: holds.clone(),
5426 cost: Default::default(),
5427 };
5428 let mut row = Entry::directory(&path);
5429 row.kind = EntryKind::Unknown;
5430 row.modified = Some(std::time::UNIX_EPOCH);
5431 assert_eq!(row.size, None);
5434 let index = std::collections::HashMap::from([(path.clone(), facts)]);
5435
5436 apply_known_facts(&mut row, &index, remote);
5437 assert_eq!(row.kind, EntryKind::Directory, "{path:?}");
5438 assert_eq!(row.label(), "15 parquet", "{path:?}");
5439 if !remote {
5445 assert_eq!(row.rows, None, "{path:?}");
5446 assert_eq!(row.cols, None, "{path:?}");
5447 assert_eq!(row.size, None, "{path:?}");
5448 assert!(row.columns.is_empty(), "{path:?}");
5449 }
5450 }
5451 }
5452
5453 #[test]
5458 fn a_datasets_counts_are_measured_rather_than_restored() {
5459 let path = std::path::PathBuf::from("/data/warehouse/events");
5460 let facts = DatasetFacts {
5461 mtime: 0,
5462 size: 4096,
5463 rows: Some(1_200_000),
5464 cols: Some(58),
5465 cols_sampled: false,
5466 columns: vec!["ts".to_string()],
5467 kind: Some(EntryKind::MultiFile),
5468 classified_by: crate::home::discover::CLASSIFIER_VERSION,
5469 holds: crate::home::discover::Holds {
5470 formats: vec![("parquet".to_string(), 15)],
5471 ..Default::default()
5472 },
5473 cost: Default::default(),
5474 };
5475 let mut row = Entry::directory(&path);
5476 row.kind = EntryKind::Unknown;
5477 row.modified = Some(std::time::UNIX_EPOCH);
5478 let index = std::collections::HashMap::from([(path.clone(), facts)]);
5479
5480 apply_known_facts(&mut row, &index, false);
5481 assert_eq!(row.kind, EntryKind::MultiFile, "the kind comes back");
5482 assert_eq!(row.label(), "15 parquet", "and what it holds");
5483 assert_eq!(
5484 row.rows, None,
5485 "but not the count: the measuring pass skips a row that has one"
5486 );
5487 assert_eq!(row.cols, None);
5488 }
5489
5490 #[test]
5499 fn a_kind_from_an_older_classifier_is_not_restored() {
5500 let remote = std::path::PathBuf::from("s3://bucket/warehouse/orders");
5501 let facts = |classified_by| DatasetFacts {
5502 mtime: 0,
5503 size: 4096,
5504 rows: Some(1_000),
5505 cols: Some(7),
5506 cols_sampled: false,
5507 columns: vec!["id".into(), "amount".into()],
5508 kind: Some(EntryKind::MultiFile),
5509 classified_by,
5510 cost: Default::default(),
5511 holds: Default::default(),
5512 };
5513 let unprobed = || {
5514 let mut row = Entry::directory(&remote);
5515 row.kind = EntryKind::Unknown;
5516 row
5517 };
5518
5519 let index = |classified_by| {
5520 std::collections::HashMap::from([(remote.clone(), facts(classified_by))])
5521 };
5522
5523 let mut row = unprobed();
5524 apply_known_facts(
5525 &mut row,
5526 &index(crate::home::discover::CLASSIFIER_VERSION),
5527 true,
5528 );
5529 assert_eq!(
5530 row.kind,
5531 EntryKind::MultiFile,
5532 "this build's own answer comes back"
5533 );
5534
5535 let mut row = unprobed();
5536 apply_known_facts(&mut row, &index(0), true);
5537 assert_eq!(
5538 row.kind,
5539 EntryKind::Unknown,
5540 "an older build's does not: it may be a lake table this one would recognize"
5541 );
5542 assert_eq!(
5543 row.rows,
5544 Some(1_000),
5545 "but what it measured is still measured"
5546 );
5547 assert_eq!(row.columns, vec!["id".to_string(), "amount".to_string()]);
5548 }
5549}
5550
5551#[cfg(test)]
5552mod build_feature_tests {
5553 use super::*;
5554
5555 #[test]
5558 fn the_builtin_catalog_lists_only_what_this_build_opens() {
5559 let urls: Vec<String> = catalogs(&crate::config::AppConfig::default())
5560 .into_iter()
5561 .filter(|c| c.origin == crate::home::catalog::Origin::Bundled)
5562 .flat_map(|c| c.datasets)
5563 .map(|d| d.location.to_string_lossy().into_owned())
5564 .collect();
5565 let web = urls.iter().filter(|u| u.starts_with("https://")).count();
5566 let stores = urls
5567 .iter()
5568 .filter(|u| is_object_store_url(Path::new(u)))
5569 .count();
5570 assert_eq!(web + stores, urls.len(), "{urls:?}");
5571 assert_eq!(web > 0, cfg!(feature = "http"), "{urls:?}");
5572 assert_eq!(stores > 0, cfg!(feature = "cloud"), "{urls:?}");
5573 }
5574
5575 #[test]
5578 fn an_empty_examples_toml_hides_the_section() {
5579 let mut config = crate::config::AppConfig::default();
5580 assert_eq!(
5582 catalogs(&config)
5583 .iter()
5584 .any(|c| c.origin == crate::home::catalog::Origin::Bundled),
5585 cfg!(any(feature = "http", feature = "cloud"))
5586 );
5587 config.read_catalogs = vec![
5588 crate::home::catalog::parse(
5589 "label = \"Mine\"\n",
5590 crate::home::catalog::EXAMPLES,
5591 crate::home::catalog::Origin::Folder,
5592 None,
5593 )
5594 .unwrap(),
5595 ];
5596 assert!(catalogs(&config).is_empty(), "{:?}", catalogs(&config));
5597 }
5598
5599 #[test]
5602 fn a_users_catalog_is_shown_whole() {
5603 let mut config = crate::config::AppConfig::default();
5604 let mine = crate::home::catalog::parse(
5605 r#"
5606 [bucket]
5607 name = "Bucket"
5608 url = "s3://bucket/prefix/"
5609 [web]
5610 name = "Web"
5611 url = "https://example.com/data.csv"
5612 "#,
5613 crate::home::catalog::MINE,
5614 crate::home::catalog::Origin::Mine,
5615 None,
5616 )
5617 .unwrap();
5618 config.read_catalogs = vec![mine];
5619 let shown = catalogs(&config);
5620 let mine = shown.iter().find(|c| c.id == "mine").unwrap();
5621 assert_eq!(mine.datasets.len(), 2);
5622 assert_eq!(mine.label, crate::home::catalog::MINE_LABEL);
5623 }
5624}
5625
5626#[cfg(test)]
5627mod place_tests {
5628 use super::same_place;
5629 use std::path::Path;
5630
5631 #[test]
5632 fn local_paths_are_one_place_however_spelled() {
5633 assert!(same_place(
5634 Path::new("/data/./sales/"),
5635 Path::new("/data/sales")
5636 ));
5637 assert!(!same_place(
5638 Path::new("/data/sales"),
5639 Path::new("/data/sale")
5640 ));
5641 assert!(same_place(
5642 Path::new("s3://bucket/dir/"),
5643 Path::new("s3://bucket/dir")
5644 ));
5645 if cfg!(windows) {
5646 assert!(same_place(
5647 Path::new("c:/data/sales.csv"),
5648 Path::new(r"C:\data\sales.csv")
5649 ));
5650 }
5651 }
5652}