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 object_store = is_object_store_url(dir);
477 let openable_row = rows.iter().any(|r| match r.kind {
478 EntryKind::File => true,
479 EntryKind::Other => false,
480 _ => object_store,
481 });
482 let laid_out = !matches!(kind, EntryKind::Directory | EntryKind::Unknown);
483 if !openable_row && !laid_out && holds_nothing_to_open(&holds, object_store) {
484 return None;
485 }
486 let mut entry = Entry::directory(&directory_dataset_url(dir));
487 entry.kind = kind;
488 entry.holds = holds;
491 entry.opens_whole_directory = true;
492 entry.name = door_name(&entry, rows);
493 Some(entry)
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498pub enum DoorKind {
499 Hive,
501 OneSchema,
503 SchemasDiffer,
505 Single,
507 Lake,
509 Mixed,
511 Unknown,
513}
514
515pub fn door_kind(door: &Entry) -> DoorKind {
519 let holds = &door.holds;
520 match door.kind {
521 EntryKind::Hive => DoorKind::Hive,
522 EntryKind::MultiFile => DoorKind::OneSchema,
523 k if k.is_lake_table() => DoorKind::Lake,
524 EntryKind::Unknown => DoorKind::Unknown,
525 _ => {
526 let Some(format) = holds.one_format() else {
527 return DoorKind::Mixed;
528 };
529 if holds.directories > 0 {
530 return DoorKind::Mixed;
531 }
532 let files = holds.data_files();
533 if files == 1 {
534 return DoorKind::Single;
535 }
536 let mostly_data = files * 2 >= files + holds.not_read + holds.unnamed;
537 let reads_many = crate::FileFormat::from_name(format)
538 .is_some_and(crate::FileFormat::reads_many_files);
539 if mostly_data && reads_many {
540 DoorKind::SchemasDiffer
541 } else {
542 DoorKind::Mixed
543 }
544 }
545 }
546}
547
548pub fn door_lands(door: &Entry) -> bool {
552 matches!(door_kind(door), DoorKind::Hive | DoorKind::OneSchema)
553}
554
555fn format_title(name: &str) -> String {
557 crate::FileFormat::from_name(name)
558 .map_or_else(|| name.to_ascii_uppercase(), |f| f.title().to_string())
559}
560
561fn door_keys(door: &Entry, rows: &[Entry]) -> Vec<String> {
564 if let Some(layout) = door.cost.partitions.as_ref()
565 && !layout.keys.is_empty()
566 {
567 return layout.keys.clone();
568 }
569 let mut keys: Vec<String> = Vec::new();
570 for row in rows {
571 if let Some((key, _)) = row.name.split_once('=')
572 && !key.is_empty()
573 && !keys.iter().any(|k| k == key)
574 {
575 keys.push(key.to_string());
576 }
577 }
578 keys
579}
580
581pub fn door_name(door: &Entry, rows: &[Entry]) -> String {
587 let holds = &door.holds;
588 let more = if holds.truncated { "+" } else { "" };
589 let files = |format: &str| {
590 let count = holds.data_files();
591 let word = if count == 1 { "file" } else { "files" };
592 format!("{count}{more} {} {word}", format_title(format))
593 };
594 let what = match door_kind(door) {
595 DoorKind::Hive => {
596 let keys = door_keys(door, rows);
597 if keys.is_empty() {
598 "hive table".to_string()
599 } else {
600 format!("hive table: {}", keys.join(", "))
601 }
602 }
603 DoorKind::OneSchema => match (holds.model_weights(), holds.one_format()) {
604 (Some((format, count)), _) => {
605 let word = if count == 1 { "file" } else { "files" };
606 format!("model, {count}{more} {} {word}", format_title(format))
607 }
608 (None, Some(format)) => format!("{}, one schema", files(format)),
609 (None, None) => "one table".to_string(),
610 },
611 DoorKind::SchemasDiffer => {
612 format!(
613 "{}, schemas differ",
614 files(holds.one_format().unwrap_or(""))
615 )
616 }
617 DoorKind::Single => files(holds.one_format().unwrap_or("")),
618 DoorKind::Lake => format!(
619 "{} files, not the table",
620 door.kind.lake_name().unwrap_or_default()
621 ),
622 DoorKind::Mixed => "mixed files".to_string(),
623 DoorKind::Unknown => return DOOR_ACTION.to_string(),
624 };
625 format!("{DOOR_ACTION} ({what})")
626}
627
628pub const DOOR_ACTION: &str = "Open all";
630
631pub fn door_what(name: &str) -> &str {
634 let what = name.strip_prefix(DOOR_ACTION).unwrap_or(name).trim();
635 (what.strip_prefix('(').and_then(|w| w.strip_suffix(')'))).unwrap_or(what)
636}
637
638pub fn door_reads(door: &Entry) -> Option<(String, Option<String>)> {
643 if !matches!(door_kind(door), DoorKind::Mixed | DoorKind::Single) {
644 return None;
645 }
646 let holds = &door.holds;
647 let more = if holds.truncated { "+" } else { "" };
648 let plain = holds.directories.saturating_sub(holds.partitions);
649 let directories = |n: usize| {
650 let word = if n == 1 { "directory" } else { "directories" };
651 format!("{n}{more} {word}")
652 };
653 let Some((format, count)) = holds.formats.first() else {
654 return (holds.directories > 0).then(|| ("every Parquet file below".to_string(), None));
655 };
656 let remote = is_object_store_url(&door.path);
658 let below = remote || holds.partitions > 0 || (holds.formats.len() == 1 && format == "parquet");
659 let below = below && holds.directories > 0;
660 let reads = if below && remote {
661 format!("every {format} file below")
662 } else if below {
663 "every Parquet file below".to_string()
664 } else {
665 format!("{count}{more} {format}")
666 };
667 let mut skips: Vec<String> = holds
668 .formats
669 .iter()
670 .skip(1)
671 .map(|(name, n)| format!("{n}{more} {name}"))
672 .collect();
673 if !below && plain > 0 {
674 skips.push(directories(plain));
675 }
676 Some((reads, (!skips.is_empty()).then(|| skips.join(", "))))
677}
678
679pub fn name_by_spec(formats: &crate::formats::Registry, rows: &mut [Entry]) {
682 if formats.is_empty() {
683 return;
684 }
685 let mut named = false;
686 for row in rows
687 .iter_mut()
688 .filter(|r| r.kind == EntryKind::Other && r.format_spec.is_none())
689 {
690 if let Some(spec) = formats.by_glob(&row.path, false).first() {
691 discover::name_spec_file(row, spec);
692 named = true;
693 }
694 }
695 for row in rows.iter_mut().filter(|r| {
697 r.kind == EntryKind::File
698 && r.format_spec.is_none()
699 && discover::data_format(&r.path).is_some_and(|f| f.separator().is_some())
700 }) {
701 if let Some(spec) = formats
702 .by_glob(&row.path, false)
703 .into_iter()
704 .find(|s| s.is_delimited())
705 {
706 row.format_spec = Some(spec.name.clone());
707 }
708 }
709 if named {
711 discover::sort_entries(rows);
712 }
713}
714
715pub fn holds_nothing_to_open(holds: &discover::Holds, object_store: bool) -> bool {
723 let below = if object_store {
724 holds.directories > 0
725 } else {
726 holds.partitions > 0 || holds.dataset_dict
727 };
728 holds.formats.is_empty() && holds.unnamed == 0 && !below
729}
730
731pub fn is_cloud_place(path: &Path) -> bool {
733 path.to_string_lossy().starts_with(CLOUD_PLACE)
734}
735
736fn is_bucket_root(path: &Path) -> bool {
739 let text = path.to_string_lossy();
740 if let Some((_, _, key)) = crate::cloud::source::azure_parts(&text) {
741 return key.trim_matches('/').is_empty();
742 }
743 let Some((scheme, rest)) = text.split_once("://") else {
744 return false;
745 };
746 matches!(scheme, "s3" | "s3a" | "gs" | "gcs") && {
747 let rest = rest.trim_end_matches('/');
748 !rest.is_empty() && !rest.contains('/')
749 }
750}
751
752pub fn parent_location(path: &Path) -> Option<PathBuf> {
755 if !matches!(
756 crate::cloud::source::input_source(path),
757 crate::cloud::source::InputSource::Local(_)
758 ) {
759 let s = path.to_string_lossy();
760 let (scheme, rest) = s.split_once("://")?;
761 let (up, _) = rest.trim_end_matches('/').rsplit_once('/')?;
762 return Some(PathBuf::from(format!("{scheme}://{up}")));
763 }
764 path.parent()
765 .filter(|p| !p.as_os_str().is_empty() && *p != path)
766 .map(Path::to_path_buf)
767}
768
769pub fn is_remote_path(path: &Path) -> bool {
773 is_cloud_place(path)
774 || !matches!(
775 crate::cloud::source::input_source(path),
776 crate::cloud::source::InputSource::Local(_)
777 )
778 || is_network_path(path)
779}
780
781pub fn is_network_path(path: &Path) -> bool {
785 crate::home::locality::Mounts::cached().is_network(path)
786}
787
788pub fn could_block_path(path: &Path) -> bool {
792 crate::home::locality::Mounts::cached().could_block(path)
793}
794
795#[doc(hidden)]
798pub fn network_fs_for_test(mountinfo: &str, path: &Path) -> bool {
799 crate::home::locality::Mounts::parse(mountinfo).is_network(path)
800}
801
802#[derive(Debug, Clone)]
804pub struct Root {
805 pub path: PathBuf,
806 pub origin: RootOrigin,
807 pub network: bool,
809 pub available: bool,
812}
813
814#[derive(Debug, Clone, Default)]
816pub struct Section {
817 pub title: String,
818 pub subtitle: Option<String>,
821 pub origin: Option<&'static str>,
824 pub root: Option<PathBuf>,
827 pub rows: Vec<Entry>,
828 pub door: Option<Entry>,
833 pub unavailable: bool,
835 pub unavailable_note: Option<String>,
838 pub folded_by_default: bool,
841 pub remote_root: Option<PathBuf>,
843 pub waiting: bool,
845 pub grouped_by_place: bool,
848 pub place_labels: std::collections::HashMap<PathBuf, String>,
851}
852
853impl Section {
854 pub fn titled(title: impl Into<String>, rows: Vec<Entry>) -> Self {
856 Section {
857 title: title.into(),
858 rows,
859 ..Default::default()
860 }
861 }
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Default)]
866pub enum CloudStatus {
867 #[default]
869 Listing,
870 Unlisted,
873 Listed,
875 Failed { short: String, detail: String },
878}
879
880#[derive(Debug, Clone, Default, PartialEq, Eq)]
884pub struct CloudSource {
885 pub id: String,
887 pub label: String,
889 pub api: crate::cloud::source::ProviderKind,
891 pub note: String,
893 pub buckets: Vec<PathBuf>,
895 pub status: CloudStatus,
896 pub listed_at: Option<std::time::SystemTime>,
898 pub refreshing: bool,
900 pub asked: bool,
902 pub details: Vec<(String, String)>,
904 pub place_details: std::collections::HashMap<PathBuf, Vec<(String, String)>>,
907}
908
909impl CloudSource {
910 pub fn count_text(&self) -> String {
912 match &self.status {
913 CloudStatus::Failed { short, .. } if self.buckets.is_empty() => short.clone(),
914 CloudStatus::Listing if self.buckets.is_empty() => String::new(),
915 CloudStatus::Unlisted if self.buckets.is_empty() => "not listed".to_string(),
916 _ => {
917 let (one, many) = match self.api {
918 crate::cloud::source::ProviderKind::Azure => ("account", "accounts"),
919 crate::cloud::source::ProviderKind::Gcs => ("project", "projects"),
920 crate::cloud::source::ProviderKind::S3 => ("bucket", "buckets"),
921 };
922 match self.buckets.len() {
923 0 => format!("no {many}"),
924 1 => format!("1 {one}"),
925 n => format!("{n} {many}"),
926 }
927 }
928 }
929 }
930
931 pub fn begin_listing(&mut self) {
934 self.asked = true;
935 if self.status == CloudStatus::Unlisted && self.buckets.is_empty() {
936 self.status = CloudStatus::Listing;
937 } else {
938 self.refreshing = true;
939 }
940 }
941
942 pub fn busy(&self) -> bool {
944 self.refreshing || (self.status == CloudStatus::Listing && self.buckets.is_empty())
945 }
946
947 pub fn failed(&self) -> bool {
949 matches!(self.status, CloudStatus::Failed { .. })
950 }
951}
952
953#[derive(Debug, Clone, PartialEq)]
956pub struct ShownCatalog {
957 pub id: String,
959 pub label: String,
961 pub origin: crate::home::catalog::Origin,
963 pub description: String,
965 pub file: Option<PathBuf>,
967 pub datasets: Vec<ShownDataset>,
968 pub broken: Option<String>,
970}
971
972#[derive(Debug, Clone, Default, PartialEq)]
974pub struct ShownDataset {
975 pub name: String,
977 pub location: PathBuf,
979 pub details: Vec<(String, String)>,
981 pub size: Option<u64>,
983 pub codebook: Option<std::sync::Arc<crate::home::codebook::Codebook>>,
985 pub bookmarks: Vec<(String, PathBuf)>,
987 pub entry: std::sync::Arc<crate::home::catalog::Dataset>,
989}
990
991impl ShownCatalog {
992 pub fn from_catalog(catalog: &crate::home::catalog::Catalog) -> Self {
994 Self {
995 id: catalog.id.clone(),
996 label: catalog.label.clone(),
997 origin: catalog.origin,
998 description: catalog.description.clone(),
999 file: catalog.file.clone(),
1000 datasets: catalog
1001 .datasets
1002 .iter()
1003 .map(|dataset| {
1004 let location = dataset.location();
1005 let mut details: Vec<(String, String)> = [
1006 ("about", &dataset.description),
1007 ("publisher", &dataset.publisher),
1008 ("license", &dataset.license),
1009 ("homepage", &dataset.homepage),
1010 ("documentation", &dataset.documentation),
1011 ]
1012 .into_iter()
1013 .filter(|(_, value)| !value.is_empty())
1014 .map(|(key, value)| (key.to_string(), value.clone()))
1015 .collect();
1016 match &dataset.url {
1017 None => details.push(("path".to_string(), display_path(&location))),
1018 Some(url) => {
1019 details.push(("url".to_string(), url.clone()));
1020 details.push(("login".to_string(), login_of(dataset)));
1021 }
1022 }
1023 ShownDataset {
1024 name: dataset.name.clone(),
1025 location,
1026 details,
1027 size: dataset.size,
1028 codebook: crate::home::codebook::Codebook::of(dataset)
1029 .map(std::sync::Arc::new),
1030 bookmarks: dataset
1031 .bookmarks
1032 .iter()
1033 .map(|(name, path)| (name.clone(), dataset.bookmark_location(path)))
1034 .collect(),
1035 entry: std::sync::Arc::new(dataset.clone()),
1036 }
1037 })
1038 .collect(),
1039 broken: None,
1040 }
1041 }
1042
1043 pub fn from_broken(broken: &crate::home::catalog::Broken) -> Self {
1045 Self {
1046 id: broken.id.clone(),
1047 label: broken.id.clone(),
1048 origin: broken.origin,
1049 description: String::new(),
1050 file: None,
1051 datasets: Vec::new(),
1052 broken: Some(broken.callout()),
1053 }
1054 }
1055
1056 pub fn origin_note(&self) -> &'static str {
1059 match self.origin {
1060 crate::home::catalog::Origin::Mine => "catalog.toml",
1061 crate::home::catalog::Origin::Listed | crate::home::catalog::Origin::Folder => {
1062 "catalog"
1063 }
1064 crate::home::catalog::Origin::Bundled => BUNDLED_ORIGIN,
1065 }
1066 }
1067}
1068
1069pub const BUNDLED_ORIGIN: &str = "comes with datui";
1071
1072pub const CATALOG_ORIGINS: [&str; 3] = ["catalog.toml", "catalog", BUNDLED_ORIGIN];
1074
1075pub fn is_catalog_origin(origin: &str) -> bool {
1077 CATALOG_ORIGINS.contains(&origin)
1078}
1079
1080pub fn login_of(dataset: &crate::home::catalog::Dataset) -> String {
1082 match dataset.object_store_auth() {
1083 Some(crate::config::DatasetAuth::Connection(connection)) => connection,
1084 Some(crate::config::DatasetAuth::Anonymous) | None => "none".to_string(),
1085 Some(crate::config::DatasetAuth::Auto) => "auto".to_string(),
1086 }
1087}
1088
1089pub fn catalogs(config: &crate::config::AppConfig) -> Vec<ShownCatalog> {
1093 let mut out: Vec<ShownCatalog> = config
1094 .shown_catalogs()
1095 .iter()
1096 .filter_map(|catalog| {
1097 let mut shown = ShownCatalog::from_catalog(catalog);
1098 if catalog.origin == crate::home::catalog::Origin::Bundled {
1099 shown
1100 .datasets
1101 .retain(|d| crate::cloud::source::opens_in_this_build(&d.location));
1102 }
1103 (!shown.datasets.is_empty()).then_some(shown)
1104 })
1105 .collect();
1106 let at = out
1108 .iter()
1109 .position(|c| c.origin == crate::home::catalog::Origin::Bundled)
1110 .unwrap_or(out.len());
1111 let broken: Vec<ShownCatalog> = config
1112 .broken_catalogs
1113 .iter()
1114 .filter(|b| !config.home.hide.contains(&b.id))
1115 .map(ShownCatalog::from_broken)
1116 .collect();
1117 out.splice(at..at, broken);
1118 out
1119}
1120
1121fn catalog_entry(
1124 dataset: &ShownDataset,
1125 network_check: fn(&Path) -> bool,
1126 missing: &mut std::collections::HashSet<PathBuf>,
1127) -> Entry {
1128 let path = &dataset.location;
1129 let local = matches!(
1130 crate::cloud::source::input_source(path),
1131 crate::cloud::source::InputSource::Local(_)
1132 );
1133 let mut entry = if is_object_store_url(path) {
1134 if names_a_file(path) {
1135 entry_for_path(path, true)
1136 } else {
1137 Entry::directory(path)
1138 }
1139 } else if !local || network_check(path) {
1140 entry_for_path(path, true)
1141 } else if path.exists() {
1142 entry_for_path(path, false)
1143 } else {
1144 missing.insert(path.clone());
1145 let mut entry = entry_for_path(path, true);
1146 entry.kind = EntryKind::Unknown;
1147 entry
1148 };
1149 entry.name = dataset.name.clone();
1150 entry
1151}
1152
1153pub fn codebook_for(
1156 catalogs: &[ShownCatalog],
1157 path: &Path,
1158) -> Option<std::sync::Arc<crate::home::codebook::Codebook>> {
1159 let text = path.to_string_lossy();
1160 catalogs
1161 .iter()
1162 .flat_map(|c| c.datasets.iter())
1163 .filter(|d| d.codebook.is_some())
1164 .filter(|d| d.location == path || within(&text, &d.location.to_string_lossy()))
1165 .max_by_key(|d| d.location.to_string_lossy().trim_end_matches('/').len())
1166 .and_then(|d| d.codebook.clone())
1167}
1168
1169pub fn catalog_entry_for(
1172 catalogs: &[ShownCatalog],
1173 path: &Path,
1174) -> Option<(String, std::sync::Arc<crate::home::catalog::Dataset>)> {
1175 let text = path.to_string_lossy();
1176 catalogs
1177 .iter()
1178 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
1179 .filter(|(_, d)| {
1180 d.location == path
1181 || same_place(&d.location, path)
1182 || within(&text, &d.location.to_string_lossy())
1183 })
1184 .rev()
1185 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
1186 .map(|(c, d)| (c.label.clone(), d.entry.clone()))
1187}
1188
1189fn bookmark_entry(name: &str, place: &Path, network_check: fn(&Path) -> bool) -> Entry {
1191 let local = matches!(
1192 crate::cloud::source::input_source(place),
1193 crate::cloud::source::InputSource::Local(_)
1194 );
1195 let mut entry = if is_object_store_url(place) && !names_a_file(place) {
1196 Entry::directory(place)
1197 } else {
1198 entry_for_path(place, !local || network_check(place) || !place.exists())
1199 };
1200 entry.name = name.to_string();
1201 entry
1202}
1203
1204fn catalog_section(
1206 catalog: &ShownCatalog,
1207 network_check: fn(&Path) -> bool,
1208 missing: &mut std::collections::HashSet<PathBuf>,
1209) -> Section {
1210 let rows = catalog
1212 .datasets
1213 .iter()
1214 .flat_map(|dataset| {
1215 std::iter::once(catalog_entry(dataset, network_check, missing)).chain(
1216 dataset
1217 .bookmarks
1218 .iter()
1219 .map(|(name, place)| bookmark_entry(name, place, network_check)),
1220 )
1221 })
1222 .collect();
1223 Section {
1224 origin: Some(catalog.origin_note()),
1225 unavailable: catalog.broken.is_some(),
1226 unavailable_note: catalog.broken.clone(),
1227 ..Section::titled(catalog.label.clone(), rows)
1228 }
1229}
1230
1231#[derive(Debug, Clone, Default, PartialEq)]
1234pub struct Measured {
1235 pub rows: Option<usize>,
1236 pub cols: Option<usize>,
1237 pub cols_sampled: bool,
1239 pub size: Option<u64>,
1240 pub modified: Option<std::time::SystemTime>,
1242 pub stat_only: bool,
1245 pub columns: Vec<String>,
1247 pub cost: crate::home::discover::Cost,
1250 pub kind: Option<crate::home::discover::EntryKind>,
1253 pub holds: crate::home::discover::Holds,
1256}
1257
1258#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1260pub enum SortMode {
1261 #[default]
1263 Natural,
1264 Size,
1266 Modified,
1268 Rows,
1270}
1271
1272impl SortMode {
1273 pub fn label_in(self, section_is_recency_ordered: bool) -> &'static str {
1276 match self {
1277 SortMode::Natural if section_is_recency_ordered => "recent",
1278 SortMode::Natural => "name",
1279 SortMode::Size => "size",
1280 SortMode::Modified => "modified",
1281 SortMode::Rows => "rows",
1282 }
1283 }
1284
1285 pub fn next(self) -> Self {
1286 match self {
1287 SortMode::Natural => SortMode::Size,
1288 SortMode::Size => SortMode::Modified,
1289 SortMode::Modified => SortMode::Rows,
1290 SortMode::Rows => SortMode::Natural,
1291 }
1292 }
1293}
1294
1295#[derive(Debug, Clone)]
1299pub enum Row<'a> {
1300 Header {
1301 section: usize,
1302 matches: usize,
1304 collapsed: bool,
1305 },
1306 Entry {
1307 section: usize,
1308 entry: &'a Entry,
1309 nested: bool,
1311 hit: Hit,
1313 },
1314 Place {
1316 section: usize,
1317 path: PathBuf,
1318 label: Option<String>,
1321 source: Option<String>,
1323 held: usize,
1325 },
1326 Door { section: usize, entry: &'a Entry },
1329 More {
1332 section: usize,
1333 hidden: usize,
1334 places: usize,
1335 measuring: bool,
1338 },
1339 Hidden { section: usize, count: usize },
1343 Up { section: usize },
1346}
1347
1348impl Row<'_> {
1349 pub fn section(&self) -> usize {
1350 match self {
1351 Row::Header { section, .. }
1352 | Row::Entry { section, .. }
1353 | Row::Door { section, .. }
1354 | Row::Place { section, .. }
1355 | Row::More { section, .. }
1356 | Row::Hidden { section, .. }
1357 | Row::Up { section } => *section,
1358 }
1359 }
1360}
1361
1362#[derive(Debug, Clone, Copy, Default, PartialEq)]
1366pub struct Hit {
1367 pub score: i32,
1368 pub column: Option<usize>,
1371}
1372
1373impl Hit {
1374 pub fn column_of<'a>(&self, entry: &'a Entry) -> Option<&'a str> {
1376 self.column
1377 .and_then(|i| entry.columns.get(i))
1378 .map(String::as_str)
1379 }
1380
1381 pub fn positions(&self, filter: &str, entry: &Entry) -> Vec<usize> {
1384 match self.column_of(entry) {
1385 Some(column) => substring_positions(filter, column),
1386 None => fuzzy_positions(filter, &entry.name),
1387 }
1388 }
1389}
1390
1391#[derive(Debug, Default)]
1396pub struct RowsCache {
1397 built: std::cell::RefCell<Option<View>>,
1398 builds: std::cell::Cell<usize>,
1399 rescored: std::cell::RefCell<Option<Hits>>,
1402}
1403
1404#[derive(Debug, Clone)]
1407struct Hits {
1408 filter: String,
1409 sections: Vec<Option<Vec<Option<Hit>>>>,
1410}
1411
1412#[derive(Debug)]
1414struct View {
1415 key: ViewKey,
1416 slots: Vec<Slot>,
1417 headers: Vec<usize>,
1420 doors: Vec<usize>,
1422 hits: Hits,
1423 has_dataset: bool,
1425}
1426
1427#[derive(Debug, PartialEq)]
1430struct ViewKey {
1431 filter: String,
1432 sort: SortMode,
1433 hide_unreadable: bool,
1434 recent_expanded: bool,
1435 shown_whole: std::collections::HashSet<PathBuf>,
1436 view_height: usize,
1437 browsing: Option<PathBuf>,
1438 folds: std::collections::HashMap<String, bool>,
1439 shape: Vec<(usize, bool)>,
1440}
1441
1442impl ViewKey {
1443 fn of(home: &HomeState) -> Self {
1444 ViewKey {
1445 filter: home.filter.clone(),
1446 sort: home.sort,
1447 hide_unreadable: home.hide_unreadable,
1448 recent_expanded: home.recent_expanded,
1449 shown_whole: home.shown_whole.clone(),
1450 view_height: home.view_height,
1451 browsing: home.browsing.clone(),
1452 folds: home.folds.clone(),
1453 shape: (home.sections.iter())
1454 .map(|s| (s.rows.len(), s.door.is_some()))
1455 .collect(),
1456 }
1457 }
1458
1459 fn matches(&self, home: &HomeState) -> bool {
1461 self.filter == home.filter
1462 && self.sort == home.sort
1463 && self.hide_unreadable == home.hide_unreadable
1464 && self.recent_expanded == home.recent_expanded
1465 && self.shown_whole == home.shown_whole
1466 && self.view_height == home.view_height
1467 && self.browsing == home.browsing
1468 && self.folds == home.folds
1469 && self.shape.iter().copied().eq(home
1470 .sections
1471 .iter()
1472 .map(|s| (s.rows.len(), s.door.is_some())))
1473 }
1474}
1475
1476#[derive(Debug)]
1478enum Slot {
1479 Plain(Row<'static>),
1481 Entry {
1482 section: usize,
1483 index: usize,
1484 nested: bool,
1485 hit: Hit,
1486 },
1487 Door {
1488 section: usize,
1489 },
1490}
1491
1492#[derive(Debug, Clone)]
1497pub struct ListLines {
1498 gaps: Vec<usize>,
1500 rows: usize,
1501}
1502
1503impl ListLines {
1504 pub fn rows(&self) -> usize {
1506 self.rows
1507 }
1508
1509 pub fn spacers(&self) -> usize {
1511 self.gaps.len()
1512 }
1513
1514 pub fn line_of(&self, row: usize) -> usize {
1516 row + self.gaps.partition_point(|&g| g <= row)
1517 }
1518
1519 pub fn total(&self) -> usize {
1521 match self.rows {
1522 0 => 0,
1523 n => self.line_of(n - 1) + 1,
1524 }
1525 }
1526
1527 pub fn first_row_from(&self, line: usize) -> usize {
1529 let (mut lo, mut hi) = (0, self.rows);
1530 while lo < hi {
1531 let mid = lo + (hi - lo) / 2;
1532 if self.line_of(mid) < line {
1533 lo = mid + 1;
1534 } else {
1535 hi = mid;
1536 }
1537 }
1538 lo
1539 }
1540
1541 pub fn row_on(&self, line: usize) -> Option<usize> {
1543 let row = self.first_row_from(line);
1544 (row < self.rows && self.line_of(row) == line).then_some(row)
1545 }
1546}
1547
1548pub fn place_of(path: &Path) -> PathBuf {
1551 parent_location(path).unwrap_or_else(|| path.to_path_buf())
1552}
1553
1554pub fn place_is_browsable(path: &Path) -> bool {
1557 is_cloud_place(path)
1558 || is_object_store_url(path)
1559 || matches!(
1560 crate::cloud::source::input_source(path),
1561 crate::cloud::source::InputSource::Local(_)
1562 )
1563}
1564
1565#[derive(Debug, Clone, PartialEq, Eq)]
1569pub enum RowKey {
1570 Header(String),
1571 Entry(PathBuf),
1572 Door(PathBuf),
1575 Place(PathBuf),
1576 More(String),
1577 Hidden(String),
1578 Up(String),
1579}
1580
1581#[derive(Debug)]
1583pub struct HomeState {
1584 pub sections: Vec<Section>,
1585 pub filter: String,
1587 pub filter_selected: bool,
1590 pub search_limit: usize,
1592 pub hide_unreadable: bool,
1594 pub formats: std::sync::Arc<crate::formats::Registry>,
1597 pub lake_here: Option<(PathBuf, &'static str)>,
1600 pub selected: usize,
1602 pub scroll: usize,
1607 pub view_height: usize,
1609 pub view_lines: usize,
1612 pub spaced: bool,
1613 pub path_input_active: bool,
1615 pub path_input: String,
1616 pub path_listing: Option<PathListing>,
1618 pub path_pick: Option<usize>,
1620 pub browsing: Option<PathBuf>,
1622 pub browse_start: Option<PathBuf>,
1625 pub status: Option<String>,
1627 pub network_check: fn(&Path) -> bool,
1630 pub visits: std::collections::HashMap<PathBuf, crate::cache::Visits>,
1633 pub newest_recent: Option<PathBuf>,
1635 pub probes: Probes,
1637 pub narrowed: Option<Narrowed>,
1639 pub cloud_kinds: std::collections::HashMap<PathBuf, (EntryKind, crate::home::discover::Holds)>,
1642 pub sort: SortMode,
1644 pub listing_in_flight: bool,
1646 pub measure_in_flight: bool,
1648 pub classifying: std::collections::HashSet<PathBuf>,
1652 pub peeking: std::collections::HashSet<PathBuf>,
1655 pub peek_failed: std::collections::HashSet<PathBuf>,
1658 pub enriched: std::collections::HashMap<PathBuf, Measured>,
1661 pub stated: std::collections::HashMap<PathBuf, Stated>,
1664 pub stat_epoch: u64,
1667 pub stale: std::collections::HashSet<PathBuf>,
1669 pub unapplied: std::collections::HashSet<PathBuf>,
1671 pub folds: std::collections::HashMap<String, bool>,
1675 pub folds_owed: bool,
1678 pub search: SearchState,
1680 pub known: Known,
1684 pub cloud: Vec<CloudSource>,
1687 pub catalogs: Vec<ShownCatalog>,
1690 pub sized: std::collections::HashSet<PathBuf>,
1692 pub web_gone: std::collections::HashMap<PathBuf, crate::error_display::HttpGone>,
1695 pub missing: std::collections::HashSet<PathBuf>,
1697 pub waiting_since: Option<std::time::Instant>,
1699 pub recent_expanded: bool,
1701 pub shown_whole: std::collections::HashSet<PathBuf>,
1704 pub trail: Vec<Mark>,
1707 pub returning: Option<RowKey>,
1710 pub returning_line: Option<usize>,
1712 pub landing: bool,
1715 pub resting: bool,
1719 pub rows_cache: RowsCache,
1721 pub catalog_places: CatalogPlaces,
1723}
1724
1725#[derive(Debug, Clone)]
1728pub struct Mark {
1729 pub place: Option<PathBuf>,
1731 pub key: Option<RowKey>,
1732 pub filter: String,
1734 pub search: Option<SearchState>,
1737 pub line: usize,
1739}
1740
1741#[derive(Debug, Clone, Default)]
1744pub struct SearchState {
1745 pub root: Option<PathBuf>,
1747 pub epoch: u64,
1749 pub results: Vec<std::sync::Arc<[Entry]>>,
1752 pub indexed: usize,
1754 pub matches: Option<crate::home::search::Matches>,
1757 pub scoring: bool,
1759 pub scanned: usize,
1761 pub running: bool,
1763 pub done: bool,
1765 pub limited: Option<String>,
1767}
1768
1769const SCORE_INLINE_MAX: usize = 2_000;
1772
1773const FRECENCY_LIFT: f64 = 3.0;
1776
1777#[derive(Debug, Clone)]
1779pub struct ScoreJob {
1780 pub epoch: u64,
1781 pub results: Vec<std::sync::Arc<[Entry]>>,
1782 pub query: String,
1783 pub base: Option<crate::home::search::Matches>,
1784 pub limit: usize,
1785}
1786
1787impl SearchState {
1788 pub fn reset(&mut self) {
1790 *self = Self::default();
1791 }
1792
1793 pub fn set_results(&mut self, results: Vec<Entry>) {
1795 self.indexed = results.len();
1796 self.results = vec![results.into()];
1797 self.matches = None;
1798 }
1799
1800 pub fn files(&self) -> impl Iterator<Item = &Entry> {
1802 self.results.iter().flat_map(|batch| batch.iter())
1803 }
1804
1805 fn scored_for(&self, query: &str) -> bool {
1807 self.matches
1808 .as_ref()
1809 .is_some_and(|m| m.query == query && m.upto == self.indexed)
1810 }
1811
1812 fn base_for(&self, query: &str) -> (Option<&crate::home::search::Matches>, usize) {
1814 match self.matches.as_ref() {
1815 Some(m) if m.narrows_to(query) && m.upto <= self.indexed => {
1816 (Some(m), m.ids.len() + self.indexed - m.upto)
1817 }
1818 _ => (None, self.indexed),
1819 }
1820 }
1821}
1822
1823impl Default for HomeState {
1824 fn default() -> Self {
1825 Self {
1826 sections: Vec::new(),
1827 cloud: Vec::new(),
1828 catalogs: Vec::new(),
1829 sized: std::collections::HashSet::new(),
1830 web_gone: Default::default(),
1831 missing: Default::default(),
1832 filter: String::new(),
1833 search_limit: crate::config::SearchConfig::default().max_results,
1834 rows_cache: RowsCache::default(),
1835 catalog_places: CatalogPlaces::default(),
1836 hide_unreadable: true,
1837 formats: Default::default(),
1838 lake_here: None,
1839 selected: 0,
1840 scroll: 0,
1841 view_height: 0,
1842 view_lines: 0,
1843 spaced: false,
1844 path_input_active: false,
1845 path_input: String::new(),
1846 path_listing: None,
1847 path_pick: None,
1848 filter_selected: false,
1849 browsing: None,
1850 browse_start: None,
1851 status: None,
1852 network_check: is_remote_path,
1853 visits: Default::default(),
1854 newest_recent: None,
1855 sort: SortMode::default(),
1856 listing_in_flight: false,
1857 measure_in_flight: false,
1858 classifying: Default::default(),
1859 peeking: std::collections::HashSet::new(),
1860 probes: Probes::default(),
1861 narrowed: None,
1862 cloud_kinds: std::collections::HashMap::new(),
1863 peek_failed: std::collections::HashSet::new(),
1864 waiting_since: None,
1865 enriched: std::collections::HashMap::new(),
1866 stated: Default::default(),
1867 stat_epoch: 0,
1868 stale: Default::default(),
1869 unapplied: Default::default(),
1870 folds: std::collections::HashMap::new(),
1871 folds_owed: false,
1872 search: SearchState::default(),
1873 known: Default::default(),
1874 recent_expanded: false,
1875 shown_whole: Default::default(),
1876 trail: Vec::new(),
1877 returning: None,
1878 returning_line: None,
1879 landing: false,
1880 resting: false,
1881 }
1882 }
1883}
1884
1885pub type Known = std::sync::Arc<std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>>;
1888
1889#[derive(Debug, Clone)]
1892pub struct ListingRequest {
1893 pub recents: Vec<PathBuf>,
1894 pub desktop_dirs: Vec<PathBuf>,
1895 pub browsing: Option<PathBuf>,
1896 pub probes: Probes,
1898 pub narrowed: Option<Narrowed>,
1900 pub network_check: fn(&Path) -> bool,
1901 pub cloud: Vec<CloudSource>,
1903 pub catalogs: Vec<ShownCatalog>,
1905 pub known: Known,
1908 pub formats: std::sync::Arc<crate::formats::Registry>,
1911}
1912
1913#[derive(Debug, Clone, Default)]
1915pub struct Listing {
1916 pub sections: Vec<Section>,
1917 pub missing: std::collections::HashSet<PathBuf>,
1919}
1920
1921impl Listing {
1922 pub fn learn(
1925 &mut self,
1926 learned: &[(PathBuf, crate::cache::DatasetFacts)],
1927 network_check: fn(&Path) -> bool,
1928 ) {
1929 if learned.is_empty() {
1930 return;
1931 }
1932 let learned: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts> =
1933 learned.iter().cloned().collect();
1934 for row in self.sections.iter_mut().flat_map(|s| s.rows.iter_mut()) {
1935 if known_facts(&learned, &row.path).is_some() {
1936 apply_known_facts(row, &learned, network_check(&row.path));
1937 }
1938 }
1939 }
1940
1941 pub fn alias_visits(
1947 &self,
1948 visits: &mut std::collections::HashMap<PathBuf, crate::cache::Visits>,
1949 ) {
1950 let names: std::collections::HashSet<std::ffi::OsString> = visits
1951 .keys()
1952 .filter_map(|p| p.file_name().map(|n| n.to_os_string()))
1953 .collect();
1954 let mut aliases = Vec::new();
1955 for row in self.sections.iter().flat_map(|s| &s.rows) {
1956 let path = &row.path;
1957 if visits.contains_key(path)
1958 || row.table.is_some()
1959 || !path.file_name().is_some_and(|n| names.contains(n))
1960 || is_network_path(path)
1961 {
1962 continue;
1963 }
1964 if let Some(v) = crate::canonical::canonicalize(path)
1965 .ok()
1966 .and_then(|canonical| visits.get(&canonical))
1967 {
1968 aliases.push((path.clone(), *v));
1969 }
1970 }
1971 visits.extend(aliases);
1972 }
1973}
1974
1975pub fn look_into_as(entry: &Entry, as_read: &crate::formats::schema_union::ReadAs) -> Entry {
1980 let mut probe = classify_row(entry);
1981 measure_row(&mut probe, entry, as_read, None);
1982 probe
1983}
1984
1985fn classify_row(entry: &Entry) -> Entry {
1987 let mut probe = entry.clone();
1988 if probe.kind == EntryKind::Unknown && probe.path.is_dir() {
1989 let (kind, holds) = discover::look_at_directory(&probe.path);
1990 probe.kind = kind;
1991 probe.holds = holds;
1992 }
1993 probe
1994}
1995
1996fn stat_and_recall(
2000 probe: &mut Entry,
2001 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2002) -> bool {
2003 if discover::unstated(probe) && !discover::stat_row(probe) {
2004 return false;
2005 }
2006 if probe.rows.is_none() && probe.columns.is_empty() && discover::on_disk(probe) {
2009 apply_known_facts(probe, known, false);
2010 }
2011 true
2012}
2013
2014fn measure_row(
2017 probe: &mut Entry,
2018 entry: &Entry,
2019 as_read: &crate::formats::schema_union::ReadAs,
2020 remembered: Option<&crate::cache::CacheManager>,
2021) {
2022 discover::enrich_with(probe, as_read, remembered);
2023 probe.size = probe.size.or(entry.size);
2024 probe.modified = probe.modified.or(entry.modified);
2025}
2026
2027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2029pub enum Reads {
2030 Files,
2032 StatOnly,
2034}
2035
2036pub fn look_into_batch(
2041 rows: Vec<Entry>,
2042 cache: &crate::cache::CacheManager,
2043 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2044 reads: Reads,
2045 mut each: impl FnMut(PathBuf, Measured),
2046) {
2047 let as_read = crate::formats::schema_union::ReadAs::default();
2048 let answer = |probe: &Entry, entry: &Entry| Measured {
2049 stat_only: reads == Reads::StatOnly,
2050 ..measured_from(probe, entry)
2051 };
2052 let classified: Vec<(Entry, Entry)> = rows
2053 .into_iter()
2054 .map(|entry| {
2055 let mut probe = entry.clone();
2056 if !stat_and_recall(&mut probe, known) {
2058 return (probe, entry);
2059 }
2060 let probe = match reads {
2062 Reads::Files => classify_row(&probe),
2063 Reads::StatOnly => probe,
2064 };
2065 if probe.kind != entry.kind || probe.modified != entry.modified {
2066 each(entry.path.clone(), answer(&probe, &entry));
2067 }
2068 (probe, entry)
2069 })
2070 .collect();
2071
2072 let mut facts = Vec::new();
2073 for (mut probe, entry) in classified {
2074 if reads == Reads::Files && probe.rows.is_none() && probe.columns.is_empty() {
2076 measure_row(&mut probe, &entry, &as_read, Some(cache));
2077 facts.extend(facts_for(&probe));
2078 }
2079 each(entry.path.clone(), answer(&probe, &entry));
2080 }
2081 cache.record_dataset_facts(&facts);
2084}
2085
2086pub fn measured_from(probe: &Entry, original: &Entry) -> Measured {
2088 Measured {
2089 rows: probe.rows,
2090 cols: probe.cols,
2091 cols_sampled: probe.cols_sampled,
2092 size: probe.size.or(original.size),
2093 modified: probe.modified.or(original.modified),
2094 stat_only: false,
2095 columns: probe.columns.clone(),
2096 kind: (probe.kind != original.kind).then_some(probe.kind),
2097 holds: probe.holds.clone(),
2098 cost: crate::home::discover::Cost {
2101 source: None,
2102 ..probe.cost.clone()
2103 },
2104 }
2105}
2106
2107#[derive(Debug, Clone)]
2110pub enum Probe {
2111 Listing(Vec<Entry>),
2113 Listed {
2115 rows: std::sync::Arc<[Entry]>,
2116 cut_short: bool,
2117 },
2118 Unreachable(Option<String>),
2120 Silent(Vec<Entry>),
2123}
2124
2125pub const NOT_ANSWERING: &str = "not answering · Ctrl+R retries";
2127
2128#[derive(Debug, Clone, Default)]
2130pub struct Probes(std::collections::HashMap<PathBuf, Probe>);
2131
2132impl Probes {
2133 pub fn listed(&self, place: &Path) -> Option<&[Entry]> {
2135 match self.0.get(place)? {
2136 Probe::Listed { rows, .. } => Some(rows),
2137 _ => None,
2138 }
2139 }
2140
2141 pub fn so_far(&self, place: &Path) -> Option<&[Entry]> {
2143 match self.0.get(place)? {
2144 Probe::Listing(rows) => Some(rows),
2145 _ => None,
2146 }
2147 }
2148
2149 pub fn settled(&self, place: &Path) -> bool {
2151 matches!(
2152 self.0.get(place),
2153 Some(Probe::Listed { .. } | Probe::Unreachable(_) | Probe::Silent(_))
2154 )
2155 }
2156
2157 pub fn silent(&self, place: &Path) -> bool {
2159 matches!(self.0.get(place), Some(Probe::Silent(_)))
2160 }
2161
2162 pub fn silent_places(&self) -> Vec<PathBuf> {
2164 (self.0.iter())
2165 .filter(|(_, probe)| matches!(probe, Probe::Silent(_)))
2166 .map(|(place, _)| place.clone())
2167 .collect()
2168 }
2169
2170 pub fn takes_pages(&self, place: &Path) -> bool {
2173 matches!(
2174 self.0.get(place),
2175 None | Some(Probe::Listing(_) | Probe::Silent(_))
2176 )
2177 }
2178
2179 pub fn go_silent(&mut self, place: &Path) {
2181 let rows = match self.0.remove(place) {
2182 Some(Probe::Listing(rows) | Probe::Silent(rows)) => rows,
2183 None => Vec::new(),
2184 Some(settled) => {
2185 self.0.insert(place.to_path_buf(), settled);
2186 return;
2187 }
2188 };
2189 self.0.insert(place.to_path_buf(), Probe::Silent(rows));
2190 }
2191
2192 pub fn cut_short(&self, place: &Path) -> bool {
2193 matches!(
2194 self.0.get(place),
2195 Some(Probe::Listed {
2196 cut_short: true,
2197 ..
2198 })
2199 )
2200 }
2201
2202 pub fn unreachable(&self, place: &Path) -> bool {
2203 matches!(self.0.get(place), Some(Probe::Unreachable(_)))
2204 }
2205
2206 pub fn error(&self, place: &Path) -> Option<&str> {
2208 match self.0.get(place)? {
2209 Probe::Unreachable(why) => why.as_deref(),
2210 _ => None,
2211 }
2212 }
2213
2214 fn rows(&self, place: &Path) -> Vec<Entry> {
2217 match self.0.get(place) {
2218 Some(Probe::Listed { rows, .. }) => rows.to_vec(),
2219 Some(Probe::Listing(rows) | Probe::Silent(rows)) => {
2220 let mut rows = rows.clone();
2221 if is_object_store_url(place) {
2222 rows.sort_by_key(|row| row.kind != EntryKind::Directory);
2223 } else {
2224 discover::sort_entries(&mut rows);
2225 }
2226 rows
2227 }
2228 _ => Vec::new(),
2229 }
2230 }
2231
2232 pub fn answered(&self) -> impl Iterator<Item = (&PathBuf, &[Entry])> {
2234 self.0.iter().filter_map(|(place, probe)| match probe {
2235 Probe::Listed { rows, .. } => Some((place, &rows[..])),
2236 _ => None,
2237 })
2238 }
2239
2240 fn entry(&self, path: &Path) -> Option<Entry> {
2242 self.answered()
2243 .flat_map(|(_, rows)| rows.iter())
2244 .find(|e| e.path == path)
2245 .cloned()
2246 }
2247
2248 pub fn read(&mut self, place: &Path, rows: &[Entry]) {
2250 let probe = (self.0)
2251 .entry(place.to_path_buf())
2252 .or_insert(Probe::Listing(Vec::new()));
2253 if let Probe::Silent(so_far) = probe {
2255 *probe = Probe::Listing(std::mem::take(so_far));
2256 }
2257 if let Probe::Listing(so_far) = probe {
2258 so_far.extend_from_slice(rows);
2259 }
2260 }
2261
2262 pub fn insert(&mut self, place: PathBuf, probe: Probe) {
2263 self.0.insert(place, probe);
2264 }
2265
2266 pub fn forget(&mut self, place: &Path) {
2268 self.0.remove(place);
2269 }
2270
2271 pub fn stopped(&mut self, place: &Path) {
2273 if let Some(Probe::Listing(_) | Probe::Silent(_)) = self.0.get(place) {
2274 self.0.remove(place);
2275 }
2276 }
2277
2278 fn listed_mut(&mut self, place: &Path) -> Option<&mut std::sync::Arc<[Entry]>> {
2279 match self.0.get_mut(place)? {
2280 Probe::Listed { rows, .. } => Some(rows),
2281 _ => None,
2282 }
2283 }
2284}
2285
2286#[derive(Debug, Clone)]
2289pub struct Narrowed {
2290 pub dir: PathBuf,
2291 pub prefix: String,
2293 pub rows: Vec<Entry>,
2294 pub truncated: bool,
2296}
2297
2298pub fn build_listing(request: &ListingRequest) -> Listing {
2302 let ListingRequest {
2303 recents,
2304 desktop_dirs,
2305 browsing,
2306 probes,
2307 narrowed,
2308 network_check,
2309 cloud,
2310 catalogs,
2311 known,
2312 formats,
2313 } = request;
2314 let network_check = *network_check;
2315 let mounts = crate::home::locality::Mounts::current();
2318 let mut sections: Vec<Section> = Vec::new();
2319
2320 if let Some(id) = browsing.as_deref().and_then(cloud_source_id) {
2322 sections.push(source_section(&id, cloud));
2323 annotate(&mut sections, known, network_check, &mounts);
2324 return Listing {
2325 sections,
2326 ..Default::default()
2327 };
2328 }
2329
2330 if let Some(dir) = browsing.clone() {
2332 let remote = network_check(&dir);
2333 sections.push(browsed_section(
2334 &dir,
2335 remote,
2336 &Listed {
2337 probes,
2338 narrowed: narrowed.as_ref(),
2339 catalogs,
2340 known,
2341 formats,
2342 },
2343 ));
2344 annotate(&mut sections, known, network_check, &mounts);
2345 return Listing {
2346 sections,
2347 ..Default::default()
2348 };
2349 }
2350
2351 let recent_rows: Vec<Entry> = recents
2353 .iter()
2354 .filter(|p| {
2357 network_check(p)
2358 || p.exists()
2359 || crate::formats::members::split(p).is_some()
2360 || crate::formats::members::split_variant(p, formats).is_some()
2361 || crate::formats::hf_splits::split_place(p).is_some()
2362 })
2363 .map(|p| {
2365 if let Some(known) = probes.entry(p) {
2368 return known;
2369 }
2370 if let Some(variant) = discover::variant_row(p, formats) {
2371 return variant;
2372 }
2373 if !network_check(p)
2374 && let Some(split) = discover::split_row(p)
2375 {
2376 return split;
2377 }
2378 let mut entry = entry_for_path(p, network_check(p));
2379 if !network_check(p) {
2380 discover::name_unlisted_file(&mut entry, formats);
2381 }
2382 if let Some(dataset) = catalogs
2385 .iter()
2386 .flat_map(|c| &c.datasets)
2387 .find(|d| d.location == *p)
2388 {
2389 entry.name = dataset.name.clone();
2390 }
2391 entry
2392 })
2393 .collect();
2394
2395 let mut elsewhere: Vec<Entry> = Vec::new();
2397
2398 let roots = HomeState::roots_with(desktop_dirs, network_check);
2399 let mut root_sections: Vec<(RootOrigin, Section)> = Vec::new();
2400 let mut cwd_listing: Option<(PathBuf, std::collections::HashSet<std::ffi::OsString>)> = None;
2403 for root in roots {
2404 if root.origin == RootOrigin::Desktop {
2407 if root.available {
2408 let mut entry = Entry::directory(&root.path);
2409 entry.name = display_path(&root.path);
2411 elsewhere.push(entry);
2412 }
2413 continue;
2414 }
2415
2416 let mut truncated = false;
2419 let rows = if root.network {
2420 truncated = probes.cut_short(&root.path);
2421 probes.rows(&root.path)
2422 } else if root.available {
2423 let scan = discover::scan_dir_specs(&root.path, formats);
2424 truncated = scan.truncated;
2425 scan.entries
2426 } else {
2427 Vec::new()
2428 };
2429 if root.origin == RootOrigin::Cwd {
2430 let key = if root.network {
2433 root.path.clone()
2434 } else {
2435 crate::canonical::canonicalize(&root.path).unwrap_or_else(|_| root.path.clone())
2436 };
2437 let names = rows
2438 .iter()
2439 .filter_map(|row| row.path.file_name().map(|n| n.to_os_string()))
2440 .collect();
2441 cwd_listing = Some((key, names));
2442 }
2443 let unreachable = root.network && probes.unreachable(&root.path);
2445 let silent = root.network && probes.silent(&root.path);
2446 let waiting = root.network && !probes.settled(&root.path);
2447 let described = mounts.describe(&root.path);
2451 let fstype = if described.network() {
2452 described.fstype
2453 } else {
2454 "network".to_string()
2455 };
2456 let mut state: Vec<String> = Vec::new();
2459 if truncated {
2460 state.push(format!(
2461 "first {}",
2462 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2463 ));
2464 }
2465 if root.network {
2466 state.push(fstype);
2467 }
2468 root_sections.push((
2469 root.origin,
2470 Section {
2471 subtitle: (!state.is_empty()).then(|| crate::glyphs::dotted(&state.join(" · "))),
2472 origin: Some(root.origin.note()),
2473 root: Some(root.path.clone()),
2474 unavailable: !root.available || unreachable || silent,
2475 unavailable_note: silent.then(|| NOT_ANSWERING.to_string()),
2476 remote_root: root.network.then(|| root.path.clone()),
2477 waiting,
2478 ..Section::titled(display_path(&root.path), rows)
2479 },
2480 ));
2481 }
2482
2483 let recent_rows: Vec<Entry> = match &cwd_listing {
2488 None => recent_rows,
2489 Some((cwd, names)) => recent_rows
2490 .into_iter()
2491 .filter(|row| {
2492 let place = place_of(&row.path);
2493 let place = if network_check(&place) {
2496 place
2497 } else {
2498 crate::canonical::canonicalize(&place).unwrap_or(place)
2499 };
2500 place != *cwd || !row.path.file_name().is_some_and(|n| names.contains(n))
2501 })
2502 .collect(),
2503 };
2504 if !recent_rows.is_empty() {
2505 let place_labels = place_labels(&recent_rows, known, network_check);
2506 sections.push(Section {
2507 grouped_by_place: true,
2509 place_labels,
2510 ..Section::titled(HomeState::RECENT_SECTION, recent_rows)
2511 });
2512 }
2513
2514 sections.extend(root_sections.into_iter().map(|(_, s)| s));
2518
2519 sections.extend(cloud_section(cloud));
2522
2523 let mut missing = std::collections::HashSet::new();
2525 for catalog in catalogs {
2526 let mut section = catalog_section(catalog, network_check, &mut missing);
2527 for row in section.rows.iter_mut().filter(|r| {
2529 r.kind == EntryKind::File
2530 && r.format_spec.is_none()
2531 && r.table.is_none()
2532 && !network_check(&r.path)
2533 && !discover::is_data_file(&r.path)
2534 }) {
2535 if let Some(spec) = formats.by_glob(&row.path, false).first() {
2536 discover::name_spec_file(row, spec);
2537 }
2538 }
2539 sections.push(section);
2540 }
2541
2542 if !elsewhere.is_empty() {
2543 sections.push(Section {
2544 folded_by_default: true,
2546 ..Section::titled("Elsewhere", elsewhere)
2547 });
2548 }
2549
2550 annotate(&mut sections, known, network_check, &mounts);
2553
2554 Listing { sections, missing }
2555}
2556
2557fn source_section(id: &str, cloud: &[CloudSource]) -> Section {
2560 let source = cloud.iter().find(|s| s.id == id);
2561 let rows = source
2562 .map(|s| s.buckets.iter().map(|b| bucket_entry(b)).collect())
2563 .unwrap_or_default();
2564 let failure = source.and_then(|s| match &s.status {
2565 CloudStatus::Failed { short, .. } => Some(short.clone()),
2566 _ => None,
2567 });
2568 Section {
2569 subtitle: source.map(|s| s.note.clone()).filter(|n| !n.is_empty()),
2570 unavailable: source.is_none() || failure.is_some(),
2571 unavailable_note: if source.is_none() {
2572 Some("source not found".to_string())
2573 } else {
2574 failure
2575 },
2576 waiting: source.is_some_and(|s| s.busy()),
2577 ..Section::titled(
2578 source.map_or_else(|| id.to_string(), |s| s.label.clone()),
2579 rows,
2580 )
2581 }
2582}
2583
2584fn cloud_section(cloud: &[CloudSource]) -> Option<Section> {
2586 (!cloud.is_empty()).then(|| {
2587 Section::titled(
2588 HomeState::CLOUD_SECTION.to_string(),
2589 cloud.iter().map(source_entry).collect(),
2590 )
2591 })
2592}
2593
2594pub(crate) struct Listed<'a> {
2597 pub probes: &'a Probes,
2598 pub narrowed: Option<&'a Narrowed>,
2599 pub catalogs: &'a [ShownCatalog],
2600 pub known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2601 pub formats: &'a crate::formats::Registry,
2602}
2603
2604fn browsed_section(dir: &Path, remote: bool, listed: &Listed) -> Section {
2607 let Listed {
2608 probes,
2609 narrowed,
2610 catalogs,
2611 known,
2612 formats,
2613 } = *listed;
2614 let dir = dir.to_path_buf();
2615
2616 let so_far = remote && probes.so_far(&dir).is_some();
2620 let database = !remote && dir.is_file();
2622 let (mut rows, truncated) = if remote {
2623 (probes.rows(&dir), probes.cut_short(&dir))
2624 } else if database {
2625 let tables = discover::database_rows(&dir);
2626 let rows = if tables.is_empty() {
2627 discover::variant_rows(&dir, formats)
2628 } else {
2629 tables
2630 };
2631 (rows, false)
2632 } else {
2633 let scan = discover::scan_dir_specs(&dir, formats);
2634 let mut rows = discover::split_rows(&dir);
2636 rows.extend(scan.entries);
2637 (rows, scan.truncated)
2638 };
2639 let narrowed = narrowed.filter(|n| remote && truncated && n.dir == dir);
2642 if let Some(narrowed) = narrowed {
2643 let listed: std::collections::HashSet<PathBuf> =
2644 rows.iter().map(|row| row.path.clone()).collect();
2645 rows.extend(
2646 narrowed
2647 .rows
2648 .iter()
2649 .filter(|row| !listed.contains(&row.path))
2650 .cloned(),
2651 );
2652 }
2653 let subtitle = if so_far {
2655 Some(format!(
2656 "{} so far",
2657 crate::numfmt::group_chrome(rows.len())
2658 ))
2659 } else if truncated {
2660 let first = format!(
2661 "first {}",
2662 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2663 );
2664 Some(match narrowed {
2665 Some(n) => format!(
2666 "{first} + {}{} {}*",
2667 crate::numfmt::group_chrome(n.rows.len()),
2668 if n.truncated { "+" } else { "" },
2669 n.prefix
2670 ),
2671 None => first,
2672 })
2673 } else {
2674 None
2675 };
2676 let unavailable = remote && (probes.unreachable(&dir) || probes.silent(&dir));
2677 let mut door = (!database)
2680 .then(|| whole_directory_row(&dir, &rows, remote))
2681 .flatten();
2682 if !remote
2686 && let Some(door) = door.as_mut()
2687 && let Ok(meta) = std::fs::metadata(&dir)
2688 {
2689 door.modified = meta.modified().ok();
2690 apply_known_facts(door, known, false);
2691 door.modified = None;
2692 door.name = door_name(door, &rows);
2693 }
2694 let title = {
2697 let text = dir.to_string_lossy();
2698 if let Some(dataset) = catalogs
2699 .iter()
2700 .flat_map(|c| c.datasets.iter())
2701 .find(|d| is_object_store_url(&d.location) && same_place(&d.location, &dir))
2702 {
2703 dataset.name.clone()
2704 } else if let Some((_, account)) = cloud_account(&dir) {
2705 account
2706 } else if let Some((_, container, key)) = crate::cloud::source::azure_parts(&text) {
2707 format!("{container}/{}", key.trim_matches('/'))
2708 .trim_end_matches('/')
2709 .to_string()
2710 } else {
2711 match crate::cloud::source::split_source_id(&text) {
2712 (Some(_), plain) => plain.into_owned(),
2713 (None, _) => display_path(&dir),
2714 }
2715 }
2716 };
2717 Section {
2718 subtitle,
2719 root: Some(dir.clone()),
2720 unavailable,
2721 unavailable_note: probes
2724 .error(&dir)
2725 .map(str::to_string)
2726 .or_else(|| probes.silent(&dir).then(|| NOT_ANSWERING.to_string())),
2727 waiting: so_far,
2730 door,
2731 ..Section::titled(title, rows)
2732 }
2733}
2734
2735pub fn index_key(path: &Path) -> PathBuf {
2739 let text = path.to_string_lossy();
2740 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
2741 return PathBuf::from(crate::cloud::source::azure_url(&account, &container, &key));
2742 }
2743 match crate::cloud::source::split_source_id(&text) {
2744 (Some(_), plain) => PathBuf::from(plain.into_owned()),
2745 (None, _) => path.to_path_buf(),
2746 }
2747}
2748
2749fn known_facts<'a>(
2751 known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2752 path: &Path,
2753) -> Option<&'a crate::cache::DatasetFacts> {
2754 known.get(path).or_else(|| known.get(&index_key(path)))
2755}
2756
2757fn place_labels(
2762 rows: &[Entry],
2763 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2764 network_check: fn(&Path) -> bool,
2765) -> std::collections::HashMap<PathBuf, String> {
2766 let mut labels = std::collections::HashMap::new();
2767 for row in rows {
2768 let place = place_of(&row.path);
2769 if labels.contains_key(&place) {
2770 continue;
2771 }
2772 let Some(facts) = known_facts(known, &place) else {
2773 continue;
2774 };
2775 if facts.classified_by != crate::home::discover::CLASSIFIER_VERSION {
2776 continue;
2777 }
2778 if !network_check(&place) {
2779 let same_mtime = std::fs::metadata(&place)
2780 .and_then(|m| m.modified())
2781 .ok()
2782 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2783 .is_some_and(|d| d.as_secs() == facts.mtime);
2784 if !same_mtime {
2785 continue;
2786 }
2787 }
2788 let Some(kind) = facts.kind else {
2789 continue;
2790 };
2791 let mut probe = Entry::directory(&place);
2792 probe.kind = kind;
2793 probe.holds = facts.holds.clone();
2794 let label = probe.label();
2795 if !label.is_empty() && !label.starts_with("dir") {
2796 labels.insert(place, label.into_owned());
2797 }
2798 }
2799 labels
2800}
2801
2802fn annotate(
2805 sections: &mut [Section],
2806 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2807 network_check: fn(&Path) -> bool,
2808 mounts: &crate::home::locality::Mounts,
2809) {
2810 for section in sections {
2811 for row in &mut section.rows {
2812 if is_cloud_place(&row.path) {
2813 row.cost.source = Some("cloud".to_string());
2814 continue;
2815 }
2816 let remote = network_check(&row.path);
2817 if !remote && discover::unstated(row) && known_facts(known, &row.path).is_some() {
2821 discover::stat_row(row);
2822 }
2823 apply_known_facts(row, known, remote);
2824 row.cost.source = Some(mounts.describe(&row.path).fstype);
2825 }
2826 if let Some(door) = section.door.as_mut()
2828 && !is_cloud_place(&door.path)
2829 {
2830 door.cost.source = Some(mounts.describe(&door.path).fstype);
2831 }
2832 }
2833}
2834
2835#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2837pub struct Stated {
2838 pub size: Option<u64>,
2839 pub modified: Option<std::time::SystemTime>,
2840 pub epoch: u64,
2842}
2843
2844fn fold_known(
2847 row: &mut Entry,
2848 enriched: &std::collections::HashMap<PathBuf, Measured>,
2849 stated: &std::collections::HashMap<PathBuf, Stated>,
2850 stale: &std::collections::HashSet<PathBuf>,
2851) -> bool {
2852 let mut any = false;
2853 if stale.contains(&row.path) {
2854 forget_counts(row);
2855 any = true;
2856 }
2857 if let Some(m) = enriched.get(&row.path) {
2858 fold_measured(row, m);
2859 any = true;
2860 }
2861 if let Some(stat) = stated.get(&row.path) {
2862 if stat.size.is_some() {
2863 row.size = stat.size;
2864 }
2865 if stat.modified.is_some() {
2866 row.modified = stat.modified;
2867 }
2868 any = true;
2869 }
2870 any
2871}
2872
2873fn forget_counts(row: &mut Entry) {
2876 row.measured = false;
2877 row.rows = None;
2878 row.cols = None;
2879 row.cols_sampled = false;
2880 row.columns.clear();
2881 take_cost(row, &discover::Cost::default());
2882}
2883
2884#[derive(PartialEq)]
2887struct Standing {
2888 hidden: bool,
2889 dataset: bool,
2890 sorted_by: Option<u64>,
2892}
2893
2894impl Standing {
2895 fn of(row: &Entry, sort: SortMode) -> Self {
2896 Standing {
2897 hidden: row.hidden_by_default(),
2898 dataset: row.kind.is_dataset() || row.kind.is_lake_table(),
2899 sorted_by: match sort {
2900 SortMode::Size => Some(row.size.unwrap_or(0)),
2901 SortMode::Rows => Some(row.rows.unwrap_or(0) as u64),
2902 SortMode::Modified => Some(
2903 (row.modified)
2904 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2905 .map_or(0, |d| d.as_secs()),
2906 ),
2907 SortMode::Natural => None,
2908 },
2909 }
2910 }
2911}
2912
2913fn fold_measured(row: &mut Entry, m: &Measured) {
2915 row.measured = true;
2916 row.rows = m.rows;
2917 row.cols = m.cols;
2918 row.cols_sampled = m.cols_sampled;
2919 if let Some(kind) = m.kind {
2920 row.kind = kind;
2921 }
2922 if m.size.is_some() {
2923 row.size = m.size;
2924 }
2925 if m.modified.is_some() {
2926 row.modified = m.modified;
2927 }
2928 if !m.columns.is_empty() && row.columns != m.columns {
2929 row.columns.clone_from(&m.columns);
2930 }
2931 if !m.holds.is_empty() && row.holds != m.holds {
2932 row.holds.clone_from(&m.holds);
2933 }
2934 take_cost(row, &m.cost);
2936}
2937
2938fn take_cost(row: &mut Entry, cost: &discover::Cost) {
2942 let source = row.cost.source.take();
2943 let variants = row.cost.tables.filter(|_| row.format_spec.is_some());
2944 row.cost = cost.clone();
2945 row.cost.source = source;
2946 if variants.is_some() {
2947 row.cost.tables = variants;
2948 }
2949}
2950
2951fn apply_known_facts(
2955 row: &mut Entry,
2956 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2957 remote: bool,
2958) {
2959 let Some(facts) = known_facts(known, &row.path) else {
2960 return;
2961 };
2962
2963 if !remote
2970 && matches!(row.kind, EntryKind::Unknown | EntryKind::MultiFile)
2971 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
2972 && let Some(kind) = facts.kind
2973 {
2974 let same_mtime = row
2975 .modified
2976 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2977 .is_some_and(|d| d.as_secs() == facts.mtime);
2978 if same_mtime {
2979 row.kind = kind;
2980 if row.holds.is_empty() {
2983 row.holds = facts.holds.clone();
2984 }
2985 }
2989 }
2990
2991 if !remote {
2992 let same_bytes = row.size.map(|s| s == facts.size).unwrap_or(false)
2993 && row
2994 .modified
2995 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2996 .map(|d| d.as_secs() == facts.mtime)
2997 .unwrap_or(false);
2998 if !same_bytes {
2999 return;
3000 }
3001 }
3002
3003 row.rows = facts.rows;
3004 row.cols = facts.cols;
3005 row.cols_sampled = facts.cols_sampled;
3006 if !facts.columns.is_empty() {
3007 row.columns = facts.columns.clone();
3008 }
3009 take_cost(row, &facts.cost);
3012 if remote {
3013 if facts.size > 0 {
3016 row.size = row.size.or(Some(facts.size));
3017 }
3018 if row.kind == EntryKind::Unknown
3022 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
3023 && let Some(kind) = facts.kind
3024 {
3025 row.kind = kind;
3026 if row.holds.is_empty() {
3028 row.holds = facts.holds.clone();
3029 }
3030 }
3031 }
3032}
3033
3034pub fn facts_for(entry: &Entry) -> Option<(PathBuf, crate::cache::DatasetFacts)> {
3036 let size = entry.size?;
3037 let mtime = entry
3038 .modified?
3039 .duration_since(std::time::UNIX_EPOCH)
3040 .ok()?
3041 .as_secs();
3042 let cost = crate::home::discover::Cost {
3044 source: None,
3045 ..entry.cost.clone()
3046 };
3047 if entry.rows.is_none() && entry.columns.is_empty() && cost == Default::default() {
3048 return None; }
3050 Some((
3051 entry.path.clone(),
3052 crate::cache::DatasetFacts {
3053 mtime,
3054 size,
3055 rows: entry.rows,
3056 cols: entry.cols,
3057 cols_sampled: entry.cols_sampled,
3058 columns: entry.columns.clone(),
3059 kind: Some(entry.kind),
3060 holds: entry.holds.clone(),
3061 classified_by: crate::home::discover::CLASSIFIER_VERSION,
3062 cost: crate::home::discover::Cost {
3064 source: None,
3065 ..entry.cost.clone()
3066 },
3067 },
3068 ))
3069}
3070
3071pub fn match_score(filter: &str, entry: &Entry) -> Option<i32> {
3075 match crate::home::fuzzy::best_match(filter, &entry.name) {
3076 Some(m) => Some(m.score),
3077 None => matching_column(filter, entry).map(|_| -COLUMN_MATCH_PENALTY),
3080 }
3081}
3082
3083pub fn match_hit(filter: &str, entry: &Entry) -> Option<Hit> {
3085 Needle::new(filter).hit(entry)
3086}
3087
3088struct Needle<'a> {
3091 filter: &'a str,
3092 lower: String,
3093}
3094
3095impl<'a> Needle<'a> {
3096 fn new(filter: &'a str) -> Self {
3097 Needle {
3098 filter,
3099 lower: filter.to_lowercase(),
3100 }
3101 }
3102
3103 fn hit(&self, entry: &Entry) -> Option<Hit> {
3104 let named = crate::home::fuzzy::best_match_with(self.filter, &entry.name, |score, _| Hit {
3105 score,
3106 column: None,
3107 });
3108 if named.is_some() {
3109 return named;
3110 }
3111 Some(Hit {
3112 score: -COLUMN_MATCH_PENALTY,
3113 column: Some(self.column(entry)?),
3114 })
3115 }
3116
3117 fn column(&self, entry: &Entry) -> Option<usize> {
3119 if self.filter.is_empty() {
3120 return None;
3121 }
3122 (entry.columns.iter()).position(|c| contains_folded(c, &self.lower))
3123 }
3124}
3125
3126fn contains_folded(haystack: &str, lower: &str) -> bool {
3129 if haystack.is_ascii() && lower.is_ascii() {
3130 let (hay, needle) = (haystack.as_bytes(), lower.as_bytes());
3131 return needle.is_empty()
3132 || hay
3133 .windows(needle.len())
3134 .any(|w| w.eq_ignore_ascii_case(needle));
3135 }
3136 haystack.to_lowercase().contains(lower)
3137}
3138
3139const COLUMN_MATCH_PENALTY: i32 = 1_000_000;
3142
3143pub fn matching_column<'a>(filter: &str, entry: &'a Entry) -> Option<&'a str> {
3146 (Needle::new(filter).column(entry)).map(|i| entry.columns[i].as_str())
3147}
3148
3149pub fn fuzzy_positions(needle: &str, haystack: &str) -> Vec<usize> {
3152 crate::home::fuzzy::best_match(needle, haystack)
3153 .map(|m| m.positions)
3154 .unwrap_or_default()
3155}
3156
3157pub fn substring_positions(needle: &str, haystack: &str) -> Vec<usize> {
3160 if needle.is_empty() {
3161 return Vec::new();
3162 }
3163 let hay: Vec<char> = haystack.to_lowercase().chars().collect();
3164 let need: Vec<char> = needle.to_lowercase().chars().collect();
3165 if need.len() > hay.len() {
3166 return Vec::new();
3167 }
3168 for start in 0..=(hay.len() - need.len()) {
3169 if hay[start..start + need.len()] == need[..] {
3170 return (start..start + need.len()).collect();
3171 }
3172 }
3173 Vec::new()
3174}
3175
3176pub fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
3179 crate::home::fuzzy::best_match(needle, haystack).map(|m| m.score)
3180}
3181
3182impl HomeState {
3183 pub fn roots(desktop_dirs: &[PathBuf]) -> Vec<Root> {
3186 Self::roots_with(desktop_dirs, is_remote_path)
3187 }
3188
3189 pub fn roots_with(desktop_dirs: &[PathBuf], is_network: fn(&Path) -> bool) -> Vec<Root> {
3191 let mut roots: Vec<Root> = Vec::new();
3192 let mut seen: Vec<PathBuf> = Vec::new();
3193
3194 let push =
3195 |path: PathBuf, origin: RootOrigin, roots: &mut Vec<Root>, seen: &mut Vec<PathBuf>| {
3196 let network = is_network(&path);
3198
3199 let key = if network {
3203 path.clone()
3204 } else {
3205 crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone())
3206 };
3207 if seen.contains(&key) {
3208 return;
3209 }
3210 seen.push(key);
3211
3212 let available = if network {
3213 true } else {
3215 std::fs::read_dir(&path).is_ok()
3216 };
3217 roots.push(Root {
3218 path,
3219 origin,
3220 available,
3221 network,
3222 });
3223 };
3224
3225 if let Ok(cwd) = std::env::current_dir() {
3229 push(cwd, RootOrigin::Cwd, &mut roots, &mut seen);
3230 }
3231
3232 for dir in desktop_dirs {
3235 push(dir.clone(), RootOrigin::Desktop, &mut roots, &mut seen);
3236 }
3237
3238 roots
3239 }
3240
3241 pub fn rebuild(&mut self, recents: &[PathBuf]) {
3244 self.rebuild_with(recents, &[])
3245 }
3246
3247 pub fn rebuild_with(&mut self, recents: &[PathBuf], desktop_dirs: &[PathBuf]) {
3250 let request = ListingRequest {
3251 recents: recents.to_vec(),
3252 desktop_dirs: desktop_dirs.to_vec(),
3253 browsing: self.browsing.clone(),
3254 probes: self.probes.clone(),
3255 narrowed: self.narrowed.clone(),
3256 network_check: self.network_check,
3257 cloud: self.cloud.clone(),
3258 catalogs: self.catalogs.clone(),
3259 known: Default::default(),
3262 formats: self.formats.clone(),
3263 };
3264 let listing = build_listing(&request);
3265 self.apply_listing(listing);
3266 }
3267
3268 pub fn apply_listing(&mut self, listing: Listing) {
3270 let mut listing = listing;
3271 for section in &mut listing.sections {
3272 name_by_spec(&self.formats, &mut section.rows);
3273 }
3274 self.missing = listing.missing;
3275 self.replace_sections(|home| home.sections = listing.sections);
3276 }
3277
3278 pub fn relist_remote(&mut self, root: &Path) {
3282 let browsed = self.browsing.as_deref() == Some(root)
3283 && (self.network_check)(root)
3284 && cloud_source_id(root).is_none();
3285 let at = (self.sections.iter()).position(|s| {
3286 s.root.as_deref() == Some(root) && (browsed || s.remote_root.as_deref() == Some(root))
3287 });
3288 let Some(at) = at else {
3289 return;
3290 };
3291 let mut section = if browsed {
3292 browsed_section(
3293 root,
3294 true,
3295 &Listed {
3296 probes: &self.probes,
3297 narrowed: self.narrowed.as_ref(),
3298 catalogs: &self.catalogs,
3299 known: &self.known,
3300 formats: &self.formats,
3301 },
3302 )
3303 } else {
3304 Section::titled("", self.probes.rows(root))
3305 };
3306 let mounts = crate::home::locality::Mounts::cached();
3307 annotate(
3308 std::slice::from_mut(&mut section),
3309 &self.known,
3310 self.network_check,
3311 &mounts,
3312 );
3313 name_by_spec(&self.formats, &mut section.rows);
3314 let silent = self.probes.silent(root);
3315 let unreachable = self.probes.unreachable(root);
3316 let waiting = !self.probes.settled(root);
3317 self.replace_sections(|home| {
3318 if browsed {
3319 home.sections[at] = section;
3320 return;
3321 }
3322 let shown = &mut home.sections[at];
3325 shown.rows = section.rows;
3326 shown.waiting = waiting;
3327 shown.unavailable = silent || unreachable;
3328 shown.unavailable_note = silent.then(|| NOT_ANSWERING.to_string());
3329 });
3330 }
3331
3332 pub fn set_cloud(&mut self, cloud: Vec<CloudSource>) {
3335 self.cloud = cloud;
3336 let mounts = crate::home::locality::Mounts::cached();
3337 let annotated = |home: &Self, mut section: Section| {
3338 annotate(
3339 std::slice::from_mut(&mut section),
3340 &home.known,
3341 home.network_check,
3342 &mounts,
3343 );
3344 section
3345 };
3346 match self.browsing.as_deref().map(cloud_source_id) {
3347 Some(Some(id)) => {
3348 let section = annotated(self, source_section(&id, &self.cloud));
3349 self.replace_sections(|home| home.sections = vec![section]);
3350 }
3351 Some(None) => {}
3352 None => {
3353 let section = cloud_section(&self.cloud).map(|s| annotated(self, s));
3354 self.replace_sections(|home| {
3355 let at = (home.sections.iter()).position(|s| s.title == Self::CLOUD_SECTION);
3356 match (at, section) {
3357 (Some(at), Some(section)) => home.sections[at] = section,
3358 (Some(at), None) => {
3359 home.sections.remove(at);
3360 }
3361 (None, Some(section)) => {
3363 let at = (home.sections.iter())
3364 .position(|s| {
3365 s.origin.is_some_and(is_catalog_origin)
3366 || s.title == "Elsewhere"
3367 || s.title == Self::SEARCH_SECTION
3368 })
3369 .unwrap_or(home.sections.len());
3370 home.sections.insert(at, section);
3371 }
3372 (None, None) => {}
3373 }
3374 });
3375 }
3376 }
3377 }
3378
3379 fn replace_sections(&mut self, change: impl FnOnce(&mut Self)) {
3382 let returning = self.returning.take();
3383 let previous = returning.clone().or_else(|| self.selected_key());
3384 let line = self.selected.saturating_sub(self.scroll);
3386 change(self);
3387 self.changed();
3388 if let (Some(browsing), Some((dir, format))) = (&self.browsing, &self.lake_here)
3390 && browsing == dir
3391 && let Some(section) = self.sections.first_mut()
3392 {
3393 let note = crate::glyphs::dotted(&format!(
3394 "{} · not read as a table",
3395 format.to_ascii_lowercase()
3396 ));
3397 section.subtitle = Some(match section.subtitle.take() {
3398 Some(state) => crate::glyphs::dotted(&format!("{note} · {state}")),
3399 None => note,
3400 });
3401 }
3402 self.sync_search_section();
3404 self.apply_measurements();
3407
3408 let placed = self.reselect(previous);
3410 if placed && returning.is_some() {
3413 self.landing = false;
3414 }
3415 if !placed {
3416 self.select_first_entry();
3417 if self.rows_still_arriving() {
3420 self.returning = returning;
3421 }
3422 } else if returning.is_some() {
3423 self.scroll_to_returning_line();
3424 } else {
3425 self.scroll = self.selected.saturating_sub(line);
3426 }
3427 self.follow_selection();
3428 }
3429
3430 pub fn leave_mark(&mut self) {
3433 let mark = Mark {
3434 place: self.browsing.clone(),
3435 key: self.selected_key(),
3436 filter: self.filter.clone(),
3437 search: (!self.search.running).then(|| SearchState {
3440 scoring: false,
3441 ..self.search.clone()
3442 }),
3443 line: {
3444 let lines = self.list_lines(self.spaced);
3445 (lines.line_of(self.selected)).saturating_sub(lines.line_of(self.scroll))
3446 },
3447 };
3448 self.trail.retain(|m| m.place != mark.place);
3451 self.trail.push(mark);
3452 }
3453
3454 pub fn come_back(&mut self, from: Option<PathBuf>) {
3459 let to = self.browsing.clone();
3460 let mark = self
3461 .trail
3462 .iter()
3463 .rposition(|m| m.place == to)
3464 .map(|at| self.trail.split_off(at).remove(0));
3465 match mark {
3466 Some(mark) => {
3467 self.filter = mark.filter;
3468 self.search = mark.search.unwrap_or_default();
3469 self.returning = mark.key;
3470 self.returning_line = Some(mark.line);
3471 }
3472 None => {
3473 self.filter.clear();
3474 self.search.reset();
3475 self.returning = from.map(RowKey::Entry);
3476 self.returning_line = None;
3477 }
3478 }
3479 }
3480
3481 fn rows_still_arriving(&self) -> bool {
3484 self.listing_in_flight
3485 || self.sections_waiting()
3486 || self.awaiting_listing().is_some()
3487 || self.search.running
3488 }
3489
3490 fn settle_return(&mut self) {
3492 let Some(key) = self.returning.clone() else {
3493 return;
3494 };
3495 if let Some(idx) = self.place_key(&key) {
3496 self.selected = idx;
3497 self.returning = None;
3498 self.landing = false;
3499 self.scroll_to_returning_line();
3500 self.follow_selection();
3501 } else if !self.rows_still_arriving() {
3502 self.returning = None;
3503 }
3504 }
3505
3506 fn scroll_to_returning_line(&mut self) {
3508 if let Some(line) = self.returning_line.take() {
3509 let lines = self.list_lines(self.spaced);
3510 self.scroll = lines.first_row_from(lines.line_of(self.selected).saturating_sub(line));
3511 }
3512 }
3513
3514 pub fn selected_key(&self) -> Option<RowKey> {
3517 let title = |section: usize| self.sections.get(section).map(|s| s.title.clone());
3518 Some(match self.cursor_row()? {
3519 Row::Header { section, .. } => RowKey::Header(title(section)?),
3520 Row::More { section, .. } => RowKey::More(title(section)?),
3521 Row::Hidden { section, .. } => RowKey::Hidden(title(section)?),
3522 Row::Up { section } => RowKey::Up(title(section)?),
3523 Row::Entry { entry, .. } => RowKey::Entry(entry.path.clone()),
3524 Row::Door { entry, .. } => RowKey::Door(entry.path.clone()),
3525 Row::Place { path, .. } => RowKey::Place(path),
3526 })
3527 }
3528
3529 pub fn reselect(&mut self, key: Option<RowKey>) -> bool {
3533 let Some(key) = key else {
3534 self.clamp_selection();
3535 return false;
3536 };
3537 match self.place_key(&key) {
3538 Some(idx) => {
3539 self.selected = idx;
3540 true
3541 }
3542 None => {
3543 self.clamp_selection();
3544 false
3545 }
3546 }
3547 }
3548
3549 fn place_key(&mut self, key: &RowKey) -> Option<usize> {
3552 if let Some(found) = self.listed(key) {
3553 return Some(found);
3554 }
3555 if let RowKey::Entry(path) = key
3556 && let Some(root) = self
3557 .sections
3558 .iter()
3559 .find(|s| !s.grouped_by_place && s.rows.iter().any(|r| r.path == *path))
3560 .and_then(|s| s.root.clone())
3561 && self.shown_whole.insert(root.clone())
3562 {
3563 match self.listed(key) {
3564 Some(found) => return Some(found),
3565 None => {
3566 self.shown_whole.remove(&root);
3567 }
3568 }
3569 }
3570 self.behind_recent_cap(key)
3571 }
3572
3573 fn listed(&self, key: &RowKey) -> Option<usize> {
3575 self.position(|row| match (row, key) {
3576 (Row::Entry { entry, .. }, RowKey::Entry(path)) => entry.path == *path,
3577 (Row::Door { entry, .. }, RowKey::Door(path)) => entry.path == *path,
3578 (Row::Place { path, .. }, RowKey::Place(wanted)) => path == wanted,
3579 (Row::Header { section, .. }, RowKey::Header(title))
3580 | (Row::More { section, .. }, RowKey::More(title))
3581 | (Row::Hidden { section, .. }, RowKey::Hidden(title))
3582 | (Row::Up { section }, RowKey::Up(title)) => self
3583 .sections
3584 .get(*section)
3585 .is_some_and(|s| s.title == *title),
3586 _ => false,
3587 })
3588 }
3589
3590 fn behind_recent_cap(&self, key: &RowKey) -> Option<usize> {
3592 let (RowKey::Entry(path) | RowKey::Place(path)) = key else {
3593 return None;
3594 };
3595 self.position(|row| {
3596 matches!(row, Row::More { section, .. }
3597 if self.sections.get(*section).is_some_and(|s| {
3598 s.grouped_by_place
3599 && s.rows
3600 .iter()
3601 .any(|r| r.path == *path || place_of(&r.path) == *path)
3602 }))
3603 })
3604 }
3605
3606 pub fn set_view_height(&mut self, height: usize) {
3609 if height == self.view_height {
3610 return;
3611 }
3612 let key = self.selected_key();
3613 self.view_height = height;
3614 self.reselect(key);
3615 }
3616
3617 pub fn set_list_shape(&mut self, lines: usize, spaced: bool) {
3621 self.view_lines = lines;
3622 self.spaced = spaced;
3623 }
3624
3625 fn follow_selection(&mut self) {
3629 if self.view_lines == 0 {
3630 let rows = self.row_count();
3631 self.scroll = settle_top(self.scroll, self.selected, self.view_height, rows);
3632 return;
3633 }
3634 let lines = self.list_lines(self.spaced);
3635 let rows = lines.rows();
3636 let line_of = |row: usize| lines.line_of(row.min(rows.saturating_sub(1)));
3637 let top = settle_top(
3638 line_of(self.scroll),
3639 line_of(self.selected),
3640 self.view_lines,
3641 lines.total(),
3642 );
3643 self.scroll = lines.first_row_from(top);
3644 }
3645
3646 fn section_folded(&self, section: &Section) -> bool {
3650 if self.browsing.is_some() {
3651 return false;
3652 }
3653 self.folds
3654 .get(§ion.title)
3655 .copied()
3656 .unwrap_or(section.folded_by_default)
3657 }
3658
3659 pub fn is_collapsed(&self, section: usize) -> bool {
3661 self.sections
3662 .get(section)
3663 .is_some_and(|s| self.section_folded(s))
3664 }
3665
3666 pub fn toggle_collapsed(&mut self, section: usize) {
3668 let folded = self.is_collapsed(section);
3669 self.set_collapsed(section, !folded);
3670 }
3671
3672 pub fn set_collapsed(&mut self, section: usize, collapsed: bool) {
3676 if self.browsing.is_some() {
3677 return;
3678 }
3679 let Some(title) = self.sections.get(section).map(|s| s.title.clone()) else {
3680 return;
3681 };
3682 self.folds.insert(title, collapsed);
3683 }
3684
3685 pub fn jump_section(&mut self, delta: isize) {
3687 self.resting = false;
3688 self.returning = None;
3689 self.landing = false;
3690 let headers = self.view().headers.clone();
3691 if headers.is_empty() {
3692 return;
3693 }
3694 let current = self.selected;
3695 self.selected = if delta > 0 {
3696 headers
3697 .iter()
3698 .copied()
3699 .find(|&h| h > current)
3700 .unwrap_or(headers[0])
3701 } else {
3702 headers
3703 .iter()
3704 .rev()
3705 .copied()
3706 .find(|&h| h < current)
3707 .unwrap_or(*headers.last().unwrap())
3708 };
3709 }
3710
3711 pub fn has_any_dataset(&self) -> bool {
3715 self.view().has_dataset
3716 }
3717
3718 pub const SEARCH_SECTION: &'static str = "Found";
3720
3721 pub const CLOUD_SECTION: &'static str = "Cloud";
3723
3724 pub const RECENT_SECTION: &'static str = "Recent";
3726
3727 pub fn cloud_source_of(&self, path: &Path) -> Option<&CloudSource> {
3730 if let Some(id) = cloud_source_id(path) {
3731 return self.cloud.iter().find(|s| s.id == id);
3732 }
3733 if let Some((id, _)) = cloud_account(path) {
3734 return self.cloud.iter().find(|s| s.id == id);
3735 }
3736 let text = path.to_string_lossy();
3737 if let Some((account, _, _)) = crate::cloud::source::azure_parts(&text) {
3738 return self.azure_account_place(&account).and_then(|place| {
3739 cloud_account(&place).and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id))
3740 });
3741 }
3742 if let (Some(id), _) = crate::cloud::source::split_source_id(&text) {
3743 return self.cloud.iter().find(|s| s.id == id);
3744 }
3745 if let Some(project) =
3746 Self::google_bucket_root(path).and_then(|b| self.project_of_bucket(&b))
3747 {
3748 return cloud_account(&project)
3749 .and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id));
3750 }
3751 let (_, plain) = crate::cloud::source::split_source_id(&text);
3752 let (scheme, rest) = plain.split_once("://")?;
3753 let bucket = rest.split('/').next()?;
3754 let root = PathBuf::from(format!("{scheme}://{bucket}"));
3755 self.cloud.iter().find(|s| s.buckets.contains(&root))
3756 }
3757
3758 pub fn parent_of(&self, path: &Path) -> Option<PathBuf> {
3761 if cloud_source_id(path).is_some() {
3762 return None;
3763 }
3764 if let Some((_, dataset)) = self.remote_dataset_of(path) {
3767 let place = &dataset.location;
3768 if same_place(path, place) {
3769 return None;
3770 }
3771 let up = self.parent_within(path)?;
3772 return Some(if same_place(&up, place) {
3774 place.clone()
3775 } else {
3776 up
3777 });
3778 }
3779 if let Some((id, _)) = cloud_account(path) {
3780 return Some(cloud_place(&id));
3781 }
3782 let text = path.to_string_lossy();
3783 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3784 let key = key.trim_matches('/');
3785 if key.is_empty() {
3786 return self.azure_account_place(&account);
3787 }
3788 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3789 let up = if up.is_empty() {
3790 String::new()
3791 } else {
3792 format!("{up}/")
3793 };
3794 return Some(PathBuf::from(crate::cloud::source::azure_url(
3795 &account, &container, &up,
3796 )));
3797 }
3798 if is_bucket_root(path) {
3799 if let Some(project) = self.project_of_bucket(path) {
3801 return Some(project);
3802 }
3803 return self.cloud_source_of(path).map(|s| cloud_place(&s.id));
3804 }
3805 parent_location(path)
3806 }
3807
3808 fn parent_within(&self, path: &Path) -> Option<PathBuf> {
3810 let text = path.to_string_lossy();
3811 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3812 let key = key.trim_matches('/');
3813 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3814 let up = if up.is_empty() {
3815 String::new()
3816 } else {
3817 format!("{up}/")
3818 };
3819 return Some(PathBuf::from(crate::cloud::source::azure_url(
3820 &account, &container, &up,
3821 )));
3822 }
3823 parent_location(path)
3824 }
3825
3826 fn remote_dataset_of(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3829 if !is_object_store_url(path) {
3830 return None;
3831 }
3832 let text = path.to_string_lossy();
3833 self.catalogs
3834 .iter()
3835 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3836 .filter(|(_, d)| {
3837 is_object_store_url(&d.location) && within(&text, &d.location.to_string_lossy())
3838 })
3839 .rev()
3840 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
3841 }
3842
3843 pub fn catalog_dataset(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3845 let places = &self.catalog_places;
3846 if places.indexes(&self.catalogs) {
3847 let key = place_key(path);
3848 let &(c, d) = places.datasets.get(&key)?;
3849 let catalog = self.catalogs.get(c)?;
3850 if let Some(dataset) = catalog.datasets.get(d)
3851 && place_key(&dataset.location) == key
3852 {
3853 return Some((catalog, dataset));
3854 }
3855 }
3856 self.catalogs
3857 .iter()
3858 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3859 .find(|(_, d)| same_place(&d.location, path))
3860 }
3861
3862 pub fn bookmark(&self, path: &Path) -> Option<(&ShownDataset, &str)> {
3864 let places = &self.catalog_places;
3865 if places.indexes(&self.catalogs) {
3866 let key = place_key(path);
3867 let &(c, d, b) = places.bookmarks.get(&key)?;
3868 if let Some(dataset) = self.catalogs.get(c).and_then(|c| c.datasets.get(d))
3869 && let Some((name, place)) = dataset.bookmarks.get(b)
3870 && place_key(place) == key
3871 {
3872 return Some((dataset, name.as_str()));
3873 }
3874 }
3875 self.catalogs
3876 .iter()
3877 .flat_map(|c| c.datasets.iter())
3878 .find_map(|d| {
3879 d.bookmarks
3880 .iter()
3881 .find(|(_, place)| same_place(place, path))
3882 .map(|(name, _)| (d, name.as_str()))
3883 })
3884 }
3885
3886 pub fn size_hint(&self, path: &Path) -> Option<u64> {
3888 self.catalog_dataset(path).and_then(|(_, d)| d.size)
3889 }
3890
3891 fn azure_account_place(&self, account: &str) -> Option<PathBuf> {
3893 self.cloud
3894 .iter()
3895 .flat_map(|s| s.buckets.iter())
3896 .find(|place| cloud_account(place).is_some_and(|(_, a)| a == account))
3897 .cloned()
3898 }
3899
3900 pub fn cloud_look(&self, entry: &Entry) -> Option<CloudLook> {
3903 if entry.kind != EntryKind::Directory
3904 || !entry.holds.is_empty()
3905 || !is_object_store_url(&entry.path)
3906 || is_cloud_place(&entry.path)
3907 || object_place_label(&entry.path).is_some()
3908 {
3909 return None;
3910 }
3911 if self.peeking.contains(&entry.path) {
3912 return Some(CloudLook::Looking);
3913 }
3914 if self.peek_failed.contains(&entry.path) {
3915 return Some(CloudLook::Failed);
3916 }
3917 match self.cloud_kinds.get(&entry.path) {
3918 None => Some(CloudLook::Waiting),
3919 Some((kind, holds)) if *kind != EntryKind::Directory || !holds.is_empty() => {
3922 Some(CloudLook::Looking)
3923 }
3924 Some(_) => None,
3925 }
3926 }
3927
3928 pub fn place_kind(&self, path: &Path) -> Option<&'static str> {
3931 if self.missing.contains(path) {
3932 return Some("missing");
3933 }
3934 if let Some((id, _)) = cloud_account(path) {
3935 return self
3936 .cloud
3937 .iter()
3938 .any(|s| s.id == id && s.api == crate::cloud::source::ProviderKind::Gcs)
3939 .then_some("project");
3940 }
3941 (is_object_store_url(path)
3943 && (self
3944 .catalog_dataset(path)
3945 .is_some_and(|(_, d)| is_object_store_url(&d.location))
3946 || (self.browsing.is_none() && self.bookmark(path).is_some())))
3947 .then_some("dataset")
3948 }
3949
3950 fn project_of_bucket(&self, bucket_root: &Path) -> Option<PathBuf> {
3952 let root = bucket_root.to_string_lossy();
3953 let root = root.trim_end_matches('/');
3954 self.probes
3955 .answered()
3956 .filter(|(place, _)| cloud_account(place).is_some())
3957 .find(|(_, rows)| {
3958 rows.iter()
3959 .any(|row| row.path.to_string_lossy().trim_end_matches('/') == root)
3960 })
3961 .map(|(place, _)| place.clone())
3962 }
3963
3964 fn google_bucket_root(path: &Path) -> Option<PathBuf> {
3966 let text = path.to_string_lossy();
3967 let rest = text
3968 .strip_prefix("gs://")
3969 .or_else(|| text.strip_prefix("gcs://"))?;
3970 let bucket = rest.split('/').next().filter(|b| !b.is_empty())?;
3971 Some(PathBuf::from(format!("gs://{bucket}")))
3972 }
3973
3974 pub fn place_details(&self, path: &Path) -> Option<&[(String, String)]> {
3977 self.cloud
3978 .iter()
3979 .find_map(|s| s.place_details.get(path))
3980 .or_else(|| self.catalog_dataset(path).map(|(_, d)| &d.details))
3981 .or_else(|| self.bookmark(path).map(|(d, _)| &d.details))
3982 .map(Vec::as_slice)
3983 }
3984
3985 pub fn location_label(&self, path: &Path) -> String {
3988 let sep = crate::glyphs::get().trail;
3989 if let Some((catalog, dataset)) = self.remote_dataset_of(path) {
3992 let text = path.to_string_lossy();
3993 let rest = within_rest(&text, &dataset.location.to_string_lossy());
3994 let mut parts = vec![catalog.label.clone(), dataset.name.clone()];
3995 parts.extend(
3996 rest.split('/')
3997 .filter(|p| !p.is_empty())
3998 .map(str::to_string),
3999 );
4000 return parts.join(&format!(" {sep} "));
4001 }
4002 if let Some(source) = self.cloud_source_of(path) {
4003 let mut parts = vec!["cloud".to_string(), source.label.clone()];
4004 let text = path.to_string_lossy();
4005 if let Some((_, account)) = cloud_account(path) {
4006 parts.push(account);
4007 } else if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text)
4008 {
4009 parts.push(account);
4010 parts.push(container);
4011 parts.extend(key.split('/').filter(|p| !p.is_empty()).map(str::to_string));
4012 } else if cloud_source_id(path).is_none() {
4013 if let Some((_, project)) = Self::google_bucket_root(path)
4014 .and_then(|b| self.project_of_bucket(&b))
4015 .as_deref()
4016 .and_then(cloud_account)
4017 {
4018 parts.push(project);
4019 }
4020 let (_, plain) = crate::cloud::source::split_source_id(&text);
4021 if let Some((_, rest)) = plain.split_once("://") {
4022 parts.extend(
4023 rest.split('/')
4024 .filter(|p| !p.is_empty())
4025 .map(str::to_string),
4026 );
4027 }
4028 }
4029 return parts.join(&format!(" {sep} "));
4030 }
4031 display_path(path)
4032 }
4033
4034 pub fn sync_search_section(&mut self) {
4037 let found = (self.sections.iter()).position(|s| s.title == Self::SEARCH_SECTION);
4039 let mut kept = self.take_hits();
4040 self.sections.retain(|s| s.title != Self::SEARCH_SECTION);
4041 self.changed();
4042 if let (Some(hits), Some(at)) = (kept.as_mut(), found)
4043 && at < hits.sections.len()
4044 {
4045 hits.sections.remove(at);
4046 }
4047 *self.rows_cache.rescored.get_mut() = kept;
4048
4049 if self.filter.is_empty() {
4050 return;
4051 }
4052 let cloud_rows: Vec<Entry> = if self.browsing.is_none() {
4054 self.cloud
4055 .iter()
4056 .flat_map(|source| {
4057 source.buckets.iter().map(move |bucket| {
4058 let mut entry = bucket_entry(bucket);
4059 entry.name = format!(
4060 "{} {} {}",
4061 source.label,
4062 crate::glyphs::get().trail,
4063 entry.name
4064 );
4065 entry.cost.source = Some(source.api.name().to_string());
4066 entry
4067 })
4068 })
4069 .filter(|e| match_score(&self.filter, e).is_some())
4070 .collect()
4071 } else {
4072 Vec::new()
4073 };
4074 self.score_search_inline();
4075 let local = self.search.root.is_some()
4076 && (self.search.indexed > 0 || self.search.running || self.search.limited.is_some());
4077 if !local {
4078 if !cloud_rows.is_empty() {
4079 let subtitle =
4080 crate::glyphs::dotted(&format!("cloud · {} names", cloud_rows.len()));
4081 self.sections.push(Section {
4082 subtitle: Some(subtitle),
4083 ..Section::titled(Self::SEARCH_SECTION, cloud_rows)
4084 });
4085 }
4086 return;
4087 }
4088
4089 let matches = self.search.matches.as_ref();
4092 let names: std::collections::HashSet<&std::ffi::OsStr> = (matches.iter())
4096 .flat_map(|m| m.top.iter())
4097 .map(|e| e.path.file_name().unwrap_or(e.path.as_os_str()))
4098 .collect();
4099 let listed: std::collections::HashSet<&PathBuf> = (self.sections.iter())
4100 .flat_map(|s| s.rows.iter().map(|r| &r.path))
4101 .filter(|path| path.file_name().is_none_or(|name| names.contains(name)))
4102 .collect();
4103 let kept: Vec<(&Entry, i32)> = matches
4104 .map(|m| {
4105 let fresh = m.query == self.filter;
4106 m.top
4107 .iter()
4108 .zip(m.scores.iter().copied())
4109 .filter(|(e, _)| !listed.contains(&e.path))
4110 .filter_map(|(e, score)| {
4111 if fresh {
4112 Some((e, score))
4113 } else {
4114 match_score(&self.filter, e).map(|s| (e, s))
4115 }
4116 })
4117 .collect()
4118 })
4119 .unwrap_or_default();
4120 let mut rows: Vec<Entry> = kept.into_iter().map(|(e, _)| e.clone()).collect();
4121 rows.extend(cloud_rows);
4122 for row in &mut rows {
4125 fold_known(row, &self.enriched, &self.stated, &self.stale);
4126 }
4127
4128 let partial = self.search.limited.is_some();
4130 let scored = self.search.scored_for(&self.filter);
4131 if rows.is_empty() && !self.search.running && !partial && scored {
4132 return;
4133 }
4134
4135 let subtitle = self.found_subtitle(rows.is_empty());
4136
4137 self.sections.push(Section {
4138 subtitle: Some(subtitle),
4139 ..Section::titled(Self::SEARCH_SECTION, rows)
4140 });
4141 }
4142
4143 fn found_subtitle(&self, empty: bool) -> String {
4145 let root = self.search.root.clone().unwrap_or_default();
4146 let dot = crate::glyphs::get().middot;
4147 let files = crate::numfmt::group_chrome(self.search.indexed);
4148 let files = if self.search.indexed == 1 {
4149 format!("{files} file")
4150 } else {
4151 format!("{files} files")
4152 };
4153 let subtitle = display_path(&root);
4154 let counted = self
4157 .search
4158 .matches
4159 .as_ref()
4160 .filter(|m| m.query == self.filter && m.ids.len() > m.top.len())
4161 .map(|m| {
4162 format!(
4163 " {dot} {} of {} matches",
4164 crate::numfmt::group_chrome(m.top.len()),
4165 crate::numfmt::group_chrome(m.ids.len())
4166 )
4167 })
4168 .unwrap_or_default();
4169 if self.search.running {
4170 format!(
4171 "{subtitle} {dot} searching {}{counted}",
4172 crate::numfmt::group_chrome(self.search.scanned)
4173 )
4174 } else if !self.search.scored_for(&self.filter) {
4175 format!("{subtitle} {dot} matching {files}")
4176 } else if empty {
4177 match &self.search.limited {
4178 Some(limit) => format!("{subtitle} {dot} no match in {files} {dot} {limit}"),
4179 None => format!("{subtitle} {dot} no match in {files}"),
4180 }
4181 } else {
4182 let searched = crate::numfmt::group_chrome(self.search.scanned);
4183 match &self.search.limited {
4184 Some(limit) => {
4185 format!("{subtitle} {dot} {limit} {dot} {searched} searched{counted}")
4186 }
4187 None => format!("{subtitle} {dot} {searched} searched{counted}"),
4188 }
4189 }
4190 }
4191
4192 pub fn search_batch(&mut self, root: &Path, mut found: Vec<Entry>, scanned: usize) {
4194 if self.search.root.as_deref() != Some(root) {
4196 return;
4197 }
4198 for row in &mut found {
4201 apply_known_facts(row, &self.known, false);
4202 }
4203 self.search.scanned = scanned;
4204 let start = self.search.indexed;
4205 let batch: std::sync::Arc<[Entry]> = found.into();
4206 if !batch.is_empty() {
4207 self.search.indexed += batch.len();
4208 self.search.results.push(batch.clone());
4209 }
4210 let limit = self.search_limit;
4213 let changed = match self.search.matches.as_mut() {
4214 Some(m) if m.query == self.filter && m.upto == start => m.extend(&batch, start, limit),
4215 _ => {
4216 let before = self.search.matches.as_ref().map(|m| m.upto);
4217 self.score_search_inline();
4218 self.search.matches.as_ref().map(|m| m.upto) != before
4219 }
4220 };
4221 let found_listed = self
4223 .sections
4224 .iter()
4225 .position(|s| s.title == Self::SEARCH_SECTION);
4226 match found_listed {
4227 Some(at) if !changed => {
4228 let empty = self.sections[at].rows.is_empty();
4229 self.sections[at].subtitle = Some(self.found_subtitle(empty));
4230 }
4231 _ => self.sync_search_section(),
4232 }
4233 self.settle_return();
4234 }
4235
4236 fn score_search_inline(&mut self) {
4238 if self.filter.is_empty() || self.search.scored_for(&self.filter) {
4239 return;
4240 }
4241 let (base, looks_at) = self.search.base_for(&self.filter);
4242 if looks_at > SCORE_INLINE_MAX {
4243 return;
4244 }
4245 let scored =
4246 crate::home::search::score(&self.search.results, &self.filter, base, self.search_limit);
4247 self.search.matches = Some(scored);
4248 }
4249
4250 pub fn score_job(&mut self) -> Option<ScoreJob> {
4253 if self.filter.is_empty() || self.search.scoring || self.search.scored_for(&self.filter) {
4254 return None;
4255 }
4256 let base = self.search.base_for(&self.filter).0.cloned();
4257 self.search.scoring = true;
4258 Some(ScoreJob {
4259 epoch: self.search.epoch,
4260 results: self.search.results.clone(),
4261 query: self.filter.clone(),
4262 base,
4263 limit: self.search_limit,
4264 })
4265 }
4266
4267 pub fn search_scored(&mut self, epoch: u64, scored: crate::home::search::Matches) {
4269 if epoch != self.search.epoch {
4270 return;
4271 }
4272 self.search.scoring = false;
4273 if self
4275 .search
4276 .matches
4277 .as_ref()
4278 .is_some_and(|m| m.query == scored.query && m.upto >= scored.upto)
4279 {
4280 return;
4281 }
4282 self.search.matches = Some(scored);
4283 self.sync_search_section();
4284 if !matches!(self.selected_row(), Some(Row::Entry { .. })) {
4286 self.select_first_entry();
4287 }
4288 self.settle_return();
4289 }
4290
4291 pub fn search_finished(&mut self, root: &Path, scanned: usize, limited: Option<String>) {
4293 if self.search.root.as_deref() != Some(root) {
4294 return;
4295 }
4296 self.search.running = false;
4297 self.search.done = true;
4298 self.search.scanned = self.search.scanned.max(scanned);
4300 self.search.limited = limited;
4301 self.sync_search_section();
4302 self.settle_return();
4303 }
4304
4305 pub fn visible(&self) -> Vec<Row<'_>> {
4308 let view = self.view();
4309 view.slots.iter().map(|slot| self.row(slot)).collect()
4310 }
4311
4312 pub fn list_lines(&self, spaced: bool) -> ListLines {
4317 let view = self.view();
4318 let rows = view.slots.len();
4319 let headers = (view.headers.iter().copied()).filter(|&h| spaced && h > 0);
4320 let doors = (view.doors.iter().map(|&d| d + 1)).filter(|&r| r < rows);
4321 let mut gaps: Vec<usize> = headers.chain(doors).collect();
4322 gaps.sort_unstable();
4323 gaps.dedup();
4324 ListLines { gaps, rows }
4325 }
4326
4327 pub fn matched(&self) -> usize {
4329 let view = self.view();
4330 (view.headers.iter())
4331 .map(|&i| match &view.slots[i] {
4332 Slot::Plain(Row::Header { matches, .. }) => *matches,
4333 _ => 0,
4334 })
4335 .sum()
4336 }
4337
4338 pub fn position(&self, mut wanted: impl FnMut(&Row<'_>) -> bool) -> Option<usize> {
4341 let view = self.view();
4342 view.slots.iter().position(|slot| wanted(&self.row(slot)))
4343 }
4344
4345 pub fn row_count(&self) -> usize {
4347 self.view().slots.len()
4348 }
4349
4350 pub fn row_at(&self, index: usize) -> Option<Row<'_>> {
4352 self.view().slots.get(index).map(|slot| self.row(slot))
4353 }
4354
4355 pub fn rows_built(&self) -> usize {
4358 self.rows_cache.builds.get()
4359 }
4360
4361 fn changed(&mut self) {
4363 *self.rows_cache.built.get_mut() = None;
4364 *self.rows_cache.rescored.get_mut() = None;
4365 }
4366
4367 fn take_hits(&mut self) -> Option<Hits> {
4370 let built = self.rows_cache.built.get_mut().take();
4371 match self.rows_cache.rescored.get_mut().take() {
4372 Some(hits) => Some(hits),
4373 None => built
4374 .filter(|view| view.key.matches(self))
4375 .map(|view| view.hits),
4376 }
4377 .filter(|hits| hits.filter == self.filter)
4378 }
4379
4380 fn rows_changed(&mut self, touched: &[(usize, usize)]) {
4384 let Some(mut hits) = self.take_hits() else {
4385 return;
4386 };
4387 let needle = Needle::new(&self.filter);
4388 for &(si, i) in touched {
4389 if let Some(Some(section)) = hits.sections.get_mut(si)
4390 && let Some(hit) = section.get_mut(i)
4391 {
4392 *hit = needle.hit(&self.sections[si].rows[i]);
4393 }
4394 }
4395 *self.rows_cache.rescored.get_mut() = Some(hits);
4396 }
4397
4398 pub fn set_catalogs(&mut self, catalogs: Vec<ShownCatalog>) {
4400 self.catalog_places = CatalogPlaces::of(&catalogs);
4401 self.catalogs = catalogs;
4402 self.changed();
4403 }
4404
4405 pub fn set_visits(&mut self, visits: std::collections::HashMap<PathBuf, crate::cache::Visits>) {
4407 self.visits = visits;
4408 self.changed();
4409 }
4410
4411 pub fn show_all(&mut self, section: usize) {
4413 match self.sections.get(section) {
4414 Some(s) if s.grouped_by_place => self.recent_expanded = true,
4415 Some(Section {
4416 root: Some(root), ..
4417 }) => {
4418 self.shown_whole.insert(root.clone());
4419 }
4420 _ => {}
4421 }
4422 }
4423
4424 pub fn cut_again(&mut self, section: usize) -> bool {
4427 let Some(key) = self.selected_key() else {
4428 return false;
4429 };
4430 let Some(s) = self.sections.get(section) else {
4431 return false;
4432 };
4433 let title = s.title.clone();
4434 let whole = if s.grouped_by_place {
4435 None
4436 } else {
4437 match s.root.clone() {
4438 Some(root) => Some(root),
4439 None => return false,
4440 }
4441 };
4442 let was_whole = match &whole {
4443 None => std::mem::replace(&mut self.recent_expanded, false),
4444 Some(root) => self.shown_whole.remove(root),
4445 };
4446 if !was_whole {
4447 return false;
4448 }
4449 if self.listed(&key).is_some() {
4450 match whole {
4451 None => self.recent_expanded = true,
4452 Some(root) => {
4453 self.shown_whole.insert(root);
4454 }
4455 }
4456 return false;
4457 }
4458 self.reselect(Some(RowKey::More(title)));
4459 true
4460 }
4461
4462 pub fn sections_mut(&mut self) -> &mut Vec<Section> {
4464 self.changed();
4465 &mut self.sections
4466 }
4467
4468 fn view(&self) -> std::cell::Ref<'_, View> {
4469 let fresh = self
4470 .rows_cache
4471 .built
4472 .borrow()
4473 .as_ref()
4474 .is_some_and(|view| view.key.matches(self));
4475 if !fresh {
4476 let view = self.build_view();
4477 self.rows_cache.builds.set(self.rows_cache.builds.get() + 1);
4478 *self.rows_cache.built.borrow_mut() = Some(view);
4479 }
4480 std::cell::Ref::map(self.rows_cache.built.borrow(), |view| {
4481 view.as_ref().expect("built above")
4482 })
4483 }
4484
4485 fn row<'a>(&'a self, slot: &Slot) -> Row<'a> {
4486 match slot {
4487 Slot::Plain(row) => row.clone(),
4488 Slot::Entry {
4489 section,
4490 index,
4491 nested,
4492 hit,
4493 } => Row::Entry {
4494 section: *section,
4495 entry: &self.sections[*section].rows[*index],
4496 nested: *nested,
4497 hit: *hit,
4498 },
4499 Slot::Door { section } => Row::Door {
4500 section: *section,
4501 entry: self.sections[*section]
4502 .door
4503 .as_ref()
4504 .expect("the shape says it has a door"),
4505 },
4506 }
4507 }
4508
4509 fn build_view(&self) -> View {
4510 let kept = (self.rows_cache.rescored.take()).filter(|hits| {
4512 hits.filter == self.filter && hits.sections.len() <= self.sections.len()
4513 });
4514 let hits = self.score_rows(kept);
4515 let slots = self.slots(&hits);
4516 View {
4517 key: ViewKey::of(self),
4518 headers: (slots.iter().enumerate())
4519 .filter(|(_, slot)| matches!(slot, Slot::Plain(Row::Header { .. })))
4520 .map(|(i, _)| i)
4521 .collect(),
4522 doors: (slots.iter().enumerate())
4523 .filter(|(_, slot)| matches!(slot, Slot::Door { .. }))
4524 .map(|(i, _)| i)
4525 .collect(),
4526 slots,
4527 hits,
4528 has_dataset: self
4529 .sections
4530 .iter()
4531 .flat_map(|s| s.rows.iter())
4532 .any(|e| e.kind.is_dataset() || e.kind.is_lake_table()),
4533 }
4534 }
4535
4536 fn score_rows(&self, kept: Option<Hits>) -> Hits {
4539 let needle = Needle::new(&self.filter);
4540 let mut kept = kept.map(|hits| hits.sections).unwrap_or_default();
4541 let sections =
4542 (self.sections.iter().enumerate())
4543 .map(|(si, section)| {
4544 let reused = (kept.get_mut(si).and_then(Option::take))
4545 .filter(|hits| hits.len() == section.rows.len());
4546 Some(reused.unwrap_or_else(|| {
4547 (section.rows.iter()).map(|row| needle.hit(row)).collect()
4548 }))
4549 })
4550 .collect();
4551 Hits {
4552 filter: self.filter.clone(),
4553 sections,
4554 }
4555 }
4556
4557 fn slots(&self, hits: &Hits) -> Vec<Slot> {
4558 let mut out: Vec<Slot> = Vec::new();
4559 for (si, section) in self.sections.iter().enumerate() {
4560 let mut matched: Vec<(usize, Hit)> = (section.rows.iter())
4561 .zip(hits.sections[si].iter().flatten())
4562 .enumerate()
4563 .filter(|(_, (row, _))| !(self.hide_unreadable && row.hidden_by_default()))
4564 .filter_map(|(i, (_, hit))| hit.map(|hit| (i, hit)))
4565 .collect();
4566
4567 let keep_empty = section.unavailable || section.waiting || section.origin.is_some();
4571 let has_door = section.door.is_some() && self.filter.is_empty();
4572 let hidden =
4575 if self.browsing.is_some() && self.hide_unreadable && self.filter.is_empty() {
4576 section
4577 .rows
4578 .iter()
4579 .filter(|row| row.hidden_by_default())
4580 .count()
4581 } else {
4582 0
4583 };
4584 let says_why = section.title == Self::SEARCH_SECTION;
4586 if matched.is_empty()
4587 && !has_door
4588 && hidden == 0
4589 && !says_why
4590 && !(keep_empty && self.filter.is_empty())
4591 {
4592 continue;
4593 }
4594
4595 let entry = |i: usize| §ion.rows[i];
4599 if !self.filter.is_empty() {
4600 let now = std::time::SystemTime::now()
4601 .duration_since(std::time::UNIX_EPOCH)
4602 .map(|d| d.as_secs())
4603 .unwrap_or_default();
4604 let lifted = |i: usize, score: i32| {
4605 let frecency = self
4606 .visits
4607 .get(&entry(i).path)
4608 .map_or(0.0, |v| v.frecency(now));
4609 score.saturating_add((frecency.min(10.0) * FRECENCY_LIFT) as i32)
4610 };
4611 matched.sort_by_cached_key(|(i, hit)| {
4612 (
4613 std::cmp::Reverse(lifted(*i, hit.score)),
4614 entry(*i).name.len(),
4615 )
4616 });
4617 }
4618
4619 match self.sort {
4622 SortMode::Natural => {}
4623 SortMode::Size => {
4624 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).size.unwrap_or(0)));
4625 }
4626 SortMode::Rows => {
4627 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).rows.unwrap_or(0)));
4628 }
4629 SortMode::Modified => {
4630 matched.sort_by_key(|(i, _)| {
4631 std::cmp::Reverse(
4632 entry(*i)
4633 .modified
4634 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
4635 .map(|d| d.as_secs())
4636 .unwrap_or(0),
4637 )
4638 });
4639 }
4640 }
4641
4642 let collapsed = self.section_folded(section);
4643 out.push(Slot::Plain(Row::Header {
4644 section: si,
4645 matches: matched.len(),
4647 collapsed,
4648 }));
4649 if collapsed {
4650 continue;
4651 }
4652 if has_door {
4656 out.push(Slot::Door { section: si });
4657 }
4658 let root = section.root.as_deref();
4660 if self.filter.is_empty()
4661 && root
4662 .is_some_and(|root| self.browsing.is_some() || self.parent_of(root).is_some())
4663 {
4664 out.push(Slot::Plain(Row::Up { section: si }));
4665 }
4666 let shown = match root {
4670 Some(_)
4671 if self.browsing.is_none()
4672 && self.filter.is_empty()
4673 && self.view_height > 0
4674 && !root.is_some_and(|root| self.shown_whole.contains(root)) =>
4675 {
4676 (self.view_height * 2 / 5).max(8)
4677 }
4678 _ => usize::MAX,
4679 };
4680 let rest = if matched.len() > shown.saturating_add(1) {
4681 matched.split_off(shown)
4682 } else {
4683 Vec::new()
4684 };
4685 let measuring = rest.iter().any(|(i, _)| self.sort_wants(entry(*i)));
4688 if section.grouped_by_place {
4689 self.slots_by_place(si, section, &matched, &mut out);
4690 } else {
4691 let in_order = self.sort == SortMode::Natural
4693 && self.filter.is_empty()
4694 && section.origin.is_some_and(is_catalog_origin)
4695 && section.root.is_none();
4696 out.extend(matched.into_iter().map(|(index, hit)| Slot::Entry {
4697 section: si,
4698 index,
4699 nested: in_order && self.bookmark(&entry(index).path).is_some(),
4700 hit,
4701 }));
4702 }
4703 if !rest.is_empty() {
4704 out.push(Slot::Plain(Row::More {
4705 section: si,
4706 hidden: rest.len(),
4707 places: 0,
4708 measuring,
4709 }));
4710 }
4711 if hidden > 0 {
4712 out.push(Slot::Plain(Row::Hidden {
4713 section: si,
4714 count: hidden,
4715 }));
4716 }
4717 }
4718 out
4719 }
4720
4721 fn slots_by_place(
4727 &self,
4728 si: usize,
4729 section: &Section,
4730 matched: &[(usize, Hit)],
4731 out: &mut Vec<Slot>,
4732 ) {
4733 let places: Vec<PathBuf> = section.rows.iter().map(|row| place_of(&row.path)).collect();
4734 let mut order: Vec<&PathBuf> = Vec::new();
4735 for place in &places {
4736 if !order.contains(&place) {
4737 order.push(place);
4738 }
4739 }
4740 let groups: Vec<(&PathBuf, Vec<&(usize, Hit)>)> = order
4741 .into_iter()
4742 .filter_map(|place| {
4743 let rows: Vec<&(usize, Hit)> = matched
4744 .iter()
4745 .filter(|(i, _)| places[*i] == *place)
4746 .collect();
4747 (!rows.is_empty()).then_some((place, rows))
4748 })
4749 .collect();
4750
4751 let capped = !self.recent_expanded && self.filter.is_empty() && self.view_height > 0;
4753 let budget = self.view_height / 3;
4754 let mut used = 0usize;
4755 let mut shown = 0usize;
4756 for (place, rows) in &groups {
4757 let cost = 1 + rows.len();
4758 if capped && shown > 0 && used + cost > budget {
4759 break;
4760 }
4761 out.push(Slot::Plain(Row::Place {
4762 section: si,
4763 path: (*place).clone(),
4764 label: section.place_labels.get(*place).cloned(),
4765 source: section.rows[rows[0].0].cost.source.clone(),
4767 held: places.iter().filter(|p| p == place).count(),
4768 }));
4769 out.extend(rows.iter().map(|(index, hit)| Slot::Entry {
4770 section: si,
4771 index: *index,
4772 nested: true,
4773 hit: *hit,
4774 }));
4775 used += cost;
4776 shown += 1;
4777 }
4778 if shown < groups.len() {
4779 out.push(Slot::Plain(Row::More {
4780 section: si,
4781 hidden: groups[shown..].iter().map(|(_, rows)| rows.len()).sum(),
4782 places: groups.len() - shown,
4783 measuring: false,
4784 }));
4785 }
4786 }
4787
4788 pub fn path_candidates(&self) -> Vec<&PathName> {
4791 let Some(listing) = self
4792 .path_listing
4793 .as_ref()
4794 .filter(|l| l.dir == typed_dir(&self.path_input))
4795 else {
4796 return Vec::new();
4797 };
4798 let segment = &self.path_input[listing.dir.len()..];
4799 let mut memo = (listing.matched.0.lock()).unwrap_or_else(|e| e.into_inner());
4800 let at = match memo.as_ref() {
4801 Some((typed, at)) if typed == segment => at.clone(),
4802 _ => {
4803 let at = path_matches(&listing.names, segment);
4804 *memo = Some((segment.to_string(), at.clone()));
4805 at
4806 }
4807 };
4808 drop(memo);
4809 at.iter().map(|&i| &listing.names[i]).collect()
4810 }
4811
4812 pub fn pick_first_path(&mut self) {
4815 self.path_pick = (!self.path_candidates().is_empty()).then_some(0);
4816 }
4817
4818 pub fn picked_path(&self) -> Option<String> {
4820 let pick = self.path_pick?;
4821 let name = *self.path_candidates().get(pick)?;
4822 let dir = typed_dir(&self.path_input);
4823 let mut path = format!("{dir}{}", name.name);
4824 if name.dir {
4825 path.push(separator_in(dir));
4826 }
4827 Some(path)
4828 }
4829
4830 pub fn path_completion(&self) -> Option<String> {
4833 let dir = typed_dir(&self.path_input);
4834 let segment = &self.path_input[dir.len()..];
4835 let candidates = self.path_candidates();
4836 match candidates.as_slice() {
4837 [] => None,
4838 [one] => {
4839 let mut path = format!("{dir}{}", one.name);
4840 if one.dir {
4841 path.push(separator_in(dir));
4842 }
4843 Some(path)
4844 }
4845 many => {
4846 let starting: Vec<&str> = many
4847 .iter()
4848 .map(|n| n.name.as_str())
4849 .filter(|n| n.starts_with(segment))
4850 .collect();
4851 let first = starting.first()?;
4852 let shared = starting
4853 .iter()
4854 .skip(1)
4855 .fold(first.to_string(), |acc, n| common_prefix(&acc, n));
4856 (shared.len() > segment.len()).then(|| format!("{dir}{shared}"))
4857 }
4858 }
4859 }
4860
4861 pub fn known_urls(&self) -> Vec<String> {
4864 let mut urls: Vec<String> = Vec::new();
4865 let mut add = |path: &Path| {
4866 let text = path.to_string_lossy();
4867 if text.contains("://") && !is_cloud_place(path) {
4868 urls.push(text.into_owned());
4869 }
4870 };
4871 for catalog in &self.catalogs {
4872 for dataset in &catalog.datasets {
4873 add(&dataset.location);
4874 }
4875 }
4876 for source in &self.cloud {
4877 for bucket in &source.buckets {
4878 add(bucket);
4879 }
4880 }
4881 for (root, rows) in self.probes.answered() {
4882 add(root);
4883 for row in rows.iter() {
4884 add(&row.path);
4885 }
4886 }
4887 for path in self.known.keys() {
4888 add(path);
4889 }
4890 for section in &self.sections {
4891 for row in §ion.rows {
4892 add(&row.path);
4893 }
4894 }
4895 urls
4896 }
4897
4898 pub fn selected_row(&self) -> Option<Row<'_>> {
4900 if self.resting {
4901 return None;
4902 }
4903 self.row_at(self.selected)
4904 }
4905
4906 pub fn cursor_row(&self) -> Option<Row<'_>> {
4908 self.row_at(self.selected)
4909 }
4910
4911 pub fn selected_entry(&self) -> Option<&Entry> {
4914 match self.selected_row()? {
4915 Row::Entry { entry, .. } | Row::Door { entry, .. } => Some(entry),
4916 _ => None,
4917 }
4918 }
4919
4920 pub fn selection_is_the_door(&self) -> bool {
4922 matches!(self.selected_row(), Some(Row::Door { .. }))
4923 }
4924
4925 pub fn step_past_door(&mut self) -> bool {
4928 let Some(Row::Up { section } | Row::Door { section, .. }) = self.selected_row() else {
4929 return false;
4930 };
4931 let first = (self.selected + 1..self.row_count()).find(
4932 |&i| matches!(self.row_at(i), Some(Row::Entry { section: s, .. }) if s == section),
4933 );
4934 let Some(first) = first else {
4935 return false;
4936 };
4937 self.move_selection((first - self.selected) as isize);
4938 self.follow_selection();
4939 true
4940 }
4941
4942 pub fn recents_in(&self, place: &Path) -> Vec<PathBuf> {
4944 self.sections
4945 .iter()
4946 .filter(|s| s.grouped_by_place)
4947 .flat_map(|s| s.rows.iter())
4948 .filter(|row| place_of(&row.path) == place)
4949 .map(|row| row.path.clone())
4950 .collect()
4951 }
4952
4953 pub fn selected_section(&self) -> Option<usize> {
4955 self.selected_row().map(|r| r.section())
4956 }
4957
4958 pub fn selected_catalog(&self) -> Option<&ShownCatalog> {
4960 if !self.selection_is_header() {
4961 return None;
4962 }
4963 let section = self.sections.get(self.selected_section()?)?;
4964 let origin = section.origin?;
4965 self.catalogs
4966 .iter()
4967 .find(|c| c.label == section.title && c.origin_note() == origin)
4968 }
4969
4970 pub fn selection_is_header(&self) -> bool {
4972 matches!(self.selected_row(), Some(Row::Header { .. }))
4973 }
4974
4975 pub fn pending_probes(&self) -> Vec<PathBuf> {
4978 let check = self.network_check;
4979 let mut out = Vec::new();
4980 for root in self.sections.iter().filter_map(|s| s.remote_root.as_ref()) {
4982 if !self.probes.settled(root) && !out.contains(root) {
4983 out.push(root.clone());
4984 }
4985 }
4986 if let Some(dir) = &self.browsing
4989 && check(dir)
4990 && cloud_source_id(dir).is_none()
4991 && !self.probes.settled(dir)
4992 && !out.contains(dir)
4993 {
4994 out.push(dir.clone());
4995 }
4996 out
4997 }
4998
4999 pub fn below_browse_start(&self) -> bool {
5002 let (Some(dir), Some(start)) = (&self.browsing, &self.browse_start) else {
5003 return false;
5004 };
5005 if dir == start {
5006 return false;
5007 }
5008 let mut current = self.parent_of(dir);
5010 let mut steps = 0;
5011 while let Some(place) = current {
5012 if &place == start {
5013 return true;
5014 }
5015 steps += 1;
5016 if steps > 64 {
5017 break;
5018 }
5019 current = self.parent_of(&place);
5020 }
5021 false
5022 }
5023
5024 pub fn sections_waiting(&self) -> bool {
5026 self.sections.iter().any(|s| s.waiting)
5027 }
5028
5029 pub fn awaiting_listing(&self) -> Option<&Path> {
5031 let dir = self.browsing.as_deref()?;
5032 if cloud_source_id(dir).is_some() {
5033 return self
5034 .cloud_source_of(dir)
5035 .is_some_and(|s| s.status == CloudStatus::Listing && s.buckets.is_empty())
5036 .then_some(dir);
5037 }
5038 ((self.network_check)(dir) && !self.probes.settled(dir)).then_some(dir)
5039 }
5040
5041 pub fn probe_ready(&mut self, root: PathBuf, rows: Vec<Entry>, cut_short: bool) {
5043 self.probes.insert(
5044 root.clone(),
5045 Probe::Listed {
5046 rows: rows.into(),
5047 cut_short,
5048 },
5049 );
5050 self.apply_cloud_kinds(&root);
5051 }
5052
5053 pub fn take_cloud_kinds(&mut self) {
5057 let roots: Vec<PathBuf> = (self.probes.answered())
5058 .map(|(root, _)| root.clone())
5059 .collect();
5060 for root in &roots {
5061 self.apply_cloud_kinds(root);
5062 }
5063 let mut labeled = false;
5064 for section in &mut self.sections {
5065 for row in &mut section.rows {
5066 if row.kind == EntryKind::Directory
5067 && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
5068 {
5069 row.kind = *kind;
5070 if !holds.is_empty() {
5071 row.holds = holds.clone();
5072 }
5073 labeled = true;
5074 }
5075 }
5076 }
5077 if labeled {
5078 self.changed();
5079 }
5080 if let Some(dir) = self.browsing.clone()
5082 && roots.contains(&dir)
5083 {
5084 self.relist_remote(&dir);
5085 }
5086 }
5087
5088 pub fn apply_cloud_kinds(&mut self, root: &Path) {
5090 let Some(rows) = self.probes.listed_mut(root) else {
5091 return;
5092 };
5093 for row in std::sync::Arc::make_mut(rows).iter_mut() {
5095 if row.kind == EntryKind::Directory
5096 && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
5097 {
5098 row.kind = *kind;
5099 if !holds.is_empty() {
5102 row.holds = holds.clone();
5103 }
5104 }
5105 }
5106 }
5107
5108 pub fn cloud_directories_to_peek(&self, limit: usize) -> Vec<PathBuf> {
5112 if limit == 0 {
5113 return Vec::new();
5114 }
5115 let view = self.view();
5116 let mut out: Vec<PathBuf> = Vec::new();
5117 for entry in self.entries_near_cursor(&view, limit) {
5118 if !is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
5121 continue;
5122 }
5123 if !matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown) {
5124 continue;
5125 }
5126 if self.browsing.is_none()
5129 && (self.catalog_dataset(&entry.path).is_some()
5130 || self.bookmark(&entry.path).is_some())
5131 {
5132 continue;
5133 }
5134 if self.cloud_kinds.contains_key(&entry.path)
5136 || self.peeking.contains(&entry.path)
5137 || self.peek_failed.contains(&entry.path)
5138 {
5139 continue;
5140 }
5141 if out.contains(&entry.path) {
5142 continue;
5143 }
5144 out.push(entry.path.clone());
5145 if out.len() >= limit {
5146 break;
5147 }
5148 }
5149 out
5150 }
5151
5152 pub fn probe_failed(&mut self, root: PathBuf, why: Option<String>) {
5154 self.probes.insert(root, Probe::Unreachable(why));
5155 }
5156
5157 pub fn measure_now(&mut self, limit: usize) -> bool {
5161 let wanted = self.unmeasured_visible(limit);
5162 let more = self.unmeasured_visible(limit + 1).len() > wanted.len();
5163 for entry in wanted {
5164 let mut probe = entry.clone();
5165 stat_and_recall(&mut probe, &self.known);
5166 if probe.rows.is_none() && probe.columns.is_empty() {
5167 discover::enrich(&mut probe);
5168 }
5169 self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
5170 }
5171 self.apply_new_measurements();
5172 more
5173 }
5174
5175 pub fn unmeasured_visible(&self, limit: usize) -> Vec<Entry> {
5182 let view = self.view();
5183 let mut out: Vec<Entry> = Vec::new();
5184 for entry in self.entries_on_screen(&view) {
5185 if self.wants_measuring(entry) && !out.iter().any(|e| e.path == entry.path) {
5186 out.push(entry.clone());
5187 if out.len() >= limit {
5188 return out;
5189 }
5190 }
5191 }
5192 if self.sort == SortMode::Rows {
5193 self.sorted_rest(&view, limit, &mut out);
5194 }
5195 out
5196 }
5197
5198 pub fn unstated_for_sort(&self, limit: usize) -> Vec<Entry> {
5202 let view = self.view();
5203 let mut out: Vec<Entry> = Vec::new();
5204 for entry in self.entries_on_screen(&view) {
5205 let stale =
5206 (self.stated.get(&entry.path)).is_some_and(|stated| stated.epoch < self.stat_epoch);
5207 if stale && self.needs_stat(entry) && !out.iter().any(|e| e.path == entry.path) {
5208 out.push(entry.clone());
5209 if out.len() >= limit {
5210 break;
5211 }
5212 }
5213 }
5214 if out.len() < limit && matches!(self.sort, SortMode::Size | SortMode::Modified) {
5215 self.sorted_rest(&view, limit, &mut out);
5216 }
5217 for entry in &mut out {
5218 entry.modified = None;
5219 }
5220 out
5221 }
5222
5223 fn sorted_rest(&self, view: &View, limit: usize, out: &mut Vec<Entry>) {
5225 let mut take = |entry: &Entry| {
5226 if self.sort_wants(entry) && !out.iter().any(|e| e.path == entry.path) {
5227 out.push(entry.clone());
5228 }
5229 out.len() >= limit
5230 };
5231 for entry in view.slots.iter().filter_map(|slot| self.entry_of(slot)) {
5232 if take(entry) {
5233 return;
5234 }
5235 }
5236 for slot in &view.slots {
5237 let Slot::Plain(Row::More {
5238 section,
5239 measuring: true,
5240 ..
5241 }) = slot
5242 else {
5243 continue;
5244 };
5245 for entry in &self.sections[*section].rows {
5246 if take(entry) {
5247 return;
5248 }
5249 }
5250 }
5251 }
5252
5253 fn sort_wants(&self, entry: &Entry) -> bool {
5256 match self.sort {
5257 SortMode::Natural => false,
5258 SortMode::Rows => self.wants_measuring(entry),
5259 SortMode::Size | SortMode::Modified => {
5260 (self.sort == SortMode::Modified || entry.kind != EntryKind::Unknown)
5261 && self.needs_stat(entry)
5262 }
5263 }
5264 }
5265
5266 fn needs_stat(&self, entry: &Entry) -> bool {
5269 let due = match self.stated.get(&entry.path) {
5270 Some(stated) => stated.epoch < self.stat_epoch,
5271 None => entry.modified.is_none(),
5272 };
5273 due && discover::on_disk(entry) && !(self.network_check)(&entry.path)
5274 }
5275
5276 fn wants_measuring(&self, entry: &Entry) -> bool {
5279 if entry.measured || self.enriched.contains_key(&entry.path) {
5280 return false;
5281 }
5282 if entry.kind != EntryKind::Unknown
5284 && discover::unstated(entry)
5285 && !self.stated.contains_key(&entry.path)
5286 && !(self.network_check)(&entry.path)
5287 {
5288 return true;
5289 }
5290 if entry.rows.is_some() {
5291 return false;
5292 }
5293 if matches!(
5296 entry.kind,
5297 EntryKind::Directory | EntryKind::Unknown | EntryKind::Other
5298 ) || entry.kind.is_lake_table()
5299 {
5300 return false;
5301 }
5302 !(self.network_check)(&entry.path)
5305 }
5306
5307 pub fn classify_now(&mut self, limit: usize) -> bool {
5310 let wanted = self.unclassified_visible(limit);
5311 let more = self.unclassified_visible(limit + 1).len() > wanted.len();
5312 for entry in wanted {
5313 let mut stated = entry.clone();
5314 stat_and_recall(&mut stated, &self.known);
5315 let probe = look_into_as(&stated, &Default::default());
5316 self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
5317 }
5318 self.apply_new_measurements();
5319 more
5320 }
5321
5322 pub fn unclassified_visible(&self, limit: usize) -> Vec<Entry> {
5328 self.unclassified_visible_where(limit, |_| true)
5329 }
5330
5331 pub fn unclassified_visible_where(
5333 &self,
5334 limit: usize,
5335 keep: impl Fn(&Entry) -> bool,
5336 ) -> Vec<Entry> {
5337 if limit == 0 {
5338 return Vec::new();
5339 }
5340 let view = self.view();
5341 let mut out: Vec<Entry> = Vec::new();
5342 for entry in self.entries_near_cursor(&view, limit) {
5343 if entry.kind != EntryKind::Unknown
5344 || self.missing.contains(&entry.path)
5345 || !keep(entry)
5346 {
5347 continue;
5348 }
5349 if self.enriched.contains_key(&entry.path) {
5351 continue;
5352 }
5353 if is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
5356 continue;
5357 }
5358 if out.iter().any(|e| e.path == entry.path) {
5360 continue;
5361 }
5362 out.push(entry.clone());
5363 if out.len() >= limit {
5364 break;
5365 }
5366 }
5367 out
5368 }
5369
5370 fn entries_on_screen<'a>(&'a self, view: &'a View) -> impl Iterator<Item = &'a Entry> + 'a {
5373 let height = if self.view_height == 0 {
5376 usize::MAX / 4
5377 } else {
5378 self.view_height
5379 };
5380 let rows = view.slots.len();
5381 let top = self.scroll.min(rows);
5382 let reach = height / 2;
5383 let bottom = top.saturating_add(height + reach).min(rows);
5384 let above = top.saturating_sub(reach);
5385 std::iter::once(self.selected)
5386 .chain(top..bottom)
5387 .chain(above..top)
5388 .filter_map(|i| self.entry_of(view.slots.get(i)?))
5389 }
5390
5391 fn entries_near_cursor<'a>(
5394 &'a self,
5395 view: &'a View,
5396 limit: usize,
5397 ) -> impl Iterator<Item = &'a Entry> + 'a {
5398 let height = if self.view_height == 0 {
5401 limit
5402 } else {
5403 self.view_height
5404 };
5405 let rows = view.slots.len();
5406 let top = self.scroll.min(rows);
5407 let ahead = top.saturating_add(2 * height).min(rows);
5408 let behind = top.saturating_sub(height);
5409 std::iter::once(self.selected)
5410 .chain(top..ahead)
5411 .chain(behind..top)
5412 .filter_map(|i| self.entry_of(view.slots.get(i)?))
5413 }
5414
5415 fn entry_of(&self, slot: &Slot) -> Option<&Entry> {
5417 match slot {
5418 Slot::Entry { section, index, .. } => Some(&self.sections[*section].rows[*index]),
5419 _ => None,
5420 }
5421 }
5422
5423 pub fn record_measurement(&mut self, path: PathBuf, measured: Measured) {
5426 self.unapplied.insert(path.clone());
5427 let before = self.stated.insert(
5428 path.clone(),
5429 Stated {
5430 size: measured.size,
5431 modified: measured.modified,
5432 epoch: self.stat_epoch,
5433 },
5434 );
5435 if !measured.stat_only {
5436 self.stale.remove(&path);
5437 self.enriched.insert(path, measured);
5438 return;
5439 }
5440 let changed = before.is_some_and(|before| {
5443 before.modified.is_some()
5444 && (before.size, before.modified) != (measured.size, measured.modified)
5445 });
5446 if changed && self.enriched.remove(&path).is_some() {
5447 self.stale.insert(path);
5448 }
5449 }
5450
5451 pub fn record_size(&mut self, path: PathBuf, measured: Measured) {
5453 match self.enriched.get_mut(&path) {
5454 Some(known) => {
5455 known.size = measured.size;
5456 self.unapplied.insert(path);
5457 }
5458 None => self.record_measurement(path, measured),
5459 }
5460 }
5461
5462 pub fn apply_measurements(&mut self) {
5465 self.unapplied.clear();
5466 for section in &mut self.sections {
5467 for row in section.rows.iter_mut().chain(section.door.iter_mut()) {
5470 fold_known(row, &self.enriched, &self.stated, &self.stale);
5471 }
5472 if let Some(door) = section.door.as_mut() {
5475 door.name = door_name(door, §ion.rows);
5476 }
5477 }
5478 self.changed();
5479 self.land_again();
5480 }
5481
5482 pub fn apply_new_measurements(&mut self) {
5486 if self.unapplied.is_empty() {
5487 return;
5488 }
5489 let unapplied = std::mem::take(&mut self.unapplied);
5490 let names: std::collections::HashSet<&std::ffi::OsStr> = (unapplied.iter())
5494 .map(|path| path.file_name().unwrap_or(path.as_os_str()))
5495 .collect();
5496 let new = |path: &Path| {
5497 path.file_name().is_none_or(|name| names.contains(name)) && unapplied.contains(path)
5498 };
5499 let mut touched: Vec<(usize, usize)> = Vec::new();
5500 let mut any = false;
5501 let mut moved = false;
5502 let sort = self.sort;
5503 for (si, section) in self.sections.iter_mut().enumerate() {
5504 let mut here = false;
5505 for (i, row) in section.rows.iter_mut().enumerate() {
5506 if !new(&row.path) {
5507 continue;
5508 }
5509 let before = Standing::of(row, sort);
5510 if fold_known(row, &self.enriched, &self.stated, &self.stale) {
5511 moved |= Standing::of(row, sort) != before;
5512 touched.push((si, i));
5513 here = true;
5514 }
5515 }
5516 if let Some(door) = section.door.as_mut()
5517 && new(&door.path)
5518 {
5519 here |= fold_known(door, &self.enriched, &self.stated, &self.stale);
5520 }
5521 if here && let Some(door) = section.door.as_mut() {
5522 door.name = door_name(door, §ion.rows);
5523 }
5524 any |= here;
5525 }
5526 if !any {
5527 return;
5528 }
5529 if moved || sort != SortMode::Natural || !self.hits_hold(&touched) {
5534 self.rows_changed(&touched);
5535 }
5536 self.land_again();
5537 }
5538
5539 fn hits_hold(&self, touched: &[(usize, usize)]) -> bool {
5543 let built = self.rows_cache.built.borrow();
5544 let Some(view) = built.as_ref().filter(|view| view.key.matches(self)) else {
5545 return false;
5546 };
5547 let needle = Needle::new(&self.filter);
5548 touched.iter().all(|&(si, i)| {
5549 let was = view.hits.sections.get(si).and_then(|s| s.as_ref()?.get(i));
5550 was == Some(&needle.hit(&self.sections[si].rows[i]))
5551 })
5552 }
5553
5554 fn land_again(&mut self) {
5557 if self.landing
5558 && let Some(Row::Door { entry, .. }) = self.row_at(self.selected)
5559 && !door_lands(entry)
5560 {
5561 self.selected = self.landing_row();
5562 self.follow_selection();
5563 }
5564 }
5565
5566 pub fn select_first_entry(&mut self) {
5572 self.returning = None;
5573 self.landing = true;
5574 if !self.filter.is_empty() {
5576 self.resting = false;
5577 }
5578 self.selected = self.landing_row();
5579 }
5580
5581 fn landing_row(&self) -> usize {
5583 if self.filter.is_empty()
5585 && let Some(newest) = self.newest_recent.as_ref()
5586 && let Some(at) = self.position(|r| {
5587 matches!(r, Row::Entry { section, entry, .. }
5588 if entry.path == *newest
5589 && self.sections[*section].title == Self::RECENT_SECTION)
5590 })
5591 {
5592 return at;
5593 }
5594 let first = self.position(|r| matches!(r, Row::Entry { .. } | Row::Door { .. }));
5595 let first = match first.and_then(|i| self.row_at(i)) {
5596 Some(Row::Door { entry, .. }) if !door_lands(entry) => self
5597 .position(|r| matches!(r, Row::Entry { .. } | Row::Hidden { .. }))
5598 .or(first),
5599 _ => first,
5600 };
5601 first
5603 .or_else(|| self.position(|r| matches!(r, Row::Hidden { .. })))
5604 .unwrap_or(0)
5605 }
5606
5607 pub fn clamp_selection(&mut self) {
5608 let n = self.row_count();
5609 if n == 0 {
5610 self.selected = 0;
5611 } else if self.selected >= n {
5612 self.selected = n - 1;
5613 }
5614 }
5615
5616 pub fn select(&mut self, index: usize) {
5618 if index < self.row_count() {
5619 self.resting = false;
5620 self.returning = None;
5621 self.landing = false;
5622 self.selected = index;
5623 }
5624 }
5625
5626 pub fn move_selection(&mut self, delta: isize) {
5627 self.resting = false;
5628 self.returning = None;
5629 self.landing = false;
5630 let n = self.row_count();
5631 if n == 0 {
5632 return;
5633 }
5634 let cur = self.selected as isize;
5635 let next = (cur + delta).rem_euclid(n as isize);
5636 self.selected = next as usize;
5637 }
5638
5639 pub fn page_selection(&mut self, delta: isize) {
5642 self.resting = false;
5643 self.returning = None;
5644 self.landing = false;
5645 let n = self.row_count();
5646 if n == 0 {
5647 return;
5648 }
5649 let next = (self.selected as isize)
5651 .saturating_add(delta)
5652 .clamp(0, n as isize - 1);
5653 self.selected = next as usize;
5654 }
5655}
5656
5657fn source_entry(source: &CloudSource) -> Entry {
5659 Entry {
5660 path: cloud_place(&source.id),
5661 kind: EntryKind::Directory,
5662 name: source.label.clone(),
5663 size: None,
5664 modified: source.listed_at,
5665 rows: None,
5666 cols: None,
5667 cols_sampled: false,
5668 columns: Vec::new(),
5669 cost: Default::default(),
5670 holds: Default::default(),
5671 opens_whole_directory: false,
5672 measured: false,
5673 format_spec: None,
5674 table: None,
5675 }
5676}
5677
5678fn bucket_entry(url: &Path) -> Entry {
5680 let mut entry = Entry::directory(url);
5681 let text = url.to_string_lossy();
5683 let (_, plain) = crate::cloud::source::split_source_id(&text);
5684 entry.name = plain
5685 .rsplit('/')
5686 .find(|part| !part.is_empty())
5687 .unwrap_or("")
5688 .to_string();
5689 entry
5690}
5691
5692pub fn names_a_file(path: &Path) -> bool {
5695 let named = path.to_string_lossy();
5696 let dotted = !named.ends_with('/')
5699 && path
5700 .file_name()
5701 .map(|last| last.to_string_lossy())
5702 .is_some_and(|last| last.trim_start_matches('.').contains('.'));
5703 discover::is_data_file(path) || dotted
5704}
5705
5706fn entry_for_path(path: &Path, remote: bool) -> Entry {
5708 if !remote && let Some(table) = discover::table_row(path) {
5710 return table;
5711 }
5712 let mut holds = discover::Holds::default();
5713 let kind = if remote {
5716 if names_a_file(path) {
5721 EntryKind::File
5722 } else {
5723 EntryKind::Unknown
5724 }
5725 } else if path.is_dir() {
5726 let (kind, found) = discover::look_at_directory(path);
5727 holds = found;
5728 kind
5729 } else {
5730 EntryKind::File
5731 };
5732 let mut entry = Entry {
5733 path: path.to_path_buf(),
5734 kind,
5735 name: path
5736 .file_name()
5737 .map(|n| n.to_string_lossy().into_owned())
5738 .unwrap_or_else(|| path.to_string_lossy().into_owned()),
5739 size: None,
5740 modified: None,
5741 rows: None,
5742 cols: None,
5743 cols_sampled: false,
5744 columns: Vec::new(),
5745 cost: Default::default(),
5746 holds,
5747 opens_whole_directory: false,
5748 measured: false,
5749 format_spec: None,
5750 table: None,
5751 };
5752 if !remote && let Ok(meta) = std::fs::metadata(path) {
5753 if meta.is_file() {
5754 entry.size = Some(meta.len());
5755 }
5756 entry.modified = meta.modified().ok();
5757 }
5758 entry
5759}
5760
5761pub fn display_path(path: &Path) -> String {
5763 if let Some(home) = dirs::home_dir()
5764 && let Ok(rest) = path.strip_prefix(&home)
5765 {
5766 if rest.as_os_str().is_empty() {
5767 return "~".to_string();
5768 }
5769 return format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display());
5771 }
5772 path.display().to_string()
5773}
5774
5775pub fn complete_path(typed: &str) -> (String, usize) {
5779 let expanded = expand_user_path(typed);
5780 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5782 let typed_ends_in_sep = typed.ends_with(is_separator);
5783
5784 let (dir, prefix) = if typed_ends_in_sep {
5785 (expanded.clone(), String::new())
5786 } else {
5787 match (expanded.parent(), expanded.file_name()) {
5788 (Some(parent), Some(name)) => {
5789 (parent.to_path_buf(), name.to_string_lossy().into_owned())
5790 }
5791 _ => (expanded.clone(), String::new()),
5792 }
5793 };
5794
5795 let Ok(entries) = std::fs::read_dir(&dir) else {
5796 return (typed.to_string(), 0);
5797 };
5798
5799 let mut names: Vec<String> = entries
5800 .flatten()
5801 .filter_map(|e| {
5802 let name = e.file_name().to_string_lossy().into_owned();
5803 if name.starts_with('.') && !prefix.starts_with('.') {
5805 return None;
5806 }
5807 name.starts_with(&prefix).then_some(name)
5808 })
5809 .collect();
5810 if names.is_empty() {
5811 return (typed.to_string(), 0);
5812 }
5813 names.sort();
5814
5815 let shared = names
5817 .iter()
5818 .skip(1)
5819 .fold(names[0].clone(), |acc, name| common_prefix(&acc, name));
5820
5821 let mut completed = typed.to_string();
5822 completed.truncate(typed.len() - prefix.len());
5823 completed.push_str(&shared);
5824
5825 if names.len() == 1 && dir.join(&shared).is_dir() && !completed.ends_with(is_separator) {
5828 let separator = typed
5829 .chars()
5830 .rev()
5831 .find(|c| is_separator(*c))
5832 .unwrap_or(std::path::MAIN_SEPARATOR);
5833 completed.push(separator);
5834 }
5835 (completed, names.len())
5836}
5837
5838#[derive(Debug, Clone, PartialEq, Eq)]
5840pub struct PathName {
5841 pub name: String,
5842 pub dir: bool,
5844}
5845
5846#[derive(Debug, Clone, Default, PartialEq, Eq)]
5848pub struct PathListing {
5849 pub dir: String,
5851 pub names: Vec<PathName>,
5852 pub failed: bool,
5854 pub matched: SegmentMatches,
5856}
5857
5858#[derive(Debug, Default)]
5862pub struct SegmentMatches(std::sync::Mutex<Option<(String, std::sync::Arc<[usize]>)>>);
5863
5864impl Clone for SegmentMatches {
5865 fn clone(&self) -> Self {
5866 Self::default()
5867 }
5868}
5869
5870impl PartialEq for SegmentMatches {
5871 fn eq(&self, _: &Self) -> bool {
5872 true
5873 }
5874}
5875
5876impl Eq for SegmentMatches {}
5877
5878fn path_matches(names: &[PathName], segment: &str) -> std::sync::Arc<[usize]> {
5881 let mut matched: Vec<(usize, i32)> = (names.iter().enumerate())
5882 .filter(|(_, n)| !n.name.starts_with('.') || segment.starts_with('.'))
5883 .filter_map(|(i, n)| {
5884 if segment.is_empty() {
5885 return Some((i, 0));
5886 }
5887 let prefix = n.name.starts_with(segment) as i32 * 1_000_000;
5888 fuzzy_score(segment, &n.name).map(|score| (i, prefix + score))
5889 })
5890 .collect();
5891 matched
5892 .sort_by(|(a, sa), (b, sb)| sb.cmp(sa).then_with(|| names[*a].name.cmp(&names[*b].name)));
5893 matched.into_iter().map(|(i, _)| i).collect()
5894}
5895
5896const PATH_LISTING_MAX: usize = 5_000;
5898
5899pub fn typed_dir(typed: &str) -> &str {
5902 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5903 let floor = typed.find("://").map_or(0, |at| at + 3);
5904 match typed[floor..].rfind(is_separator) {
5905 Some(at) => &typed[..floor + at + 1],
5906 None => &typed[..floor],
5907 }
5908}
5909
5910fn separator_in(dir: &str) -> char {
5913 if typed_dir_is_url(dir) {
5914 return '/';
5915 }
5916 dir.chars()
5917 .rev()
5918 .find(|c| *c == '/' || (cfg!(windows) && *c == '\\'))
5919 .unwrap_or(std::path::MAIN_SEPARATOR)
5920}
5921
5922pub fn typed_dir_is_url(dir: &str) -> bool {
5924 dir.contains("://")
5925}
5926
5927pub fn list_typed_dir(dir: &str) -> PathListing {
5930 let path = if dir.is_empty() {
5931 PathBuf::from(".")
5932 } else {
5933 expand_user_path(dir)
5934 };
5935 let Ok(entries) = std::fs::read_dir(&path) else {
5936 return PathListing {
5937 dir: dir.to_string(),
5938 names: Vec::new(),
5939 failed: true,
5940 matched: Default::default(),
5941 };
5942 };
5943 let mut names: Vec<PathName> = entries
5944 .flatten()
5945 .take(PATH_LISTING_MAX)
5946 .map(|e| {
5947 let name = e.file_name().to_string_lossy().into_owned();
5948 let dir = e.file_type().is_ok_and(|t| t.is_dir())
5950 || (e.file_type().is_ok_and(|t| t.is_symlink()) && e.path().is_dir());
5951 PathName { name, dir }
5952 })
5953 .collect();
5954 names.sort_by(|a, b| a.name.cmp(&b.name));
5955 PathListing {
5956 dir: dir.to_string(),
5957 names,
5958 failed: false,
5959 matched: Default::default(),
5960 }
5961}
5962
5963pub fn names_under(dir: &str, urls: impl IntoIterator<Item = String>) -> PathListing {
5966 let mut names: Vec<PathName> = Vec::new();
5967 for url in urls {
5968 let forms = match crate::cloud::source::azure_parts(&url) {
5970 Some((_, container, key)) => vec![url.clone(), format!("az://{container}/{key}")],
5971 None => vec![url],
5972 };
5973 for form in forms {
5974 let Some(rest) = form.strip_prefix(dir) else {
5975 continue;
5976 };
5977 let (name, more) = match rest.split_once('/') {
5978 Some((name, more)) => (name, Some(more)),
5979 None => (rest, None),
5980 };
5981 if name.is_empty() {
5982 continue;
5983 }
5984 let is_dir = more.is_some() || !names_a_file(Path::new(&form));
5987 match names.iter_mut().find(|n| n.name == name) {
5988 Some(known) => known.dir |= is_dir,
5989 None => names.push(PathName {
5990 name: name.to_string(),
5991 dir: is_dir,
5992 }),
5993 }
5994 }
5995 }
5996 names.sort_by(|a, b| a.name.cmp(&b.name));
5997 PathListing {
5998 dir: dir.to_string(),
5999 names,
6000 failed: false,
6001 matched: Default::default(),
6002 }
6003}
6004
6005fn common_prefix(a: &str, b: &str) -> String {
6006 a.chars()
6007 .zip(b.chars())
6008 .take_while(|(x, y)| x == y)
6009 .map(|(x, _)| x)
6010 .collect()
6011}
6012
6013pub fn expand_user_path(raw: &str) -> PathBuf {
6015 crate::config::expand_config_path(raw)
6016}
6017
6018#[cfg(test)]
6019mod holds_flow_tests {
6020 use super::*;
6021
6022 #[test]
6025 fn a_rows_label_is_one_decision_for_the_list_and_the_pane() {
6026 let mut directory = Entry::for_test(Path::new("s3://bucket/warehouse"), "warehouse");
6027 directory.kind = EntryKind::Directory;
6028 let said = |look, place_kind| describe(&directory, place_kind, look, 0, None);
6029 let g = crate::glyphs::get();
6030
6031 let waiting = said(Some(CloudLook::Waiting), None);
6032 assert_eq!(
6033 (waiting.short.as_str(), waiting.words.as_str()),
6034 (g.ellipsis, "")
6035 );
6036 assert_eq!(said(Some(CloudLook::Looking), None).words, "");
6037 assert!(
6038 said(Some(CloudLook::Failed), None)
6039 .words
6040 .contains("listing failed")
6041 );
6042 assert_eq!(said(None, None).words, "directory");
6043 let curated = said(None, Some("dataset"));
6044 assert_eq!(
6045 (curated.short.as_str(), curated.words.as_str()),
6046 ("dataset", "dataset")
6047 );
6048 assert!(curated.curated);
6049
6050 directory.holds = crate::home::discover::Holds {
6051 formats: vec![("parquet".to_string(), 12)],
6052 ..Default::default()
6053 };
6054 let counted = describe(&directory, None, None, 0, None);
6055 assert_eq!(counted.short, "12 parquet");
6056 assert_eq!(
6057 counted.words, "directory",
6058 "the count is the pane's `contains` line"
6059 );
6060 let curated = describe(&directory, Some("dataset"), None, 0, None);
6061 assert_eq!(
6062 curated.short, "dataset",
6063 "the curated word wins over the count"
6064 );
6065
6066 directory.opens_whole_directory = true;
6067 assert_eq!(
6068 describe(&directory, Some("dataset"), None, 0, None),
6069 RowLabel::default()
6070 );
6071 }
6072
6073 #[cfg(windows)]
6076 #[test]
6077 fn a_windows_home_path_is_shown_and_typed_with_backslashes() {
6078 let home = dirs::home_dir().unwrap();
6079 let path = home.join("data").join("a.csv");
6080 let shown = display_path(&path);
6081 assert_eq!(shown, r"~\data\a.csv");
6082 assert_eq!(expand_user_path(&shown), path);
6083 }
6084
6085 #[cfg(windows)]
6088 #[test]
6089 fn a_dot_above_a_windows_recent_does_not_make_it_a_file() {
6090 let path = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\orders");
6091 assert_eq!(entry_for_path(path, true).kind, EntryKind::Unknown);
6092 let file = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\a.parquet");
6093 assert_eq!(entry_for_path(file, true).kind, EntryKind::File);
6094 }
6095
6096 fn counted(n: usize) -> crate::home::discover::Holds {
6097 crate::home::discover::Holds {
6098 formats: vec![("parquet".to_string(), n)],
6099 ..Default::default()
6100 }
6101 }
6102
6103 fn in_flight() -> (EntryKind, crate::home::discover::Holds) {
6106 (
6107 EntryKind::Directory,
6108 crate::home::discover::Holds::default(),
6109 )
6110 }
6111
6112 #[test]
6113 fn a_claim_staked_before_a_peek_lands_keeps_the_count_a_row_already_has() {
6114 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6115 let path = root.join("orders");
6116 let mut row = Entry::for_test(&path, "orders");
6117 row.kind = EntryKind::Directory;
6118 row.holds = counted(15);
6121
6122 let mut home = HomeState::default();
6123 home.probe_ready(root.clone(), vec![row], false);
6124 home.cloud_kinds.insert(path, in_flight());
6125 home.apply_cloud_kinds(&root);
6126
6127 assert_eq!(
6128 home.probes.listed(&root).unwrap()[0].holds.label(),
6129 "15 parquet",
6130 "the placeholder erased a count the row already had"
6131 );
6132 }
6133
6134 #[test]
6135 fn a_cloud_directory_waits_then_looks_then_answers() {
6136 let path = std::path::PathBuf::from("gs://pitscope/seasons");
6137 let mut row = Entry::for_test(&path, "seasons");
6138 row.kind = EntryKind::Directory;
6139 let mut home = HomeState::default();
6140
6141 assert_eq!(
6142 home.cloud_look(&row),
6143 Some(CloudLook::Waiting),
6144 "not asked yet"
6145 );
6146 home.peeking.insert(path.clone());
6147 assert_eq!(
6148 home.cloud_look(&row),
6149 Some(CloudLook::Looking),
6150 "being looked into"
6151 );
6152 home.peeking.remove(&path);
6153
6154 home.cloud_kinds
6157 .insert(path.clone(), (EntryKind::Directory, counted(12)));
6158 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
6159 home.cloud_kinds
6160 .insert(path.clone(), (EntryKind::Hive, Default::default()));
6161 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
6162
6163 home.cloud_kinds.insert(path.clone(), in_flight());
6165 assert_eq!(home.cloud_look(&row), None, "answered");
6166
6167 let mut home = HomeState {
6170 network_check: |_| true,
6171 ..Default::default()
6172 };
6173 let root = std::path::PathBuf::from("gs://pitscope");
6174 home.probe_ready(root.clone(), vec![row.clone()], false);
6175 home.browsing = Some(root);
6176 home.rebuild(&[]);
6177 assert_eq!(
6178 home.cloud_directories_to_peek(4),
6179 std::slice::from_ref(&path)
6180 );
6181 home.peek_failed.insert(path.clone());
6182 assert_eq!(home.cloud_look(&row), Some(CloudLook::Failed));
6183 assert!(home.cloud_directories_to_peek(4).is_empty());
6184
6185 let mut counted_row = Entry::for_test(&path.join("x"), "x");
6188 counted_row.kind = EntryKind::Directory;
6189 counted_row.holds = counted(3);
6190 assert_eq!(home.cloud_look(&counted_row), None);
6191 let mut bucket = Entry::for_test(std::path::Path::new("gs://pitscope"), "pitscope");
6192 bucket.kind = EntryKind::Directory;
6193 assert_eq!(home.cloud_look(&bucket), None);
6194 let mut local = Entry::for_test(std::path::Path::new("/data/seasons"), "seasons");
6195 local.kind = EntryKind::Directory;
6196 assert_eq!(home.cloud_look(&local), None);
6197 }
6198
6199 #[test]
6200 fn a_peeks_answer_replaces_the_count_a_row_had() {
6201 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6202 let path = root.join("orders");
6203 let mut row = Entry::for_test(&path, "orders");
6204 row.kind = EntryKind::Directory;
6205 row.holds = counted(15);
6206
6207 let mut home = HomeState::default();
6208 home.probe_ready(root.clone(), vec![row], false);
6209 home.cloud_kinds
6210 .insert(path, (EntryKind::MultiFile, counted(40)));
6211 home.apply_cloud_kinds(&root);
6212
6213 assert_eq!(
6214 home.probes.listed(&root).unwrap()[0].holds.label(),
6215 "40 parquet"
6216 );
6217 assert_eq!(
6218 home.probes.listed(&root).unwrap()[0].kind,
6219 EntryKind::MultiFile
6220 );
6221 }
6222
6223 #[test]
6224 fn a_peek_answers_only_the_rows_that_asked() {
6225 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6226 let settled = root.join("sales");
6230 let mut row = Entry::for_test(&settled, "sales");
6231 row.kind = EntryKind::Hive;
6232 row.holds = counted(40);
6233
6234 let mut home = HomeState::default();
6235 home.probe_ready(root.clone(), vec![row], false);
6236 home.cloud_kinds
6237 .insert(settled, (EntryKind::Directory, counted(1)));
6238 home.apply_cloud_kinds(&root);
6239
6240 assert_eq!(home.probes.listed(&root).unwrap()[0].kind, EntryKind::Hive);
6241 assert_eq!(
6242 home.probes.listed(&root).unwrap()[0].holds.label(),
6243 "40 parquet"
6244 );
6245 }
6246
6247 #[test]
6254 fn a_peek_goes_to_the_row_the_cursor_is_on_and_is_never_asked_twice() {
6255 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6256 let mut home = HomeState {
6257 network_check: |_| true,
6258 ..Default::default()
6259 };
6260 let rows: Vec<Entry> = ["a", "b", "c", "d", "e"]
6261 .iter()
6262 .map(|n| {
6263 let mut row = Entry::for_test(&root.join(n), n);
6264 row.kind = EntryKind::Directory;
6265 row
6266 })
6267 .collect();
6268 home.probe_ready(root.clone(), rows, false);
6269 home.browsing = Some(root.clone());
6270 home.view_height = 10;
6271 home.rebuild(&[]);
6272 home.cloud_kinds
6274 .insert(root.join("b"), (EntryKind::MultiFile, counted(3)));
6275 home.peeking.insert(root.join("c"));
6276
6277 home.selected = home
6280 .visible()
6281 .iter()
6282 .position(|r| matches!(r, Row::Entry { entry, .. } if entry.name == "e"))
6283 .expect("the row is listed");
6284 let asked = home.cloud_directories_to_peek(3);
6285 assert_eq!(
6286 asked.first(),
6287 Some(&root.join("e")),
6288 "the highlighted row is the one about to be acted on"
6289 );
6290 assert_eq!(asked.len(), 3, "the budget is a budget");
6291 assert!(
6292 !asked.contains(&root.join("b")),
6293 "a directory already looked into is not asked again"
6294 );
6295 assert!(
6296 !asked.contains(&root.join("c")),
6297 "nor one with a request already out"
6298 );
6299 }
6300
6301 #[test]
6302 fn a_measurement_that_counted_nothing_keeps_the_count_a_row_already_has() {
6303 let path = std::path::PathBuf::from("/data/warehouse/orders");
6304 let mut row = Entry::for_test(&path, "orders");
6305 row.kind = EntryKind::Directory;
6306 row.holds = counted(15);
6307
6308 let mut home = HomeState::default();
6309 home.sections.push(Section::titled("Here", vec![row]));
6310 home.enriched.insert(
6312 path,
6313 Measured {
6314 kind: Some(EntryKind::Directory),
6315 ..Default::default()
6316 },
6317 );
6318 home.apply_measurements();
6319
6320 assert_eq!(
6321 home.sections[0].rows[0].holds.label(),
6322 "15 parquet",
6323 "a measurement with nothing to say erased the label"
6324 );
6325 }
6326}
6327
6328#[cfg(test)]
6329mod look_into_batch_tests {
6330 use super::*;
6331 use polars::prelude::*;
6332
6333 #[test]
6336 fn every_kind_is_sent_before_any_count() {
6337 let dir = tempfile::tempdir().unwrap();
6338 let cache_dir = tempfile::tempdir().unwrap();
6339 let cache = crate::cache::CacheManager::with_dir(cache_dir.path().to_path_buf());
6340 let mut rows = Vec::new();
6341 for name in ["a", "b"] {
6342 let partition = dir.path().join(name).join("year=2024");
6343 std::fs::create_dir_all(&partition).unwrap();
6344 let mut frame = df!("x" => [1i32, 2, 3]).unwrap();
6345 let file = std::fs::File::create(partition.join("part.parquet")).unwrap();
6346 ParquetWriter::new(file).finish(&mut frame).unwrap();
6347 rows.push(Entry::new(dir.path().join(name), EntryKind::Unknown));
6348 }
6349
6350 let mut sent = Vec::new();
6351 look_into_batch(
6352 rows,
6353 &cache,
6354 &Default::default(),
6355 Reads::Files,
6356 |path, m| {
6357 let name = path.file_name().unwrap().to_string_lossy().into_owned();
6358 sent.push((name, m.kind, m.rows));
6359 },
6360 );
6361
6362 let hive = Some(EntryKind::Hive);
6363 assert_eq!(
6364 sent,
6365 vec![
6366 ("a".to_string(), hive, None),
6367 ("b".to_string(), hive, None),
6368 ("a".to_string(), hive, Some(3)),
6369 ("b".to_string(), hive, Some(3)),
6370 ]
6371 );
6372 }
6373}
6374
6375#[cfg(test)]
6376mod known_facts_tests {
6377 use super::*;
6378 use crate::cache::DatasetFacts;
6379
6380 #[test]
6388 fn what_a_directory_holds_is_restored_beside_its_kind() {
6389 let holds = crate::home::discover::Holds {
6390 formats: vec![("parquet".to_string(), 15)],
6391 ..Default::default()
6392 };
6393 for (path, remote) in [
6394 (
6395 std::path::PathBuf::from("s3://bucket/warehouse/orders"),
6396 true,
6397 ),
6398 (std::path::PathBuf::from("/data/warehouse/orders"), false),
6399 ] {
6400 let facts = DatasetFacts {
6401 mtime: 0,
6402 size: 4096,
6403 rows: Some(999),
6406 cols: Some(72),
6407 cols_sampled: false,
6408 columns: vec!["lat".to_string()],
6409 kind: Some(EntryKind::Directory),
6412 classified_by: crate::home::discover::CLASSIFIER_VERSION,
6413 holds: holds.clone(),
6414 cost: Default::default(),
6415 };
6416 let mut row = Entry::directory(&path);
6417 row.kind = EntryKind::Unknown;
6418 row.modified = Some(std::time::UNIX_EPOCH);
6419 assert_eq!(row.size, None);
6422 let index = std::collections::HashMap::from([(path.clone(), facts)]);
6423
6424 apply_known_facts(&mut row, &index, remote);
6425 assert_eq!(row.kind, EntryKind::Directory, "{path:?}");
6426 assert_eq!(row.label(), "15 parquet", "{path:?}");
6427 if !remote {
6433 assert_eq!(row.rows, None, "{path:?}");
6434 assert_eq!(row.cols, None, "{path:?}");
6435 assert_eq!(row.size, None, "{path:?}");
6436 assert!(row.columns.is_empty(), "{path:?}");
6437 }
6438 }
6439 }
6440
6441 #[test]
6446 fn a_datasets_counts_are_measured_rather_than_restored() {
6447 let path = std::path::PathBuf::from("/data/warehouse/events");
6448 let facts = DatasetFacts {
6449 mtime: 0,
6450 size: 4096,
6451 rows: Some(1_200_000),
6452 cols: Some(58),
6453 cols_sampled: false,
6454 columns: vec!["ts".to_string()],
6455 kind: Some(EntryKind::MultiFile),
6456 classified_by: crate::home::discover::CLASSIFIER_VERSION,
6457 holds: crate::home::discover::Holds {
6458 formats: vec![("parquet".to_string(), 15)],
6459 ..Default::default()
6460 },
6461 cost: Default::default(),
6462 };
6463 let mut row = Entry::directory(&path);
6464 row.kind = EntryKind::Unknown;
6465 row.modified = Some(std::time::UNIX_EPOCH);
6466 let index = std::collections::HashMap::from([(path.clone(), facts)]);
6467
6468 apply_known_facts(&mut row, &index, false);
6469 assert_eq!(row.kind, EntryKind::MultiFile, "the kind comes back");
6470 assert_eq!(row.label(), "15 parquet", "and what it holds");
6471 assert_eq!(
6472 row.rows, None,
6473 "but not the count: the measuring pass skips a row that has one"
6474 );
6475 assert_eq!(row.cols, None);
6476 }
6477
6478 #[test]
6487 fn a_kind_from_an_older_classifier_is_not_restored() {
6488 let remote = std::path::PathBuf::from("s3://bucket/warehouse/orders");
6489 let facts = |classified_by| DatasetFacts {
6490 mtime: 0,
6491 size: 4096,
6492 rows: Some(1_000),
6493 cols: Some(7),
6494 cols_sampled: false,
6495 columns: vec!["id".into(), "amount".into()],
6496 kind: Some(EntryKind::MultiFile),
6497 classified_by,
6498 cost: Default::default(),
6499 holds: Default::default(),
6500 };
6501 let unprobed = || {
6502 let mut row = Entry::directory(&remote);
6503 row.kind = EntryKind::Unknown;
6504 row
6505 };
6506
6507 let index = |classified_by| {
6508 std::collections::HashMap::from([(remote.clone(), facts(classified_by))])
6509 };
6510
6511 let mut row = unprobed();
6512 apply_known_facts(
6513 &mut row,
6514 &index(crate::home::discover::CLASSIFIER_VERSION),
6515 true,
6516 );
6517 assert_eq!(
6518 row.kind,
6519 EntryKind::MultiFile,
6520 "this build's own answer comes back"
6521 );
6522
6523 let mut row = unprobed();
6524 apply_known_facts(&mut row, &index(0), true);
6525 assert_eq!(
6526 row.kind,
6527 EntryKind::Unknown,
6528 "an older build's does not: it may be a lake table this one would recognize"
6529 );
6530 assert_eq!(
6531 row.rows,
6532 Some(1_000),
6533 "but what it measured is still measured"
6534 );
6535 assert_eq!(row.columns, vec!["id".to_string(), "amount".to_string()]);
6536 }
6537}
6538
6539#[cfg(test)]
6540mod build_feature_tests {
6541 use super::*;
6542
6543 #[test]
6546 fn the_builtin_catalog_lists_only_what_this_build_opens() {
6547 let urls: Vec<String> = catalogs(&crate::config::AppConfig::default())
6548 .into_iter()
6549 .filter(|c| c.origin == crate::home::catalog::Origin::Bundled)
6550 .flat_map(|c| c.datasets)
6551 .map(|d| d.location.to_string_lossy().into_owned())
6552 .collect();
6553 let web = urls.iter().filter(|u| u.starts_with("https://")).count();
6554 let stores = urls
6555 .iter()
6556 .filter(|u| is_object_store_url(Path::new(u)))
6557 .count();
6558 assert_eq!(web + stores, urls.len(), "{urls:?}");
6559 assert_eq!(web > 0, cfg!(feature = "http"), "{urls:?}");
6560 assert_eq!(stores > 0, cfg!(feature = "cloud"), "{urls:?}");
6561 }
6562
6563 #[test]
6566 fn an_empty_examples_toml_hides_the_section() {
6567 let mut config = crate::config::AppConfig::default();
6568 assert_eq!(
6570 catalogs(&config)
6571 .iter()
6572 .any(|c| c.origin == crate::home::catalog::Origin::Bundled),
6573 cfg!(any(feature = "http", feature = "cloud"))
6574 );
6575 config.read_catalogs = vec![
6576 crate::home::catalog::parse(
6577 "label = \"Mine\"\n",
6578 crate::home::catalog::EXAMPLES,
6579 crate::home::catalog::Origin::Folder,
6580 None,
6581 )
6582 .unwrap(),
6583 ];
6584 assert!(catalogs(&config).is_empty(), "{:?}", catalogs(&config));
6585 }
6586
6587 #[test]
6590 fn a_users_catalog_is_shown_whole() {
6591 let mut config = crate::config::AppConfig::default();
6592 let mine = crate::home::catalog::parse(
6593 r#"
6594 [bucket]
6595 name = "Bucket"
6596 url = "s3://bucket/prefix/"
6597 [web]
6598 name = "Web"
6599 url = "https://example.com/data.csv"
6600 "#,
6601 crate::home::catalog::MINE,
6602 crate::home::catalog::Origin::Mine,
6603 None,
6604 )
6605 .unwrap();
6606 config.read_catalogs = vec![mine];
6607 let shown = catalogs(&config);
6608 let mine = shown.iter().find(|c| c.id == "mine").unwrap();
6609 assert_eq!(mine.datasets.len(), 2);
6610 assert_eq!(mine.label, crate::home::catalog::MINE_LABEL);
6611 }
6612}
6613
6614#[cfg(test)]
6615mod place_tests {
6616 use super::same_place;
6617 use std::path::Path;
6618
6619 #[test]
6620 fn local_paths_are_one_place_however_spelled() {
6621 assert!(same_place(
6622 Path::new("/data/./sales/"),
6623 Path::new("/data/sales")
6624 ));
6625 assert!(!same_place(
6626 Path::new("/data/sales"),
6627 Path::new("/data/sale")
6628 ));
6629 assert!(same_place(
6630 Path::new("s3://bucket/dir/"),
6631 Path::new("s3://bucket/dir")
6632 ));
6633 if cfg!(windows) {
6634 assert!(same_place(
6635 Path::new("c:/data/sales.csv"),
6636 Path::new(r"C:\data\sales.csv")
6637 ));
6638 }
6639 }
6640}