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 {
586 let holds = &door.holds;
587 let more = if holds.truncated { "+" } else { "" };
588 let files = |format: &str| {
589 let count = holds.data_files();
590 let word = if count == 1 { "file" } else { "files" };
591 format!("{count}{more} {} {word}", format_title(format))
592 };
593 let what = match door_kind(door) {
594 DoorKind::Hive => {
595 let keys = door_keys(door, rows);
596 if keys.is_empty() {
597 "hive table".to_string()
598 } else {
599 format!("hive table: {}", keys.join(", "))
600 }
601 }
602 DoorKind::OneSchema => match (holds.model_weights(), holds.one_format()) {
603 (Some((format, count)), _) => {
604 let word = if count == 1 { "file" } else { "files" };
605 format!("model, {count}{more} {} {word}", format_title(format))
606 }
607 (None, Some(format)) => format!("{}, one schema", files(format)),
608 (None, None) => "one table".to_string(),
609 },
610 DoorKind::SchemasDiffer => {
611 format!(
612 "{}, schemas differ",
613 files(holds.one_format().unwrap_or(""))
614 )
615 }
616 DoorKind::Single => files(holds.one_format().unwrap_or("")),
617 DoorKind::Lake => format!(
618 "{} files, not the table",
619 door.kind.lake_name().unwrap_or_default()
620 ),
621 DoorKind::Mixed => "mixed files".to_string(),
622 DoorKind::Unknown => return DOOR_ACTION.to_string(),
623 };
624 format!("{DOOR_ACTION} ({what})")
625}
626
627pub const DOOR_ACTION: &str = "open all";
629
630pub fn door_reads(door: &Entry) -> Option<(String, Option<String>)> {
635 if !matches!(door_kind(door), DoorKind::Mixed | DoorKind::Single) {
636 return None;
637 }
638 let holds = &door.holds;
639 let more = if holds.truncated { "+" } else { "" };
640 let plain = holds.directories.saturating_sub(holds.partitions);
641 let directories = |n: usize| {
642 let word = if n == 1 { "directory" } else { "directories" };
643 format!("{n}{more} {word}")
644 };
645 let Some((format, count)) = holds.formats.first() else {
646 return (holds.directories > 0).then(|| ("every Parquet file below".to_string(), None));
647 };
648 let remote = is_object_store_url(&door.path);
650 let below = remote || holds.partitions > 0 || (holds.formats.len() == 1 && format == "parquet");
651 let below = below && holds.directories > 0;
652 let reads = if below && remote {
653 format!("every {format} file below")
654 } else if below {
655 "every Parquet file below".to_string()
656 } else {
657 format!("{count}{more} {format}")
658 };
659 let mut skips: Vec<String> = holds
660 .formats
661 .iter()
662 .skip(1)
663 .map(|(name, n)| format!("{n}{more} {name}"))
664 .collect();
665 if !below && plain > 0 {
666 skips.push(directories(plain));
667 }
668 Some((reads, (!skips.is_empty()).then(|| skips.join(", "))))
669}
670
671pub fn name_by_spec(formats: &crate::formats::Registry, rows: &mut [Entry]) {
674 if formats.is_empty() {
675 return;
676 }
677 let mut named = false;
678 for row in rows
679 .iter_mut()
680 .filter(|r| r.kind == EntryKind::Other && r.format_spec.is_none())
681 {
682 if let Some(spec) = formats.by_glob(&row.path, false).first() {
683 discover::name_spec_file(row, spec);
684 named = true;
685 }
686 }
687 for row in rows.iter_mut().filter(|r| {
689 r.kind == EntryKind::File
690 && r.format_spec.is_none()
691 && discover::data_format(&r.path).is_some_and(|f| f.separator().is_some())
692 }) {
693 if let Some(spec) = formats
694 .by_glob(&row.path, false)
695 .into_iter()
696 .find(|s| s.is_delimited())
697 {
698 row.format_spec = Some(spec.name.clone());
699 }
700 }
701 if named {
703 discover::sort_entries(rows);
704 }
705}
706
707pub fn holds_nothing_to_open(holds: &discover::Holds, object_store: bool) -> bool {
715 let below = if object_store {
716 holds.directories > 0
717 } else {
718 holds.partitions > 0 || holds.dataset_dict
719 };
720 holds.formats.is_empty() && holds.unnamed == 0 && !below
721}
722
723pub fn is_cloud_place(path: &Path) -> bool {
725 path.to_string_lossy().starts_with(CLOUD_PLACE)
726}
727
728fn is_bucket_root(path: &Path) -> bool {
731 let text = path.to_string_lossy();
732 if let Some((_, _, key)) = crate::cloud::source::azure_parts(&text) {
733 return key.trim_matches('/').is_empty();
734 }
735 let Some((scheme, rest)) = text.split_once("://") else {
736 return false;
737 };
738 matches!(scheme, "s3" | "s3a" | "gs" | "gcs") && {
739 let rest = rest.trim_end_matches('/');
740 !rest.is_empty() && !rest.contains('/')
741 }
742}
743
744pub fn parent_location(path: &Path) -> Option<PathBuf> {
747 if !matches!(
748 crate::cloud::source::input_source(path),
749 crate::cloud::source::InputSource::Local(_)
750 ) {
751 let s = path.to_string_lossy();
752 let (scheme, rest) = s.split_once("://")?;
753 let (up, _) = rest.trim_end_matches('/').rsplit_once('/')?;
754 return Some(PathBuf::from(format!("{scheme}://{up}")));
755 }
756 path.parent()
757 .filter(|p| !p.as_os_str().is_empty() && *p != path)
758 .map(Path::to_path_buf)
759}
760
761pub fn is_remote_path(path: &Path) -> bool {
765 is_cloud_place(path)
766 || !matches!(
767 crate::cloud::source::input_source(path),
768 crate::cloud::source::InputSource::Local(_)
769 )
770 || is_network_path(path)
771}
772
773pub fn is_network_path(path: &Path) -> bool {
777 crate::home::locality::Mounts::cached().is_network(path)
778}
779
780pub fn could_block_path(path: &Path) -> bool {
784 crate::home::locality::Mounts::cached().could_block(path)
785}
786
787#[doc(hidden)]
790pub fn network_fs_for_test(mountinfo: &str, path: &Path) -> bool {
791 crate::home::locality::Mounts::parse(mountinfo).is_network(path)
792}
793
794#[derive(Debug, Clone)]
796pub struct Root {
797 pub path: PathBuf,
798 pub origin: RootOrigin,
799 pub network: bool,
801 pub available: bool,
804}
805
806#[derive(Debug, Clone, Default)]
808pub struct Section {
809 pub title: String,
810 pub subtitle: Option<String>,
813 pub origin: Option<&'static str>,
816 pub root: Option<PathBuf>,
819 pub rows: Vec<Entry>,
820 pub door: Option<Entry>,
825 pub unavailable: bool,
827 pub unavailable_note: Option<String>,
830 pub folded_by_default: bool,
833 pub remote_root: Option<PathBuf>,
835 pub waiting: bool,
837 pub grouped_by_place: bool,
840 pub place_labels: std::collections::HashMap<PathBuf, String>,
843}
844
845impl Section {
846 pub fn titled(title: impl Into<String>, rows: Vec<Entry>) -> Self {
848 Section {
849 title: title.into(),
850 rows,
851 ..Default::default()
852 }
853 }
854}
855
856#[derive(Debug, Clone, PartialEq, Eq, Default)]
858pub enum CloudStatus {
859 #[default]
861 Listing,
862 Unlisted,
865 Listed,
867 Failed { short: String, detail: String },
870}
871
872#[derive(Debug, Clone, Default, PartialEq, Eq)]
876pub struct CloudSource {
877 pub id: String,
879 pub label: String,
881 pub api: crate::cloud::source::ProviderKind,
883 pub note: String,
885 pub buckets: Vec<PathBuf>,
887 pub status: CloudStatus,
888 pub listed_at: Option<std::time::SystemTime>,
890 pub refreshing: bool,
892 pub asked: bool,
894 pub details: Vec<(String, String)>,
896 pub place_details: std::collections::HashMap<PathBuf, Vec<(String, String)>>,
899}
900
901impl CloudSource {
902 pub fn count_text(&self) -> String {
904 match &self.status {
905 CloudStatus::Failed { short, .. } if self.buckets.is_empty() => short.clone(),
906 CloudStatus::Listing if self.buckets.is_empty() => String::new(),
907 CloudStatus::Unlisted if self.buckets.is_empty() => "not listed".to_string(),
908 _ => {
909 let (one, many) = match self.api {
910 crate::cloud::source::ProviderKind::Azure => ("account", "accounts"),
911 crate::cloud::source::ProviderKind::Gcs => ("project", "projects"),
912 crate::cloud::source::ProviderKind::S3 => ("bucket", "buckets"),
913 };
914 match self.buckets.len() {
915 0 => format!("no {many}"),
916 1 => format!("1 {one}"),
917 n => format!("{n} {many}"),
918 }
919 }
920 }
921 }
922
923 pub fn begin_listing(&mut self) {
926 self.asked = true;
927 if self.status == CloudStatus::Unlisted && self.buckets.is_empty() {
928 self.status = CloudStatus::Listing;
929 } else {
930 self.refreshing = true;
931 }
932 }
933
934 pub fn busy(&self) -> bool {
936 self.refreshing || (self.status == CloudStatus::Listing && self.buckets.is_empty())
937 }
938
939 pub fn failed(&self) -> bool {
941 matches!(self.status, CloudStatus::Failed { .. })
942 }
943}
944
945#[derive(Debug, Clone, PartialEq)]
948pub struct ShownCatalog {
949 pub id: String,
951 pub label: String,
953 pub origin: crate::home::catalog::Origin,
955 pub description: String,
957 pub file: Option<PathBuf>,
959 pub datasets: Vec<ShownDataset>,
960 pub broken: Option<String>,
962}
963
964#[derive(Debug, Clone, Default, PartialEq)]
966pub struct ShownDataset {
967 pub name: String,
969 pub location: PathBuf,
971 pub details: Vec<(String, String)>,
973 pub size: Option<u64>,
975 pub codebook: Option<std::sync::Arc<crate::home::codebook::Codebook>>,
977 pub bookmarks: Vec<(String, PathBuf)>,
979 pub entry: std::sync::Arc<crate::home::catalog::Dataset>,
981}
982
983impl ShownCatalog {
984 pub fn from_catalog(catalog: &crate::home::catalog::Catalog) -> Self {
986 Self {
987 id: catalog.id.clone(),
988 label: catalog.label.clone(),
989 origin: catalog.origin,
990 description: catalog.description.clone(),
991 file: catalog.file.clone(),
992 datasets: catalog
993 .datasets
994 .iter()
995 .map(|dataset| {
996 let location = dataset.location();
997 let mut details: Vec<(String, String)> = [
998 ("about", &dataset.description),
999 ("publisher", &dataset.publisher),
1000 ("license", &dataset.license),
1001 ("homepage", &dataset.homepage),
1002 ("documentation", &dataset.documentation),
1003 ]
1004 .into_iter()
1005 .filter(|(_, value)| !value.is_empty())
1006 .map(|(key, value)| (key.to_string(), value.clone()))
1007 .collect();
1008 match &dataset.url {
1009 None => details.push(("path".to_string(), display_path(&location))),
1010 Some(url) => {
1011 details.push(("url".to_string(), url.clone()));
1012 details.push(("login".to_string(), login_of(dataset)));
1013 }
1014 }
1015 ShownDataset {
1016 name: dataset.name.clone(),
1017 location,
1018 details,
1019 size: dataset.size,
1020 codebook: crate::home::codebook::Codebook::of(dataset)
1021 .map(std::sync::Arc::new),
1022 bookmarks: dataset
1023 .bookmarks
1024 .iter()
1025 .map(|(name, path)| (name.clone(), dataset.bookmark_location(path)))
1026 .collect(),
1027 entry: std::sync::Arc::new(dataset.clone()),
1028 }
1029 })
1030 .collect(),
1031 broken: None,
1032 }
1033 }
1034
1035 pub fn from_broken(broken: &crate::home::catalog::Broken) -> Self {
1037 Self {
1038 id: broken.id.clone(),
1039 label: broken.id.clone(),
1040 origin: broken.origin,
1041 description: String::new(),
1042 file: None,
1043 datasets: Vec::new(),
1044 broken: Some(broken.callout()),
1045 }
1046 }
1047
1048 pub fn origin_note(&self) -> &'static str {
1051 match self.origin {
1052 crate::home::catalog::Origin::Mine => "catalog.toml",
1053 crate::home::catalog::Origin::Listed | crate::home::catalog::Origin::Folder => {
1054 "catalog"
1055 }
1056 crate::home::catalog::Origin::Bundled => BUNDLED_ORIGIN,
1057 }
1058 }
1059}
1060
1061pub const BUNDLED_ORIGIN: &str = "comes with datui";
1063
1064pub const CATALOG_ORIGINS: [&str; 3] = ["catalog.toml", "catalog", BUNDLED_ORIGIN];
1066
1067pub fn is_catalog_origin(origin: &str) -> bool {
1069 CATALOG_ORIGINS.contains(&origin)
1070}
1071
1072pub fn login_of(dataset: &crate::home::catalog::Dataset) -> String {
1074 match dataset.object_store_auth() {
1075 Some(crate::config::DatasetAuth::Connection(connection)) => connection,
1076 Some(crate::config::DatasetAuth::Anonymous) | None => "none".to_string(),
1077 Some(crate::config::DatasetAuth::Auto) => "auto".to_string(),
1078 }
1079}
1080
1081pub fn catalogs(config: &crate::config::AppConfig) -> Vec<ShownCatalog> {
1085 let mut out: Vec<ShownCatalog> = config
1086 .shown_catalogs()
1087 .iter()
1088 .filter_map(|catalog| {
1089 let mut shown = ShownCatalog::from_catalog(catalog);
1090 if catalog.origin == crate::home::catalog::Origin::Bundled {
1091 shown
1092 .datasets
1093 .retain(|d| crate::cloud::source::opens_in_this_build(&d.location));
1094 }
1095 (!shown.datasets.is_empty()).then_some(shown)
1096 })
1097 .collect();
1098 let at = out
1100 .iter()
1101 .position(|c| c.origin == crate::home::catalog::Origin::Bundled)
1102 .unwrap_or(out.len());
1103 let broken: Vec<ShownCatalog> = config
1104 .broken_catalogs
1105 .iter()
1106 .filter(|b| !config.home.hide.contains(&b.id))
1107 .map(ShownCatalog::from_broken)
1108 .collect();
1109 out.splice(at..at, broken);
1110 out
1111}
1112
1113fn catalog_entry(
1116 dataset: &ShownDataset,
1117 network_check: fn(&Path) -> bool,
1118 missing: &mut std::collections::HashSet<PathBuf>,
1119) -> Entry {
1120 let path = &dataset.location;
1121 let local = matches!(
1122 crate::cloud::source::input_source(path),
1123 crate::cloud::source::InputSource::Local(_)
1124 );
1125 let mut entry = if is_object_store_url(path) {
1126 if names_a_file(path) {
1127 entry_for_path(path, true)
1128 } else {
1129 Entry::directory(path)
1130 }
1131 } else if !local || network_check(path) {
1132 entry_for_path(path, true)
1133 } else if path.exists() {
1134 entry_for_path(path, false)
1135 } else {
1136 missing.insert(path.clone());
1137 let mut entry = entry_for_path(path, true);
1138 entry.kind = EntryKind::Unknown;
1139 entry
1140 };
1141 entry.name = dataset.name.clone();
1142 entry
1143}
1144
1145pub fn codebook_for(
1148 catalogs: &[ShownCatalog],
1149 path: &Path,
1150) -> Option<std::sync::Arc<crate::home::codebook::Codebook>> {
1151 let text = path.to_string_lossy();
1152 catalogs
1153 .iter()
1154 .flat_map(|c| c.datasets.iter())
1155 .filter(|d| d.codebook.is_some())
1156 .filter(|d| d.location == path || within(&text, &d.location.to_string_lossy()))
1157 .max_by_key(|d| d.location.to_string_lossy().trim_end_matches('/').len())
1158 .and_then(|d| d.codebook.clone())
1159}
1160
1161pub fn catalog_entry_for(
1164 catalogs: &[ShownCatalog],
1165 path: &Path,
1166) -> Option<(String, std::sync::Arc<crate::home::catalog::Dataset>)> {
1167 let text = path.to_string_lossy();
1168 catalogs
1169 .iter()
1170 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
1171 .filter(|(_, d)| {
1172 d.location == path
1173 || same_place(&d.location, path)
1174 || within(&text, &d.location.to_string_lossy())
1175 })
1176 .rev()
1177 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
1178 .map(|(c, d)| (c.label.clone(), d.entry.clone()))
1179}
1180
1181fn bookmark_entry(name: &str, place: &Path, network_check: fn(&Path) -> bool) -> Entry {
1183 let local = matches!(
1184 crate::cloud::source::input_source(place),
1185 crate::cloud::source::InputSource::Local(_)
1186 );
1187 let mut entry = if is_object_store_url(place) && !names_a_file(place) {
1188 Entry::directory(place)
1189 } else {
1190 entry_for_path(place, !local || network_check(place) || !place.exists())
1191 };
1192 entry.name = name.to_string();
1193 entry
1194}
1195
1196fn catalog_section(
1198 catalog: &ShownCatalog,
1199 network_check: fn(&Path) -> bool,
1200 missing: &mut std::collections::HashSet<PathBuf>,
1201) -> Section {
1202 let rows = catalog
1204 .datasets
1205 .iter()
1206 .flat_map(|dataset| {
1207 std::iter::once(catalog_entry(dataset, network_check, missing)).chain(
1208 dataset
1209 .bookmarks
1210 .iter()
1211 .map(|(name, place)| bookmark_entry(name, place, network_check)),
1212 )
1213 })
1214 .collect();
1215 Section {
1216 origin: Some(catalog.origin_note()),
1217 unavailable: catalog.broken.is_some(),
1218 unavailable_note: catalog.broken.clone(),
1219 ..Section::titled(catalog.label.clone(), rows)
1220 }
1221}
1222
1223#[derive(Debug, Clone, Default, PartialEq)]
1226pub struct Measured {
1227 pub rows: Option<usize>,
1228 pub cols: Option<usize>,
1229 pub cols_sampled: bool,
1231 pub size: Option<u64>,
1232 pub modified: Option<std::time::SystemTime>,
1234 pub stat_only: bool,
1237 pub columns: Vec<String>,
1239 pub cost: crate::home::discover::Cost,
1242 pub kind: Option<crate::home::discover::EntryKind>,
1245 pub holds: crate::home::discover::Holds,
1248}
1249
1250#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1252pub enum SortMode {
1253 #[default]
1255 Natural,
1256 Size,
1258 Modified,
1260 Rows,
1262}
1263
1264impl SortMode {
1265 pub fn label_in(self, section_is_recency_ordered: bool) -> &'static str {
1268 match self {
1269 SortMode::Natural if section_is_recency_ordered => "recent",
1270 SortMode::Natural => "name",
1271 SortMode::Size => "size",
1272 SortMode::Modified => "modified",
1273 SortMode::Rows => "rows",
1274 }
1275 }
1276
1277 pub fn next(self) -> Self {
1278 match self {
1279 SortMode::Natural => SortMode::Size,
1280 SortMode::Size => SortMode::Modified,
1281 SortMode::Modified => SortMode::Rows,
1282 SortMode::Rows => SortMode::Natural,
1283 }
1284 }
1285}
1286
1287#[derive(Debug, Clone)]
1291pub enum Row<'a> {
1292 Header {
1293 section: usize,
1294 matches: usize,
1296 collapsed: bool,
1297 },
1298 Entry {
1299 section: usize,
1300 entry: &'a Entry,
1301 nested: bool,
1303 hit: Hit,
1305 },
1306 Place {
1308 section: usize,
1309 path: PathBuf,
1310 label: Option<String>,
1313 source: Option<String>,
1315 held: usize,
1317 },
1318 Door { section: usize, entry: &'a Entry },
1321 More {
1324 section: usize,
1325 hidden: usize,
1326 places: usize,
1327 measuring: bool,
1330 },
1331 Hidden { section: usize, count: usize },
1335 Up { section: usize },
1338}
1339
1340impl Row<'_> {
1341 pub fn section(&self) -> usize {
1342 match self {
1343 Row::Header { section, .. }
1344 | Row::Entry { section, .. }
1345 | Row::Door { section, .. }
1346 | Row::Place { section, .. }
1347 | Row::More { section, .. }
1348 | Row::Hidden { section, .. }
1349 | Row::Up { section } => *section,
1350 }
1351 }
1352}
1353
1354#[derive(Debug, Clone, Copy, Default, PartialEq)]
1358pub struct Hit {
1359 pub score: i32,
1360 pub column: Option<usize>,
1363}
1364
1365impl Hit {
1366 pub fn column_of<'a>(&self, entry: &'a Entry) -> Option<&'a str> {
1368 self.column
1369 .and_then(|i| entry.columns.get(i))
1370 .map(String::as_str)
1371 }
1372
1373 pub fn positions(&self, filter: &str, entry: &Entry) -> Vec<usize> {
1376 match self.column_of(entry) {
1377 Some(column) => substring_positions(filter, column),
1378 None => fuzzy_positions(filter, &entry.name),
1379 }
1380 }
1381}
1382
1383#[derive(Debug, Default)]
1388pub struct RowsCache {
1389 built: std::cell::RefCell<Option<View>>,
1390 builds: std::cell::Cell<usize>,
1391 rescored: std::cell::RefCell<Option<Hits>>,
1394}
1395
1396#[derive(Debug, Clone)]
1399struct Hits {
1400 filter: String,
1401 sections: Vec<Option<Vec<Option<Hit>>>>,
1402}
1403
1404#[derive(Debug)]
1406struct View {
1407 key: ViewKey,
1408 slots: Vec<Slot>,
1409 headers: Vec<usize>,
1412 hits: Hits,
1413 has_dataset: bool,
1415}
1416
1417#[derive(Debug, PartialEq)]
1420struct ViewKey {
1421 filter: String,
1422 sort: SortMode,
1423 hide_unreadable: bool,
1424 recent_expanded: bool,
1425 shown_whole: std::collections::HashSet<PathBuf>,
1426 view_height: usize,
1427 browsing: Option<PathBuf>,
1428 folds: std::collections::HashMap<String, bool>,
1429 shape: Vec<(usize, bool)>,
1430}
1431
1432impl ViewKey {
1433 fn of(home: &HomeState) -> Self {
1434 ViewKey {
1435 filter: home.filter.clone(),
1436 sort: home.sort,
1437 hide_unreadable: home.hide_unreadable,
1438 recent_expanded: home.recent_expanded,
1439 shown_whole: home.shown_whole.clone(),
1440 view_height: home.view_height,
1441 browsing: home.browsing.clone(),
1442 folds: home.folds.clone(),
1443 shape: (home.sections.iter())
1444 .map(|s| (s.rows.len(), s.door.is_some()))
1445 .collect(),
1446 }
1447 }
1448
1449 fn matches(&self, home: &HomeState) -> bool {
1451 self.filter == home.filter
1452 && self.sort == home.sort
1453 && self.hide_unreadable == home.hide_unreadable
1454 && self.recent_expanded == home.recent_expanded
1455 && self.shown_whole == home.shown_whole
1456 && self.view_height == home.view_height
1457 && self.browsing == home.browsing
1458 && self.folds == home.folds
1459 && self.shape.iter().copied().eq(home
1460 .sections
1461 .iter()
1462 .map(|s| (s.rows.len(), s.door.is_some())))
1463 }
1464}
1465
1466#[derive(Debug)]
1468enum Slot {
1469 Plain(Row<'static>),
1471 Entry {
1472 section: usize,
1473 index: usize,
1474 nested: bool,
1475 hit: Hit,
1476 },
1477 Door {
1478 section: usize,
1479 },
1480}
1481
1482#[derive(Debug, Clone)]
1486pub struct ListLines {
1487 headers: Vec<usize>,
1488 rows: usize,
1489 spaced: bool,
1490}
1491
1492impl ListLines {
1493 pub fn rows(&self) -> usize {
1495 self.rows
1496 }
1497
1498 pub fn headers(&self) -> usize {
1500 self.headers.len()
1501 }
1502
1503 pub fn line_of(&self, row: usize) -> usize {
1505 if !self.spaced {
1506 return row;
1507 }
1508 let above = self.headers.partition_point(|&h| h <= row);
1510 let first_at_top = self.headers.first() == Some(&0);
1511 row + above.saturating_sub(usize::from(first_at_top))
1512 }
1513
1514 pub fn total(&self) -> usize {
1516 match self.rows {
1517 0 => 0,
1518 n => self.line_of(n - 1) + 1,
1519 }
1520 }
1521
1522 pub fn first_row_from(&self, line: usize) -> usize {
1524 let (mut lo, mut hi) = (0, self.rows);
1525 while lo < hi {
1526 let mid = lo + (hi - lo) / 2;
1527 if self.line_of(mid) < line {
1528 lo = mid + 1;
1529 } else {
1530 hi = mid;
1531 }
1532 }
1533 lo
1534 }
1535
1536 pub fn row_on(&self, line: usize) -> Option<usize> {
1538 let row = self.first_row_from(line);
1539 (row < self.rows && self.line_of(row) == line).then_some(row)
1540 }
1541}
1542
1543pub fn place_of(path: &Path) -> PathBuf {
1546 parent_location(path).unwrap_or_else(|| path.to_path_buf())
1547}
1548
1549pub fn place_is_browsable(path: &Path) -> bool {
1552 is_cloud_place(path)
1553 || is_object_store_url(path)
1554 || matches!(
1555 crate::cloud::source::input_source(path),
1556 crate::cloud::source::InputSource::Local(_)
1557 )
1558}
1559
1560#[derive(Debug, Clone, PartialEq, Eq)]
1564pub enum RowKey {
1565 Header(String),
1566 Entry(PathBuf),
1567 Door(PathBuf),
1570 Place(PathBuf),
1571 More(String),
1572 Hidden(String),
1573 Up(String),
1574}
1575
1576#[derive(Debug)]
1578pub struct HomeState {
1579 pub sections: Vec<Section>,
1580 pub filter: String,
1582 pub filter_selected: bool,
1585 pub search_limit: usize,
1587 pub hide_unreadable: bool,
1589 pub formats: std::sync::Arc<crate::formats::Registry>,
1592 pub lake_here: Option<(PathBuf, &'static str)>,
1595 pub selected: usize,
1597 pub scroll: usize,
1602 pub view_height: usize,
1604 pub path_input_active: bool,
1606 pub path_input: String,
1607 pub path_listing: Option<PathListing>,
1609 pub path_pick: Option<usize>,
1611 pub browsing: Option<PathBuf>,
1613 pub browse_start: Option<PathBuf>,
1616 pub status: Option<String>,
1618 pub network_check: fn(&Path) -> bool,
1621 pub visits: std::collections::HashMap<PathBuf, crate::cache::Visits>,
1624 pub newest_recent: Option<PathBuf>,
1626 pub probes: Probes,
1628 pub narrowed: Option<Narrowed>,
1630 pub cloud_kinds: std::collections::HashMap<PathBuf, (EntryKind, crate::home::discover::Holds)>,
1633 pub sort: SortMode,
1635 pub listing_in_flight: bool,
1637 pub measure_in_flight: bool,
1639 pub classifying: std::collections::HashSet<PathBuf>,
1643 pub peeking: std::collections::HashSet<PathBuf>,
1646 pub peek_failed: std::collections::HashSet<PathBuf>,
1649 pub enriched: std::collections::HashMap<PathBuf, Measured>,
1652 pub stated: std::collections::HashMap<PathBuf, Stated>,
1655 pub stat_epoch: u64,
1658 pub stale: std::collections::HashSet<PathBuf>,
1660 pub unapplied: std::collections::HashSet<PathBuf>,
1662 pub folds: std::collections::HashMap<String, bool>,
1666 pub folds_owed: bool,
1669 pub search: SearchState,
1671 pub known: Known,
1675 pub cloud: Vec<CloudSource>,
1678 pub catalogs: Vec<ShownCatalog>,
1681 pub sized: std::collections::HashSet<PathBuf>,
1683 pub web_gone: std::collections::HashMap<PathBuf, crate::error_display::HttpGone>,
1686 pub missing: std::collections::HashSet<PathBuf>,
1688 pub waiting_since: Option<std::time::Instant>,
1690 pub recent_expanded: bool,
1692 pub shown_whole: std::collections::HashSet<PathBuf>,
1695 pub trail: Vec<Mark>,
1698 pub returning: Option<RowKey>,
1701 pub returning_line: Option<usize>,
1703 pub landing: bool,
1706 pub resting: bool,
1710 pub rows_cache: RowsCache,
1712 pub catalog_places: CatalogPlaces,
1714}
1715
1716#[derive(Debug, Clone)]
1719pub struct Mark {
1720 pub place: Option<PathBuf>,
1722 pub key: Option<RowKey>,
1723 pub filter: String,
1725 pub search: Option<SearchState>,
1728 pub line: usize,
1730}
1731
1732#[derive(Debug, Clone, Default)]
1735pub struct SearchState {
1736 pub root: Option<PathBuf>,
1738 pub epoch: u64,
1740 pub results: Vec<std::sync::Arc<[Entry]>>,
1743 pub indexed: usize,
1745 pub matches: Option<crate::home::search::Matches>,
1748 pub scoring: bool,
1750 pub scanned: usize,
1752 pub running: bool,
1754 pub done: bool,
1756 pub limited: Option<String>,
1758}
1759
1760const SCORE_INLINE_MAX: usize = 2_000;
1763
1764const FRECENCY_LIFT: f64 = 3.0;
1767
1768#[derive(Debug, Clone)]
1770pub struct ScoreJob {
1771 pub epoch: u64,
1772 pub results: Vec<std::sync::Arc<[Entry]>>,
1773 pub query: String,
1774 pub base: Option<crate::home::search::Matches>,
1775 pub limit: usize,
1776}
1777
1778impl SearchState {
1779 pub fn reset(&mut self) {
1781 *self = Self::default();
1782 }
1783
1784 pub fn set_results(&mut self, results: Vec<Entry>) {
1786 self.indexed = results.len();
1787 self.results = vec![results.into()];
1788 self.matches = None;
1789 }
1790
1791 pub fn files(&self) -> impl Iterator<Item = &Entry> {
1793 self.results.iter().flat_map(|batch| batch.iter())
1794 }
1795
1796 fn scored_for(&self, query: &str) -> bool {
1798 self.matches
1799 .as_ref()
1800 .is_some_and(|m| m.query == query && m.upto == self.indexed)
1801 }
1802
1803 fn base_for(&self, query: &str) -> (Option<&crate::home::search::Matches>, usize) {
1805 match self.matches.as_ref() {
1806 Some(m) if m.narrows_to(query) && m.upto <= self.indexed => {
1807 (Some(m), m.ids.len() + self.indexed - m.upto)
1808 }
1809 _ => (None, self.indexed),
1810 }
1811 }
1812}
1813
1814impl Default for HomeState {
1815 fn default() -> Self {
1816 Self {
1817 sections: Vec::new(),
1818 cloud: Vec::new(),
1819 catalogs: Vec::new(),
1820 sized: std::collections::HashSet::new(),
1821 web_gone: Default::default(),
1822 missing: Default::default(),
1823 filter: String::new(),
1824 search_limit: crate::config::SearchConfig::default().max_results,
1825 rows_cache: RowsCache::default(),
1826 catalog_places: CatalogPlaces::default(),
1827 hide_unreadable: true,
1828 formats: Default::default(),
1829 lake_here: None,
1830 selected: 0,
1831 scroll: 0,
1832 view_height: 0,
1833 path_input_active: false,
1834 path_input: String::new(),
1835 path_listing: None,
1836 path_pick: None,
1837 filter_selected: false,
1838 browsing: None,
1839 browse_start: None,
1840 status: None,
1841 network_check: is_remote_path,
1842 visits: Default::default(),
1843 newest_recent: None,
1844 sort: SortMode::default(),
1845 listing_in_flight: false,
1846 measure_in_flight: false,
1847 classifying: Default::default(),
1848 peeking: std::collections::HashSet::new(),
1849 probes: Probes::default(),
1850 narrowed: None,
1851 cloud_kinds: std::collections::HashMap::new(),
1852 peek_failed: std::collections::HashSet::new(),
1853 waiting_since: None,
1854 enriched: std::collections::HashMap::new(),
1855 stated: Default::default(),
1856 stat_epoch: 0,
1857 stale: Default::default(),
1858 unapplied: Default::default(),
1859 folds: std::collections::HashMap::new(),
1860 folds_owed: false,
1861 search: SearchState::default(),
1862 known: Default::default(),
1863 recent_expanded: false,
1864 shown_whole: Default::default(),
1865 trail: Vec::new(),
1866 returning: None,
1867 returning_line: None,
1868 landing: false,
1869 resting: false,
1870 }
1871 }
1872}
1873
1874pub type Known = std::sync::Arc<std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>>;
1877
1878#[derive(Debug, Clone)]
1881pub struct ListingRequest {
1882 pub recents: Vec<PathBuf>,
1883 pub desktop_dirs: Vec<PathBuf>,
1884 pub browsing: Option<PathBuf>,
1885 pub probes: Probes,
1887 pub narrowed: Option<Narrowed>,
1889 pub network_check: fn(&Path) -> bool,
1890 pub cloud: Vec<CloudSource>,
1892 pub catalogs: Vec<ShownCatalog>,
1894 pub known: Known,
1897 pub formats: std::sync::Arc<crate::formats::Registry>,
1900}
1901
1902#[derive(Debug, Clone, Default)]
1904pub struct Listing {
1905 pub sections: Vec<Section>,
1906 pub missing: std::collections::HashSet<PathBuf>,
1908}
1909
1910impl Listing {
1911 pub fn learn(
1914 &mut self,
1915 learned: &[(PathBuf, crate::cache::DatasetFacts)],
1916 network_check: fn(&Path) -> bool,
1917 ) {
1918 if learned.is_empty() {
1919 return;
1920 }
1921 let learned: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts> =
1922 learned.iter().cloned().collect();
1923 for row in self.sections.iter_mut().flat_map(|s| s.rows.iter_mut()) {
1924 if known_facts(&learned, &row.path).is_some() {
1925 apply_known_facts(row, &learned, network_check(&row.path));
1926 }
1927 }
1928 }
1929
1930 pub fn alias_visits(
1936 &self,
1937 visits: &mut std::collections::HashMap<PathBuf, crate::cache::Visits>,
1938 ) {
1939 let names: std::collections::HashSet<std::ffi::OsString> = visits
1940 .keys()
1941 .filter_map(|p| p.file_name().map(|n| n.to_os_string()))
1942 .collect();
1943 let mut aliases = Vec::new();
1944 for row in self.sections.iter().flat_map(|s| &s.rows) {
1945 let path = &row.path;
1946 if visits.contains_key(path)
1947 || row.table.is_some()
1948 || !path.file_name().is_some_and(|n| names.contains(n))
1949 || is_network_path(path)
1950 {
1951 continue;
1952 }
1953 if let Some(v) = crate::canonical::canonicalize(path)
1954 .ok()
1955 .and_then(|canonical| visits.get(&canonical))
1956 {
1957 aliases.push((path.clone(), *v));
1958 }
1959 }
1960 visits.extend(aliases);
1961 }
1962}
1963
1964pub fn look_into_as(entry: &Entry, as_read: &crate::formats::schema_union::ReadAs) -> Entry {
1969 let mut probe = classify_row(entry);
1970 measure_row(&mut probe, entry, as_read, None);
1971 probe
1972}
1973
1974fn classify_row(entry: &Entry) -> Entry {
1976 let mut probe = entry.clone();
1977 if probe.kind == EntryKind::Unknown && probe.path.is_dir() {
1978 let (kind, holds) = discover::look_at_directory(&probe.path);
1979 probe.kind = kind;
1980 probe.holds = holds;
1981 }
1982 probe
1983}
1984
1985fn stat_and_recall(
1989 probe: &mut Entry,
1990 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
1991) -> bool {
1992 if discover::unstated(probe) && !discover::stat_row(probe) {
1993 return false;
1994 }
1995 if probe.rows.is_none() && probe.columns.is_empty() && discover::on_disk(probe) {
1998 apply_known_facts(probe, known, false);
1999 }
2000 true
2001}
2002
2003fn measure_row(
2006 probe: &mut Entry,
2007 entry: &Entry,
2008 as_read: &crate::formats::schema_union::ReadAs,
2009 remembered: Option<&crate::cache::CacheManager>,
2010) {
2011 discover::enrich_with(probe, as_read, remembered);
2012 probe.size = probe.size.or(entry.size);
2013 probe.modified = probe.modified.or(entry.modified);
2014}
2015
2016#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2018pub enum Reads {
2019 Files,
2021 StatOnly,
2023}
2024
2025pub fn look_into_batch(
2030 rows: Vec<Entry>,
2031 cache: &crate::cache::CacheManager,
2032 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2033 reads: Reads,
2034 mut each: impl FnMut(PathBuf, Measured),
2035) {
2036 let as_read = crate::formats::schema_union::ReadAs::default();
2037 let answer = |probe: &Entry, entry: &Entry| Measured {
2038 stat_only: reads == Reads::StatOnly,
2039 ..measured_from(probe, entry)
2040 };
2041 let classified: Vec<(Entry, Entry)> = rows
2042 .into_iter()
2043 .map(|entry| {
2044 let mut probe = entry.clone();
2045 if !stat_and_recall(&mut probe, known) {
2047 return (probe, entry);
2048 }
2049 let probe = match reads {
2051 Reads::Files => classify_row(&probe),
2052 Reads::StatOnly => probe,
2053 };
2054 if probe.kind != entry.kind || probe.modified != entry.modified {
2055 each(entry.path.clone(), answer(&probe, &entry));
2056 }
2057 (probe, entry)
2058 })
2059 .collect();
2060
2061 let mut facts = Vec::new();
2062 for (mut probe, entry) in classified {
2063 if reads == Reads::Files && probe.rows.is_none() && probe.columns.is_empty() {
2065 measure_row(&mut probe, &entry, &as_read, Some(cache));
2066 facts.extend(facts_for(&probe));
2067 }
2068 each(entry.path.clone(), answer(&probe, &entry));
2069 }
2070 cache.record_dataset_facts(&facts);
2073}
2074
2075pub fn measured_from(probe: &Entry, original: &Entry) -> Measured {
2077 Measured {
2078 rows: probe.rows,
2079 cols: probe.cols,
2080 cols_sampled: probe.cols_sampled,
2081 size: probe.size.or(original.size),
2082 modified: probe.modified.or(original.modified),
2083 stat_only: false,
2084 columns: probe.columns.clone(),
2085 kind: (probe.kind != original.kind).then_some(probe.kind),
2086 holds: probe.holds.clone(),
2087 cost: crate::home::discover::Cost {
2090 source: None,
2091 ..probe.cost.clone()
2092 },
2093 }
2094}
2095
2096#[derive(Debug, Clone)]
2099pub enum Probe {
2100 Listing(Vec<Entry>),
2102 Listed {
2104 rows: std::sync::Arc<[Entry]>,
2105 cut_short: bool,
2106 },
2107 Unreachable(Option<String>),
2109 Silent(Vec<Entry>),
2112}
2113
2114pub const NOT_ANSWERING: &str = "not answering · Ctrl+R retries";
2116
2117#[derive(Debug, Clone, Default)]
2119pub struct Probes(std::collections::HashMap<PathBuf, Probe>);
2120
2121impl Probes {
2122 pub fn listed(&self, place: &Path) -> Option<&[Entry]> {
2124 match self.0.get(place)? {
2125 Probe::Listed { rows, .. } => Some(rows),
2126 _ => None,
2127 }
2128 }
2129
2130 pub fn so_far(&self, place: &Path) -> Option<&[Entry]> {
2132 match self.0.get(place)? {
2133 Probe::Listing(rows) => Some(rows),
2134 _ => None,
2135 }
2136 }
2137
2138 pub fn settled(&self, place: &Path) -> bool {
2140 matches!(
2141 self.0.get(place),
2142 Some(Probe::Listed { .. } | Probe::Unreachable(_) | Probe::Silent(_))
2143 )
2144 }
2145
2146 pub fn silent(&self, place: &Path) -> bool {
2148 matches!(self.0.get(place), Some(Probe::Silent(_)))
2149 }
2150
2151 pub fn silent_places(&self) -> Vec<PathBuf> {
2153 (self.0.iter())
2154 .filter(|(_, probe)| matches!(probe, Probe::Silent(_)))
2155 .map(|(place, _)| place.clone())
2156 .collect()
2157 }
2158
2159 pub fn takes_pages(&self, place: &Path) -> bool {
2162 matches!(
2163 self.0.get(place),
2164 None | Some(Probe::Listing(_) | Probe::Silent(_))
2165 )
2166 }
2167
2168 pub fn go_silent(&mut self, place: &Path) {
2170 let rows = match self.0.remove(place) {
2171 Some(Probe::Listing(rows) | Probe::Silent(rows)) => rows,
2172 None => Vec::new(),
2173 Some(settled) => {
2174 self.0.insert(place.to_path_buf(), settled);
2175 return;
2176 }
2177 };
2178 self.0.insert(place.to_path_buf(), Probe::Silent(rows));
2179 }
2180
2181 pub fn cut_short(&self, place: &Path) -> bool {
2182 matches!(
2183 self.0.get(place),
2184 Some(Probe::Listed {
2185 cut_short: true,
2186 ..
2187 })
2188 )
2189 }
2190
2191 pub fn unreachable(&self, place: &Path) -> bool {
2192 matches!(self.0.get(place), Some(Probe::Unreachable(_)))
2193 }
2194
2195 pub fn error(&self, place: &Path) -> Option<&str> {
2197 match self.0.get(place)? {
2198 Probe::Unreachable(why) => why.as_deref(),
2199 _ => None,
2200 }
2201 }
2202
2203 fn rows(&self, place: &Path) -> Vec<Entry> {
2206 match self.0.get(place) {
2207 Some(Probe::Listed { rows, .. }) => rows.to_vec(),
2208 Some(Probe::Listing(rows) | Probe::Silent(rows)) => {
2209 let mut rows = rows.clone();
2210 if is_object_store_url(place) {
2211 rows.sort_by_key(|row| row.kind != EntryKind::Directory);
2212 } else {
2213 discover::sort_entries(&mut rows);
2214 }
2215 rows
2216 }
2217 _ => Vec::new(),
2218 }
2219 }
2220
2221 pub fn answered(&self) -> impl Iterator<Item = (&PathBuf, &[Entry])> {
2223 self.0.iter().filter_map(|(place, probe)| match probe {
2224 Probe::Listed { rows, .. } => Some((place, &rows[..])),
2225 _ => None,
2226 })
2227 }
2228
2229 fn entry(&self, path: &Path) -> Option<Entry> {
2231 self.answered()
2232 .flat_map(|(_, rows)| rows.iter())
2233 .find(|e| e.path == path)
2234 .cloned()
2235 }
2236
2237 pub fn read(&mut self, place: &Path, rows: &[Entry]) {
2239 let probe = (self.0)
2240 .entry(place.to_path_buf())
2241 .or_insert(Probe::Listing(Vec::new()));
2242 if let Probe::Silent(so_far) = probe {
2244 *probe = Probe::Listing(std::mem::take(so_far));
2245 }
2246 if let Probe::Listing(so_far) = probe {
2247 so_far.extend_from_slice(rows);
2248 }
2249 }
2250
2251 pub fn insert(&mut self, place: PathBuf, probe: Probe) {
2252 self.0.insert(place, probe);
2253 }
2254
2255 pub fn forget(&mut self, place: &Path) {
2257 self.0.remove(place);
2258 }
2259
2260 pub fn stopped(&mut self, place: &Path) {
2262 if let Some(Probe::Listing(_) | Probe::Silent(_)) = self.0.get(place) {
2263 self.0.remove(place);
2264 }
2265 }
2266
2267 fn listed_mut(&mut self, place: &Path) -> Option<&mut std::sync::Arc<[Entry]>> {
2268 match self.0.get_mut(place)? {
2269 Probe::Listed { rows, .. } => Some(rows),
2270 _ => None,
2271 }
2272 }
2273}
2274
2275#[derive(Debug, Clone)]
2278pub struct Narrowed {
2279 pub dir: PathBuf,
2280 pub prefix: String,
2282 pub rows: Vec<Entry>,
2283 pub truncated: bool,
2285}
2286
2287pub fn build_listing(request: &ListingRequest) -> Listing {
2291 let ListingRequest {
2292 recents,
2293 desktop_dirs,
2294 browsing,
2295 probes,
2296 narrowed,
2297 network_check,
2298 cloud,
2299 catalogs,
2300 known,
2301 formats,
2302 } = request;
2303 let network_check = *network_check;
2304 let mounts = crate::home::locality::Mounts::current();
2307 let mut sections: Vec<Section> = Vec::new();
2308
2309 if let Some(id) = browsing.as_deref().and_then(cloud_source_id) {
2311 sections.push(source_section(&id, cloud));
2312 annotate(&mut sections, known, network_check, &mounts);
2313 return Listing {
2314 sections,
2315 ..Default::default()
2316 };
2317 }
2318
2319 if let Some(dir) = browsing.clone() {
2321 let remote = network_check(&dir);
2322 sections.push(browsed_section(
2323 &dir,
2324 remote,
2325 &Listed {
2326 probes,
2327 narrowed: narrowed.as_ref(),
2328 catalogs,
2329 known,
2330 formats,
2331 },
2332 ));
2333 annotate(&mut sections, known, network_check, &mounts);
2334 return Listing {
2335 sections,
2336 ..Default::default()
2337 };
2338 }
2339
2340 let recent_rows: Vec<Entry> = recents
2342 .iter()
2343 .filter(|p| {
2346 network_check(p)
2347 || p.exists()
2348 || crate::formats::members::split(p).is_some()
2349 || crate::formats::members::split_variant(p, formats).is_some()
2350 || crate::formats::hf_splits::split_place(p).is_some()
2351 })
2352 .map(|p| {
2354 if let Some(known) = probes.entry(p) {
2357 return known;
2358 }
2359 if let Some(variant) = discover::variant_row(p, formats) {
2360 return variant;
2361 }
2362 if !network_check(p)
2363 && let Some(split) = discover::split_row(p)
2364 {
2365 return split;
2366 }
2367 let mut entry = entry_for_path(p, network_check(p));
2368 if !network_check(p) {
2369 discover::name_unlisted_file(&mut entry, formats);
2370 }
2371 if let Some(dataset) = catalogs
2374 .iter()
2375 .flat_map(|c| &c.datasets)
2376 .find(|d| d.location == *p)
2377 {
2378 entry.name = dataset.name.clone();
2379 }
2380 entry
2381 })
2382 .collect();
2383
2384 let mut elsewhere: Vec<Entry> = Vec::new();
2386
2387 let roots = HomeState::roots_with(desktop_dirs, network_check);
2388 let mut root_sections: Vec<(RootOrigin, Section)> = Vec::new();
2389 let mut cwd_listing: Option<(PathBuf, std::collections::HashSet<std::ffi::OsString>)> = None;
2392 for root in roots {
2393 if root.origin == RootOrigin::Desktop {
2396 if root.available {
2397 let mut entry = Entry::directory(&root.path);
2398 entry.name = display_path(&root.path);
2400 elsewhere.push(entry);
2401 }
2402 continue;
2403 }
2404
2405 let mut truncated = false;
2408 let rows = if root.network {
2409 truncated = probes.cut_short(&root.path);
2410 probes.rows(&root.path)
2411 } else if root.available {
2412 let scan = discover::scan_dir_specs(&root.path, formats);
2413 truncated = scan.truncated;
2414 scan.entries
2415 } else {
2416 Vec::new()
2417 };
2418 if root.origin == RootOrigin::Cwd {
2419 let key = if root.network {
2422 root.path.clone()
2423 } else {
2424 crate::canonical::canonicalize(&root.path).unwrap_or_else(|_| root.path.clone())
2425 };
2426 let names = rows
2427 .iter()
2428 .filter_map(|row| row.path.file_name().map(|n| n.to_os_string()))
2429 .collect();
2430 cwd_listing = Some((key, names));
2431 }
2432 let unreachable = root.network && probes.unreachable(&root.path);
2434 let silent = root.network && probes.silent(&root.path);
2435 let waiting = root.network && !probes.settled(&root.path);
2436 let described = mounts.describe(&root.path);
2440 let fstype = if described.network() {
2441 described.fstype
2442 } else {
2443 "network".to_string()
2444 };
2445 let mut state: Vec<String> = Vec::new();
2448 if truncated {
2449 state.push(format!(
2450 "first {}",
2451 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2452 ));
2453 }
2454 if root.network {
2455 state.push(fstype);
2456 }
2457 root_sections.push((
2458 root.origin,
2459 Section {
2460 subtitle: (!state.is_empty()).then(|| crate::glyphs::dotted(&state.join(" · "))),
2461 origin: Some(root.origin.note()),
2462 root: Some(root.path.clone()),
2463 unavailable: !root.available || unreachable || silent,
2464 unavailable_note: silent.then(|| NOT_ANSWERING.to_string()),
2465 remote_root: root.network.then(|| root.path.clone()),
2466 waiting,
2467 ..Section::titled(display_path(&root.path), rows)
2468 },
2469 ));
2470 }
2471
2472 let recent_rows: Vec<Entry> = match &cwd_listing {
2477 None => recent_rows,
2478 Some((cwd, names)) => recent_rows
2479 .into_iter()
2480 .filter(|row| {
2481 let place = place_of(&row.path);
2482 let place = if network_check(&place) {
2485 place
2486 } else {
2487 crate::canonical::canonicalize(&place).unwrap_or(place)
2488 };
2489 place != *cwd || !row.path.file_name().is_some_and(|n| names.contains(n))
2490 })
2491 .collect(),
2492 };
2493 if !recent_rows.is_empty() {
2494 let place_labels = place_labels(&recent_rows, known, network_check);
2495 sections.push(Section {
2496 grouped_by_place: true,
2498 place_labels,
2499 ..Section::titled(HomeState::RECENT_SECTION, recent_rows)
2500 });
2501 }
2502
2503 sections.extend(root_sections.into_iter().map(|(_, s)| s));
2507
2508 sections.extend(cloud_section(cloud));
2511
2512 let mut missing = std::collections::HashSet::new();
2514 for catalog in catalogs {
2515 let mut section = catalog_section(catalog, network_check, &mut missing);
2516 for row in section.rows.iter_mut().filter(|r| {
2518 r.kind == EntryKind::File
2519 && r.format_spec.is_none()
2520 && r.table.is_none()
2521 && !network_check(&r.path)
2522 && !discover::is_data_file(&r.path)
2523 }) {
2524 if let Some(spec) = formats.by_glob(&row.path, false).first() {
2525 discover::name_spec_file(row, spec);
2526 }
2527 }
2528 sections.push(section);
2529 }
2530
2531 if !elsewhere.is_empty() {
2532 sections.push(Section {
2533 folded_by_default: true,
2535 ..Section::titled("Elsewhere", elsewhere)
2536 });
2537 }
2538
2539 annotate(&mut sections, known, network_check, &mounts);
2542
2543 Listing { sections, missing }
2544}
2545
2546fn source_section(id: &str, cloud: &[CloudSource]) -> Section {
2549 let source = cloud.iter().find(|s| s.id == id);
2550 let rows = source
2551 .map(|s| s.buckets.iter().map(|b| bucket_entry(b)).collect())
2552 .unwrap_or_default();
2553 let failure = source.and_then(|s| match &s.status {
2554 CloudStatus::Failed { short, .. } => Some(short.clone()),
2555 _ => None,
2556 });
2557 Section {
2558 subtitle: source.map(|s| s.note.clone()).filter(|n| !n.is_empty()),
2559 unavailable: source.is_none() || failure.is_some(),
2560 unavailable_note: if source.is_none() {
2561 Some("source not found".to_string())
2562 } else {
2563 failure
2564 },
2565 waiting: source.is_some_and(|s| s.busy()),
2566 ..Section::titled(
2567 source.map_or_else(|| id.to_string(), |s| s.label.clone()),
2568 rows,
2569 )
2570 }
2571}
2572
2573fn cloud_section(cloud: &[CloudSource]) -> Option<Section> {
2575 (!cloud.is_empty()).then(|| {
2576 Section::titled(
2577 HomeState::CLOUD_SECTION.to_string(),
2578 cloud.iter().map(source_entry).collect(),
2579 )
2580 })
2581}
2582
2583pub(crate) struct Listed<'a> {
2586 pub probes: &'a Probes,
2587 pub narrowed: Option<&'a Narrowed>,
2588 pub catalogs: &'a [ShownCatalog],
2589 pub known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2590 pub formats: &'a crate::formats::Registry,
2591}
2592
2593fn browsed_section(dir: &Path, remote: bool, listed: &Listed) -> Section {
2596 let Listed {
2597 probes,
2598 narrowed,
2599 catalogs,
2600 known,
2601 formats,
2602 } = *listed;
2603 let dir = dir.to_path_buf();
2604
2605 let so_far = remote && probes.so_far(&dir).is_some();
2609 let database = !remote && dir.is_file();
2611 let (mut rows, truncated) = if remote {
2612 (probes.rows(&dir), probes.cut_short(&dir))
2613 } else if database {
2614 let tables = discover::database_rows(&dir);
2615 let rows = if tables.is_empty() {
2616 discover::variant_rows(&dir, formats)
2617 } else {
2618 tables
2619 };
2620 (rows, false)
2621 } else {
2622 let scan = discover::scan_dir_specs(&dir, formats);
2623 let mut rows = discover::split_rows(&dir);
2625 rows.extend(scan.entries);
2626 (rows, scan.truncated)
2627 };
2628 let narrowed = narrowed.filter(|n| remote && truncated && n.dir == dir);
2631 if let Some(narrowed) = narrowed {
2632 let listed: std::collections::HashSet<PathBuf> =
2633 rows.iter().map(|row| row.path.clone()).collect();
2634 rows.extend(
2635 narrowed
2636 .rows
2637 .iter()
2638 .filter(|row| !listed.contains(&row.path))
2639 .cloned(),
2640 );
2641 }
2642 let subtitle = if so_far {
2644 Some(format!(
2645 "{} so far",
2646 crate::numfmt::group_chrome(rows.len())
2647 ))
2648 } else if truncated {
2649 let first = format!(
2650 "first {}",
2651 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2652 );
2653 Some(match narrowed {
2654 Some(n) => format!(
2655 "{first} + {}{} {}*",
2656 crate::numfmt::group_chrome(n.rows.len()),
2657 if n.truncated { "+" } else { "" },
2658 n.prefix
2659 ),
2660 None => first,
2661 })
2662 } else {
2663 None
2664 };
2665 let unavailable = remote && (probes.unreachable(&dir) || probes.silent(&dir));
2666 let mut door = (!database)
2669 .then(|| whole_directory_row(&dir, &rows, remote))
2670 .flatten();
2671 if !remote
2675 && let Some(door) = door.as_mut()
2676 && let Ok(meta) = std::fs::metadata(&dir)
2677 {
2678 door.modified = meta.modified().ok();
2679 apply_known_facts(door, known, false);
2680 door.modified = None;
2681 door.name = door_name(door, &rows);
2682 }
2683 let title = {
2686 let text = dir.to_string_lossy();
2687 if let Some(dataset) = catalogs
2688 .iter()
2689 .flat_map(|c| c.datasets.iter())
2690 .find(|d| is_object_store_url(&d.location) && same_place(&d.location, &dir))
2691 {
2692 dataset.name.clone()
2693 } else if let Some((_, account)) = cloud_account(&dir) {
2694 account
2695 } else if let Some((_, container, key)) = crate::cloud::source::azure_parts(&text) {
2696 format!("{container}/{}", key.trim_matches('/'))
2697 .trim_end_matches('/')
2698 .to_string()
2699 } else {
2700 match crate::cloud::source::split_source_id(&text) {
2701 (Some(_), plain) => plain.into_owned(),
2702 (None, _) => display_path(&dir),
2703 }
2704 }
2705 };
2706 Section {
2707 subtitle,
2708 root: Some(dir.clone()),
2709 unavailable,
2710 unavailable_note: probes
2713 .error(&dir)
2714 .map(str::to_string)
2715 .or_else(|| probes.silent(&dir).then(|| NOT_ANSWERING.to_string())),
2716 waiting: so_far,
2719 door,
2720 ..Section::titled(title, rows)
2721 }
2722}
2723
2724pub fn index_key(path: &Path) -> PathBuf {
2728 let text = path.to_string_lossy();
2729 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
2730 return PathBuf::from(crate::cloud::source::azure_url(&account, &container, &key));
2731 }
2732 match crate::cloud::source::split_source_id(&text) {
2733 (Some(_), plain) => PathBuf::from(plain.into_owned()),
2734 (None, _) => path.to_path_buf(),
2735 }
2736}
2737
2738fn known_facts<'a>(
2740 known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2741 path: &Path,
2742) -> Option<&'a crate::cache::DatasetFacts> {
2743 known.get(path).or_else(|| known.get(&index_key(path)))
2744}
2745
2746fn place_labels(
2751 rows: &[Entry],
2752 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2753 network_check: fn(&Path) -> bool,
2754) -> std::collections::HashMap<PathBuf, String> {
2755 let mut labels = std::collections::HashMap::new();
2756 for row in rows {
2757 let place = place_of(&row.path);
2758 if labels.contains_key(&place) {
2759 continue;
2760 }
2761 let Some(facts) = known_facts(known, &place) else {
2762 continue;
2763 };
2764 if facts.classified_by != crate::home::discover::CLASSIFIER_VERSION {
2765 continue;
2766 }
2767 if !network_check(&place) {
2768 let same_mtime = std::fs::metadata(&place)
2769 .and_then(|m| m.modified())
2770 .ok()
2771 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2772 .is_some_and(|d| d.as_secs() == facts.mtime);
2773 if !same_mtime {
2774 continue;
2775 }
2776 }
2777 let Some(kind) = facts.kind else {
2778 continue;
2779 };
2780 let mut probe = Entry::directory(&place);
2781 probe.kind = kind;
2782 probe.holds = facts.holds.clone();
2783 let label = probe.label();
2784 if !label.is_empty() && !label.starts_with("dir") {
2785 labels.insert(place, label.into_owned());
2786 }
2787 }
2788 labels
2789}
2790
2791fn annotate(
2794 sections: &mut [Section],
2795 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2796 network_check: fn(&Path) -> bool,
2797 mounts: &crate::home::locality::Mounts,
2798) {
2799 for section in sections {
2800 for row in &mut section.rows {
2801 if is_cloud_place(&row.path) {
2802 row.cost.source = Some("cloud".to_string());
2803 continue;
2804 }
2805 let remote = network_check(&row.path);
2806 if !remote && discover::unstated(row) && known_facts(known, &row.path).is_some() {
2810 discover::stat_row(row);
2811 }
2812 apply_known_facts(row, known, remote);
2813 row.cost.source = Some(mounts.describe(&row.path).fstype);
2814 }
2815 if let Some(door) = section.door.as_mut()
2817 && !is_cloud_place(&door.path)
2818 {
2819 door.cost.source = Some(mounts.describe(&door.path).fstype);
2820 }
2821 }
2822}
2823
2824#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2826pub struct Stated {
2827 pub size: Option<u64>,
2828 pub modified: Option<std::time::SystemTime>,
2829 pub epoch: u64,
2831}
2832
2833fn fold_known(
2836 row: &mut Entry,
2837 enriched: &std::collections::HashMap<PathBuf, Measured>,
2838 stated: &std::collections::HashMap<PathBuf, Stated>,
2839 stale: &std::collections::HashSet<PathBuf>,
2840) -> bool {
2841 let mut any = false;
2842 if stale.contains(&row.path) {
2843 forget_counts(row);
2844 any = true;
2845 }
2846 if let Some(m) = enriched.get(&row.path) {
2847 fold_measured(row, m);
2848 any = true;
2849 }
2850 if let Some(stat) = stated.get(&row.path) {
2851 if stat.size.is_some() {
2852 row.size = stat.size;
2853 }
2854 if stat.modified.is_some() {
2855 row.modified = stat.modified;
2856 }
2857 any = true;
2858 }
2859 any
2860}
2861
2862fn forget_counts(row: &mut Entry) {
2865 row.measured = false;
2866 row.rows = None;
2867 row.cols = None;
2868 row.cols_sampled = false;
2869 row.columns.clear();
2870 take_cost(row, &discover::Cost::default());
2871}
2872
2873#[derive(PartialEq)]
2876struct Standing {
2877 hidden: bool,
2878 dataset: bool,
2879 sorted_by: Option<u64>,
2881}
2882
2883impl Standing {
2884 fn of(row: &Entry, sort: SortMode) -> Self {
2885 Standing {
2886 hidden: row.hidden_by_default(),
2887 dataset: row.kind.is_dataset() || row.kind.is_lake_table(),
2888 sorted_by: match sort {
2889 SortMode::Size => Some(row.size.unwrap_or(0)),
2890 SortMode::Rows => Some(row.rows.unwrap_or(0) as u64),
2891 SortMode::Modified => Some(
2892 (row.modified)
2893 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2894 .map_or(0, |d| d.as_secs()),
2895 ),
2896 SortMode::Natural => None,
2897 },
2898 }
2899 }
2900}
2901
2902fn fold_measured(row: &mut Entry, m: &Measured) {
2904 row.measured = true;
2905 row.rows = m.rows;
2906 row.cols = m.cols;
2907 row.cols_sampled = m.cols_sampled;
2908 if let Some(kind) = m.kind {
2909 row.kind = kind;
2910 }
2911 if m.size.is_some() {
2912 row.size = m.size;
2913 }
2914 if m.modified.is_some() {
2915 row.modified = m.modified;
2916 }
2917 if !m.columns.is_empty() && row.columns != m.columns {
2918 row.columns.clone_from(&m.columns);
2919 }
2920 if !m.holds.is_empty() && row.holds != m.holds {
2921 row.holds.clone_from(&m.holds);
2922 }
2923 take_cost(row, &m.cost);
2925}
2926
2927fn take_cost(row: &mut Entry, cost: &discover::Cost) {
2931 let source = row.cost.source.take();
2932 let variants = row.cost.tables.filter(|_| row.format_spec.is_some());
2933 row.cost = cost.clone();
2934 row.cost.source = source;
2935 if variants.is_some() {
2936 row.cost.tables = variants;
2937 }
2938}
2939
2940fn apply_known_facts(
2944 row: &mut Entry,
2945 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2946 remote: bool,
2947) {
2948 let Some(facts) = known_facts(known, &row.path) else {
2949 return;
2950 };
2951
2952 if !remote
2959 && matches!(row.kind, EntryKind::Unknown | EntryKind::MultiFile)
2960 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
2961 && let Some(kind) = facts.kind
2962 {
2963 let same_mtime = row
2964 .modified
2965 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2966 .is_some_and(|d| d.as_secs() == facts.mtime);
2967 if same_mtime {
2968 row.kind = kind;
2969 if row.holds.is_empty() {
2972 row.holds = facts.holds.clone();
2973 }
2974 }
2978 }
2979
2980 if !remote {
2981 let same_bytes = row.size.map(|s| s == facts.size).unwrap_or(false)
2982 && row
2983 .modified
2984 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2985 .map(|d| d.as_secs() == facts.mtime)
2986 .unwrap_or(false);
2987 if !same_bytes {
2988 return;
2989 }
2990 }
2991
2992 row.rows = facts.rows;
2993 row.cols = facts.cols;
2994 row.cols_sampled = facts.cols_sampled;
2995 if !facts.columns.is_empty() {
2996 row.columns = facts.columns.clone();
2997 }
2998 take_cost(row, &facts.cost);
3001 if remote {
3002 if facts.size > 0 {
3005 row.size = row.size.or(Some(facts.size));
3006 }
3007 if row.kind == EntryKind::Unknown
3011 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
3012 && let Some(kind) = facts.kind
3013 {
3014 row.kind = kind;
3015 if row.holds.is_empty() {
3017 row.holds = facts.holds.clone();
3018 }
3019 }
3020 }
3021}
3022
3023pub fn facts_for(entry: &Entry) -> Option<(PathBuf, crate::cache::DatasetFacts)> {
3025 let size = entry.size?;
3026 let mtime = entry
3027 .modified?
3028 .duration_since(std::time::UNIX_EPOCH)
3029 .ok()?
3030 .as_secs();
3031 let cost = crate::home::discover::Cost {
3033 source: None,
3034 ..entry.cost.clone()
3035 };
3036 if entry.rows.is_none() && entry.columns.is_empty() && cost == Default::default() {
3037 return None; }
3039 Some((
3040 entry.path.clone(),
3041 crate::cache::DatasetFacts {
3042 mtime,
3043 size,
3044 rows: entry.rows,
3045 cols: entry.cols,
3046 cols_sampled: entry.cols_sampled,
3047 columns: entry.columns.clone(),
3048 kind: Some(entry.kind),
3049 holds: entry.holds.clone(),
3050 classified_by: crate::home::discover::CLASSIFIER_VERSION,
3051 cost: crate::home::discover::Cost {
3053 source: None,
3054 ..entry.cost.clone()
3055 },
3056 },
3057 ))
3058}
3059
3060pub fn match_score(filter: &str, entry: &Entry) -> Option<i32> {
3064 match crate::home::fuzzy::best_match(filter, &entry.name) {
3065 Some(m) => Some(m.score),
3066 None => matching_column(filter, entry).map(|_| -COLUMN_MATCH_PENALTY),
3069 }
3070}
3071
3072pub fn match_hit(filter: &str, entry: &Entry) -> Option<Hit> {
3074 Needle::new(filter).hit(entry)
3075}
3076
3077struct Needle<'a> {
3080 filter: &'a str,
3081 lower: String,
3082}
3083
3084impl<'a> Needle<'a> {
3085 fn new(filter: &'a str) -> Self {
3086 Needle {
3087 filter,
3088 lower: filter.to_lowercase(),
3089 }
3090 }
3091
3092 fn hit(&self, entry: &Entry) -> Option<Hit> {
3093 let named = crate::home::fuzzy::best_match_with(self.filter, &entry.name, |score, _| Hit {
3094 score,
3095 column: None,
3096 });
3097 if named.is_some() {
3098 return named;
3099 }
3100 Some(Hit {
3101 score: -COLUMN_MATCH_PENALTY,
3102 column: Some(self.column(entry)?),
3103 })
3104 }
3105
3106 fn column(&self, entry: &Entry) -> Option<usize> {
3108 if self.filter.is_empty() {
3109 return None;
3110 }
3111 (entry.columns.iter()).position(|c| contains_folded(c, &self.lower))
3112 }
3113}
3114
3115fn contains_folded(haystack: &str, lower: &str) -> bool {
3118 if haystack.is_ascii() && lower.is_ascii() {
3119 let (hay, needle) = (haystack.as_bytes(), lower.as_bytes());
3120 return needle.is_empty()
3121 || hay
3122 .windows(needle.len())
3123 .any(|w| w.eq_ignore_ascii_case(needle));
3124 }
3125 haystack.to_lowercase().contains(lower)
3126}
3127
3128const COLUMN_MATCH_PENALTY: i32 = 1_000_000;
3131
3132pub fn matching_column<'a>(filter: &str, entry: &'a Entry) -> Option<&'a str> {
3135 (Needle::new(filter).column(entry)).map(|i| entry.columns[i].as_str())
3136}
3137
3138pub fn fuzzy_positions(needle: &str, haystack: &str) -> Vec<usize> {
3141 crate::home::fuzzy::best_match(needle, haystack)
3142 .map(|m| m.positions)
3143 .unwrap_or_default()
3144}
3145
3146pub fn substring_positions(needle: &str, haystack: &str) -> Vec<usize> {
3149 if needle.is_empty() {
3150 return Vec::new();
3151 }
3152 let hay: Vec<char> = haystack.to_lowercase().chars().collect();
3153 let need: Vec<char> = needle.to_lowercase().chars().collect();
3154 if need.len() > hay.len() {
3155 return Vec::new();
3156 }
3157 for start in 0..=(hay.len() - need.len()) {
3158 if hay[start..start + need.len()] == need[..] {
3159 return (start..start + need.len()).collect();
3160 }
3161 }
3162 Vec::new()
3163}
3164
3165pub fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
3168 crate::home::fuzzy::best_match(needle, haystack).map(|m| m.score)
3169}
3170
3171impl HomeState {
3172 pub fn roots(desktop_dirs: &[PathBuf]) -> Vec<Root> {
3175 Self::roots_with(desktop_dirs, is_remote_path)
3176 }
3177
3178 pub fn roots_with(desktop_dirs: &[PathBuf], is_network: fn(&Path) -> bool) -> Vec<Root> {
3180 let mut roots: Vec<Root> = Vec::new();
3181 let mut seen: Vec<PathBuf> = Vec::new();
3182
3183 let push =
3184 |path: PathBuf, origin: RootOrigin, roots: &mut Vec<Root>, seen: &mut Vec<PathBuf>| {
3185 let network = is_network(&path);
3187
3188 let key = if network {
3192 path.clone()
3193 } else {
3194 crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone())
3195 };
3196 if seen.contains(&key) {
3197 return;
3198 }
3199 seen.push(key);
3200
3201 let available = if network {
3202 true } else {
3204 std::fs::read_dir(&path).is_ok()
3205 };
3206 roots.push(Root {
3207 path,
3208 origin,
3209 available,
3210 network,
3211 });
3212 };
3213
3214 if let Ok(cwd) = std::env::current_dir() {
3218 push(cwd, RootOrigin::Cwd, &mut roots, &mut seen);
3219 }
3220
3221 for dir in desktop_dirs {
3224 push(dir.clone(), RootOrigin::Desktop, &mut roots, &mut seen);
3225 }
3226
3227 roots
3228 }
3229
3230 pub fn rebuild(&mut self, recents: &[PathBuf]) {
3233 self.rebuild_with(recents, &[])
3234 }
3235
3236 pub fn rebuild_with(&mut self, recents: &[PathBuf], desktop_dirs: &[PathBuf]) {
3239 let request = ListingRequest {
3240 recents: recents.to_vec(),
3241 desktop_dirs: desktop_dirs.to_vec(),
3242 browsing: self.browsing.clone(),
3243 probes: self.probes.clone(),
3244 narrowed: self.narrowed.clone(),
3245 network_check: self.network_check,
3246 cloud: self.cloud.clone(),
3247 catalogs: self.catalogs.clone(),
3248 known: Default::default(),
3251 formats: self.formats.clone(),
3252 };
3253 let listing = build_listing(&request);
3254 self.apply_listing(listing);
3255 }
3256
3257 pub fn apply_listing(&mut self, listing: Listing) {
3259 let mut listing = listing;
3260 for section in &mut listing.sections {
3261 name_by_spec(&self.formats, &mut section.rows);
3262 }
3263 self.missing = listing.missing;
3264 self.replace_sections(|home| home.sections = listing.sections);
3265 }
3266
3267 pub fn relist_remote(&mut self, root: &Path) {
3271 let browsed = self.browsing.as_deref() == Some(root)
3272 && (self.network_check)(root)
3273 && cloud_source_id(root).is_none();
3274 let at = (self.sections.iter()).position(|s| {
3275 s.root.as_deref() == Some(root) && (browsed || s.remote_root.as_deref() == Some(root))
3276 });
3277 let Some(at) = at else {
3278 return;
3279 };
3280 let mut section = if browsed {
3281 browsed_section(
3282 root,
3283 true,
3284 &Listed {
3285 probes: &self.probes,
3286 narrowed: self.narrowed.as_ref(),
3287 catalogs: &self.catalogs,
3288 known: &self.known,
3289 formats: &self.formats,
3290 },
3291 )
3292 } else {
3293 Section::titled("", self.probes.rows(root))
3294 };
3295 let mounts = crate::home::locality::Mounts::cached();
3296 annotate(
3297 std::slice::from_mut(&mut section),
3298 &self.known,
3299 self.network_check,
3300 &mounts,
3301 );
3302 name_by_spec(&self.formats, &mut section.rows);
3303 let silent = self.probes.silent(root);
3304 let unreachable = self.probes.unreachable(root);
3305 let waiting = !self.probes.settled(root);
3306 self.replace_sections(|home| {
3307 if browsed {
3308 home.sections[at] = section;
3309 return;
3310 }
3311 let shown = &mut home.sections[at];
3314 shown.rows = section.rows;
3315 shown.waiting = waiting;
3316 shown.unavailable = silent || unreachable;
3317 shown.unavailable_note = silent.then(|| NOT_ANSWERING.to_string());
3318 });
3319 }
3320
3321 pub fn set_cloud(&mut self, cloud: Vec<CloudSource>) {
3324 self.cloud = cloud;
3325 let mounts = crate::home::locality::Mounts::cached();
3326 let annotated = |home: &Self, mut section: Section| {
3327 annotate(
3328 std::slice::from_mut(&mut section),
3329 &home.known,
3330 home.network_check,
3331 &mounts,
3332 );
3333 section
3334 };
3335 match self.browsing.as_deref().map(cloud_source_id) {
3336 Some(Some(id)) => {
3337 let section = annotated(self, source_section(&id, &self.cloud));
3338 self.replace_sections(|home| home.sections = vec![section]);
3339 }
3340 Some(None) => {}
3341 None => {
3342 let section = cloud_section(&self.cloud).map(|s| annotated(self, s));
3343 self.replace_sections(|home| {
3344 let at = (home.sections.iter()).position(|s| s.title == Self::CLOUD_SECTION);
3345 match (at, section) {
3346 (Some(at), Some(section)) => home.sections[at] = section,
3347 (Some(at), None) => {
3348 home.sections.remove(at);
3349 }
3350 (None, Some(section)) => {
3352 let at = (home.sections.iter())
3353 .position(|s| {
3354 s.origin.is_some_and(is_catalog_origin)
3355 || s.title == "Elsewhere"
3356 || s.title == Self::SEARCH_SECTION
3357 })
3358 .unwrap_or(home.sections.len());
3359 home.sections.insert(at, section);
3360 }
3361 (None, None) => {}
3362 }
3363 });
3364 }
3365 }
3366 }
3367
3368 fn replace_sections(&mut self, change: impl FnOnce(&mut Self)) {
3371 let returning = self.returning.take();
3372 let previous = returning.clone().or_else(|| self.selected_key());
3373 let line = self.selected.saturating_sub(self.scroll);
3375 change(self);
3376 self.changed();
3377 if let (Some(browsing), Some((dir, format))) = (&self.browsing, &self.lake_here)
3379 && browsing == dir
3380 && let Some(section) = self.sections.first_mut()
3381 {
3382 let note = crate::glyphs::dotted(&format!(
3383 "{} · not read as a table",
3384 format.to_ascii_lowercase()
3385 ));
3386 section.subtitle = Some(match section.subtitle.take() {
3387 Some(state) => crate::glyphs::dotted(&format!("{note} · {state}")),
3388 None => note,
3389 });
3390 }
3391 self.sync_search_section();
3393 self.apply_measurements();
3396
3397 let placed = self.reselect(previous);
3399 if placed && returning.is_some() {
3402 self.landing = false;
3403 }
3404 if !placed {
3405 self.select_first_entry();
3406 if self.rows_still_arriving() {
3409 self.returning = returning;
3410 }
3411 } else if returning.is_some() {
3412 self.scroll_to_returning_line();
3413 } else {
3414 self.scroll = self.selected.saturating_sub(line);
3415 }
3416 self.follow_selection();
3417 }
3418
3419 pub fn leave_mark(&mut self) {
3422 let mark = Mark {
3423 place: self.browsing.clone(),
3424 key: self.selected_key(),
3425 filter: self.filter.clone(),
3426 search: (!self.search.running).then(|| SearchState {
3429 scoring: false,
3430 ..self.search.clone()
3431 }),
3432 line: self.selected.saturating_sub(self.scroll),
3433 };
3434 self.trail.retain(|m| m.place != mark.place);
3437 self.trail.push(mark);
3438 }
3439
3440 pub fn come_back(&mut self, from: Option<PathBuf>) {
3445 let to = self.browsing.clone();
3446 let mark = self
3447 .trail
3448 .iter()
3449 .rposition(|m| m.place == to)
3450 .map(|at| self.trail.split_off(at).remove(0));
3451 match mark {
3452 Some(mark) => {
3453 self.filter = mark.filter;
3454 self.search = mark.search.unwrap_or_default();
3455 self.returning = mark.key;
3456 self.returning_line = Some(mark.line);
3457 }
3458 None => {
3459 self.filter.clear();
3460 self.search.reset();
3461 self.returning = from.map(RowKey::Entry);
3462 self.returning_line = None;
3463 }
3464 }
3465 }
3466
3467 fn rows_still_arriving(&self) -> bool {
3470 self.listing_in_flight
3471 || self.sections_waiting()
3472 || self.awaiting_listing().is_some()
3473 || self.search.running
3474 }
3475
3476 fn settle_return(&mut self) {
3478 let Some(key) = self.returning.clone() else {
3479 return;
3480 };
3481 if let Some(idx) = self.place_key(&key) {
3482 self.selected = idx;
3483 self.returning = None;
3484 self.landing = false;
3485 self.scroll_to_returning_line();
3486 self.follow_selection();
3487 } else if !self.rows_still_arriving() {
3488 self.returning = None;
3489 }
3490 }
3491
3492 fn scroll_to_returning_line(&mut self) {
3494 if let Some(line) = self.returning_line.take() {
3495 self.scroll = self.selected.saturating_sub(line);
3496 }
3497 }
3498
3499 pub fn selected_key(&self) -> Option<RowKey> {
3502 let title = |section: usize| self.sections.get(section).map(|s| s.title.clone());
3503 Some(match self.cursor_row()? {
3504 Row::Header { section, .. } => RowKey::Header(title(section)?),
3505 Row::More { section, .. } => RowKey::More(title(section)?),
3506 Row::Hidden { section, .. } => RowKey::Hidden(title(section)?),
3507 Row::Up { section } => RowKey::Up(title(section)?),
3508 Row::Entry { entry, .. } => RowKey::Entry(entry.path.clone()),
3509 Row::Door { entry, .. } => RowKey::Door(entry.path.clone()),
3510 Row::Place { path, .. } => RowKey::Place(path),
3511 })
3512 }
3513
3514 pub fn reselect(&mut self, key: Option<RowKey>) -> bool {
3518 let Some(key) = key else {
3519 self.clamp_selection();
3520 return false;
3521 };
3522 match self.place_key(&key) {
3523 Some(idx) => {
3524 self.selected = idx;
3525 true
3526 }
3527 None => {
3528 self.clamp_selection();
3529 false
3530 }
3531 }
3532 }
3533
3534 fn place_key(&mut self, key: &RowKey) -> Option<usize> {
3537 if let Some(found) = self.listed(key) {
3538 return Some(found);
3539 }
3540 if let RowKey::Entry(path) = key
3541 && let Some(root) = self
3542 .sections
3543 .iter()
3544 .find(|s| !s.grouped_by_place && s.rows.iter().any(|r| r.path == *path))
3545 .and_then(|s| s.root.clone())
3546 && self.shown_whole.insert(root.clone())
3547 {
3548 match self.listed(key) {
3549 Some(found) => return Some(found),
3550 None => {
3551 self.shown_whole.remove(&root);
3552 }
3553 }
3554 }
3555 self.behind_recent_cap(key)
3556 }
3557
3558 fn listed(&self, key: &RowKey) -> Option<usize> {
3560 self.position(|row| match (row, key) {
3561 (Row::Entry { entry, .. }, RowKey::Entry(path)) => entry.path == *path,
3562 (Row::Door { entry, .. }, RowKey::Door(path)) => entry.path == *path,
3563 (Row::Place { path, .. }, RowKey::Place(wanted)) => path == wanted,
3564 (Row::Header { section, .. }, RowKey::Header(title))
3565 | (Row::More { section, .. }, RowKey::More(title))
3566 | (Row::Hidden { section, .. }, RowKey::Hidden(title))
3567 | (Row::Up { section }, RowKey::Up(title)) => self
3568 .sections
3569 .get(*section)
3570 .is_some_and(|s| s.title == *title),
3571 _ => false,
3572 })
3573 }
3574
3575 fn behind_recent_cap(&self, key: &RowKey) -> Option<usize> {
3577 let (RowKey::Entry(path) | RowKey::Place(path)) = key else {
3578 return None;
3579 };
3580 self.position(|row| {
3581 matches!(row, Row::More { section, .. }
3582 if self.sections.get(*section).is_some_and(|s| {
3583 s.grouped_by_place
3584 && s.rows
3585 .iter()
3586 .any(|r| r.path == *path || place_of(&r.path) == *path)
3587 }))
3588 })
3589 }
3590
3591 pub fn set_view_height(&mut self, height: usize) {
3594 if height == self.view_height {
3595 return;
3596 }
3597 let key = self.selected_key();
3598 self.view_height = height;
3599 self.reselect(key);
3600 }
3601
3602 fn follow_selection(&mut self) {
3606 let rows = self.row_count();
3607 self.scroll = settle_top(self.scroll, self.selected, self.view_height, rows);
3608 }
3609
3610 fn section_folded(&self, section: &Section) -> bool {
3614 if self.browsing.is_some() {
3615 return false;
3616 }
3617 self.folds
3618 .get(§ion.title)
3619 .copied()
3620 .unwrap_or(section.folded_by_default)
3621 }
3622
3623 pub fn is_collapsed(&self, section: usize) -> bool {
3625 self.sections
3626 .get(section)
3627 .is_some_and(|s| self.section_folded(s))
3628 }
3629
3630 pub fn toggle_collapsed(&mut self, section: usize) {
3632 let folded = self.is_collapsed(section);
3633 self.set_collapsed(section, !folded);
3634 }
3635
3636 pub fn set_collapsed(&mut self, section: usize, collapsed: bool) {
3640 if self.browsing.is_some() {
3641 return;
3642 }
3643 let Some(title) = self.sections.get(section).map(|s| s.title.clone()) else {
3644 return;
3645 };
3646 self.folds.insert(title, collapsed);
3647 }
3648
3649 pub fn jump_section(&mut self, delta: isize) {
3651 self.resting = false;
3652 self.returning = None;
3653 self.landing = false;
3654 let headers = self.view().headers.clone();
3655 if headers.is_empty() {
3656 return;
3657 }
3658 let current = self.selected;
3659 self.selected = if delta > 0 {
3660 headers
3661 .iter()
3662 .copied()
3663 .find(|&h| h > current)
3664 .unwrap_or(headers[0])
3665 } else {
3666 headers
3667 .iter()
3668 .rev()
3669 .copied()
3670 .find(|&h| h < current)
3671 .unwrap_or(*headers.last().unwrap())
3672 };
3673 }
3674
3675 pub fn has_any_dataset(&self) -> bool {
3679 self.view().has_dataset
3680 }
3681
3682 pub const SEARCH_SECTION: &'static str = "Found";
3684
3685 pub const CLOUD_SECTION: &'static str = "Cloud";
3687
3688 pub const RECENT_SECTION: &'static str = "Recent";
3690
3691 pub fn cloud_source_of(&self, path: &Path) -> Option<&CloudSource> {
3694 if let Some(id) = cloud_source_id(path) {
3695 return self.cloud.iter().find(|s| s.id == id);
3696 }
3697 if let Some((id, _)) = cloud_account(path) {
3698 return self.cloud.iter().find(|s| s.id == id);
3699 }
3700 let text = path.to_string_lossy();
3701 if let Some((account, _, _)) = crate::cloud::source::azure_parts(&text) {
3702 return self.azure_account_place(&account).and_then(|place| {
3703 cloud_account(&place).and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id))
3704 });
3705 }
3706 if let (Some(id), _) = crate::cloud::source::split_source_id(&text) {
3707 return self.cloud.iter().find(|s| s.id == id);
3708 }
3709 if let Some(project) =
3710 Self::google_bucket_root(path).and_then(|b| self.project_of_bucket(&b))
3711 {
3712 return cloud_account(&project)
3713 .and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id));
3714 }
3715 let (_, plain) = crate::cloud::source::split_source_id(&text);
3716 let (scheme, rest) = plain.split_once("://")?;
3717 let bucket = rest.split('/').next()?;
3718 let root = PathBuf::from(format!("{scheme}://{bucket}"));
3719 self.cloud.iter().find(|s| s.buckets.contains(&root))
3720 }
3721
3722 pub fn parent_of(&self, path: &Path) -> Option<PathBuf> {
3725 if cloud_source_id(path).is_some() {
3726 return None;
3727 }
3728 if let Some((_, dataset)) = self.remote_dataset_of(path) {
3731 let place = &dataset.location;
3732 if same_place(path, place) {
3733 return None;
3734 }
3735 let up = self.parent_within(path)?;
3736 return Some(if same_place(&up, place) {
3738 place.clone()
3739 } else {
3740 up
3741 });
3742 }
3743 if let Some((id, _)) = cloud_account(path) {
3744 return Some(cloud_place(&id));
3745 }
3746 let text = path.to_string_lossy();
3747 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3748 let key = key.trim_matches('/');
3749 if key.is_empty() {
3750 return self.azure_account_place(&account);
3751 }
3752 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3753 let up = if up.is_empty() {
3754 String::new()
3755 } else {
3756 format!("{up}/")
3757 };
3758 return Some(PathBuf::from(crate::cloud::source::azure_url(
3759 &account, &container, &up,
3760 )));
3761 }
3762 if is_bucket_root(path) {
3763 if let Some(project) = self.project_of_bucket(path) {
3765 return Some(project);
3766 }
3767 return self.cloud_source_of(path).map(|s| cloud_place(&s.id));
3768 }
3769 parent_location(path)
3770 }
3771
3772 fn parent_within(&self, path: &Path) -> Option<PathBuf> {
3774 let text = path.to_string_lossy();
3775 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3776 let key = key.trim_matches('/');
3777 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3778 let up = if up.is_empty() {
3779 String::new()
3780 } else {
3781 format!("{up}/")
3782 };
3783 return Some(PathBuf::from(crate::cloud::source::azure_url(
3784 &account, &container, &up,
3785 )));
3786 }
3787 parent_location(path)
3788 }
3789
3790 fn remote_dataset_of(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3793 if !is_object_store_url(path) {
3794 return None;
3795 }
3796 let text = path.to_string_lossy();
3797 self.catalogs
3798 .iter()
3799 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3800 .filter(|(_, d)| {
3801 is_object_store_url(&d.location) && within(&text, &d.location.to_string_lossy())
3802 })
3803 .rev()
3804 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
3805 }
3806
3807 pub fn catalog_dataset(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3809 let places = &self.catalog_places;
3810 if places.indexes(&self.catalogs) {
3811 let key = place_key(path);
3812 let &(c, d) = places.datasets.get(&key)?;
3813 let catalog = self.catalogs.get(c)?;
3814 if let Some(dataset) = catalog.datasets.get(d)
3815 && place_key(&dataset.location) == key
3816 {
3817 return Some((catalog, dataset));
3818 }
3819 }
3820 self.catalogs
3821 .iter()
3822 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3823 .find(|(_, d)| same_place(&d.location, path))
3824 }
3825
3826 pub fn bookmark(&self, path: &Path) -> Option<(&ShownDataset, &str)> {
3828 let places = &self.catalog_places;
3829 if places.indexes(&self.catalogs) {
3830 let key = place_key(path);
3831 let &(c, d, b) = places.bookmarks.get(&key)?;
3832 if let Some(dataset) = self.catalogs.get(c).and_then(|c| c.datasets.get(d))
3833 && let Some((name, place)) = dataset.bookmarks.get(b)
3834 && place_key(place) == key
3835 {
3836 return Some((dataset, name.as_str()));
3837 }
3838 }
3839 self.catalogs
3840 .iter()
3841 .flat_map(|c| c.datasets.iter())
3842 .find_map(|d| {
3843 d.bookmarks
3844 .iter()
3845 .find(|(_, place)| same_place(place, path))
3846 .map(|(name, _)| (d, name.as_str()))
3847 })
3848 }
3849
3850 pub fn size_hint(&self, path: &Path) -> Option<u64> {
3852 self.catalog_dataset(path).and_then(|(_, d)| d.size)
3853 }
3854
3855 fn azure_account_place(&self, account: &str) -> Option<PathBuf> {
3857 self.cloud
3858 .iter()
3859 .flat_map(|s| s.buckets.iter())
3860 .find(|place| cloud_account(place).is_some_and(|(_, a)| a == account))
3861 .cloned()
3862 }
3863
3864 pub fn cloud_look(&self, entry: &Entry) -> Option<CloudLook> {
3867 if entry.kind != EntryKind::Directory
3868 || !entry.holds.is_empty()
3869 || !is_object_store_url(&entry.path)
3870 || is_cloud_place(&entry.path)
3871 || object_place_label(&entry.path).is_some()
3872 {
3873 return None;
3874 }
3875 if self.peeking.contains(&entry.path) {
3876 return Some(CloudLook::Looking);
3877 }
3878 if self.peek_failed.contains(&entry.path) {
3879 return Some(CloudLook::Failed);
3880 }
3881 match self.cloud_kinds.get(&entry.path) {
3882 None => Some(CloudLook::Waiting),
3883 Some((kind, holds)) if *kind != EntryKind::Directory || !holds.is_empty() => {
3886 Some(CloudLook::Looking)
3887 }
3888 Some(_) => None,
3889 }
3890 }
3891
3892 pub fn place_kind(&self, path: &Path) -> Option<&'static str> {
3895 if self.missing.contains(path) {
3896 return Some("missing");
3897 }
3898 if let Some((id, _)) = cloud_account(path) {
3899 return self
3900 .cloud
3901 .iter()
3902 .any(|s| s.id == id && s.api == crate::cloud::source::ProviderKind::Gcs)
3903 .then_some("project");
3904 }
3905 (is_object_store_url(path)
3907 && (self
3908 .catalog_dataset(path)
3909 .is_some_and(|(_, d)| is_object_store_url(&d.location))
3910 || (self.browsing.is_none() && self.bookmark(path).is_some())))
3911 .then_some("dataset")
3912 }
3913
3914 fn project_of_bucket(&self, bucket_root: &Path) -> Option<PathBuf> {
3916 let root = bucket_root.to_string_lossy();
3917 let root = root.trim_end_matches('/');
3918 self.probes
3919 .answered()
3920 .filter(|(place, _)| cloud_account(place).is_some())
3921 .find(|(_, rows)| {
3922 rows.iter()
3923 .any(|row| row.path.to_string_lossy().trim_end_matches('/') == root)
3924 })
3925 .map(|(place, _)| place.clone())
3926 }
3927
3928 fn google_bucket_root(path: &Path) -> Option<PathBuf> {
3930 let text = path.to_string_lossy();
3931 let rest = text
3932 .strip_prefix("gs://")
3933 .or_else(|| text.strip_prefix("gcs://"))?;
3934 let bucket = rest.split('/').next().filter(|b| !b.is_empty())?;
3935 Some(PathBuf::from(format!("gs://{bucket}")))
3936 }
3937
3938 pub fn place_details(&self, path: &Path) -> Option<&[(String, String)]> {
3941 self.cloud
3942 .iter()
3943 .find_map(|s| s.place_details.get(path))
3944 .or_else(|| self.catalog_dataset(path).map(|(_, d)| &d.details))
3945 .or_else(|| self.bookmark(path).map(|(d, _)| &d.details))
3946 .map(Vec::as_slice)
3947 }
3948
3949 pub fn location_label(&self, path: &Path) -> String {
3952 let sep = crate::glyphs::get().trail;
3953 if let Some((catalog, dataset)) = self.remote_dataset_of(path) {
3956 let text = path.to_string_lossy();
3957 let rest = within_rest(&text, &dataset.location.to_string_lossy());
3958 let mut parts = vec![catalog.label.clone(), dataset.name.clone()];
3959 parts.extend(
3960 rest.split('/')
3961 .filter(|p| !p.is_empty())
3962 .map(str::to_string),
3963 );
3964 return parts.join(&format!(" {sep} "));
3965 }
3966 if let Some(source) = self.cloud_source_of(path) {
3967 let mut parts = vec!["cloud".to_string(), source.label.clone()];
3968 let text = path.to_string_lossy();
3969 if let Some((_, account)) = cloud_account(path) {
3970 parts.push(account);
3971 } else if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text)
3972 {
3973 parts.push(account);
3974 parts.push(container);
3975 parts.extend(key.split('/').filter(|p| !p.is_empty()).map(str::to_string));
3976 } else if cloud_source_id(path).is_none() {
3977 if let Some((_, project)) = Self::google_bucket_root(path)
3978 .and_then(|b| self.project_of_bucket(&b))
3979 .as_deref()
3980 .and_then(cloud_account)
3981 {
3982 parts.push(project);
3983 }
3984 let (_, plain) = crate::cloud::source::split_source_id(&text);
3985 if let Some((_, rest)) = plain.split_once("://") {
3986 parts.extend(
3987 rest.split('/')
3988 .filter(|p| !p.is_empty())
3989 .map(str::to_string),
3990 );
3991 }
3992 }
3993 return parts.join(&format!(" {sep} "));
3994 }
3995 display_path(path)
3996 }
3997
3998 pub fn sync_search_section(&mut self) {
4001 let found = (self.sections.iter()).position(|s| s.title == Self::SEARCH_SECTION);
4003 let mut kept = self.take_hits();
4004 self.sections.retain(|s| s.title != Self::SEARCH_SECTION);
4005 self.changed();
4006 if let (Some(hits), Some(at)) = (kept.as_mut(), found)
4007 && at < hits.sections.len()
4008 {
4009 hits.sections.remove(at);
4010 }
4011 *self.rows_cache.rescored.get_mut() = kept;
4012
4013 if self.filter.is_empty() {
4014 return;
4015 }
4016 let cloud_rows: Vec<Entry> = if self.browsing.is_none() {
4018 self.cloud
4019 .iter()
4020 .flat_map(|source| {
4021 source.buckets.iter().map(move |bucket| {
4022 let mut entry = bucket_entry(bucket);
4023 entry.name = format!(
4024 "{} {} {}",
4025 source.label,
4026 crate::glyphs::get().trail,
4027 entry.name
4028 );
4029 entry.cost.source = Some(source.api.name().to_string());
4030 entry
4031 })
4032 })
4033 .filter(|e| match_score(&self.filter, e).is_some())
4034 .collect()
4035 } else {
4036 Vec::new()
4037 };
4038 self.score_search_inline();
4039 let local = self.search.root.is_some()
4040 && (self.search.indexed > 0 || self.search.running || self.search.limited.is_some());
4041 if !local {
4042 if !cloud_rows.is_empty() {
4043 let subtitle =
4044 crate::glyphs::dotted(&format!("cloud · {} names", cloud_rows.len()));
4045 self.sections.push(Section {
4046 subtitle: Some(subtitle),
4047 ..Section::titled(Self::SEARCH_SECTION, cloud_rows)
4048 });
4049 }
4050 return;
4051 }
4052
4053 let matches = self.search.matches.as_ref();
4056 let names: std::collections::HashSet<&std::ffi::OsStr> = (matches.iter())
4060 .flat_map(|m| m.top.iter())
4061 .map(|e| e.path.file_name().unwrap_or(e.path.as_os_str()))
4062 .collect();
4063 let listed: std::collections::HashSet<&PathBuf> = (self.sections.iter())
4064 .flat_map(|s| s.rows.iter().map(|r| &r.path))
4065 .filter(|path| path.file_name().is_none_or(|name| names.contains(name)))
4066 .collect();
4067 let kept: Vec<(&Entry, i32)> = matches
4068 .map(|m| {
4069 let fresh = m.query == self.filter;
4070 m.top
4071 .iter()
4072 .zip(m.scores.iter().copied())
4073 .filter(|(e, _)| !listed.contains(&e.path))
4074 .filter_map(|(e, score)| {
4075 if fresh {
4076 Some((e, score))
4077 } else {
4078 match_score(&self.filter, e).map(|s| (e, s))
4079 }
4080 })
4081 .collect()
4082 })
4083 .unwrap_or_default();
4084 let mut rows: Vec<Entry> = kept.into_iter().map(|(e, _)| e.clone()).collect();
4085 rows.extend(cloud_rows);
4086 for row in &mut rows {
4089 fold_known(row, &self.enriched, &self.stated, &self.stale);
4090 }
4091
4092 let partial = self.search.limited.is_some();
4094 let scored = self.search.scored_for(&self.filter);
4095 if rows.is_empty() && !self.search.running && !partial && scored {
4096 return;
4097 }
4098
4099 let subtitle = self.found_subtitle(rows.is_empty());
4100
4101 self.sections.push(Section {
4102 subtitle: Some(subtitle),
4103 ..Section::titled(Self::SEARCH_SECTION, rows)
4104 });
4105 }
4106
4107 fn found_subtitle(&self, empty: bool) -> String {
4109 let root = self.search.root.clone().unwrap_or_default();
4110 let dot = crate::glyphs::get().middot;
4111 let files = crate::numfmt::group_chrome(self.search.indexed);
4112 let files = if self.search.indexed == 1 {
4113 format!("{files} file")
4114 } else {
4115 format!("{files} files")
4116 };
4117 let subtitle = display_path(&root);
4118 let counted = self
4121 .search
4122 .matches
4123 .as_ref()
4124 .filter(|m| m.query == self.filter && m.ids.len() > m.top.len())
4125 .map(|m| {
4126 format!(
4127 " {dot} {} of {} matches",
4128 crate::numfmt::group_chrome(m.top.len()),
4129 crate::numfmt::group_chrome(m.ids.len())
4130 )
4131 })
4132 .unwrap_or_default();
4133 if self.search.running {
4134 format!(
4135 "{subtitle} {dot} searching {}{counted}",
4136 crate::numfmt::group_chrome(self.search.scanned)
4137 )
4138 } else if !self.search.scored_for(&self.filter) {
4139 format!("{subtitle} {dot} matching {files}")
4140 } else if empty {
4141 match &self.search.limited {
4142 Some(limit) => format!("{subtitle} {dot} no match in {files} {dot} {limit}"),
4143 None => format!("{subtitle} {dot} no match in {files}"),
4144 }
4145 } else {
4146 let searched = crate::numfmt::group_chrome(self.search.scanned);
4147 match &self.search.limited {
4148 Some(limit) => {
4149 format!("{subtitle} {dot} {limit} {dot} {searched} searched{counted}")
4150 }
4151 None => format!("{subtitle} {dot} {searched} searched{counted}"),
4152 }
4153 }
4154 }
4155
4156 pub fn search_batch(&mut self, root: &Path, mut found: Vec<Entry>, scanned: usize) {
4158 if self.search.root.as_deref() != Some(root) {
4160 return;
4161 }
4162 for row in &mut found {
4165 apply_known_facts(row, &self.known, false);
4166 }
4167 self.search.scanned = scanned;
4168 let start = self.search.indexed;
4169 let batch: std::sync::Arc<[Entry]> = found.into();
4170 if !batch.is_empty() {
4171 self.search.indexed += batch.len();
4172 self.search.results.push(batch.clone());
4173 }
4174 let limit = self.search_limit;
4177 let changed = match self.search.matches.as_mut() {
4178 Some(m) if m.query == self.filter && m.upto == start => m.extend(&batch, start, limit),
4179 _ => {
4180 let before = self.search.matches.as_ref().map(|m| m.upto);
4181 self.score_search_inline();
4182 self.search.matches.as_ref().map(|m| m.upto) != before
4183 }
4184 };
4185 let found_listed = self
4187 .sections
4188 .iter()
4189 .position(|s| s.title == Self::SEARCH_SECTION);
4190 match found_listed {
4191 Some(at) if !changed => {
4192 let empty = self.sections[at].rows.is_empty();
4193 self.sections[at].subtitle = Some(self.found_subtitle(empty));
4194 }
4195 _ => self.sync_search_section(),
4196 }
4197 self.settle_return();
4198 }
4199
4200 fn score_search_inline(&mut self) {
4202 if self.filter.is_empty() || self.search.scored_for(&self.filter) {
4203 return;
4204 }
4205 let (base, looks_at) = self.search.base_for(&self.filter);
4206 if looks_at > SCORE_INLINE_MAX {
4207 return;
4208 }
4209 let scored =
4210 crate::home::search::score(&self.search.results, &self.filter, base, self.search_limit);
4211 self.search.matches = Some(scored);
4212 }
4213
4214 pub fn score_job(&mut self) -> Option<ScoreJob> {
4217 if self.filter.is_empty() || self.search.scoring || self.search.scored_for(&self.filter) {
4218 return None;
4219 }
4220 let base = self.search.base_for(&self.filter).0.cloned();
4221 self.search.scoring = true;
4222 Some(ScoreJob {
4223 epoch: self.search.epoch,
4224 results: self.search.results.clone(),
4225 query: self.filter.clone(),
4226 base,
4227 limit: self.search_limit,
4228 })
4229 }
4230
4231 pub fn search_scored(&mut self, epoch: u64, scored: crate::home::search::Matches) {
4233 if epoch != self.search.epoch {
4234 return;
4235 }
4236 self.search.scoring = false;
4237 if self
4239 .search
4240 .matches
4241 .as_ref()
4242 .is_some_and(|m| m.query == scored.query && m.upto >= scored.upto)
4243 {
4244 return;
4245 }
4246 self.search.matches = Some(scored);
4247 self.sync_search_section();
4248 if !matches!(self.selected_row(), Some(Row::Entry { .. })) {
4250 self.select_first_entry();
4251 }
4252 self.settle_return();
4253 }
4254
4255 pub fn search_finished(&mut self, root: &Path, scanned: usize, limited: Option<String>) {
4257 if self.search.root.as_deref() != Some(root) {
4258 return;
4259 }
4260 self.search.running = false;
4261 self.search.done = true;
4262 self.search.scanned = self.search.scanned.max(scanned);
4264 self.search.limited = limited;
4265 self.sync_search_section();
4266 self.settle_return();
4267 }
4268
4269 pub fn visible(&self) -> Vec<Row<'_>> {
4272 let view = self.view();
4273 view.slots.iter().map(|slot| self.row(slot)).collect()
4274 }
4275
4276 pub fn list_lines(&self, spaced: bool) -> ListLines {
4280 let view = self.view();
4281 ListLines {
4282 headers: view.headers.clone(),
4283 rows: view.slots.len(),
4284 spaced,
4285 }
4286 }
4287
4288 pub fn matched(&self) -> usize {
4290 let view = self.view();
4291 (view.headers.iter())
4292 .map(|&i| match &view.slots[i] {
4293 Slot::Plain(Row::Header { matches, .. }) => *matches,
4294 _ => 0,
4295 })
4296 .sum()
4297 }
4298
4299 pub fn position(&self, mut wanted: impl FnMut(&Row<'_>) -> bool) -> Option<usize> {
4302 let view = self.view();
4303 view.slots.iter().position(|slot| wanted(&self.row(slot)))
4304 }
4305
4306 pub fn row_count(&self) -> usize {
4308 self.view().slots.len()
4309 }
4310
4311 pub fn row_at(&self, index: usize) -> Option<Row<'_>> {
4313 self.view().slots.get(index).map(|slot| self.row(slot))
4314 }
4315
4316 pub fn rows_built(&self) -> usize {
4319 self.rows_cache.builds.get()
4320 }
4321
4322 fn changed(&mut self) {
4324 *self.rows_cache.built.get_mut() = None;
4325 *self.rows_cache.rescored.get_mut() = None;
4326 }
4327
4328 fn take_hits(&mut self) -> Option<Hits> {
4331 let built = self.rows_cache.built.get_mut().take();
4332 match self.rows_cache.rescored.get_mut().take() {
4333 Some(hits) => Some(hits),
4334 None => built
4335 .filter(|view| view.key.matches(self))
4336 .map(|view| view.hits),
4337 }
4338 .filter(|hits| hits.filter == self.filter)
4339 }
4340
4341 fn rows_changed(&mut self, touched: &[(usize, usize)]) {
4345 let Some(mut hits) = self.take_hits() else {
4346 return;
4347 };
4348 let needle = Needle::new(&self.filter);
4349 for &(si, i) in touched {
4350 if let Some(Some(section)) = hits.sections.get_mut(si)
4351 && let Some(hit) = section.get_mut(i)
4352 {
4353 *hit = needle.hit(&self.sections[si].rows[i]);
4354 }
4355 }
4356 *self.rows_cache.rescored.get_mut() = Some(hits);
4357 }
4358
4359 pub fn set_catalogs(&mut self, catalogs: Vec<ShownCatalog>) {
4361 self.catalog_places = CatalogPlaces::of(&catalogs);
4362 self.catalogs = catalogs;
4363 self.changed();
4364 }
4365
4366 pub fn set_visits(&mut self, visits: std::collections::HashMap<PathBuf, crate::cache::Visits>) {
4368 self.visits = visits;
4369 self.changed();
4370 }
4371
4372 pub fn show_all(&mut self, section: usize) {
4374 match self.sections.get(section) {
4375 Some(s) if s.grouped_by_place => self.recent_expanded = true,
4376 Some(Section {
4377 root: Some(root), ..
4378 }) => {
4379 self.shown_whole.insert(root.clone());
4380 }
4381 _ => {}
4382 }
4383 }
4384
4385 pub fn cut_again(&mut self, section: usize) -> bool {
4388 let Some(key) = self.selected_key() else {
4389 return false;
4390 };
4391 let Some(s) = self.sections.get(section) else {
4392 return false;
4393 };
4394 let title = s.title.clone();
4395 let whole = if s.grouped_by_place {
4396 None
4397 } else {
4398 match s.root.clone() {
4399 Some(root) => Some(root),
4400 None => return false,
4401 }
4402 };
4403 let was_whole = match &whole {
4404 None => std::mem::replace(&mut self.recent_expanded, false),
4405 Some(root) => self.shown_whole.remove(root),
4406 };
4407 if !was_whole {
4408 return false;
4409 }
4410 if self.listed(&key).is_some() {
4411 match whole {
4412 None => self.recent_expanded = true,
4413 Some(root) => {
4414 self.shown_whole.insert(root);
4415 }
4416 }
4417 return false;
4418 }
4419 self.reselect(Some(RowKey::More(title)));
4420 true
4421 }
4422
4423 pub fn sections_mut(&mut self) -> &mut Vec<Section> {
4425 self.changed();
4426 &mut self.sections
4427 }
4428
4429 fn view(&self) -> std::cell::Ref<'_, View> {
4430 let fresh = self
4431 .rows_cache
4432 .built
4433 .borrow()
4434 .as_ref()
4435 .is_some_and(|view| view.key.matches(self));
4436 if !fresh {
4437 let view = self.build_view();
4438 self.rows_cache.builds.set(self.rows_cache.builds.get() + 1);
4439 *self.rows_cache.built.borrow_mut() = Some(view);
4440 }
4441 std::cell::Ref::map(self.rows_cache.built.borrow(), |view| {
4442 view.as_ref().expect("built above")
4443 })
4444 }
4445
4446 fn row<'a>(&'a self, slot: &Slot) -> Row<'a> {
4447 match slot {
4448 Slot::Plain(row) => row.clone(),
4449 Slot::Entry {
4450 section,
4451 index,
4452 nested,
4453 hit,
4454 } => Row::Entry {
4455 section: *section,
4456 entry: &self.sections[*section].rows[*index],
4457 nested: *nested,
4458 hit: *hit,
4459 },
4460 Slot::Door { section } => Row::Door {
4461 section: *section,
4462 entry: self.sections[*section]
4463 .door
4464 .as_ref()
4465 .expect("the shape says it has a door"),
4466 },
4467 }
4468 }
4469
4470 fn build_view(&self) -> View {
4471 let kept = (self.rows_cache.rescored.take()).filter(|hits| {
4473 hits.filter == self.filter && hits.sections.len() <= self.sections.len()
4474 });
4475 let hits = self.score_rows(kept);
4476 let slots = self.slots(&hits);
4477 View {
4478 key: ViewKey::of(self),
4479 headers: (slots.iter().enumerate())
4480 .filter(|(_, slot)| matches!(slot, Slot::Plain(Row::Header { .. })))
4481 .map(|(i, _)| i)
4482 .collect(),
4483 slots,
4484 hits,
4485 has_dataset: self
4486 .sections
4487 .iter()
4488 .flat_map(|s| s.rows.iter())
4489 .any(|e| e.kind.is_dataset() || e.kind.is_lake_table()),
4490 }
4491 }
4492
4493 fn score_rows(&self, kept: Option<Hits>) -> Hits {
4496 let needle = Needle::new(&self.filter);
4497 let mut kept = kept.map(|hits| hits.sections).unwrap_or_default();
4498 let sections =
4499 (self.sections.iter().enumerate())
4500 .map(|(si, section)| {
4501 let reused = (kept.get_mut(si).and_then(Option::take))
4502 .filter(|hits| hits.len() == section.rows.len());
4503 Some(reused.unwrap_or_else(|| {
4504 (section.rows.iter()).map(|row| needle.hit(row)).collect()
4505 }))
4506 })
4507 .collect();
4508 Hits {
4509 filter: self.filter.clone(),
4510 sections,
4511 }
4512 }
4513
4514 fn slots(&self, hits: &Hits) -> Vec<Slot> {
4515 let mut out: Vec<Slot> = Vec::new();
4516 for (si, section) in self.sections.iter().enumerate() {
4517 let mut matched: Vec<(usize, Hit)> = (section.rows.iter())
4518 .zip(hits.sections[si].iter().flatten())
4519 .enumerate()
4520 .filter(|(_, (row, _))| !(self.hide_unreadable && row.hidden_by_default()))
4521 .filter_map(|(i, (_, hit))| hit.map(|hit| (i, hit)))
4522 .collect();
4523
4524 let keep_empty = section.unavailable || section.waiting || section.origin.is_some();
4528 let has_door = section.door.is_some() && self.filter.is_empty();
4529 let hidden =
4532 if self.browsing.is_some() && self.hide_unreadable && self.filter.is_empty() {
4533 section
4534 .rows
4535 .iter()
4536 .filter(|row| row.hidden_by_default())
4537 .count()
4538 } else {
4539 0
4540 };
4541 let says_why = section.title == Self::SEARCH_SECTION;
4543 if matched.is_empty()
4544 && !has_door
4545 && hidden == 0
4546 && !says_why
4547 && !(keep_empty && self.filter.is_empty())
4548 {
4549 continue;
4550 }
4551
4552 let entry = |i: usize| §ion.rows[i];
4556 if !self.filter.is_empty() {
4557 let now = std::time::SystemTime::now()
4558 .duration_since(std::time::UNIX_EPOCH)
4559 .map(|d| d.as_secs())
4560 .unwrap_or_default();
4561 let lifted = |i: usize, score: i32| {
4562 let frecency = self
4563 .visits
4564 .get(&entry(i).path)
4565 .map_or(0.0, |v| v.frecency(now));
4566 score.saturating_add((frecency.min(10.0) * FRECENCY_LIFT) as i32)
4567 };
4568 matched.sort_by_cached_key(|(i, hit)| {
4569 (
4570 std::cmp::Reverse(lifted(*i, hit.score)),
4571 entry(*i).name.len(),
4572 )
4573 });
4574 }
4575
4576 match self.sort {
4579 SortMode::Natural => {}
4580 SortMode::Size => {
4581 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).size.unwrap_or(0)));
4582 }
4583 SortMode::Rows => {
4584 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).rows.unwrap_or(0)));
4585 }
4586 SortMode::Modified => {
4587 matched.sort_by_key(|(i, _)| {
4588 std::cmp::Reverse(
4589 entry(*i)
4590 .modified
4591 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
4592 .map(|d| d.as_secs())
4593 .unwrap_or(0),
4594 )
4595 });
4596 }
4597 }
4598
4599 let collapsed = self.section_folded(section);
4600 out.push(Slot::Plain(Row::Header {
4601 section: si,
4602 matches: matched.len(),
4604 collapsed,
4605 }));
4606 if collapsed {
4607 continue;
4608 }
4609 let root = section.root.as_deref();
4611 if self.filter.is_empty()
4612 && root
4613 .is_some_and(|root| self.browsing.is_some() || self.parent_of(root).is_some())
4614 {
4615 out.push(Slot::Plain(Row::Up { section: si }));
4616 }
4617 if has_door {
4620 out.push(Slot::Door { section: si });
4621 }
4622 let shown = match root {
4626 Some(_)
4627 if self.browsing.is_none()
4628 && self.filter.is_empty()
4629 && self.view_height > 0
4630 && !root.is_some_and(|root| self.shown_whole.contains(root)) =>
4631 {
4632 (self.view_height * 2 / 5).max(8)
4633 }
4634 _ => usize::MAX,
4635 };
4636 let rest = if matched.len() > shown.saturating_add(1) {
4637 matched.split_off(shown)
4638 } else {
4639 Vec::new()
4640 };
4641 let measuring = rest.iter().any(|(i, _)| self.sort_wants(entry(*i)));
4644 if section.grouped_by_place {
4645 self.slots_by_place(si, section, &matched, &mut out);
4646 } else {
4647 let in_order = self.sort == SortMode::Natural
4649 && self.filter.is_empty()
4650 && section.origin.is_some_and(is_catalog_origin)
4651 && section.root.is_none();
4652 out.extend(matched.into_iter().map(|(index, hit)| Slot::Entry {
4653 section: si,
4654 index,
4655 nested: in_order && self.bookmark(&entry(index).path).is_some(),
4656 hit,
4657 }));
4658 }
4659 if !rest.is_empty() {
4660 out.push(Slot::Plain(Row::More {
4661 section: si,
4662 hidden: rest.len(),
4663 places: 0,
4664 measuring,
4665 }));
4666 }
4667 if hidden > 0 {
4668 out.push(Slot::Plain(Row::Hidden {
4669 section: si,
4670 count: hidden,
4671 }));
4672 }
4673 }
4674 out
4675 }
4676
4677 fn slots_by_place(
4683 &self,
4684 si: usize,
4685 section: &Section,
4686 matched: &[(usize, Hit)],
4687 out: &mut Vec<Slot>,
4688 ) {
4689 let places: Vec<PathBuf> = section.rows.iter().map(|row| place_of(&row.path)).collect();
4690 let mut order: Vec<&PathBuf> = Vec::new();
4691 for place in &places {
4692 if !order.contains(&place) {
4693 order.push(place);
4694 }
4695 }
4696 let groups: Vec<(&PathBuf, Vec<&(usize, Hit)>)> = order
4697 .into_iter()
4698 .filter_map(|place| {
4699 let rows: Vec<&(usize, Hit)> = matched
4700 .iter()
4701 .filter(|(i, _)| places[*i] == *place)
4702 .collect();
4703 (!rows.is_empty()).then_some((place, rows))
4704 })
4705 .collect();
4706
4707 let capped = !self.recent_expanded && self.filter.is_empty() && self.view_height > 0;
4709 let budget = self.view_height / 3;
4710 let mut used = 0usize;
4711 let mut shown = 0usize;
4712 for (place, rows) in &groups {
4713 let cost = 1 + rows.len();
4714 if capped && shown > 0 && used + cost > budget {
4715 break;
4716 }
4717 out.push(Slot::Plain(Row::Place {
4718 section: si,
4719 path: (*place).clone(),
4720 label: section.place_labels.get(*place).cloned(),
4721 source: section.rows[rows[0].0].cost.source.clone(),
4723 held: places.iter().filter(|p| p == place).count(),
4724 }));
4725 out.extend(rows.iter().map(|(index, hit)| Slot::Entry {
4726 section: si,
4727 index: *index,
4728 nested: true,
4729 hit: *hit,
4730 }));
4731 used += cost;
4732 shown += 1;
4733 }
4734 if shown < groups.len() {
4735 out.push(Slot::Plain(Row::More {
4736 section: si,
4737 hidden: groups[shown..].iter().map(|(_, rows)| rows.len()).sum(),
4738 places: groups.len() - shown,
4739 measuring: false,
4740 }));
4741 }
4742 }
4743
4744 pub fn path_candidates(&self) -> Vec<&PathName> {
4747 let Some(listing) = self
4748 .path_listing
4749 .as_ref()
4750 .filter(|l| l.dir == typed_dir(&self.path_input))
4751 else {
4752 return Vec::new();
4753 };
4754 let segment = &self.path_input[listing.dir.len()..];
4755 let mut memo = (listing.matched.0.lock()).unwrap_or_else(|e| e.into_inner());
4756 let at = match memo.as_ref() {
4757 Some((typed, at)) if typed == segment => at.clone(),
4758 _ => {
4759 let at = path_matches(&listing.names, segment);
4760 *memo = Some((segment.to_string(), at.clone()));
4761 at
4762 }
4763 };
4764 drop(memo);
4765 at.iter().map(|&i| &listing.names[i]).collect()
4766 }
4767
4768 pub fn pick_first_path(&mut self) {
4771 self.path_pick = (!self.path_candidates().is_empty()).then_some(0);
4772 }
4773
4774 pub fn picked_path(&self) -> Option<String> {
4776 let pick = self.path_pick?;
4777 let name = *self.path_candidates().get(pick)?;
4778 let dir = typed_dir(&self.path_input);
4779 let mut path = format!("{dir}{}", name.name);
4780 if name.dir {
4781 path.push(separator_in(dir));
4782 }
4783 Some(path)
4784 }
4785
4786 pub fn path_completion(&self) -> Option<String> {
4789 let dir = typed_dir(&self.path_input);
4790 let segment = &self.path_input[dir.len()..];
4791 let candidates = self.path_candidates();
4792 match candidates.as_slice() {
4793 [] => None,
4794 [one] => {
4795 let mut path = format!("{dir}{}", one.name);
4796 if one.dir {
4797 path.push(separator_in(dir));
4798 }
4799 Some(path)
4800 }
4801 many => {
4802 let starting: Vec<&str> = many
4803 .iter()
4804 .map(|n| n.name.as_str())
4805 .filter(|n| n.starts_with(segment))
4806 .collect();
4807 let first = starting.first()?;
4808 let shared = starting
4809 .iter()
4810 .skip(1)
4811 .fold(first.to_string(), |acc, n| common_prefix(&acc, n));
4812 (shared.len() > segment.len()).then(|| format!("{dir}{shared}"))
4813 }
4814 }
4815 }
4816
4817 pub fn known_urls(&self) -> Vec<String> {
4820 let mut urls: Vec<String> = Vec::new();
4821 let mut add = |path: &Path| {
4822 let text = path.to_string_lossy();
4823 if text.contains("://") && !is_cloud_place(path) {
4824 urls.push(text.into_owned());
4825 }
4826 };
4827 for catalog in &self.catalogs {
4828 for dataset in &catalog.datasets {
4829 add(&dataset.location);
4830 }
4831 }
4832 for source in &self.cloud {
4833 for bucket in &source.buckets {
4834 add(bucket);
4835 }
4836 }
4837 for (root, rows) in self.probes.answered() {
4838 add(root);
4839 for row in rows.iter() {
4840 add(&row.path);
4841 }
4842 }
4843 for path in self.known.keys() {
4844 add(path);
4845 }
4846 for section in &self.sections {
4847 for row in §ion.rows {
4848 add(&row.path);
4849 }
4850 }
4851 urls
4852 }
4853
4854 pub fn selected_row(&self) -> Option<Row<'_>> {
4856 if self.resting {
4857 return None;
4858 }
4859 self.row_at(self.selected)
4860 }
4861
4862 pub fn cursor_row(&self) -> Option<Row<'_>> {
4864 self.row_at(self.selected)
4865 }
4866
4867 pub fn selected_entry(&self) -> Option<&Entry> {
4870 match self.selected_row()? {
4871 Row::Entry { entry, .. } | Row::Door { entry, .. } => Some(entry),
4872 _ => None,
4873 }
4874 }
4875
4876 pub fn selection_is_the_door(&self) -> bool {
4878 matches!(self.selected_row(), Some(Row::Door { .. }))
4879 }
4880
4881 pub fn step_past_door(&mut self) -> bool {
4884 let Some(Row::Up { section } | Row::Door { section, .. }) = self.selected_row() else {
4885 return false;
4886 };
4887 let first = (self.selected + 1..self.row_count()).find(
4888 |&i| matches!(self.row_at(i), Some(Row::Entry { section: s, .. }) if s == section),
4889 );
4890 let Some(first) = first else {
4891 return false;
4892 };
4893 self.move_selection((first - self.selected) as isize);
4894 self.follow_selection();
4895 true
4896 }
4897
4898 pub fn recents_in(&self, place: &Path) -> Vec<PathBuf> {
4900 self.sections
4901 .iter()
4902 .filter(|s| s.grouped_by_place)
4903 .flat_map(|s| s.rows.iter())
4904 .filter(|row| place_of(&row.path) == place)
4905 .map(|row| row.path.clone())
4906 .collect()
4907 }
4908
4909 pub fn selected_section(&self) -> Option<usize> {
4911 self.selected_row().map(|r| r.section())
4912 }
4913
4914 pub fn selected_catalog(&self) -> Option<&ShownCatalog> {
4916 if !self.selection_is_header() {
4917 return None;
4918 }
4919 let section = self.sections.get(self.selected_section()?)?;
4920 let origin = section.origin?;
4921 self.catalogs
4922 .iter()
4923 .find(|c| c.label == section.title && c.origin_note() == origin)
4924 }
4925
4926 pub fn selection_is_header(&self) -> bool {
4928 matches!(self.selected_row(), Some(Row::Header { .. }))
4929 }
4930
4931 pub fn pending_probes(&self) -> Vec<PathBuf> {
4934 let check = self.network_check;
4935 let mut out = Vec::new();
4936 for root in self.sections.iter().filter_map(|s| s.remote_root.as_ref()) {
4938 if !self.probes.settled(root) && !out.contains(root) {
4939 out.push(root.clone());
4940 }
4941 }
4942 if let Some(dir) = &self.browsing
4945 && check(dir)
4946 && cloud_source_id(dir).is_none()
4947 && !self.probes.settled(dir)
4948 && !out.contains(dir)
4949 {
4950 out.push(dir.clone());
4951 }
4952 out
4953 }
4954
4955 pub fn below_browse_start(&self) -> bool {
4958 let (Some(dir), Some(start)) = (&self.browsing, &self.browse_start) else {
4959 return false;
4960 };
4961 if dir == start {
4962 return false;
4963 }
4964 let mut current = self.parent_of(dir);
4966 let mut steps = 0;
4967 while let Some(place) = current {
4968 if &place == start {
4969 return true;
4970 }
4971 steps += 1;
4972 if steps > 64 {
4973 break;
4974 }
4975 current = self.parent_of(&place);
4976 }
4977 false
4978 }
4979
4980 pub fn sections_waiting(&self) -> bool {
4982 self.sections.iter().any(|s| s.waiting)
4983 }
4984
4985 pub fn awaiting_listing(&self) -> Option<&Path> {
4987 let dir = self.browsing.as_deref()?;
4988 if cloud_source_id(dir).is_some() {
4989 return self
4990 .cloud_source_of(dir)
4991 .is_some_and(|s| s.status == CloudStatus::Listing && s.buckets.is_empty())
4992 .then_some(dir);
4993 }
4994 ((self.network_check)(dir) && !self.probes.settled(dir)).then_some(dir)
4995 }
4996
4997 pub fn probe_ready(&mut self, root: PathBuf, rows: Vec<Entry>, cut_short: bool) {
4999 self.probes.insert(
5000 root.clone(),
5001 Probe::Listed {
5002 rows: rows.into(),
5003 cut_short,
5004 },
5005 );
5006 self.apply_cloud_kinds(&root);
5007 }
5008
5009 pub fn take_cloud_kinds(&mut self) {
5013 let roots: Vec<PathBuf> = (self.probes.answered())
5014 .map(|(root, _)| root.clone())
5015 .collect();
5016 for root in &roots {
5017 self.apply_cloud_kinds(root);
5018 }
5019 let mut labeled = false;
5020 for section in &mut self.sections {
5021 for row in &mut section.rows {
5022 if row.kind == EntryKind::Directory
5023 && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
5024 {
5025 row.kind = *kind;
5026 if !holds.is_empty() {
5027 row.holds = holds.clone();
5028 }
5029 labeled = true;
5030 }
5031 }
5032 }
5033 if labeled {
5034 self.changed();
5035 }
5036 if let Some(dir) = self.browsing.clone()
5038 && roots.contains(&dir)
5039 {
5040 self.relist_remote(&dir);
5041 }
5042 }
5043
5044 pub fn apply_cloud_kinds(&mut self, root: &Path) {
5046 let Some(rows) = self.probes.listed_mut(root) else {
5047 return;
5048 };
5049 for row in std::sync::Arc::make_mut(rows).iter_mut() {
5051 if row.kind == EntryKind::Directory
5052 && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
5053 {
5054 row.kind = *kind;
5055 if !holds.is_empty() {
5058 row.holds = holds.clone();
5059 }
5060 }
5061 }
5062 }
5063
5064 pub fn cloud_directories_to_peek(&self, limit: usize) -> Vec<PathBuf> {
5068 if limit == 0 {
5069 return Vec::new();
5070 }
5071 let view = self.view();
5072 let mut out: Vec<PathBuf> = Vec::new();
5073 for entry in self.entries_near_cursor(&view, limit) {
5074 if !is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
5077 continue;
5078 }
5079 if !matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown) {
5080 continue;
5081 }
5082 if self.browsing.is_none()
5085 && (self.catalog_dataset(&entry.path).is_some()
5086 || self.bookmark(&entry.path).is_some())
5087 {
5088 continue;
5089 }
5090 if self.cloud_kinds.contains_key(&entry.path)
5092 || self.peeking.contains(&entry.path)
5093 || self.peek_failed.contains(&entry.path)
5094 {
5095 continue;
5096 }
5097 if out.contains(&entry.path) {
5098 continue;
5099 }
5100 out.push(entry.path.clone());
5101 if out.len() >= limit {
5102 break;
5103 }
5104 }
5105 out
5106 }
5107
5108 pub fn probe_failed(&mut self, root: PathBuf, why: Option<String>) {
5110 self.probes.insert(root, Probe::Unreachable(why));
5111 }
5112
5113 pub fn measure_now(&mut self, limit: usize) -> bool {
5117 let wanted = self.unmeasured_visible(limit);
5118 let more = self.unmeasured_visible(limit + 1).len() > wanted.len();
5119 for entry in wanted {
5120 let mut probe = entry.clone();
5121 stat_and_recall(&mut probe, &self.known);
5122 if probe.rows.is_none() && probe.columns.is_empty() {
5123 discover::enrich(&mut probe);
5124 }
5125 self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
5126 }
5127 self.apply_new_measurements();
5128 more
5129 }
5130
5131 pub fn unmeasured_visible(&self, limit: usize) -> Vec<Entry> {
5138 let view = self.view();
5139 let mut out: Vec<Entry> = Vec::new();
5140 for entry in self.entries_on_screen(&view) {
5141 if self.wants_measuring(entry) && !out.iter().any(|e| e.path == entry.path) {
5142 out.push(entry.clone());
5143 if out.len() >= limit {
5144 return out;
5145 }
5146 }
5147 }
5148 if self.sort == SortMode::Rows {
5149 self.sorted_rest(&view, limit, &mut out);
5150 }
5151 out
5152 }
5153
5154 pub fn unstated_for_sort(&self, limit: usize) -> Vec<Entry> {
5158 let view = self.view();
5159 let mut out: Vec<Entry> = Vec::new();
5160 for entry in self.entries_on_screen(&view) {
5161 let stale =
5162 (self.stated.get(&entry.path)).is_some_and(|stated| stated.epoch < self.stat_epoch);
5163 if stale && self.needs_stat(entry) && !out.iter().any(|e| e.path == entry.path) {
5164 out.push(entry.clone());
5165 if out.len() >= limit {
5166 break;
5167 }
5168 }
5169 }
5170 if out.len() < limit && matches!(self.sort, SortMode::Size | SortMode::Modified) {
5171 self.sorted_rest(&view, limit, &mut out);
5172 }
5173 for entry in &mut out {
5174 entry.modified = None;
5175 }
5176 out
5177 }
5178
5179 fn sorted_rest(&self, view: &View, limit: usize, out: &mut Vec<Entry>) {
5181 let mut take = |entry: &Entry| {
5182 if self.sort_wants(entry) && !out.iter().any(|e| e.path == entry.path) {
5183 out.push(entry.clone());
5184 }
5185 out.len() >= limit
5186 };
5187 for entry in view.slots.iter().filter_map(|slot| self.entry_of(slot)) {
5188 if take(entry) {
5189 return;
5190 }
5191 }
5192 for slot in &view.slots {
5193 let Slot::Plain(Row::More {
5194 section,
5195 measuring: true,
5196 ..
5197 }) = slot
5198 else {
5199 continue;
5200 };
5201 for entry in &self.sections[*section].rows {
5202 if take(entry) {
5203 return;
5204 }
5205 }
5206 }
5207 }
5208
5209 fn sort_wants(&self, entry: &Entry) -> bool {
5212 match self.sort {
5213 SortMode::Natural => false,
5214 SortMode::Rows => self.wants_measuring(entry),
5215 SortMode::Size | SortMode::Modified => {
5216 (self.sort == SortMode::Modified || entry.kind != EntryKind::Unknown)
5217 && self.needs_stat(entry)
5218 }
5219 }
5220 }
5221
5222 fn needs_stat(&self, entry: &Entry) -> bool {
5225 let due = match self.stated.get(&entry.path) {
5226 Some(stated) => stated.epoch < self.stat_epoch,
5227 None => entry.modified.is_none(),
5228 };
5229 due && discover::on_disk(entry) && !(self.network_check)(&entry.path)
5230 }
5231
5232 fn wants_measuring(&self, entry: &Entry) -> bool {
5235 if entry.measured || self.enriched.contains_key(&entry.path) {
5236 return false;
5237 }
5238 if entry.kind != EntryKind::Unknown
5240 && discover::unstated(entry)
5241 && !self.stated.contains_key(&entry.path)
5242 && !(self.network_check)(&entry.path)
5243 {
5244 return true;
5245 }
5246 if entry.rows.is_some() {
5247 return false;
5248 }
5249 if matches!(
5252 entry.kind,
5253 EntryKind::Directory | EntryKind::Unknown | EntryKind::Other
5254 ) || entry.kind.is_lake_table()
5255 {
5256 return false;
5257 }
5258 !(self.network_check)(&entry.path)
5261 }
5262
5263 pub fn classify_now(&mut self, limit: usize) -> bool {
5266 let wanted = self.unclassified_visible(limit);
5267 let more = self.unclassified_visible(limit + 1).len() > wanted.len();
5268 for entry in wanted {
5269 let mut stated = entry.clone();
5270 stat_and_recall(&mut stated, &self.known);
5271 let probe = look_into_as(&stated, &Default::default());
5272 self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
5273 }
5274 self.apply_new_measurements();
5275 more
5276 }
5277
5278 pub fn unclassified_visible(&self, limit: usize) -> Vec<Entry> {
5284 self.unclassified_visible_where(limit, |_| true)
5285 }
5286
5287 pub fn unclassified_visible_where(
5289 &self,
5290 limit: usize,
5291 keep: impl Fn(&Entry) -> bool,
5292 ) -> Vec<Entry> {
5293 if limit == 0 {
5294 return Vec::new();
5295 }
5296 let view = self.view();
5297 let mut out: Vec<Entry> = Vec::new();
5298 for entry in self.entries_near_cursor(&view, limit) {
5299 if entry.kind != EntryKind::Unknown
5300 || self.missing.contains(&entry.path)
5301 || !keep(entry)
5302 {
5303 continue;
5304 }
5305 if self.enriched.contains_key(&entry.path) {
5307 continue;
5308 }
5309 if is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
5312 continue;
5313 }
5314 if out.iter().any(|e| e.path == entry.path) {
5316 continue;
5317 }
5318 out.push(entry.clone());
5319 if out.len() >= limit {
5320 break;
5321 }
5322 }
5323 out
5324 }
5325
5326 fn entries_on_screen<'a>(&'a self, view: &'a View) -> impl Iterator<Item = &'a Entry> + 'a {
5329 let height = if self.view_height == 0 {
5332 usize::MAX / 4
5333 } else {
5334 self.view_height
5335 };
5336 let rows = view.slots.len();
5337 let top = self.scroll.min(rows);
5338 let reach = height / 2;
5339 let bottom = top.saturating_add(height + reach).min(rows);
5340 let above = top.saturating_sub(reach);
5341 std::iter::once(self.selected)
5342 .chain(top..bottom)
5343 .chain(above..top)
5344 .filter_map(|i| self.entry_of(view.slots.get(i)?))
5345 }
5346
5347 fn entries_near_cursor<'a>(
5350 &'a self,
5351 view: &'a View,
5352 limit: usize,
5353 ) -> impl Iterator<Item = &'a Entry> + 'a {
5354 let height = if self.view_height == 0 {
5357 limit
5358 } else {
5359 self.view_height
5360 };
5361 let rows = view.slots.len();
5362 let top = self.scroll.min(rows);
5363 let ahead = top.saturating_add(2 * height).min(rows);
5364 let behind = top.saturating_sub(height);
5365 std::iter::once(self.selected)
5366 .chain(top..ahead)
5367 .chain(behind..top)
5368 .filter_map(|i| self.entry_of(view.slots.get(i)?))
5369 }
5370
5371 fn entry_of(&self, slot: &Slot) -> Option<&Entry> {
5373 match slot {
5374 Slot::Entry { section, index, .. } => Some(&self.sections[*section].rows[*index]),
5375 _ => None,
5376 }
5377 }
5378
5379 pub fn record_measurement(&mut self, path: PathBuf, measured: Measured) {
5382 self.unapplied.insert(path.clone());
5383 let before = self.stated.insert(
5384 path.clone(),
5385 Stated {
5386 size: measured.size,
5387 modified: measured.modified,
5388 epoch: self.stat_epoch,
5389 },
5390 );
5391 if !measured.stat_only {
5392 self.stale.remove(&path);
5393 self.enriched.insert(path, measured);
5394 return;
5395 }
5396 let changed = before.is_some_and(|before| {
5399 before.modified.is_some()
5400 && (before.size, before.modified) != (measured.size, measured.modified)
5401 });
5402 if changed && self.enriched.remove(&path).is_some() {
5403 self.stale.insert(path);
5404 }
5405 }
5406
5407 pub fn record_size(&mut self, path: PathBuf, measured: Measured) {
5409 match self.enriched.get_mut(&path) {
5410 Some(known) => {
5411 known.size = measured.size;
5412 self.unapplied.insert(path);
5413 }
5414 None => self.record_measurement(path, measured),
5415 }
5416 }
5417
5418 pub fn apply_measurements(&mut self) {
5421 self.unapplied.clear();
5422 for section in &mut self.sections {
5423 for row in section.rows.iter_mut().chain(section.door.iter_mut()) {
5426 fold_known(row, &self.enriched, &self.stated, &self.stale);
5427 }
5428 if let Some(door) = section.door.as_mut() {
5431 door.name = door_name(door, §ion.rows);
5432 }
5433 }
5434 self.changed();
5435 self.land_again();
5436 }
5437
5438 pub fn apply_new_measurements(&mut self) {
5442 if self.unapplied.is_empty() {
5443 return;
5444 }
5445 let unapplied = std::mem::take(&mut self.unapplied);
5446 let names: std::collections::HashSet<&std::ffi::OsStr> = (unapplied.iter())
5450 .map(|path| path.file_name().unwrap_or(path.as_os_str()))
5451 .collect();
5452 let new = |path: &Path| {
5453 path.file_name().is_none_or(|name| names.contains(name)) && unapplied.contains(path)
5454 };
5455 let mut touched: Vec<(usize, usize)> = Vec::new();
5456 let mut any = false;
5457 let mut moved = false;
5458 let sort = self.sort;
5459 for (si, section) in self.sections.iter_mut().enumerate() {
5460 let mut here = false;
5461 for (i, row) in section.rows.iter_mut().enumerate() {
5462 if !new(&row.path) {
5463 continue;
5464 }
5465 let before = Standing::of(row, sort);
5466 if fold_known(row, &self.enriched, &self.stated, &self.stale) {
5467 moved |= Standing::of(row, sort) != before;
5468 touched.push((si, i));
5469 here = true;
5470 }
5471 }
5472 if let Some(door) = section.door.as_mut()
5473 && new(&door.path)
5474 {
5475 here |= fold_known(door, &self.enriched, &self.stated, &self.stale);
5476 }
5477 if here && let Some(door) = section.door.as_mut() {
5478 door.name = door_name(door, §ion.rows);
5479 }
5480 any |= here;
5481 }
5482 if !any {
5483 return;
5484 }
5485 if moved || sort != SortMode::Natural || !self.hits_hold(&touched) {
5490 self.rows_changed(&touched);
5491 }
5492 self.land_again();
5493 }
5494
5495 fn hits_hold(&self, touched: &[(usize, usize)]) -> bool {
5499 let built = self.rows_cache.built.borrow();
5500 let Some(view) = built.as_ref().filter(|view| view.key.matches(self)) else {
5501 return false;
5502 };
5503 let needle = Needle::new(&self.filter);
5504 touched.iter().all(|&(si, i)| {
5505 let was = view.hits.sections.get(si).and_then(|s| s.as_ref()?.get(i));
5506 was == Some(&needle.hit(&self.sections[si].rows[i]))
5507 })
5508 }
5509
5510 fn land_again(&mut self) {
5513 if self.landing
5514 && let Some(Row::Door { entry, .. }) = self.row_at(self.selected)
5515 && !door_lands(entry)
5516 {
5517 self.selected = self.landing_row();
5518 self.follow_selection();
5519 }
5520 }
5521
5522 pub fn select_first_entry(&mut self) {
5528 self.returning = None;
5529 self.landing = true;
5530 if !self.filter.is_empty() {
5532 self.resting = false;
5533 }
5534 self.selected = self.landing_row();
5535 }
5536
5537 fn landing_row(&self) -> usize {
5539 if self.filter.is_empty()
5541 && let Some(newest) = self.newest_recent.as_ref()
5542 && let Some(at) = self.position(|r| {
5543 matches!(r, Row::Entry { section, entry, .. }
5544 if entry.path == *newest
5545 && self.sections[*section].title == Self::RECENT_SECTION)
5546 })
5547 {
5548 return at;
5549 }
5550 let first = self.position(|r| matches!(r, Row::Entry { .. } | Row::Door { .. }));
5551 let first = match first.and_then(|i| self.row_at(i)) {
5552 Some(Row::Door { entry, .. }) if !door_lands(entry) => self
5553 .position(|r| matches!(r, Row::Entry { .. } | Row::Hidden { .. }))
5554 .or(first),
5555 _ => first,
5556 };
5557 first
5559 .or_else(|| self.position(|r| matches!(r, Row::Hidden { .. })))
5560 .unwrap_or(0)
5561 }
5562
5563 pub fn clamp_selection(&mut self) {
5564 let n = self.row_count();
5565 if n == 0 {
5566 self.selected = 0;
5567 } else if self.selected >= n {
5568 self.selected = n - 1;
5569 }
5570 }
5571
5572 pub fn select(&mut self, index: usize) {
5574 if index < self.row_count() {
5575 self.resting = false;
5576 self.returning = None;
5577 self.landing = false;
5578 self.selected = index;
5579 }
5580 }
5581
5582 pub fn move_selection(&mut self, delta: isize) {
5583 self.resting = false;
5584 self.returning = None;
5585 self.landing = false;
5586 let n = self.row_count();
5587 if n == 0 {
5588 return;
5589 }
5590 let cur = self.selected as isize;
5591 let next = (cur + delta).rem_euclid(n as isize);
5592 self.selected = next as usize;
5593 }
5594
5595 pub fn page_selection(&mut self, delta: isize) {
5598 self.resting = false;
5599 self.returning = None;
5600 self.landing = false;
5601 let n = self.row_count();
5602 if n == 0 {
5603 return;
5604 }
5605 let next = (self.selected as isize)
5607 .saturating_add(delta)
5608 .clamp(0, n as isize - 1);
5609 self.selected = next as usize;
5610 }
5611}
5612
5613fn source_entry(source: &CloudSource) -> Entry {
5615 Entry {
5616 path: cloud_place(&source.id),
5617 kind: EntryKind::Directory,
5618 name: source.label.clone(),
5619 size: None,
5620 modified: source.listed_at,
5621 rows: None,
5622 cols: None,
5623 cols_sampled: false,
5624 columns: Vec::new(),
5625 cost: Default::default(),
5626 holds: Default::default(),
5627 opens_whole_directory: false,
5628 measured: false,
5629 format_spec: None,
5630 table: None,
5631 }
5632}
5633
5634fn bucket_entry(url: &Path) -> Entry {
5636 let mut entry = Entry::directory(url);
5637 let text = url.to_string_lossy();
5639 let (_, plain) = crate::cloud::source::split_source_id(&text);
5640 entry.name = plain
5641 .rsplit('/')
5642 .find(|part| !part.is_empty())
5643 .unwrap_or("")
5644 .to_string();
5645 entry
5646}
5647
5648pub fn names_a_file(path: &Path) -> bool {
5651 let named = path.to_string_lossy();
5652 let dotted = !named.ends_with('/')
5655 && path
5656 .file_name()
5657 .map(|last| last.to_string_lossy())
5658 .is_some_and(|last| last.trim_start_matches('.').contains('.'));
5659 discover::is_data_file(path) || dotted
5660}
5661
5662fn entry_for_path(path: &Path, remote: bool) -> Entry {
5664 if !remote && let Some(table) = discover::table_row(path) {
5666 return table;
5667 }
5668 let mut holds = discover::Holds::default();
5669 let kind = if remote {
5672 if names_a_file(path) {
5677 EntryKind::File
5678 } else {
5679 EntryKind::Unknown
5680 }
5681 } else if path.is_dir() {
5682 let (kind, found) = discover::look_at_directory(path);
5683 holds = found;
5684 kind
5685 } else {
5686 EntryKind::File
5687 };
5688 let mut entry = Entry {
5689 path: path.to_path_buf(),
5690 kind,
5691 name: path
5692 .file_name()
5693 .map(|n| n.to_string_lossy().into_owned())
5694 .unwrap_or_else(|| path.to_string_lossy().into_owned()),
5695 size: None,
5696 modified: None,
5697 rows: None,
5698 cols: None,
5699 cols_sampled: false,
5700 columns: Vec::new(),
5701 cost: Default::default(),
5702 holds,
5703 opens_whole_directory: false,
5704 measured: false,
5705 format_spec: None,
5706 table: None,
5707 };
5708 if !remote && let Ok(meta) = std::fs::metadata(path) {
5709 if meta.is_file() {
5710 entry.size = Some(meta.len());
5711 }
5712 entry.modified = meta.modified().ok();
5713 }
5714 entry
5715}
5716
5717pub fn display_path(path: &Path) -> String {
5719 if let Some(home) = dirs::home_dir()
5720 && let Ok(rest) = path.strip_prefix(&home)
5721 {
5722 if rest.as_os_str().is_empty() {
5723 return "~".to_string();
5724 }
5725 return format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display());
5727 }
5728 path.display().to_string()
5729}
5730
5731pub fn complete_path(typed: &str) -> (String, usize) {
5735 let expanded = expand_user_path(typed);
5736 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5738 let typed_ends_in_sep = typed.ends_with(is_separator);
5739
5740 let (dir, prefix) = if typed_ends_in_sep {
5741 (expanded.clone(), String::new())
5742 } else {
5743 match (expanded.parent(), expanded.file_name()) {
5744 (Some(parent), Some(name)) => {
5745 (parent.to_path_buf(), name.to_string_lossy().into_owned())
5746 }
5747 _ => (expanded.clone(), String::new()),
5748 }
5749 };
5750
5751 let Ok(entries) = std::fs::read_dir(&dir) else {
5752 return (typed.to_string(), 0);
5753 };
5754
5755 let mut names: Vec<String> = entries
5756 .flatten()
5757 .filter_map(|e| {
5758 let name = e.file_name().to_string_lossy().into_owned();
5759 if name.starts_with('.') && !prefix.starts_with('.') {
5761 return None;
5762 }
5763 name.starts_with(&prefix).then_some(name)
5764 })
5765 .collect();
5766 if names.is_empty() {
5767 return (typed.to_string(), 0);
5768 }
5769 names.sort();
5770
5771 let shared = names
5773 .iter()
5774 .skip(1)
5775 .fold(names[0].clone(), |acc, name| common_prefix(&acc, name));
5776
5777 let mut completed = typed.to_string();
5778 completed.truncate(typed.len() - prefix.len());
5779 completed.push_str(&shared);
5780
5781 if names.len() == 1 && dir.join(&shared).is_dir() && !completed.ends_with(is_separator) {
5784 let separator = typed
5785 .chars()
5786 .rev()
5787 .find(|c| is_separator(*c))
5788 .unwrap_or(std::path::MAIN_SEPARATOR);
5789 completed.push(separator);
5790 }
5791 (completed, names.len())
5792}
5793
5794#[derive(Debug, Clone, PartialEq, Eq)]
5796pub struct PathName {
5797 pub name: String,
5798 pub dir: bool,
5800}
5801
5802#[derive(Debug, Clone, Default, PartialEq, Eq)]
5804pub struct PathListing {
5805 pub dir: String,
5807 pub names: Vec<PathName>,
5808 pub failed: bool,
5810 pub matched: SegmentMatches,
5812}
5813
5814#[derive(Debug, Default)]
5818pub struct SegmentMatches(std::sync::Mutex<Option<(String, std::sync::Arc<[usize]>)>>);
5819
5820impl Clone for SegmentMatches {
5821 fn clone(&self) -> Self {
5822 Self::default()
5823 }
5824}
5825
5826impl PartialEq for SegmentMatches {
5827 fn eq(&self, _: &Self) -> bool {
5828 true
5829 }
5830}
5831
5832impl Eq for SegmentMatches {}
5833
5834fn path_matches(names: &[PathName], segment: &str) -> std::sync::Arc<[usize]> {
5837 let mut matched: Vec<(usize, i32)> = (names.iter().enumerate())
5838 .filter(|(_, n)| !n.name.starts_with('.') || segment.starts_with('.'))
5839 .filter_map(|(i, n)| {
5840 if segment.is_empty() {
5841 return Some((i, 0));
5842 }
5843 let prefix = n.name.starts_with(segment) as i32 * 1_000_000;
5844 fuzzy_score(segment, &n.name).map(|score| (i, prefix + score))
5845 })
5846 .collect();
5847 matched
5848 .sort_by(|(a, sa), (b, sb)| sb.cmp(sa).then_with(|| names[*a].name.cmp(&names[*b].name)));
5849 matched.into_iter().map(|(i, _)| i).collect()
5850}
5851
5852const PATH_LISTING_MAX: usize = 5_000;
5854
5855pub fn typed_dir(typed: &str) -> &str {
5858 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5859 let floor = typed.find("://").map_or(0, |at| at + 3);
5860 match typed[floor..].rfind(is_separator) {
5861 Some(at) => &typed[..floor + at + 1],
5862 None => &typed[..floor],
5863 }
5864}
5865
5866fn separator_in(dir: &str) -> char {
5869 if typed_dir_is_url(dir) {
5870 return '/';
5871 }
5872 dir.chars()
5873 .rev()
5874 .find(|c| *c == '/' || (cfg!(windows) && *c == '\\'))
5875 .unwrap_or(std::path::MAIN_SEPARATOR)
5876}
5877
5878pub fn typed_dir_is_url(dir: &str) -> bool {
5880 dir.contains("://")
5881}
5882
5883pub fn list_typed_dir(dir: &str) -> PathListing {
5886 let path = if dir.is_empty() {
5887 PathBuf::from(".")
5888 } else {
5889 expand_user_path(dir)
5890 };
5891 let Ok(entries) = std::fs::read_dir(&path) else {
5892 return PathListing {
5893 dir: dir.to_string(),
5894 names: Vec::new(),
5895 failed: true,
5896 matched: Default::default(),
5897 };
5898 };
5899 let mut names: Vec<PathName> = entries
5900 .flatten()
5901 .take(PATH_LISTING_MAX)
5902 .map(|e| {
5903 let name = e.file_name().to_string_lossy().into_owned();
5904 let dir = e.file_type().is_ok_and(|t| t.is_dir())
5906 || (e.file_type().is_ok_and(|t| t.is_symlink()) && e.path().is_dir());
5907 PathName { name, dir }
5908 })
5909 .collect();
5910 names.sort_by(|a, b| a.name.cmp(&b.name));
5911 PathListing {
5912 dir: dir.to_string(),
5913 names,
5914 failed: false,
5915 matched: Default::default(),
5916 }
5917}
5918
5919pub fn names_under(dir: &str, urls: impl IntoIterator<Item = String>) -> PathListing {
5922 let mut names: Vec<PathName> = Vec::new();
5923 for url in urls {
5924 let forms = match crate::cloud::source::azure_parts(&url) {
5926 Some((_, container, key)) => vec![url.clone(), format!("az://{container}/{key}")],
5927 None => vec![url],
5928 };
5929 for form in forms {
5930 let Some(rest) = form.strip_prefix(dir) else {
5931 continue;
5932 };
5933 let (name, more) = match rest.split_once('/') {
5934 Some((name, more)) => (name, Some(more)),
5935 None => (rest, None),
5936 };
5937 if name.is_empty() {
5938 continue;
5939 }
5940 let is_dir = more.is_some() || !names_a_file(Path::new(&form));
5943 match names.iter_mut().find(|n| n.name == name) {
5944 Some(known) => known.dir |= is_dir,
5945 None => names.push(PathName {
5946 name: name.to_string(),
5947 dir: is_dir,
5948 }),
5949 }
5950 }
5951 }
5952 names.sort_by(|a, b| a.name.cmp(&b.name));
5953 PathListing {
5954 dir: dir.to_string(),
5955 names,
5956 failed: false,
5957 matched: Default::default(),
5958 }
5959}
5960
5961fn common_prefix(a: &str, b: &str) -> String {
5962 a.chars()
5963 .zip(b.chars())
5964 .take_while(|(x, y)| x == y)
5965 .map(|(x, _)| x)
5966 .collect()
5967}
5968
5969pub fn expand_user_path(raw: &str) -> PathBuf {
5971 crate::config::expand_config_path(raw)
5972}
5973
5974#[cfg(test)]
5975mod holds_flow_tests {
5976 use super::*;
5977
5978 #[test]
5981 fn a_rows_label_is_one_decision_for_the_list_and_the_pane() {
5982 let mut directory = Entry::for_test(Path::new("s3://bucket/warehouse"), "warehouse");
5983 directory.kind = EntryKind::Directory;
5984 let said = |look, place_kind| describe(&directory, place_kind, look, 0, None);
5985 let g = crate::glyphs::get();
5986
5987 let waiting = said(Some(CloudLook::Waiting), None);
5988 assert_eq!(
5989 (waiting.short.as_str(), waiting.words.as_str()),
5990 (g.ellipsis, "")
5991 );
5992 assert_eq!(said(Some(CloudLook::Looking), None).words, "");
5993 assert!(
5994 said(Some(CloudLook::Failed), None)
5995 .words
5996 .contains("listing failed")
5997 );
5998 assert_eq!(said(None, None).words, "directory");
5999 let curated = said(None, Some("dataset"));
6000 assert_eq!(
6001 (curated.short.as_str(), curated.words.as_str()),
6002 ("dataset", "dataset")
6003 );
6004 assert!(curated.curated);
6005
6006 directory.holds = crate::home::discover::Holds {
6007 formats: vec![("parquet".to_string(), 12)],
6008 ..Default::default()
6009 };
6010 let counted = describe(&directory, None, None, 0, None);
6011 assert_eq!(counted.short, "12 parquet");
6012 assert_eq!(
6013 counted.words, "directory",
6014 "the count is the pane's `contains` line"
6015 );
6016 let curated = describe(&directory, Some("dataset"), None, 0, None);
6017 assert_eq!(
6018 curated.short, "dataset",
6019 "the curated word wins over the count"
6020 );
6021
6022 directory.opens_whole_directory = true;
6023 assert_eq!(
6024 describe(&directory, Some("dataset"), None, 0, None),
6025 RowLabel::default()
6026 );
6027 }
6028
6029 #[cfg(windows)]
6032 #[test]
6033 fn a_windows_home_path_is_shown_and_typed_with_backslashes() {
6034 let home = dirs::home_dir().unwrap();
6035 let path = home.join("data").join("a.csv");
6036 let shown = display_path(&path);
6037 assert_eq!(shown, r"~\data\a.csv");
6038 assert_eq!(expand_user_path(&shown), path);
6039 }
6040
6041 #[cfg(windows)]
6044 #[test]
6045 fn a_dot_above_a_windows_recent_does_not_make_it_a_file() {
6046 let path = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\orders");
6047 assert_eq!(entry_for_path(path, true).kind, EntryKind::Unknown);
6048 let file = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\a.parquet");
6049 assert_eq!(entry_for_path(file, true).kind, EntryKind::File);
6050 }
6051
6052 fn counted(n: usize) -> crate::home::discover::Holds {
6053 crate::home::discover::Holds {
6054 formats: vec![("parquet".to_string(), n)],
6055 ..Default::default()
6056 }
6057 }
6058
6059 fn in_flight() -> (EntryKind, crate::home::discover::Holds) {
6062 (
6063 EntryKind::Directory,
6064 crate::home::discover::Holds::default(),
6065 )
6066 }
6067
6068 #[test]
6069 fn a_claim_staked_before_a_peek_lands_keeps_the_count_a_row_already_has() {
6070 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6071 let path = root.join("orders");
6072 let mut row = Entry::for_test(&path, "orders");
6073 row.kind = EntryKind::Directory;
6074 row.holds = counted(15);
6077
6078 let mut home = HomeState::default();
6079 home.probe_ready(root.clone(), vec![row], false);
6080 home.cloud_kinds.insert(path, in_flight());
6081 home.apply_cloud_kinds(&root);
6082
6083 assert_eq!(
6084 home.probes.listed(&root).unwrap()[0].holds.label(),
6085 "15 parquet",
6086 "the placeholder erased a count the row already had"
6087 );
6088 }
6089
6090 #[test]
6091 fn a_cloud_directory_waits_then_looks_then_answers() {
6092 let path = std::path::PathBuf::from("gs://pitscope/seasons");
6093 let mut row = Entry::for_test(&path, "seasons");
6094 row.kind = EntryKind::Directory;
6095 let mut home = HomeState::default();
6096
6097 assert_eq!(
6098 home.cloud_look(&row),
6099 Some(CloudLook::Waiting),
6100 "not asked yet"
6101 );
6102 home.peeking.insert(path.clone());
6103 assert_eq!(
6104 home.cloud_look(&row),
6105 Some(CloudLook::Looking),
6106 "being looked into"
6107 );
6108 home.peeking.remove(&path);
6109
6110 home.cloud_kinds
6113 .insert(path.clone(), (EntryKind::Directory, counted(12)));
6114 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
6115 home.cloud_kinds
6116 .insert(path.clone(), (EntryKind::Hive, Default::default()));
6117 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
6118
6119 home.cloud_kinds.insert(path.clone(), in_flight());
6121 assert_eq!(home.cloud_look(&row), None, "answered");
6122
6123 let mut home = HomeState {
6126 network_check: |_| true,
6127 ..Default::default()
6128 };
6129 let root = std::path::PathBuf::from("gs://pitscope");
6130 home.probe_ready(root.clone(), vec![row.clone()], false);
6131 home.browsing = Some(root);
6132 home.rebuild(&[]);
6133 assert_eq!(
6134 home.cloud_directories_to_peek(4),
6135 std::slice::from_ref(&path)
6136 );
6137 home.peek_failed.insert(path.clone());
6138 assert_eq!(home.cloud_look(&row), Some(CloudLook::Failed));
6139 assert!(home.cloud_directories_to_peek(4).is_empty());
6140
6141 let mut counted_row = Entry::for_test(&path.join("x"), "x");
6144 counted_row.kind = EntryKind::Directory;
6145 counted_row.holds = counted(3);
6146 assert_eq!(home.cloud_look(&counted_row), None);
6147 let mut bucket = Entry::for_test(std::path::Path::new("gs://pitscope"), "pitscope");
6148 bucket.kind = EntryKind::Directory;
6149 assert_eq!(home.cloud_look(&bucket), None);
6150 let mut local = Entry::for_test(std::path::Path::new("/data/seasons"), "seasons");
6151 local.kind = EntryKind::Directory;
6152 assert_eq!(home.cloud_look(&local), None);
6153 }
6154
6155 #[test]
6156 fn a_peeks_answer_replaces_the_count_a_row_had() {
6157 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6158 let path = root.join("orders");
6159 let mut row = Entry::for_test(&path, "orders");
6160 row.kind = EntryKind::Directory;
6161 row.holds = counted(15);
6162
6163 let mut home = HomeState::default();
6164 home.probe_ready(root.clone(), vec![row], false);
6165 home.cloud_kinds
6166 .insert(path, (EntryKind::MultiFile, counted(40)));
6167 home.apply_cloud_kinds(&root);
6168
6169 assert_eq!(
6170 home.probes.listed(&root).unwrap()[0].holds.label(),
6171 "40 parquet"
6172 );
6173 assert_eq!(
6174 home.probes.listed(&root).unwrap()[0].kind,
6175 EntryKind::MultiFile
6176 );
6177 }
6178
6179 #[test]
6180 fn a_peek_answers_only_the_rows_that_asked() {
6181 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6182 let settled = root.join("sales");
6186 let mut row = Entry::for_test(&settled, "sales");
6187 row.kind = EntryKind::Hive;
6188 row.holds = counted(40);
6189
6190 let mut home = HomeState::default();
6191 home.probe_ready(root.clone(), vec![row], false);
6192 home.cloud_kinds
6193 .insert(settled, (EntryKind::Directory, counted(1)));
6194 home.apply_cloud_kinds(&root);
6195
6196 assert_eq!(home.probes.listed(&root).unwrap()[0].kind, EntryKind::Hive);
6197 assert_eq!(
6198 home.probes.listed(&root).unwrap()[0].holds.label(),
6199 "40 parquet"
6200 );
6201 }
6202
6203 #[test]
6210 fn a_peek_goes_to_the_row_the_cursor_is_on_and_is_never_asked_twice() {
6211 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6212 let mut home = HomeState {
6213 network_check: |_| true,
6214 ..Default::default()
6215 };
6216 let rows: Vec<Entry> = ["a", "b", "c", "d", "e"]
6217 .iter()
6218 .map(|n| {
6219 let mut row = Entry::for_test(&root.join(n), n);
6220 row.kind = EntryKind::Directory;
6221 row
6222 })
6223 .collect();
6224 home.probe_ready(root.clone(), rows, false);
6225 home.browsing = Some(root.clone());
6226 home.view_height = 10;
6227 home.rebuild(&[]);
6228 home.cloud_kinds
6230 .insert(root.join("b"), (EntryKind::MultiFile, counted(3)));
6231 home.peeking.insert(root.join("c"));
6232
6233 home.selected = home
6236 .visible()
6237 .iter()
6238 .position(|r| matches!(r, Row::Entry { entry, .. } if entry.name == "e"))
6239 .expect("the row is listed");
6240 let asked = home.cloud_directories_to_peek(3);
6241 assert_eq!(
6242 asked.first(),
6243 Some(&root.join("e")),
6244 "the highlighted row is the one about to be acted on"
6245 );
6246 assert_eq!(asked.len(), 3, "the budget is a budget");
6247 assert!(
6248 !asked.contains(&root.join("b")),
6249 "a directory already looked into is not asked again"
6250 );
6251 assert!(
6252 !asked.contains(&root.join("c")),
6253 "nor one with a request already out"
6254 );
6255 }
6256
6257 #[test]
6258 fn a_measurement_that_counted_nothing_keeps_the_count_a_row_already_has() {
6259 let path = std::path::PathBuf::from("/data/warehouse/orders");
6260 let mut row = Entry::for_test(&path, "orders");
6261 row.kind = EntryKind::Directory;
6262 row.holds = counted(15);
6263
6264 let mut home = HomeState::default();
6265 home.sections.push(Section::titled("Here", vec![row]));
6266 home.enriched.insert(
6268 path,
6269 Measured {
6270 kind: Some(EntryKind::Directory),
6271 ..Default::default()
6272 },
6273 );
6274 home.apply_measurements();
6275
6276 assert_eq!(
6277 home.sections[0].rows[0].holds.label(),
6278 "15 parquet",
6279 "a measurement with nothing to say erased the label"
6280 );
6281 }
6282}
6283
6284#[cfg(test)]
6285mod look_into_batch_tests {
6286 use super::*;
6287 use polars::prelude::*;
6288
6289 #[test]
6292 fn every_kind_is_sent_before_any_count() {
6293 let dir = tempfile::tempdir().unwrap();
6294 let cache_dir = tempfile::tempdir().unwrap();
6295 let cache = crate::cache::CacheManager::with_dir(cache_dir.path().to_path_buf());
6296 let mut rows = Vec::new();
6297 for name in ["a", "b"] {
6298 let partition = dir.path().join(name).join("year=2024");
6299 std::fs::create_dir_all(&partition).unwrap();
6300 let mut frame = df!("x" => [1i32, 2, 3]).unwrap();
6301 let file = std::fs::File::create(partition.join("part.parquet")).unwrap();
6302 ParquetWriter::new(file).finish(&mut frame).unwrap();
6303 rows.push(Entry::new(dir.path().join(name), EntryKind::Unknown));
6304 }
6305
6306 let mut sent = Vec::new();
6307 look_into_batch(
6308 rows,
6309 &cache,
6310 &Default::default(),
6311 Reads::Files,
6312 |path, m| {
6313 let name = path.file_name().unwrap().to_string_lossy().into_owned();
6314 sent.push((name, m.kind, m.rows));
6315 },
6316 );
6317
6318 let hive = Some(EntryKind::Hive);
6319 assert_eq!(
6320 sent,
6321 vec![
6322 ("a".to_string(), hive, None),
6323 ("b".to_string(), hive, None),
6324 ("a".to_string(), hive, Some(3)),
6325 ("b".to_string(), hive, Some(3)),
6326 ]
6327 );
6328 }
6329}
6330
6331#[cfg(test)]
6332mod known_facts_tests {
6333 use super::*;
6334 use crate::cache::DatasetFacts;
6335
6336 #[test]
6344 fn what_a_directory_holds_is_restored_beside_its_kind() {
6345 let holds = crate::home::discover::Holds {
6346 formats: vec![("parquet".to_string(), 15)],
6347 ..Default::default()
6348 };
6349 for (path, remote) in [
6350 (
6351 std::path::PathBuf::from("s3://bucket/warehouse/orders"),
6352 true,
6353 ),
6354 (std::path::PathBuf::from("/data/warehouse/orders"), false),
6355 ] {
6356 let facts = DatasetFacts {
6357 mtime: 0,
6358 size: 4096,
6359 rows: Some(999),
6362 cols: Some(72),
6363 cols_sampled: false,
6364 columns: vec!["lat".to_string()],
6365 kind: Some(EntryKind::Directory),
6368 classified_by: crate::home::discover::CLASSIFIER_VERSION,
6369 holds: holds.clone(),
6370 cost: Default::default(),
6371 };
6372 let mut row = Entry::directory(&path);
6373 row.kind = EntryKind::Unknown;
6374 row.modified = Some(std::time::UNIX_EPOCH);
6375 assert_eq!(row.size, None);
6378 let index = std::collections::HashMap::from([(path.clone(), facts)]);
6379
6380 apply_known_facts(&mut row, &index, remote);
6381 assert_eq!(row.kind, EntryKind::Directory, "{path:?}");
6382 assert_eq!(row.label(), "15 parquet", "{path:?}");
6383 if !remote {
6389 assert_eq!(row.rows, None, "{path:?}");
6390 assert_eq!(row.cols, None, "{path:?}");
6391 assert_eq!(row.size, None, "{path:?}");
6392 assert!(row.columns.is_empty(), "{path:?}");
6393 }
6394 }
6395 }
6396
6397 #[test]
6402 fn a_datasets_counts_are_measured_rather_than_restored() {
6403 let path = std::path::PathBuf::from("/data/warehouse/events");
6404 let facts = DatasetFacts {
6405 mtime: 0,
6406 size: 4096,
6407 rows: Some(1_200_000),
6408 cols: Some(58),
6409 cols_sampled: false,
6410 columns: vec!["ts".to_string()],
6411 kind: Some(EntryKind::MultiFile),
6412 classified_by: crate::home::discover::CLASSIFIER_VERSION,
6413 holds: crate::home::discover::Holds {
6414 formats: vec![("parquet".to_string(), 15)],
6415 ..Default::default()
6416 },
6417 cost: Default::default(),
6418 };
6419 let mut row = Entry::directory(&path);
6420 row.kind = EntryKind::Unknown;
6421 row.modified = Some(std::time::UNIX_EPOCH);
6422 let index = std::collections::HashMap::from([(path.clone(), facts)]);
6423
6424 apply_known_facts(&mut row, &index, false);
6425 assert_eq!(row.kind, EntryKind::MultiFile, "the kind comes back");
6426 assert_eq!(row.label(), "15 parquet", "and what it holds");
6427 assert_eq!(
6428 row.rows, None,
6429 "but not the count: the measuring pass skips a row that has one"
6430 );
6431 assert_eq!(row.cols, None);
6432 }
6433
6434 #[test]
6443 fn a_kind_from_an_older_classifier_is_not_restored() {
6444 let remote = std::path::PathBuf::from("s3://bucket/warehouse/orders");
6445 let facts = |classified_by| DatasetFacts {
6446 mtime: 0,
6447 size: 4096,
6448 rows: Some(1_000),
6449 cols: Some(7),
6450 cols_sampled: false,
6451 columns: vec!["id".into(), "amount".into()],
6452 kind: Some(EntryKind::MultiFile),
6453 classified_by,
6454 cost: Default::default(),
6455 holds: Default::default(),
6456 };
6457 let unprobed = || {
6458 let mut row = Entry::directory(&remote);
6459 row.kind = EntryKind::Unknown;
6460 row
6461 };
6462
6463 let index = |classified_by| {
6464 std::collections::HashMap::from([(remote.clone(), facts(classified_by))])
6465 };
6466
6467 let mut row = unprobed();
6468 apply_known_facts(
6469 &mut row,
6470 &index(crate::home::discover::CLASSIFIER_VERSION),
6471 true,
6472 );
6473 assert_eq!(
6474 row.kind,
6475 EntryKind::MultiFile,
6476 "this build's own answer comes back"
6477 );
6478
6479 let mut row = unprobed();
6480 apply_known_facts(&mut row, &index(0), true);
6481 assert_eq!(
6482 row.kind,
6483 EntryKind::Unknown,
6484 "an older build's does not: it may be a lake table this one would recognize"
6485 );
6486 assert_eq!(
6487 row.rows,
6488 Some(1_000),
6489 "but what it measured is still measured"
6490 );
6491 assert_eq!(row.columns, vec!["id".to_string(), "amount".to_string()]);
6492 }
6493}
6494
6495#[cfg(test)]
6496mod build_feature_tests {
6497 use super::*;
6498
6499 #[test]
6502 fn the_builtin_catalog_lists_only_what_this_build_opens() {
6503 let urls: Vec<String> = catalogs(&crate::config::AppConfig::default())
6504 .into_iter()
6505 .filter(|c| c.origin == crate::home::catalog::Origin::Bundled)
6506 .flat_map(|c| c.datasets)
6507 .map(|d| d.location.to_string_lossy().into_owned())
6508 .collect();
6509 let web = urls.iter().filter(|u| u.starts_with("https://")).count();
6510 let stores = urls
6511 .iter()
6512 .filter(|u| is_object_store_url(Path::new(u)))
6513 .count();
6514 assert_eq!(web + stores, urls.len(), "{urls:?}");
6515 assert_eq!(web > 0, cfg!(feature = "http"), "{urls:?}");
6516 assert_eq!(stores > 0, cfg!(feature = "cloud"), "{urls:?}");
6517 }
6518
6519 #[test]
6522 fn an_empty_examples_toml_hides_the_section() {
6523 let mut config = crate::config::AppConfig::default();
6524 assert_eq!(
6526 catalogs(&config)
6527 .iter()
6528 .any(|c| c.origin == crate::home::catalog::Origin::Bundled),
6529 cfg!(any(feature = "http", feature = "cloud"))
6530 );
6531 config.read_catalogs = vec![
6532 crate::home::catalog::parse(
6533 "label = \"Mine\"\n",
6534 crate::home::catalog::EXAMPLES,
6535 crate::home::catalog::Origin::Folder,
6536 None,
6537 )
6538 .unwrap(),
6539 ];
6540 assert!(catalogs(&config).is_empty(), "{:?}", catalogs(&config));
6541 }
6542
6543 #[test]
6546 fn a_users_catalog_is_shown_whole() {
6547 let mut config = crate::config::AppConfig::default();
6548 let mine = crate::home::catalog::parse(
6549 r#"
6550 [bucket]
6551 name = "Bucket"
6552 url = "s3://bucket/prefix/"
6553 [web]
6554 name = "Web"
6555 url = "https://example.com/data.csv"
6556 "#,
6557 crate::home::catalog::MINE,
6558 crate::home::catalog::Origin::Mine,
6559 None,
6560 )
6561 .unwrap();
6562 config.read_catalogs = vec![mine];
6563 let shown = catalogs(&config);
6564 let mine = shown.iter().find(|c| c.id == "mine").unwrap();
6565 assert_eq!(mine.datasets.len(), 2);
6566 assert_eq!(mine.label, crate::home::catalog::MINE_LABEL);
6567 }
6568}
6569
6570#[cfg(test)]
6571mod place_tests {
6572 use super::same_place;
6573 use std::path::Path;
6574
6575 #[test]
6576 fn local_paths_are_one_place_however_spelled() {
6577 assert!(same_place(
6578 Path::new("/data/./sales/"),
6579 Path::new("/data/sales")
6580 ));
6581 assert!(!same_place(
6582 Path::new("/data/sales"),
6583 Path::new("/data/sale")
6584 ));
6585 assert!(same_place(
6586 Path::new("s3://bucket/dir/"),
6587 Path::new("s3://bucket/dir")
6588 ));
6589 if cfg!(windows) {
6590 assert!(same_place(
6591 Path::new("c:/data/sales.csv"),
6592 Path::new(r"C:\data\sales.csv")
6593 ));
6594 }
6595 }
6596}