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 = !object_store && rows.iter().any(|r| r.kind == EntryKind::File);
478 let laid_out = !matches!(kind, EntryKind::Directory | EntryKind::Unknown);
479 if !openable_row && !laid_out && holds_nothing_to_open(&holds, object_store) {
480 return None;
481 }
482 let mut entry = Entry::directory(&directory_dataset_url(dir));
483 entry.kind = kind;
484 entry.holds = holds;
487 entry.opens_whole_directory = true;
488 entry.name = door_name(&entry, rows);
489 Some(entry)
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494pub enum DoorKind {
495 Hive,
497 OneSchema,
499 SchemasDiffer,
501 Single,
503 Lake,
505 Mixed,
507 Unknown,
509}
510
511pub fn door_kind(door: &Entry) -> DoorKind {
515 let holds = &door.holds;
516 match door.kind {
517 EntryKind::Hive => DoorKind::Hive,
518 EntryKind::MultiFile => DoorKind::OneSchema,
519 k if k.is_lake_table() => DoorKind::Lake,
520 EntryKind::Unknown => DoorKind::Unknown,
521 _ => {
522 let Some(format) = holds.one_format() else {
523 return DoorKind::Mixed;
524 };
525 if holds.directories > 0 {
526 return DoorKind::Mixed;
527 }
528 let files = holds.data_files();
529 if files == 1 {
530 return DoorKind::Single;
531 }
532 let mostly_data = files * 2 >= files + holds.not_read + holds.unnamed;
533 let reads_many = crate::FileFormat::from_name(format)
534 .is_some_and(crate::FileFormat::reads_many_files);
535 if mostly_data && reads_many {
536 DoorKind::SchemasDiffer
537 } else {
538 DoorKind::Mixed
539 }
540 }
541 }
542}
543
544pub fn door_lands(door: &Entry) -> bool {
548 matches!(door_kind(door), DoorKind::Hive | DoorKind::OneSchema)
549}
550
551fn format_title(name: &str) -> String {
553 crate::FileFormat::from_name(name)
554 .map_or_else(|| name.to_ascii_uppercase(), |f| f.title().to_string())
555}
556
557fn door_keys(door: &Entry, rows: &[Entry]) -> Vec<String> {
560 if let Some(layout) = door.cost.partitions.as_ref()
561 && !layout.keys.is_empty()
562 {
563 return layout.keys.clone();
564 }
565 let mut keys: Vec<String> = Vec::new();
566 for row in rows {
567 if let Some((key, _)) = row.name.split_once('=')
568 && !key.is_empty()
569 && !keys.iter().any(|k| k == key)
570 {
571 keys.push(key.to_string());
572 }
573 }
574 keys
575}
576
577pub fn door_name(door: &Entry, rows: &[Entry]) -> String {
583 let holds = &door.holds;
584 let more = if holds.truncated { "+" } else { "" };
585 let files = |format: &str| {
586 let count = holds.data_files();
587 let word = if count == 1 { "file" } else { "files" };
588 format!("{count}{more} {} {word}", format_title(format))
589 };
590 let what = match door_kind(door) {
591 DoorKind::Hive => {
592 let keys = door_keys(door, rows);
593 if keys.is_empty() {
594 "hive table".to_string()
595 } else {
596 format!("hive table: {}", keys.join(", "))
597 }
598 }
599 DoorKind::OneSchema => match (holds.model_weights(), holds.one_format()) {
600 (Some((format, count)), _) => {
601 let word = if count == 1 { "file" } else { "files" };
602 format!("model, {count}{more} {} {word}", format_title(format))
603 }
604 (None, Some(format)) => format!("{}, one schema", files(format)),
605 (None, None) => "one table".to_string(),
606 },
607 DoorKind::SchemasDiffer => {
608 format!(
609 "{}, schemas differ",
610 files(holds.one_format().unwrap_or(""))
611 )
612 }
613 DoorKind::Single => files(holds.one_format().unwrap_or("")),
614 DoorKind::Lake => format!(
615 "{} files, not the table",
616 door.kind.lake_name().unwrap_or_default()
617 ),
618 DoorKind::Mixed => match door_reads(door) {
619 Some((reads, None)) => reads,
620 Some((reads, Some(_))) => match door_skips(door).as_slice() {
622 [(_, what)] => format!("{reads}, {what} skipped"),
623 skips => {
624 let n: usize = skips.iter().map(|(n, _)| n).sum();
625 format!("{reads}, {n}{more} skipped")
626 }
627 },
628 None if holds.unnamed > 0 => {
629 let word = if holds.unnamed == 1 { "file" } else { "files" };
630 format!("{}{more} {word} with no extension", holds.unnamed)
631 }
632 None => "mixed files".to_string(),
633 },
634 DoorKind::Unknown => return DOOR_ACTION.to_string(),
635 };
636 format!("{DOOR_ACTION} ({what})")
637}
638
639pub const DOOR_ACTION: &str = "Open all";
641
642pub fn door_what(name: &str) -> &str {
645 let what = name.strip_prefix(DOOR_ACTION).unwrap_or(name).trim();
646 (what.strip_prefix('(').and_then(|w| w.strip_suffix(')'))).unwrap_or(what)
647}
648
649pub fn door_reads(door: &Entry) -> Option<(String, Option<String>)> {
654 if !matches!(door_kind(door), DoorKind::Mixed | DoorKind::Single) {
655 return None;
656 }
657 let holds = &door.holds;
658 let more = if holds.truncated { "+" } else { "" };
659 let Some((format, count)) = holds.formats.first() else {
660 return (holds.directories > 0).then(|| ("every Parquet file below".to_string(), None));
661 };
662 let reads = if reads_below(door) && is_object_store_url(&door.path) {
663 format!("every {} file below", format_title(format))
664 } else if reads_below(door) {
665 "every Parquet file below".to_string()
666 } else {
667 counted(*count, more, &format_title(format), "file", "files")
668 };
669 let skips: Vec<String> = door_skips(door).into_iter().map(|(_, what)| what).collect();
670 Some((reads, (!skips.is_empty()).then(|| skips.join(", "))))
671}
672
673fn counted(n: usize, more: &str, title: &str, one: &str, many: &str) -> String {
675 let word = if n == 1 { one } else { many };
676 if title.is_empty() {
677 format!("{n}{more} {word}")
678 } else {
679 format!("{n}{more} {title} {word}")
680 }
681}
682
683fn reads_below(door: &Entry) -> bool {
686 let holds = &door.holds;
687 let only_parquet = holds.formats.len() == 1 && holds.formats[0].0 == "parquet";
688 holds.directories > 0
689 && (is_object_store_url(&door.path) || holds.partitions > 0 || only_parquet)
690}
691
692fn door_skips(door: &Entry) -> Vec<(usize, String)> {
695 let holds = &door.holds;
696 let more = if holds.truncated { "+" } else { "" };
697 let mut skips: Vec<(usize, String)> = holds
698 .formats
699 .iter()
700 .skip(1)
701 .map(|(name, n)| (*n, counted(*n, more, &format_title(name), "file", "files")))
702 .collect();
703 let plain = holds.directories.saturating_sub(holds.partitions);
704 if !reads_below(door) && plain > 0 {
705 skips.push((plain, counted(plain, more, "", "directory", "directories")));
706 }
707 skips
708}
709
710pub fn name_by_spec(formats: &crate::formats::Registry, rows: &mut [Entry]) {
713 if formats.is_empty() {
714 return;
715 }
716 let mut named = false;
717 for row in rows
718 .iter_mut()
719 .filter(|r| r.kind == EntryKind::Other && r.format_spec.is_none())
720 {
721 if let Some(spec) = formats.by_glob(&row.path, false).first() {
722 discover::name_spec_file(row, spec);
723 named = true;
724 }
725 }
726 for row in rows.iter_mut().filter(|r| {
728 r.kind == EntryKind::File
729 && r.format_spec.is_none()
730 && discover::data_format(&r.path).is_some_and(|f| f.separator().is_some())
731 }) {
732 if let Some(spec) = formats
733 .by_glob(&row.path, false)
734 .into_iter()
735 .find(|s| s.is_delimited())
736 {
737 row.format_spec = Some(spec.name.clone());
738 }
739 }
740 if named {
742 discover::sort_entries(rows);
743 }
744}
745
746pub fn holds_nothing_to_open(holds: &discover::Holds, object_store: bool) -> bool {
755 let below = holds.partitions > 0 || holds.dataset_dict;
756 let unnamed = holds.unnamed > 0 && !object_store;
757 holds.formats.is_empty() && !unnamed && !below
758}
759
760pub fn is_cloud_place(path: &Path) -> bool {
762 path.to_string_lossy().starts_with(CLOUD_PLACE)
763}
764
765fn is_bucket_root(path: &Path) -> bool {
768 let text = path.to_string_lossy();
769 if let Some((_, _, key)) = crate::cloud::source::azure_parts(&text) {
770 return key.trim_matches('/').is_empty();
771 }
772 let Some((scheme, rest)) = text.split_once("://") else {
773 return false;
774 };
775 matches!(scheme, "s3" | "s3a" | "gs" | "gcs") && {
776 let rest = rest.trim_end_matches('/');
777 !rest.is_empty() && !rest.contains('/')
778 }
779}
780
781pub fn parent_location(path: &Path) -> Option<PathBuf> {
784 if !matches!(
785 crate::cloud::source::input_source(path),
786 crate::cloud::source::InputSource::Local(_)
787 ) {
788 let s = path.to_string_lossy();
789 let (scheme, rest) = s.split_once("://")?;
790 let (up, _) = rest.trim_end_matches('/').rsplit_once('/')?;
791 return Some(PathBuf::from(format!("{scheme}://{up}")));
792 }
793 path.parent()
794 .filter(|p| !p.as_os_str().is_empty() && *p != path)
795 .map(Path::to_path_buf)
796}
797
798pub fn is_remote_path(path: &Path) -> bool {
802 is_cloud_place(path)
803 || !matches!(
804 crate::cloud::source::input_source(path),
805 crate::cloud::source::InputSource::Local(_)
806 )
807 || is_network_path(path)
808}
809
810pub fn is_network_path(path: &Path) -> bool {
814 crate::home::locality::Mounts::cached().is_network(path)
815}
816
817pub fn could_block_path(path: &Path) -> bool {
821 crate::home::locality::Mounts::cached().could_block(path)
822}
823
824#[doc(hidden)]
827pub fn network_fs_for_test(mountinfo: &str, path: &Path) -> bool {
828 crate::home::locality::Mounts::parse(mountinfo).is_network(path)
829}
830
831#[derive(Debug, Clone)]
833pub struct Root {
834 pub path: PathBuf,
835 pub origin: RootOrigin,
836 pub network: bool,
838 pub available: bool,
841}
842
843#[derive(Debug, Clone, Default)]
845pub struct Section {
846 pub title: String,
847 pub subtitle: Option<String>,
850 pub origin: Option<&'static str>,
853 pub root: Option<PathBuf>,
856 pub rows: Vec<Entry>,
857 pub door: Option<Entry>,
862 pub unavailable: bool,
864 pub unavailable_note: Option<String>,
867 pub folded_by_default: bool,
870 pub remote_root: Option<PathBuf>,
872 pub waiting: bool,
874 pub grouped_by_place: bool,
877 pub place_labels: std::collections::HashMap<PathBuf, String>,
880}
881
882impl Section {
883 pub fn titled(title: impl Into<String>, rows: Vec<Entry>) -> Self {
885 Section {
886 title: title.into(),
887 rows,
888 ..Default::default()
889 }
890 }
891}
892
893#[derive(Debug, Clone, PartialEq, Eq, Default)]
895pub enum CloudStatus {
896 #[default]
898 Listing,
899 Unlisted,
902 Listed,
904 Failed { short: String, detail: String },
907}
908
909#[derive(Debug, Clone, Default, PartialEq, Eq)]
913pub struct CloudSource {
914 pub id: String,
916 pub label: String,
918 pub api: crate::cloud::source::ProviderKind,
920 pub note: String,
922 pub buckets: Vec<PathBuf>,
924 pub status: CloudStatus,
925 pub listed_at: Option<std::time::SystemTime>,
927 pub refreshing: bool,
929 pub asked: bool,
931 pub details: Vec<(String, String)>,
933 pub place_details: std::collections::HashMap<PathBuf, Vec<(String, String)>>,
936}
937
938impl CloudSource {
939 pub fn count_text(&self) -> String {
941 match &self.status {
942 CloudStatus::Failed { short, .. } if self.buckets.is_empty() => short.clone(),
943 CloudStatus::Listing if self.buckets.is_empty() => String::new(),
944 CloudStatus::Unlisted if self.buckets.is_empty() => "not listed".to_string(),
945 _ => {
946 let (one, many) = match self.api {
947 crate::cloud::source::ProviderKind::Azure => ("account", "accounts"),
948 crate::cloud::source::ProviderKind::Gcs => ("project", "projects"),
949 crate::cloud::source::ProviderKind::S3 => ("bucket", "buckets"),
950 };
951 match self.buckets.len() {
952 0 => format!("no {many}"),
953 1 => format!("1 {one}"),
954 n => format!("{n} {many}"),
955 }
956 }
957 }
958 }
959
960 pub fn begin_listing(&mut self) {
963 self.asked = true;
964 if self.status == CloudStatus::Unlisted && self.buckets.is_empty() {
965 self.status = CloudStatus::Listing;
966 } else {
967 self.refreshing = true;
968 }
969 }
970
971 pub fn busy(&self) -> bool {
973 self.refreshing || (self.status == CloudStatus::Listing && self.buckets.is_empty())
974 }
975
976 pub fn failed(&self) -> bool {
978 matches!(self.status, CloudStatus::Failed { .. })
979 }
980}
981
982#[derive(Debug, Clone, PartialEq)]
985pub struct ShownCatalog {
986 pub id: String,
988 pub label: String,
990 pub origin: crate::home::catalog::Origin,
992 pub description: String,
994 pub file: Option<PathBuf>,
996 pub datasets: Vec<ShownDataset>,
997 pub broken: Option<String>,
999}
1000
1001#[derive(Debug, Clone, Default, PartialEq)]
1003pub struct ShownDataset {
1004 pub name: String,
1006 pub location: PathBuf,
1008 pub details: Vec<(String, String)>,
1010 pub size: Option<u64>,
1012 pub codebook: Option<std::sync::Arc<crate::home::codebook::Codebook>>,
1014 pub bookmarks: Vec<(String, PathBuf)>,
1016 pub entry: std::sync::Arc<crate::home::catalog::Dataset>,
1018}
1019
1020impl ShownCatalog {
1021 pub fn from_catalog(catalog: &crate::home::catalog::Catalog) -> Self {
1023 Self {
1024 id: catalog.id.clone(),
1025 label: catalog.label.clone(),
1026 origin: catalog.origin,
1027 description: catalog.description.clone(),
1028 file: catalog.file.clone(),
1029 datasets: catalog
1030 .datasets
1031 .iter()
1032 .map(|dataset| {
1033 let location = dataset.location();
1034 let mut details: Vec<(String, String)> = [
1035 ("about", &dataset.description),
1036 ("publisher", &dataset.publisher),
1037 ("license", &dataset.license),
1038 ("homepage", &dataset.homepage),
1039 ("documentation", &dataset.documentation),
1040 ]
1041 .into_iter()
1042 .filter(|(_, value)| !value.is_empty())
1043 .map(|(key, value)| (key.to_string(), value.clone()))
1044 .collect();
1045 match &dataset.url {
1046 None => details.push(("path".to_string(), display_path(&location))),
1047 Some(url) => {
1048 details.push(("url".to_string(), url.clone()));
1049 details.push(("login".to_string(), login_of(dataset)));
1050 }
1051 }
1052 ShownDataset {
1053 name: dataset.name.clone(),
1054 location,
1055 details,
1056 size: dataset.size,
1057 codebook: crate::home::codebook::Codebook::of(dataset)
1058 .map(std::sync::Arc::new),
1059 bookmarks: dataset
1060 .bookmarks
1061 .iter()
1062 .map(|(name, path)| (name.clone(), dataset.bookmark_location(path)))
1063 .collect(),
1064 entry: std::sync::Arc::new(dataset.clone()),
1065 }
1066 })
1067 .collect(),
1068 broken: None,
1069 }
1070 }
1071
1072 pub fn from_broken(broken: &crate::home::catalog::Broken) -> Self {
1074 Self {
1075 id: broken.id.clone(),
1076 label: broken.id.clone(),
1077 origin: broken.origin,
1078 description: String::new(),
1079 file: None,
1080 datasets: Vec::new(),
1081 broken: Some(broken.callout()),
1082 }
1083 }
1084
1085 pub fn origin_note(&self) -> &'static str {
1088 match self.origin {
1089 crate::home::catalog::Origin::Mine => "catalog.toml",
1090 crate::home::catalog::Origin::Listed | crate::home::catalog::Origin::Folder => {
1091 "catalog"
1092 }
1093 crate::home::catalog::Origin::Bundled => BUNDLED_ORIGIN,
1094 }
1095 }
1096}
1097
1098pub const BUNDLED_ORIGIN: &str = "comes with datui";
1100
1101pub const CATALOG_ORIGINS: [&str; 3] = ["catalog.toml", "catalog", BUNDLED_ORIGIN];
1103
1104pub fn is_catalog_origin(origin: &str) -> bool {
1106 CATALOG_ORIGINS.contains(&origin)
1107}
1108
1109pub fn login_of(dataset: &crate::home::catalog::Dataset) -> String {
1111 match dataset.object_store_auth() {
1112 Some(crate::config::DatasetAuth::Connection(connection)) => connection,
1113 Some(crate::config::DatasetAuth::Anonymous) | None => "none".to_string(),
1114 Some(crate::config::DatasetAuth::Auto) => "auto".to_string(),
1115 }
1116}
1117
1118pub fn catalogs(config: &crate::config::AppConfig) -> Vec<ShownCatalog> {
1122 let mut out: Vec<ShownCatalog> = config
1123 .shown_catalogs()
1124 .iter()
1125 .filter_map(|catalog| {
1126 let mut shown = ShownCatalog::from_catalog(catalog);
1127 if catalog.origin == crate::home::catalog::Origin::Bundled {
1128 shown
1129 .datasets
1130 .retain(|d| crate::cloud::source::opens_in_this_build(&d.location));
1131 }
1132 (!shown.datasets.is_empty()).then_some(shown)
1133 })
1134 .collect();
1135 let at = out
1137 .iter()
1138 .position(|c| c.origin == crate::home::catalog::Origin::Bundled)
1139 .unwrap_or(out.len());
1140 let broken: Vec<ShownCatalog> = config
1141 .broken_catalogs
1142 .iter()
1143 .filter(|b| !config.home.hide.contains(&b.id))
1144 .map(ShownCatalog::from_broken)
1145 .collect();
1146 out.splice(at..at, broken);
1147 out
1148}
1149
1150fn catalog_entry(
1153 dataset: &ShownDataset,
1154 network_check: fn(&Path) -> bool,
1155 missing: &mut std::collections::HashSet<PathBuf>,
1156) -> Entry {
1157 let path = &dataset.location;
1158 let local = matches!(
1159 crate::cloud::source::input_source(path),
1160 crate::cloud::source::InputSource::Local(_)
1161 );
1162 let mut entry = if is_object_store_url(path) {
1163 if names_a_file(path) {
1164 entry_for_path(path, true)
1165 } else {
1166 Entry::directory(path)
1167 }
1168 } else if !local || network_check(path) {
1169 entry_for_path(path, true)
1170 } else if path.exists() {
1171 entry_for_path(path, false)
1172 } else {
1173 missing.insert(path.clone());
1174 let mut entry = entry_for_path(path, true);
1175 entry.kind = EntryKind::Unknown;
1176 entry
1177 };
1178 entry.name = dataset.name.clone();
1179 entry
1180}
1181
1182pub fn codebook_for(
1185 catalogs: &[ShownCatalog],
1186 path: &Path,
1187) -> Option<std::sync::Arc<crate::home::codebook::Codebook>> {
1188 let text = path.to_string_lossy();
1189 catalogs
1190 .iter()
1191 .flat_map(|c| c.datasets.iter())
1192 .filter(|d| d.codebook.is_some())
1193 .filter(|d| d.location == path || within(&text, &d.location.to_string_lossy()))
1194 .max_by_key(|d| d.location.to_string_lossy().trim_end_matches('/').len())
1195 .and_then(|d| d.codebook.clone())
1196}
1197
1198pub fn catalog_entry_for(
1201 catalogs: &[ShownCatalog],
1202 path: &Path,
1203) -> Option<(String, std::sync::Arc<crate::home::catalog::Dataset>)> {
1204 let text = path.to_string_lossy();
1205 catalogs
1206 .iter()
1207 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
1208 .filter(|(_, d)| {
1209 d.location == path
1210 || same_place(&d.location, path)
1211 || within(&text, &d.location.to_string_lossy())
1212 })
1213 .rev()
1214 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
1215 .map(|(c, d)| (c.label.clone(), d.entry.clone()))
1216}
1217
1218fn bookmark_entry(name: &str, place: &Path, network_check: fn(&Path) -> bool) -> Entry {
1220 let local = matches!(
1221 crate::cloud::source::input_source(place),
1222 crate::cloud::source::InputSource::Local(_)
1223 );
1224 let mut entry = if is_object_store_url(place) && !names_a_file(place) {
1225 Entry::directory(place)
1226 } else {
1227 entry_for_path(place, !local || network_check(place) || !place.exists())
1228 };
1229 entry.name = name.to_string();
1230 entry
1231}
1232
1233fn catalog_section(
1235 catalog: &ShownCatalog,
1236 network_check: fn(&Path) -> bool,
1237 missing: &mut std::collections::HashSet<PathBuf>,
1238) -> Section {
1239 let rows = catalog
1241 .datasets
1242 .iter()
1243 .flat_map(|dataset| {
1244 std::iter::once(catalog_entry(dataset, network_check, missing)).chain(
1245 dataset
1246 .bookmarks
1247 .iter()
1248 .map(|(name, place)| bookmark_entry(name, place, network_check)),
1249 )
1250 })
1251 .collect();
1252 Section {
1253 origin: Some(catalog.origin_note()),
1254 unavailable: catalog.broken.is_some(),
1255 unavailable_note: catalog.broken.clone(),
1256 ..Section::titled(catalog.label.clone(), rows)
1257 }
1258}
1259
1260#[derive(Debug, Clone, Default, PartialEq)]
1263pub struct Measured {
1264 pub rows: Option<usize>,
1265 pub cols: Option<usize>,
1266 pub cols_sampled: bool,
1268 pub size: Option<u64>,
1269 pub modified: Option<std::time::SystemTime>,
1271 pub stat_only: bool,
1274 pub columns: Vec<String>,
1276 pub cost: crate::home::discover::Cost,
1279 pub kind: Option<crate::home::discover::EntryKind>,
1282 pub holds: crate::home::discover::Holds,
1285}
1286
1287#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1289pub enum SortMode {
1290 #[default]
1292 Natural,
1293 Size,
1295 Modified,
1297 Rows,
1299}
1300
1301impl SortMode {
1302 pub fn label_in(self, section_is_recency_ordered: bool) -> &'static str {
1305 match self {
1306 SortMode::Natural if section_is_recency_ordered => "recent",
1307 SortMode::Natural => "name",
1308 SortMode::Size => "size",
1309 SortMode::Modified => "modified",
1310 SortMode::Rows => "rows",
1311 }
1312 }
1313
1314 pub fn next(self) -> Self {
1315 match self {
1316 SortMode::Natural => SortMode::Size,
1317 SortMode::Size => SortMode::Modified,
1318 SortMode::Modified => SortMode::Rows,
1319 SortMode::Rows => SortMode::Natural,
1320 }
1321 }
1322}
1323
1324#[derive(Debug, Clone)]
1328pub enum Row<'a> {
1329 Header {
1330 section: usize,
1331 matches: usize,
1333 collapsed: bool,
1334 },
1335 Entry {
1336 section: usize,
1337 entry: &'a Entry,
1338 nested: bool,
1340 hit: Hit,
1342 },
1343 Place {
1345 section: usize,
1346 path: PathBuf,
1347 label: Option<String>,
1350 source: Option<String>,
1352 held: usize,
1354 },
1355 Door { section: usize, entry: &'a Entry },
1358 More {
1361 section: usize,
1362 hidden: usize,
1363 places: usize,
1364 measuring: bool,
1367 },
1368 Hidden { section: usize, count: usize },
1372 Up { section: usize },
1375}
1376
1377impl Row<'_> {
1378 pub fn section(&self) -> usize {
1379 match self {
1380 Row::Header { section, .. }
1381 | Row::Entry { section, .. }
1382 | Row::Door { section, .. }
1383 | Row::Place { section, .. }
1384 | Row::More { section, .. }
1385 | Row::Hidden { section, .. }
1386 | Row::Up { section } => *section,
1387 }
1388 }
1389}
1390
1391#[derive(Debug, Clone, Copy, Default, PartialEq)]
1395pub struct Hit {
1396 pub score: i32,
1397 pub column: Option<usize>,
1400}
1401
1402impl Hit {
1403 pub fn column_of<'a>(&self, entry: &'a Entry) -> Option<&'a str> {
1405 self.column
1406 .and_then(|i| entry.columns.get(i))
1407 .map(String::as_str)
1408 }
1409
1410 pub fn positions(&self, filter: &str, entry: &Entry) -> Vec<usize> {
1413 match self.column_of(entry) {
1414 Some(column) => substring_positions(filter, column),
1415 None => fuzzy_positions(filter, &entry.name),
1416 }
1417 }
1418}
1419
1420#[derive(Debug, Default)]
1425pub struct RowsCache {
1426 built: std::cell::RefCell<Option<View>>,
1427 builds: std::cell::Cell<usize>,
1428 rescored: std::cell::RefCell<Option<Hits>>,
1431}
1432
1433#[derive(Debug, Clone)]
1436struct Hits {
1437 filter: String,
1438 sections: Vec<Option<Vec<Option<Hit>>>>,
1439}
1440
1441#[derive(Debug)]
1443struct View {
1444 key: ViewKey,
1445 slots: Vec<Slot>,
1446 headers: Vec<usize>,
1449 doors: Vec<usize>,
1451 hits: Hits,
1452 has_dataset: bool,
1454}
1455
1456#[derive(Debug, PartialEq)]
1459struct ViewKey {
1460 filter: String,
1461 sort: SortMode,
1462 hide_unreadable: bool,
1463 recent_expanded: bool,
1464 shown_whole: std::collections::HashSet<PathBuf>,
1465 view_height: usize,
1466 browsing: Option<PathBuf>,
1467 folds: std::collections::HashMap<String, bool>,
1468 shape: Vec<(usize, bool)>,
1469}
1470
1471impl ViewKey {
1472 fn of(home: &HomeState) -> Self {
1473 ViewKey {
1474 filter: home.filter.clone(),
1475 sort: home.sort,
1476 hide_unreadable: home.hide_unreadable,
1477 recent_expanded: home.recent_expanded,
1478 shown_whole: home.shown_whole.clone(),
1479 view_height: home.view_height,
1480 browsing: home.browsing.clone(),
1481 folds: home.folds.clone(),
1482 shape: (home.sections.iter())
1483 .map(|s| (s.rows.len(), s.door.is_some()))
1484 .collect(),
1485 }
1486 }
1487
1488 fn matches(&self, home: &HomeState) -> bool {
1490 self.filter == home.filter
1491 && self.sort == home.sort
1492 && self.hide_unreadable == home.hide_unreadable
1493 && self.recent_expanded == home.recent_expanded
1494 && self.shown_whole == home.shown_whole
1495 && self.view_height == home.view_height
1496 && self.browsing == home.browsing
1497 && self.folds == home.folds
1498 && self.shape.iter().copied().eq(home
1499 .sections
1500 .iter()
1501 .map(|s| (s.rows.len(), s.door.is_some())))
1502 }
1503}
1504
1505#[derive(Debug)]
1507enum Slot {
1508 Plain(Row<'static>),
1510 Entry {
1511 section: usize,
1512 index: usize,
1513 nested: bool,
1514 hit: Hit,
1515 },
1516 Door {
1517 section: usize,
1518 },
1519}
1520
1521#[derive(Debug, Clone)]
1526pub struct ListLines {
1527 gaps: Vec<usize>,
1529 rows: usize,
1530}
1531
1532impl ListLines {
1533 pub fn rows(&self) -> usize {
1535 self.rows
1536 }
1537
1538 pub fn spacers(&self) -> usize {
1540 self.gaps.len()
1541 }
1542
1543 pub fn line_of(&self, row: usize) -> usize {
1545 row + self.gaps.partition_point(|&g| g <= row)
1546 }
1547
1548 pub fn total(&self) -> usize {
1550 match self.rows {
1551 0 => 0,
1552 n => self.line_of(n - 1) + 1,
1553 }
1554 }
1555
1556 pub fn first_row_from(&self, line: usize) -> usize {
1558 let (mut lo, mut hi) = (0, self.rows);
1559 while lo < hi {
1560 let mid = lo + (hi - lo) / 2;
1561 if self.line_of(mid) < line {
1562 lo = mid + 1;
1563 } else {
1564 hi = mid;
1565 }
1566 }
1567 lo
1568 }
1569
1570 pub fn row_on(&self, line: usize) -> Option<usize> {
1572 let row = self.first_row_from(line);
1573 (row < self.rows && self.line_of(row) == line).then_some(row)
1574 }
1575}
1576
1577pub fn place_of(path: &Path) -> PathBuf {
1580 parent_location(path).unwrap_or_else(|| path.to_path_buf())
1581}
1582
1583pub fn place_is_browsable(path: &Path) -> bool {
1586 is_cloud_place(path)
1587 || is_object_store_url(path)
1588 || matches!(
1589 crate::cloud::source::input_source(path),
1590 crate::cloud::source::InputSource::Local(_)
1591 )
1592}
1593
1594#[derive(Debug, Clone, PartialEq, Eq)]
1598pub enum RowKey {
1599 Header(String),
1600 Entry(PathBuf),
1601 Door(PathBuf),
1604 Place(PathBuf),
1605 More(String),
1606 Hidden(String),
1607 Up(String),
1608}
1609
1610#[derive(Debug)]
1612pub struct HomeState {
1613 pub sections: Vec<Section>,
1614 pub filter: String,
1616 pub filter_selected: bool,
1619 pub search_limit: usize,
1621 pub hide_unreadable: bool,
1623 pub formats: std::sync::Arc<crate::formats::Registry>,
1626 pub lake_here: Option<(PathBuf, &'static str)>,
1629 pub selected: usize,
1631 pub scroll: usize,
1636 pub view_height: usize,
1638 pub view_lines: usize,
1641 pub spaced: bool,
1642 pub path_input_active: bool,
1644 pub path_input: String,
1645 pub path_listing: Option<PathListing>,
1647 pub path_pick: Option<usize>,
1649 pub browsing: Option<PathBuf>,
1651 pub browse_start: Option<PathBuf>,
1654 pub status: Option<String>,
1656 pub network_check: fn(&Path) -> bool,
1659 pub visits: std::collections::HashMap<PathBuf, crate::cache::Visits>,
1662 pub newest_recent: Option<PathBuf>,
1664 pub probes: Probes,
1666 pub narrowed: Option<Narrowed>,
1668 pub cloud_kinds: std::collections::HashMap<PathBuf, (EntryKind, crate::home::discover::Holds)>,
1671 pub sort: SortMode,
1673 pub listing_in_flight: bool,
1675 pub measure_in_flight: bool,
1677 pub classifying: std::collections::HashSet<PathBuf>,
1681 pub peeking: std::collections::HashSet<PathBuf>,
1684 pub peek_failed: std::collections::HashSet<PathBuf>,
1687 pub enriched: std::collections::HashMap<PathBuf, Measured>,
1690 pub stated: std::collections::HashMap<PathBuf, Stated>,
1693 pub stat_epoch: u64,
1696 pub stale: std::collections::HashSet<PathBuf>,
1698 pub unapplied: std::collections::HashSet<PathBuf>,
1700 pub folds: std::collections::HashMap<String, bool>,
1704 pub folds_owed: bool,
1707 pub search: SearchState,
1709 pub known: Known,
1713 pub cloud: Vec<CloudSource>,
1716 pub catalogs: Vec<ShownCatalog>,
1719 pub sized: std::collections::HashSet<PathBuf>,
1721 pub web_gone: std::collections::HashMap<PathBuf, crate::error_display::HttpGone>,
1724 pub missing: std::collections::HashSet<PathBuf>,
1726 pub waiting_since: Option<std::time::Instant>,
1728 pub recent_expanded: bool,
1730 pub shown_whole: std::collections::HashSet<PathBuf>,
1733 pub trail: Vec<Mark>,
1736 pub returning: Option<RowKey>,
1739 pub returning_line: Option<usize>,
1741 pub landing: bool,
1744 pub resting: bool,
1748 pub rows_cache: RowsCache,
1750 pub catalog_places: CatalogPlaces,
1752}
1753
1754#[derive(Debug, Clone)]
1757pub struct Mark {
1758 pub place: Option<PathBuf>,
1760 pub key: Option<RowKey>,
1761 pub filter: String,
1763 pub search: Option<SearchState>,
1766 pub line: usize,
1768}
1769
1770#[derive(Debug, Clone, Default)]
1773pub struct SearchState {
1774 pub root: Option<PathBuf>,
1776 pub epoch: u64,
1778 pub results: Vec<std::sync::Arc<[Entry]>>,
1781 pub indexed: usize,
1783 pub matches: Option<crate::home::search::Matches>,
1786 pub scoring: bool,
1788 pub scanned: usize,
1790 pub running: bool,
1792 pub done: bool,
1794 pub limited: Option<String>,
1796}
1797
1798const SCORE_INLINE_MAX: usize = 2_000;
1801
1802const FRECENCY_LIFT: f64 = 3.0;
1805
1806#[derive(Debug, Clone)]
1808pub struct ScoreJob {
1809 pub epoch: u64,
1810 pub results: Vec<std::sync::Arc<[Entry]>>,
1811 pub query: String,
1812 pub base: Option<crate::home::search::Matches>,
1813 pub limit: usize,
1814}
1815
1816impl SearchState {
1817 pub fn reset(&mut self) {
1819 *self = Self::default();
1820 }
1821
1822 pub fn set_results(&mut self, results: Vec<Entry>) {
1824 self.indexed = results.len();
1825 self.results = vec![results.into()];
1826 self.matches = None;
1827 }
1828
1829 pub fn files(&self) -> impl Iterator<Item = &Entry> {
1831 self.results.iter().flat_map(|batch| batch.iter())
1832 }
1833
1834 fn scored_for(&self, query: &str) -> bool {
1836 self.matches
1837 .as_ref()
1838 .is_some_and(|m| m.query == query && m.upto == self.indexed)
1839 }
1840
1841 fn base_for(&self, query: &str) -> (Option<&crate::home::search::Matches>, usize) {
1843 match self.matches.as_ref() {
1844 Some(m) if m.narrows_to(query) && m.upto <= self.indexed => {
1845 (Some(m), m.ids.len() + self.indexed - m.upto)
1846 }
1847 _ => (None, self.indexed),
1848 }
1849 }
1850}
1851
1852impl Default for HomeState {
1853 fn default() -> Self {
1854 Self {
1855 sections: Vec::new(),
1856 cloud: Vec::new(),
1857 catalogs: Vec::new(),
1858 sized: std::collections::HashSet::new(),
1859 web_gone: Default::default(),
1860 missing: Default::default(),
1861 filter: String::new(),
1862 search_limit: crate::config::SearchConfig::default().max_results,
1863 rows_cache: RowsCache::default(),
1864 catalog_places: CatalogPlaces::default(),
1865 hide_unreadable: true,
1866 formats: Default::default(),
1867 lake_here: None,
1868 selected: 0,
1869 scroll: 0,
1870 view_height: 0,
1871 view_lines: 0,
1872 spaced: false,
1873 path_input_active: false,
1874 path_input: String::new(),
1875 path_listing: None,
1876 path_pick: None,
1877 filter_selected: false,
1878 browsing: None,
1879 browse_start: None,
1880 status: None,
1881 network_check: is_remote_path,
1882 visits: Default::default(),
1883 newest_recent: None,
1884 sort: SortMode::default(),
1885 listing_in_flight: false,
1886 measure_in_flight: false,
1887 classifying: Default::default(),
1888 peeking: std::collections::HashSet::new(),
1889 probes: Probes::default(),
1890 narrowed: None,
1891 cloud_kinds: std::collections::HashMap::new(),
1892 peek_failed: std::collections::HashSet::new(),
1893 waiting_since: None,
1894 enriched: std::collections::HashMap::new(),
1895 stated: Default::default(),
1896 stat_epoch: 0,
1897 stale: Default::default(),
1898 unapplied: Default::default(),
1899 folds: std::collections::HashMap::new(),
1900 folds_owed: false,
1901 search: SearchState::default(),
1902 known: Default::default(),
1903 recent_expanded: false,
1904 shown_whole: Default::default(),
1905 trail: Vec::new(),
1906 returning: None,
1907 returning_line: None,
1908 landing: false,
1909 resting: false,
1910 }
1911 }
1912}
1913
1914pub type Known = std::sync::Arc<std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>>;
1917
1918#[derive(Debug, Clone)]
1921pub struct ListingRequest {
1922 pub recents: Vec<PathBuf>,
1923 pub desktop_dirs: Vec<PathBuf>,
1924 pub browsing: Option<PathBuf>,
1925 pub probes: Probes,
1927 pub narrowed: Option<Narrowed>,
1929 pub network_check: fn(&Path) -> bool,
1930 pub cloud: Vec<CloudSource>,
1932 pub catalogs: Vec<ShownCatalog>,
1934 pub known: Known,
1937 pub formats: std::sync::Arc<crate::formats::Registry>,
1940}
1941
1942#[derive(Debug, Clone, Default)]
1944pub struct Listing {
1945 pub sections: Vec<Section>,
1946 pub missing: std::collections::HashSet<PathBuf>,
1948}
1949
1950impl Listing {
1951 pub fn learn(
1954 &mut self,
1955 learned: &[(PathBuf, crate::cache::DatasetFacts)],
1956 network_check: fn(&Path) -> bool,
1957 ) {
1958 if learned.is_empty() {
1959 return;
1960 }
1961 let learned: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts> =
1962 learned.iter().cloned().collect();
1963 for row in self.sections.iter_mut().flat_map(|s| s.rows.iter_mut()) {
1964 if known_facts(&learned, &row.path).is_some() {
1965 apply_known_facts(row, &learned, network_check(&row.path));
1966 }
1967 }
1968 }
1969
1970 pub fn alias_visits(
1976 &self,
1977 visits: &mut std::collections::HashMap<PathBuf, crate::cache::Visits>,
1978 ) {
1979 let names: std::collections::HashSet<std::ffi::OsString> = visits
1980 .keys()
1981 .filter_map(|p| p.file_name().map(|n| n.to_os_string()))
1982 .collect();
1983 let mut aliases = Vec::new();
1984 for row in self.sections.iter().flat_map(|s| &s.rows) {
1985 let path = &row.path;
1986 if visits.contains_key(path)
1987 || row.table.is_some()
1988 || !path.file_name().is_some_and(|n| names.contains(n))
1989 || is_network_path(path)
1990 {
1991 continue;
1992 }
1993 if let Some(v) = crate::canonical::canonicalize(path)
1994 .ok()
1995 .and_then(|canonical| visits.get(&canonical))
1996 {
1997 aliases.push((path.clone(), *v));
1998 }
1999 }
2000 visits.extend(aliases);
2001 }
2002}
2003
2004pub fn look_into_as(entry: &Entry, as_read: &crate::formats::schema_union::ReadAs) -> Entry {
2009 let mut probe = classify_row(entry);
2010 measure_row(&mut probe, entry, as_read, None);
2011 probe
2012}
2013
2014fn classify_row(entry: &Entry) -> Entry {
2016 let mut probe = entry.clone();
2017 if probe.kind == EntryKind::Unknown && probe.path.is_dir() {
2018 let (kind, holds) = discover::look_at_directory(&probe.path);
2019 probe.kind = kind;
2020 probe.holds = holds;
2021 }
2022 probe
2023}
2024
2025fn stat_and_recall(
2029 probe: &mut Entry,
2030 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2031) -> bool {
2032 if discover::unstated(probe) && !discover::stat_row(probe) {
2033 return false;
2034 }
2035 if probe.rows.is_none() && probe.columns.is_empty() && discover::on_disk(probe) {
2038 apply_known_facts(probe, known, false);
2039 }
2040 true
2041}
2042
2043fn measure_row(
2046 probe: &mut Entry,
2047 entry: &Entry,
2048 as_read: &crate::formats::schema_union::ReadAs,
2049 remembered: Option<&crate::cache::CacheManager>,
2050) {
2051 discover::enrich_with(probe, as_read, remembered);
2052 probe.size = probe.size.or(entry.size);
2053 probe.modified = probe.modified.or(entry.modified);
2054}
2055
2056#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2058pub enum Reads {
2059 Files,
2061 StatOnly,
2063}
2064
2065pub fn look_into_batch(
2070 rows: Vec<Entry>,
2071 cache: &crate::cache::CacheManager,
2072 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2073 reads: Reads,
2074 mut each: impl FnMut(PathBuf, Measured),
2075) {
2076 let as_read = crate::formats::schema_union::ReadAs::default();
2077 let answer = |probe: &Entry, entry: &Entry| Measured {
2078 stat_only: reads == Reads::StatOnly,
2079 ..measured_from(probe, entry)
2080 };
2081 let classified: Vec<(Entry, Entry)> = rows
2082 .into_iter()
2083 .map(|entry| {
2084 let mut probe = entry.clone();
2085 if !stat_and_recall(&mut probe, known) {
2087 return (probe, entry);
2088 }
2089 let probe = match reads {
2091 Reads::Files => classify_row(&probe),
2092 Reads::StatOnly => probe,
2093 };
2094 if probe.kind != entry.kind || probe.modified != entry.modified {
2095 each(entry.path.clone(), answer(&probe, &entry));
2096 }
2097 (probe, entry)
2098 })
2099 .collect();
2100
2101 let mut facts = Vec::new();
2102 for (mut probe, entry) in classified {
2103 if reads == Reads::Files && probe.rows.is_none() && probe.columns.is_empty() {
2105 measure_row(&mut probe, &entry, &as_read, Some(cache));
2106 facts.extend(facts_for(&probe));
2107 }
2108 each(entry.path.clone(), answer(&probe, &entry));
2109 }
2110 cache.record_dataset_facts(&facts);
2113}
2114
2115pub fn measured_from(probe: &Entry, original: &Entry) -> Measured {
2117 Measured {
2118 rows: probe.rows,
2119 cols: probe.cols,
2120 cols_sampled: probe.cols_sampled,
2121 size: probe.size.or(original.size),
2122 modified: probe.modified.or(original.modified),
2123 stat_only: false,
2124 columns: probe.columns.clone(),
2125 kind: (probe.kind != original.kind).then_some(probe.kind),
2126 holds: probe.holds.clone(),
2127 cost: crate::home::discover::Cost {
2130 source: None,
2131 ..probe.cost.clone()
2132 },
2133 }
2134}
2135
2136#[derive(Debug, Clone)]
2139pub enum Probe {
2140 Listing(Vec<Entry>),
2142 Listed {
2144 rows: std::sync::Arc<[Entry]>,
2145 cut_short: bool,
2146 },
2147 Unreachable(Option<String>),
2149 Silent(Vec<Entry>),
2152}
2153
2154pub const NOT_ANSWERING: &str = "not answering · Ctrl+R retries";
2156
2157#[derive(Debug, Clone, Default)]
2159pub struct Probes(std::collections::HashMap<PathBuf, Probe>);
2160
2161impl Probes {
2162 pub fn listed(&self, place: &Path) -> Option<&[Entry]> {
2164 match self.0.get(place)? {
2165 Probe::Listed { rows, .. } => Some(rows),
2166 _ => None,
2167 }
2168 }
2169
2170 pub fn so_far(&self, place: &Path) -> Option<&[Entry]> {
2172 match self.0.get(place)? {
2173 Probe::Listing(rows) => Some(rows),
2174 _ => None,
2175 }
2176 }
2177
2178 pub fn settled(&self, place: &Path) -> bool {
2180 matches!(
2181 self.0.get(place),
2182 Some(Probe::Listed { .. } | Probe::Unreachable(_) | Probe::Silent(_))
2183 )
2184 }
2185
2186 pub fn silent(&self, place: &Path) -> bool {
2188 matches!(self.0.get(place), Some(Probe::Silent(_)))
2189 }
2190
2191 pub fn silent_places(&self) -> Vec<PathBuf> {
2193 (self.0.iter())
2194 .filter(|(_, probe)| matches!(probe, Probe::Silent(_)))
2195 .map(|(place, _)| place.clone())
2196 .collect()
2197 }
2198
2199 pub fn takes_pages(&self, place: &Path) -> bool {
2202 matches!(
2203 self.0.get(place),
2204 None | Some(Probe::Listing(_) | Probe::Silent(_))
2205 )
2206 }
2207
2208 pub fn go_silent(&mut self, place: &Path) {
2210 let rows = match self.0.remove(place) {
2211 Some(Probe::Listing(rows) | Probe::Silent(rows)) => rows,
2212 None => Vec::new(),
2213 Some(settled) => {
2214 self.0.insert(place.to_path_buf(), settled);
2215 return;
2216 }
2217 };
2218 self.0.insert(place.to_path_buf(), Probe::Silent(rows));
2219 }
2220
2221 pub fn cut_short(&self, place: &Path) -> bool {
2222 matches!(
2223 self.0.get(place),
2224 Some(Probe::Listed {
2225 cut_short: true,
2226 ..
2227 })
2228 )
2229 }
2230
2231 pub fn unreachable(&self, place: &Path) -> bool {
2232 matches!(self.0.get(place), Some(Probe::Unreachable(_)))
2233 }
2234
2235 pub fn error(&self, place: &Path) -> Option<&str> {
2237 match self.0.get(place)? {
2238 Probe::Unreachable(why) => why.as_deref(),
2239 _ => None,
2240 }
2241 }
2242
2243 fn rows(&self, place: &Path) -> Vec<Entry> {
2246 match self.0.get(place) {
2247 Some(Probe::Listed { rows, .. }) => rows.to_vec(),
2248 Some(Probe::Listing(rows) | Probe::Silent(rows)) => {
2249 let mut rows = rows.clone();
2250 if is_object_store_url(place) {
2251 rows.sort_by_key(|row| row.kind != EntryKind::Directory);
2252 } else {
2253 discover::sort_entries(&mut rows);
2254 }
2255 rows
2256 }
2257 _ => Vec::new(),
2258 }
2259 }
2260
2261 pub fn answered(&self) -> impl Iterator<Item = (&PathBuf, &[Entry])> {
2263 self.0.iter().filter_map(|(place, probe)| match probe {
2264 Probe::Listed { rows, .. } => Some((place, &rows[..])),
2265 _ => None,
2266 })
2267 }
2268
2269 fn entry(&self, path: &Path) -> Option<Entry> {
2271 self.answered()
2272 .flat_map(|(_, rows)| rows.iter())
2273 .find(|e| e.path == path)
2274 .cloned()
2275 }
2276
2277 pub fn read(&mut self, place: &Path, rows: &[Entry]) {
2279 let probe = (self.0)
2280 .entry(place.to_path_buf())
2281 .or_insert(Probe::Listing(Vec::new()));
2282 if let Probe::Silent(so_far) = probe {
2284 *probe = Probe::Listing(std::mem::take(so_far));
2285 }
2286 if let Probe::Listing(so_far) = probe {
2287 so_far.extend_from_slice(rows);
2288 }
2289 }
2290
2291 pub fn insert(&mut self, place: PathBuf, probe: Probe) {
2292 self.0.insert(place, probe);
2293 }
2294
2295 pub fn forget(&mut self, place: &Path) {
2297 self.0.remove(place);
2298 }
2299
2300 pub fn stopped(&mut self, place: &Path) {
2302 if let Some(Probe::Listing(_) | Probe::Silent(_)) = self.0.get(place) {
2303 self.0.remove(place);
2304 }
2305 }
2306
2307 fn listed_mut(&mut self, place: &Path) -> Option<&mut std::sync::Arc<[Entry]>> {
2308 match self.0.get_mut(place)? {
2309 Probe::Listed { rows, .. } => Some(rows),
2310 _ => None,
2311 }
2312 }
2313}
2314
2315#[derive(Debug, Clone)]
2318pub struct Narrowed {
2319 pub dir: PathBuf,
2320 pub prefix: String,
2322 pub rows: Vec<Entry>,
2323 pub truncated: bool,
2325}
2326
2327pub fn build_listing(request: &ListingRequest) -> Listing {
2331 let ListingRequest {
2332 recents,
2333 desktop_dirs,
2334 browsing,
2335 probes,
2336 narrowed,
2337 network_check,
2338 cloud,
2339 catalogs,
2340 known,
2341 formats,
2342 } = request;
2343 let network_check = *network_check;
2344 let mounts = crate::home::locality::Mounts::current();
2347 let mut sections: Vec<Section> = Vec::new();
2348
2349 if let Some(id) = browsing.as_deref().and_then(cloud_source_id) {
2351 sections.push(source_section(&id, cloud));
2352 annotate(&mut sections, known, network_check, &mounts);
2353 return Listing {
2354 sections,
2355 ..Default::default()
2356 };
2357 }
2358
2359 if let Some(dir) = browsing.clone() {
2361 let remote = network_check(&dir);
2362 sections.push(browsed_section(
2363 &dir,
2364 remote,
2365 &Listed {
2366 probes,
2367 narrowed: narrowed.as_ref(),
2368 catalogs,
2369 known,
2370 formats,
2371 },
2372 ));
2373 annotate(&mut sections, known, network_check, &mounts);
2374 return Listing {
2375 sections,
2376 ..Default::default()
2377 };
2378 }
2379
2380 let recent_rows: Vec<Entry> = recents
2382 .iter()
2383 .filter(|p| {
2386 network_check(p)
2387 || p.exists()
2388 || crate::formats::members::split(p).is_some()
2389 || crate::formats::members::split_variant(p, formats).is_some()
2390 || crate::formats::hf_splits::split_place(p).is_some()
2391 })
2392 .map(|p| {
2394 if let Some(known) = probes.entry(p) {
2397 return known;
2398 }
2399 if let Some(variant) = discover::variant_row(p, formats) {
2400 return variant;
2401 }
2402 if !network_check(p)
2403 && let Some(split) = discover::split_row(p)
2404 {
2405 return split;
2406 }
2407 let mut entry = entry_for_path(p, network_check(p));
2408 if !network_check(p) {
2409 discover::name_unlisted_file(&mut entry, formats);
2410 }
2411 if let Some(dataset) = catalogs
2414 .iter()
2415 .flat_map(|c| &c.datasets)
2416 .find(|d| d.location == *p)
2417 {
2418 entry.name = dataset.name.clone();
2419 }
2420 entry
2421 })
2422 .collect();
2423
2424 let mut elsewhere: Vec<Entry> = Vec::new();
2426
2427 let roots = HomeState::roots_with(desktop_dirs, network_check);
2428 let mut root_sections: Vec<(RootOrigin, Section)> = Vec::new();
2429 let mut cwd_listing: Option<(PathBuf, std::collections::HashSet<std::ffi::OsString>)> = None;
2432 for root in roots {
2433 if root.origin == RootOrigin::Desktop {
2436 if root.available {
2437 let mut entry = Entry::directory(&root.path);
2438 entry.name = display_path(&root.path);
2440 elsewhere.push(entry);
2441 }
2442 continue;
2443 }
2444
2445 let mut truncated = false;
2448 let rows = if root.network {
2449 truncated = probes.cut_short(&root.path);
2450 probes.rows(&root.path)
2451 } else if root.available {
2452 let scan = discover::scan_dir_specs(&root.path, formats);
2453 truncated = scan.truncated;
2454 scan.entries
2455 } else {
2456 Vec::new()
2457 };
2458 if root.origin == RootOrigin::Cwd {
2459 let key = if root.network {
2462 root.path.clone()
2463 } else {
2464 crate::canonical::canonicalize(&root.path).unwrap_or_else(|_| root.path.clone())
2465 };
2466 let names = rows
2467 .iter()
2468 .filter_map(|row| row.path.file_name().map(|n| n.to_os_string()))
2469 .collect();
2470 cwd_listing = Some((key, names));
2471 }
2472 let unreachable = root.network && probes.unreachable(&root.path);
2474 let silent = root.network && probes.silent(&root.path);
2475 let waiting = root.network && !probes.settled(&root.path);
2476 let described = mounts.describe(&root.path);
2480 let fstype = if described.network() {
2481 described.fstype
2482 } else {
2483 "network".to_string()
2484 };
2485 let mut state: Vec<String> = Vec::new();
2488 if truncated {
2489 state.push(format!(
2490 "first {}",
2491 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2492 ));
2493 }
2494 if root.network {
2495 state.push(fstype);
2496 }
2497 root_sections.push((
2498 root.origin,
2499 Section {
2500 subtitle: (!state.is_empty()).then(|| crate::glyphs::dotted(&state.join(" · "))),
2501 origin: Some(root.origin.note()),
2502 root: Some(root.path.clone()),
2503 unavailable: !root.available || unreachable || silent,
2504 unavailable_note: silent.then(|| NOT_ANSWERING.to_string()),
2505 remote_root: root.network.then(|| root.path.clone()),
2506 waiting,
2507 ..Section::titled(display_path(&root.path), rows)
2508 },
2509 ));
2510 }
2511
2512 let recent_rows: Vec<Entry> = match &cwd_listing {
2517 None => recent_rows,
2518 Some((cwd, names)) => recent_rows
2519 .into_iter()
2520 .filter(|row| {
2521 let place = place_of(&row.path);
2522 let place = if network_check(&place) {
2525 place
2526 } else {
2527 crate::canonical::canonicalize(&place).unwrap_or(place)
2528 };
2529 place != *cwd || !row.path.file_name().is_some_and(|n| names.contains(n))
2530 })
2531 .collect(),
2532 };
2533 if !recent_rows.is_empty() {
2534 let place_labels = place_labels(&recent_rows, known, network_check);
2535 sections.push(Section {
2536 grouped_by_place: true,
2538 place_labels,
2539 ..Section::titled(HomeState::RECENT_SECTION, recent_rows)
2540 });
2541 }
2542
2543 sections.extend(root_sections.into_iter().map(|(_, s)| s));
2547
2548 sections.extend(cloud_section(cloud));
2551
2552 let mut missing = std::collections::HashSet::new();
2554 for catalog in catalogs {
2555 let mut section = catalog_section(catalog, network_check, &mut missing);
2556 for row in section.rows.iter_mut().filter(|r| {
2558 r.kind == EntryKind::File
2559 && r.format_spec.is_none()
2560 && r.table.is_none()
2561 && !network_check(&r.path)
2562 && !discover::is_data_file(&r.path)
2563 }) {
2564 if let Some(spec) = formats.by_glob(&row.path, false).first() {
2565 discover::name_spec_file(row, spec);
2566 }
2567 }
2568 sections.push(section);
2569 }
2570
2571 if !elsewhere.is_empty() {
2572 sections.push(Section {
2573 folded_by_default: true,
2575 ..Section::titled("Elsewhere", elsewhere)
2576 });
2577 }
2578
2579 annotate(&mut sections, known, network_check, &mounts);
2582
2583 Listing { sections, missing }
2584}
2585
2586fn source_section(id: &str, cloud: &[CloudSource]) -> Section {
2589 let source = cloud.iter().find(|s| s.id == id);
2590 let rows = source
2591 .map(|s| s.buckets.iter().map(|b| bucket_entry(b)).collect())
2592 .unwrap_or_default();
2593 let failure = source.and_then(|s| match &s.status {
2594 CloudStatus::Failed { short, .. } => Some(short.clone()),
2595 _ => None,
2596 });
2597 Section {
2598 subtitle: source.map(|s| s.note.clone()).filter(|n| !n.is_empty()),
2599 unavailable: source.is_none() || failure.is_some(),
2600 unavailable_note: if source.is_none() {
2601 Some("source not found".to_string())
2602 } else {
2603 failure
2604 },
2605 waiting: source.is_some_and(|s| s.busy()),
2606 ..Section::titled(
2607 source.map_or_else(|| id.to_string(), |s| s.label.clone()),
2608 rows,
2609 )
2610 }
2611}
2612
2613fn cloud_section(cloud: &[CloudSource]) -> Option<Section> {
2615 (!cloud.is_empty()).then(|| {
2616 Section::titled(
2617 HomeState::CLOUD_SECTION.to_string(),
2618 cloud.iter().map(source_entry).collect(),
2619 )
2620 })
2621}
2622
2623pub(crate) struct Listed<'a> {
2626 pub probes: &'a Probes,
2627 pub narrowed: Option<&'a Narrowed>,
2628 pub catalogs: &'a [ShownCatalog],
2629 pub known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2630 pub formats: &'a crate::formats::Registry,
2631}
2632
2633fn browsed_section(dir: &Path, remote: bool, listed: &Listed) -> Section {
2636 let Listed {
2637 probes,
2638 narrowed,
2639 catalogs,
2640 known,
2641 formats,
2642 } = *listed;
2643 let dir = dir.to_path_buf();
2644
2645 let so_far = remote && probes.so_far(&dir).is_some();
2649 let database = !remote && dir.is_file();
2651 let (mut rows, truncated) = if remote {
2652 (probes.rows(&dir), probes.cut_short(&dir))
2653 } else if database {
2654 let tables = discover::database_rows(&dir);
2655 let rows = if tables.is_empty() {
2656 discover::variant_rows(&dir, formats)
2657 } else {
2658 tables
2659 };
2660 (rows, false)
2661 } else {
2662 let scan = discover::scan_dir_specs(&dir, formats);
2663 let mut rows = discover::split_rows(&dir);
2665 rows.extend(scan.entries);
2666 (rows, scan.truncated)
2667 };
2668 let narrowed = narrowed.filter(|n| remote && truncated && n.dir == dir);
2671 if let Some(narrowed) = narrowed {
2672 let listed: std::collections::HashSet<PathBuf> =
2673 rows.iter().map(|row| row.path.clone()).collect();
2674 rows.extend(
2675 narrowed
2676 .rows
2677 .iter()
2678 .filter(|row| !listed.contains(&row.path))
2679 .cloned(),
2680 );
2681 }
2682 let subtitle = if so_far {
2684 Some(format!(
2685 "{} so far",
2686 crate::numfmt::group_chrome(rows.len())
2687 ))
2688 } else if truncated {
2689 let first = format!(
2690 "first {}",
2691 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2692 );
2693 Some(match narrowed {
2694 Some(n) => format!(
2695 "{first} + {}{} {}*",
2696 crate::numfmt::group_chrome(n.rows.len()),
2697 if n.truncated { "+" } else { "" },
2698 n.prefix
2699 ),
2700 None => first,
2701 })
2702 } else {
2703 None
2704 };
2705 let unavailable = remote && (probes.unreachable(&dir) || probes.silent(&dir));
2706 let mut door = (!database)
2709 .then(|| whole_directory_row(&dir, &rows, remote))
2710 .flatten();
2711 if !remote
2715 && let Some(door) = door.as_mut()
2716 && let Ok(meta) = std::fs::metadata(&dir)
2717 {
2718 door.modified = meta.modified().ok();
2719 apply_known_facts(door, known, false);
2720 door.modified = None;
2721 door.name = door_name(door, &rows);
2722 }
2723 let title = {
2726 let text = dir.to_string_lossy();
2727 if let Some(dataset) = catalogs
2728 .iter()
2729 .flat_map(|c| c.datasets.iter())
2730 .find(|d| is_object_store_url(&d.location) && same_place(&d.location, &dir))
2731 {
2732 dataset.name.clone()
2733 } else if let Some((_, account)) = cloud_account(&dir) {
2734 account
2735 } else if let Some((_, container, key)) = crate::cloud::source::azure_parts(&text) {
2736 format!("{container}/{}", key.trim_matches('/'))
2737 .trim_end_matches('/')
2738 .to_string()
2739 } else {
2740 match crate::cloud::source::split_source_id(&text) {
2741 (Some(_), plain) => plain.into_owned(),
2742 (None, _) => display_path(&dir),
2743 }
2744 }
2745 };
2746 Section {
2747 subtitle,
2748 root: Some(dir.clone()),
2749 unavailable,
2750 unavailable_note: probes
2753 .error(&dir)
2754 .map(str::to_string)
2755 .or_else(|| probes.silent(&dir).then(|| NOT_ANSWERING.to_string())),
2756 waiting: so_far,
2759 door,
2760 ..Section::titled(title, rows)
2761 }
2762}
2763
2764pub fn index_key(path: &Path) -> PathBuf {
2768 let text = path.to_string_lossy();
2769 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
2770 return PathBuf::from(crate::cloud::source::azure_url(&account, &container, &key));
2771 }
2772 match crate::cloud::source::split_source_id(&text) {
2773 (Some(_), plain) => PathBuf::from(plain.into_owned()),
2774 (None, _) => path.to_path_buf(),
2775 }
2776}
2777
2778fn known_facts<'a>(
2780 known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2781 path: &Path,
2782) -> Option<&'a crate::cache::DatasetFacts> {
2783 known.get(path).or_else(|| known.get(&index_key(path)))
2784}
2785
2786fn place_labels(
2791 rows: &[Entry],
2792 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2793 network_check: fn(&Path) -> bool,
2794) -> std::collections::HashMap<PathBuf, String> {
2795 let mut labels = std::collections::HashMap::new();
2796 for row in rows {
2797 let place = place_of(&row.path);
2798 if labels.contains_key(&place) {
2799 continue;
2800 }
2801 let Some(facts) = known_facts(known, &place) else {
2802 continue;
2803 };
2804 if facts.classified_by != crate::home::discover::CLASSIFIER_VERSION {
2805 continue;
2806 }
2807 if !network_check(&place) {
2808 let same_mtime = std::fs::metadata(&place)
2809 .and_then(|m| m.modified())
2810 .ok()
2811 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2812 .is_some_and(|d| d.as_secs() == facts.mtime);
2813 if !same_mtime {
2814 continue;
2815 }
2816 }
2817 let Some(kind) = facts.kind else {
2818 continue;
2819 };
2820 let mut probe = Entry::directory(&place);
2821 probe.kind = kind;
2822 probe.holds = facts.holds.clone();
2823 let label = probe.label();
2824 if !label.is_empty() && !label.starts_with("dir") {
2825 labels.insert(place, label.into_owned());
2826 }
2827 }
2828 labels
2829}
2830
2831fn annotate(
2834 sections: &mut [Section],
2835 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2836 network_check: fn(&Path) -> bool,
2837 mounts: &crate::home::locality::Mounts,
2838) {
2839 for section in sections {
2840 for row in &mut section.rows {
2841 if is_cloud_place(&row.path) {
2842 row.cost.source = Some("cloud".to_string());
2843 continue;
2844 }
2845 let remote = network_check(&row.path);
2846 if !remote && discover::unstated(row) && known_facts(known, &row.path).is_some() {
2850 discover::stat_row(row);
2851 }
2852 apply_known_facts(row, known, remote);
2853 row.cost.source = Some(mounts.describe(&row.path).fstype);
2854 }
2855 if let Some(door) = section.door.as_mut()
2857 && !is_cloud_place(&door.path)
2858 {
2859 door.cost.source = Some(mounts.describe(&door.path).fstype);
2860 }
2861 }
2862}
2863
2864#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2866pub struct Stated {
2867 pub size: Option<u64>,
2868 pub modified: Option<std::time::SystemTime>,
2869 pub epoch: u64,
2871}
2872
2873fn fold_known(
2876 row: &mut Entry,
2877 enriched: &std::collections::HashMap<PathBuf, Measured>,
2878 stated: &std::collections::HashMap<PathBuf, Stated>,
2879 stale: &std::collections::HashSet<PathBuf>,
2880) -> bool {
2881 let mut any = false;
2882 if stale.contains(&row.path) {
2883 forget_counts(row);
2884 any = true;
2885 }
2886 if let Some(m) = enriched.get(&row.path) {
2887 fold_measured(row, m);
2888 any = true;
2889 }
2890 if let Some(stat) = stated.get(&row.path) {
2891 if stat.size.is_some() {
2892 row.size = stat.size;
2893 }
2894 if stat.modified.is_some() {
2895 row.modified = stat.modified;
2896 }
2897 any = true;
2898 }
2899 any
2900}
2901
2902fn forget_counts(row: &mut Entry) {
2905 row.measured = false;
2906 row.rows = None;
2907 row.cols = None;
2908 row.cols_sampled = false;
2909 row.columns.clear();
2910 take_cost(row, &discover::Cost::default());
2911}
2912
2913#[derive(PartialEq)]
2916struct Standing {
2917 hidden: bool,
2918 dataset: bool,
2919 sorted_by: Option<u64>,
2921}
2922
2923impl Standing {
2924 fn of(row: &Entry, sort: SortMode) -> Self {
2925 Standing {
2926 hidden: row.hidden_by_default(),
2927 dataset: row.kind.is_dataset() || row.kind.is_lake_table(),
2928 sorted_by: match sort {
2929 SortMode::Size => Some(row.size.unwrap_or(0)),
2930 SortMode::Rows => Some(row.rows.unwrap_or(0) as u64),
2931 SortMode::Modified => Some(
2932 (row.modified)
2933 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2934 .map_or(0, |d| d.as_secs()),
2935 ),
2936 SortMode::Natural => None,
2937 },
2938 }
2939 }
2940}
2941
2942fn fold_measured(row: &mut Entry, m: &Measured) {
2944 row.measured = true;
2945 row.rows = m.rows;
2946 row.cols = m.cols;
2947 row.cols_sampled = m.cols_sampled;
2948 if let Some(kind) = m.kind {
2949 row.kind = kind;
2950 }
2951 if m.size.is_some() {
2952 row.size = m.size;
2953 }
2954 if m.modified.is_some() {
2955 row.modified = m.modified;
2956 }
2957 if !m.columns.is_empty() && row.columns != m.columns {
2958 row.columns.clone_from(&m.columns);
2959 }
2960 if !m.holds.is_empty() && row.holds != m.holds {
2961 row.holds.clone_from(&m.holds);
2962 }
2963 take_cost(row, &m.cost);
2965}
2966
2967fn take_cost(row: &mut Entry, cost: &discover::Cost) {
2971 let source = row.cost.source.take();
2972 let variants = row.cost.tables.filter(|_| row.format_spec.is_some());
2973 row.cost = cost.clone();
2974 row.cost.source = source;
2975 if variants.is_some() {
2976 row.cost.tables = variants;
2977 }
2978}
2979
2980fn apply_known_facts(
2984 row: &mut Entry,
2985 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2986 remote: bool,
2987) {
2988 let Some(facts) = known_facts(known, &row.path) else {
2989 return;
2990 };
2991
2992 if !remote
2999 && matches!(row.kind, EntryKind::Unknown | EntryKind::MultiFile)
3000 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
3001 && let Some(kind) = facts.kind
3002 {
3003 let same_mtime = row
3004 .modified
3005 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
3006 .is_some_and(|d| d.as_secs() == facts.mtime);
3007 if same_mtime {
3008 row.kind = kind;
3009 if row.holds.is_empty() {
3012 row.holds = facts.holds.clone();
3013 }
3014 }
3018 }
3019
3020 if !remote {
3021 let same_bytes = row.size.map(|s| s == facts.size).unwrap_or(false)
3022 && row
3023 .modified
3024 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
3025 .map(|d| d.as_secs() == facts.mtime)
3026 .unwrap_or(false);
3027 if !same_bytes {
3028 return;
3029 }
3030 }
3031
3032 row.rows = facts.rows;
3033 row.cols = facts.cols;
3034 row.cols_sampled = facts.cols_sampled;
3035 if !facts.columns.is_empty() {
3036 row.columns = facts.columns.clone();
3037 }
3038 take_cost(row, &facts.cost);
3041 if remote {
3042 if facts.size > 0 {
3045 row.size = row.size.or(Some(facts.size));
3046 }
3047 if row.kind == EntryKind::Unknown
3051 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
3052 && let Some(kind) = facts.kind
3053 {
3054 row.kind = kind;
3055 if row.holds.is_empty() {
3057 row.holds = facts.holds.clone();
3058 }
3059 }
3060 }
3061}
3062
3063pub fn facts_for(entry: &Entry) -> Option<(PathBuf, crate::cache::DatasetFacts)> {
3065 let size = entry.size?;
3066 let mtime = entry
3067 .modified?
3068 .duration_since(std::time::UNIX_EPOCH)
3069 .ok()?
3070 .as_secs();
3071 let cost = crate::home::discover::Cost {
3073 source: None,
3074 ..entry.cost.clone()
3075 };
3076 if entry.rows.is_none() && entry.columns.is_empty() && cost == Default::default() {
3077 return None; }
3079 Some((
3080 entry.path.clone(),
3081 crate::cache::DatasetFacts {
3082 mtime,
3083 size,
3084 rows: entry.rows,
3085 cols: entry.cols,
3086 cols_sampled: entry.cols_sampled,
3087 columns: entry.columns.clone(),
3088 kind: Some(entry.kind),
3089 holds: entry.holds.clone(),
3090 classified_by: crate::home::discover::CLASSIFIER_VERSION,
3091 cost: crate::home::discover::Cost {
3093 source: None,
3094 ..entry.cost.clone()
3095 },
3096 },
3097 ))
3098}
3099
3100pub fn match_score(filter: &str, entry: &Entry) -> Option<i32> {
3104 match crate::home::fuzzy::best_match(filter, &entry.name) {
3105 Some(m) => Some(m.score),
3106 None => matching_column(filter, entry).map(|_| -COLUMN_MATCH_PENALTY),
3109 }
3110}
3111
3112pub fn match_hit(filter: &str, entry: &Entry) -> Option<Hit> {
3114 Needle::new(filter).hit(entry)
3115}
3116
3117struct Needle<'a> {
3120 filter: &'a str,
3121 lower: String,
3122}
3123
3124impl<'a> Needle<'a> {
3125 fn new(filter: &'a str) -> Self {
3126 Needle {
3127 filter,
3128 lower: filter.to_lowercase(),
3129 }
3130 }
3131
3132 fn hit(&self, entry: &Entry) -> Option<Hit> {
3133 let named = crate::home::fuzzy::best_match_with(self.filter, &entry.name, |score, _| Hit {
3134 score,
3135 column: None,
3136 });
3137 if named.is_some() {
3138 return named;
3139 }
3140 Some(Hit {
3141 score: -COLUMN_MATCH_PENALTY,
3142 column: Some(self.column(entry)?),
3143 })
3144 }
3145
3146 fn column(&self, entry: &Entry) -> Option<usize> {
3148 if self.filter.is_empty() {
3149 return None;
3150 }
3151 (entry.columns.iter()).position(|c| contains_folded(c, &self.lower))
3152 }
3153}
3154
3155fn contains_folded(haystack: &str, lower: &str) -> bool {
3158 if haystack.is_ascii() && lower.is_ascii() {
3159 let (hay, needle) = (haystack.as_bytes(), lower.as_bytes());
3160 return needle.is_empty()
3161 || hay
3162 .windows(needle.len())
3163 .any(|w| w.eq_ignore_ascii_case(needle));
3164 }
3165 haystack.to_lowercase().contains(lower)
3166}
3167
3168const COLUMN_MATCH_PENALTY: i32 = 1_000_000;
3171
3172pub fn matching_column<'a>(filter: &str, entry: &'a Entry) -> Option<&'a str> {
3175 (Needle::new(filter).column(entry)).map(|i| entry.columns[i].as_str())
3176}
3177
3178pub fn fuzzy_positions(needle: &str, haystack: &str) -> Vec<usize> {
3181 crate::home::fuzzy::best_match(needle, haystack)
3182 .map(|m| m.positions)
3183 .unwrap_or_default()
3184}
3185
3186pub fn substring_positions(needle: &str, haystack: &str) -> Vec<usize> {
3189 if needle.is_empty() {
3190 return Vec::new();
3191 }
3192 let hay: Vec<char> = haystack.to_lowercase().chars().collect();
3193 let need: Vec<char> = needle.to_lowercase().chars().collect();
3194 if need.len() > hay.len() {
3195 return Vec::new();
3196 }
3197 for start in 0..=(hay.len() - need.len()) {
3198 if hay[start..start + need.len()] == need[..] {
3199 return (start..start + need.len()).collect();
3200 }
3201 }
3202 Vec::new()
3203}
3204
3205pub fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
3208 crate::home::fuzzy::best_match(needle, haystack).map(|m| m.score)
3209}
3210
3211impl HomeState {
3212 pub fn roots(desktop_dirs: &[PathBuf]) -> Vec<Root> {
3215 Self::roots_with(desktop_dirs, is_remote_path)
3216 }
3217
3218 pub fn roots_with(desktop_dirs: &[PathBuf], is_network: fn(&Path) -> bool) -> Vec<Root> {
3220 let mut roots: Vec<Root> = Vec::new();
3221 let mut seen: Vec<PathBuf> = Vec::new();
3222
3223 let push =
3224 |path: PathBuf, origin: RootOrigin, roots: &mut Vec<Root>, seen: &mut Vec<PathBuf>| {
3225 let network = is_network(&path);
3227
3228 let key = if network {
3232 path.clone()
3233 } else {
3234 crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone())
3235 };
3236 if seen.contains(&key) {
3237 return;
3238 }
3239 seen.push(key);
3240
3241 let available = if network {
3242 true } else {
3244 std::fs::read_dir(&path).is_ok()
3245 };
3246 roots.push(Root {
3247 path,
3248 origin,
3249 available,
3250 network,
3251 });
3252 };
3253
3254 if let Ok(cwd) = std::env::current_dir() {
3258 push(cwd, RootOrigin::Cwd, &mut roots, &mut seen);
3259 }
3260
3261 for dir in desktop_dirs {
3264 push(dir.clone(), RootOrigin::Desktop, &mut roots, &mut seen);
3265 }
3266
3267 roots
3268 }
3269
3270 pub fn rebuild(&mut self, recents: &[PathBuf]) {
3273 self.rebuild_with(recents, &[])
3274 }
3275
3276 pub fn rebuild_with(&mut self, recents: &[PathBuf], desktop_dirs: &[PathBuf]) {
3279 let request = ListingRequest {
3280 recents: recents.to_vec(),
3281 desktop_dirs: desktop_dirs.to_vec(),
3282 browsing: self.browsing.clone(),
3283 probes: self.probes.clone(),
3284 narrowed: self.narrowed.clone(),
3285 network_check: self.network_check,
3286 cloud: self.cloud.clone(),
3287 catalogs: self.catalogs.clone(),
3288 known: Default::default(),
3291 formats: self.formats.clone(),
3292 };
3293 let listing = build_listing(&request);
3294 self.apply_listing(listing);
3295 }
3296
3297 pub fn apply_listing(&mut self, listing: Listing) {
3299 let mut listing = listing;
3300 for section in &mut listing.sections {
3301 name_by_spec(&self.formats, &mut section.rows);
3302 }
3303 self.missing = listing.missing;
3304 self.replace_sections(|home| home.sections = listing.sections);
3305 }
3306
3307 pub fn relist_remote(&mut self, root: &Path) {
3311 let browsed = self.browsing.as_deref() == Some(root)
3312 && (self.network_check)(root)
3313 && cloud_source_id(root).is_none();
3314 let at = (self.sections.iter()).position(|s| {
3315 s.root.as_deref() == Some(root) && (browsed || s.remote_root.as_deref() == Some(root))
3316 });
3317 let Some(at) = at else {
3318 return;
3319 };
3320 let mut section = if browsed {
3321 browsed_section(
3322 root,
3323 true,
3324 &Listed {
3325 probes: &self.probes,
3326 narrowed: self.narrowed.as_ref(),
3327 catalogs: &self.catalogs,
3328 known: &self.known,
3329 formats: &self.formats,
3330 },
3331 )
3332 } else {
3333 Section::titled("", self.probes.rows(root))
3334 };
3335 let mounts = crate::home::locality::Mounts::cached();
3336 annotate(
3337 std::slice::from_mut(&mut section),
3338 &self.known,
3339 self.network_check,
3340 &mounts,
3341 );
3342 name_by_spec(&self.formats, &mut section.rows);
3343 let silent = self.probes.silent(root);
3344 let unreachable = self.probes.unreachable(root);
3345 let waiting = !self.probes.settled(root);
3346 self.replace_sections(|home| {
3347 if browsed {
3348 home.sections[at] = section;
3349 return;
3350 }
3351 let shown = &mut home.sections[at];
3354 shown.rows = section.rows;
3355 shown.waiting = waiting;
3356 shown.unavailable = silent || unreachable;
3357 shown.unavailable_note = silent.then(|| NOT_ANSWERING.to_string());
3358 });
3359 }
3360
3361 pub fn set_cloud(&mut self, cloud: Vec<CloudSource>) {
3364 self.cloud = cloud;
3365 let mounts = crate::home::locality::Mounts::cached();
3366 let annotated = |home: &Self, mut section: Section| {
3367 annotate(
3368 std::slice::from_mut(&mut section),
3369 &home.known,
3370 home.network_check,
3371 &mounts,
3372 );
3373 section
3374 };
3375 match self.browsing.as_deref().map(cloud_source_id) {
3376 Some(Some(id)) => {
3377 let section = annotated(self, source_section(&id, &self.cloud));
3378 self.replace_sections(|home| home.sections = vec![section]);
3379 }
3380 Some(None) => {}
3381 None => {
3382 let section = cloud_section(&self.cloud).map(|s| annotated(self, s));
3383 self.replace_sections(|home| {
3384 let at = (home.sections.iter()).position(|s| s.title == Self::CLOUD_SECTION);
3385 match (at, section) {
3386 (Some(at), Some(section)) => home.sections[at] = section,
3387 (Some(at), None) => {
3388 home.sections.remove(at);
3389 }
3390 (None, Some(section)) => {
3392 let at = (home.sections.iter())
3393 .position(|s| {
3394 s.origin.is_some_and(is_catalog_origin)
3395 || s.title == "Elsewhere"
3396 || s.title == Self::SEARCH_SECTION
3397 })
3398 .unwrap_or(home.sections.len());
3399 home.sections.insert(at, section);
3400 }
3401 (None, None) => {}
3402 }
3403 });
3404 }
3405 }
3406 }
3407
3408 fn replace_sections(&mut self, change: impl FnOnce(&mut Self)) {
3411 let returning = self.returning.take();
3412 let previous = returning.clone().or_else(|| self.selected_key());
3413 let line = self.selected.saturating_sub(self.scroll);
3415 change(self);
3416 self.changed();
3417 if let (Some(browsing), Some((dir, format))) = (&self.browsing, &self.lake_here)
3419 && browsing == dir
3420 && let Some(section) = self.sections.first_mut()
3421 {
3422 let note = crate::glyphs::dotted(&format!(
3423 "{} · not read as a table",
3424 format.to_ascii_lowercase()
3425 ));
3426 section.subtitle = Some(match section.subtitle.take() {
3427 Some(state) => crate::glyphs::dotted(&format!("{note} · {state}")),
3428 None => note,
3429 });
3430 }
3431 self.sync_search_section();
3433 self.apply_measurements();
3436
3437 let placed = self.reselect(previous);
3439 if placed && returning.is_some() {
3442 self.landing = false;
3443 }
3444 if !placed {
3445 self.select_first_entry();
3446 if self.rows_still_arriving() {
3449 self.returning = returning;
3450 }
3451 } else if returning.is_some() {
3452 self.scroll_to_returning_line();
3453 } else {
3454 self.scroll = self.selected.saturating_sub(line);
3455 }
3456 self.follow_selection();
3457 }
3458
3459 pub fn leave_mark(&mut self) {
3462 let mark = Mark {
3463 place: self.browsing.clone(),
3464 key: self.selected_key(),
3465 filter: self.filter.clone(),
3466 search: (!self.search.running).then(|| SearchState {
3469 scoring: false,
3470 ..self.search.clone()
3471 }),
3472 line: {
3473 let lines = self.list_lines(self.spaced);
3474 (lines.line_of(self.selected)).saturating_sub(lines.line_of(self.scroll))
3475 },
3476 };
3477 self.trail.retain(|m| m.place != mark.place);
3480 self.trail.push(mark);
3481 }
3482
3483 pub fn come_back(&mut self, from: Option<PathBuf>) {
3488 let to = self.browsing.clone();
3489 let mark = self
3490 .trail
3491 .iter()
3492 .rposition(|m| m.place == to)
3493 .map(|at| self.trail.split_off(at).remove(0));
3494 match mark {
3495 Some(mark) => {
3496 self.filter = mark.filter;
3497 self.search = mark.search.unwrap_or_default();
3498 self.returning = mark.key;
3499 self.returning_line = Some(mark.line);
3500 }
3501 None => {
3502 self.filter.clear();
3503 self.search.reset();
3504 self.returning = from.map(RowKey::Entry);
3505 self.returning_line = None;
3506 }
3507 }
3508 }
3509
3510 fn rows_still_arriving(&self) -> bool {
3513 self.listing_in_flight
3514 || self.sections_waiting()
3515 || self.awaiting_listing().is_some()
3516 || self.search.running
3517 }
3518
3519 fn settle_return(&mut self) {
3521 let Some(key) = self.returning.clone() else {
3522 return;
3523 };
3524 if let Some(idx) = self.place_key(&key) {
3525 self.selected = idx;
3526 self.returning = None;
3527 self.landing = false;
3528 self.scroll_to_returning_line();
3529 self.follow_selection();
3530 } else if !self.rows_still_arriving() {
3531 self.returning = None;
3532 }
3533 }
3534
3535 fn scroll_to_returning_line(&mut self) {
3537 if let Some(line) = self.returning_line.take() {
3538 let lines = self.list_lines(self.spaced);
3539 self.scroll = lines.first_row_from(lines.line_of(self.selected).saturating_sub(line));
3540 }
3541 }
3542
3543 pub fn selected_key(&self) -> Option<RowKey> {
3546 let title = |section: usize| self.sections.get(section).map(|s| s.title.clone());
3547 Some(match self.cursor_row()? {
3548 Row::Header { section, .. } => RowKey::Header(title(section)?),
3549 Row::More { section, .. } => RowKey::More(title(section)?),
3550 Row::Hidden { section, .. } => RowKey::Hidden(title(section)?),
3551 Row::Up { section } => RowKey::Up(title(section)?),
3552 Row::Entry { entry, .. } => RowKey::Entry(entry.path.clone()),
3553 Row::Door { entry, .. } => RowKey::Door(entry.path.clone()),
3554 Row::Place { path, .. } => RowKey::Place(path),
3555 })
3556 }
3557
3558 pub fn reselect(&mut self, key: Option<RowKey>) -> bool {
3562 let Some(key) = key else {
3563 self.clamp_selection();
3564 return false;
3565 };
3566 match self.place_key(&key) {
3567 Some(idx) => {
3568 self.selected = idx;
3569 true
3570 }
3571 None => {
3572 self.clamp_selection();
3573 false
3574 }
3575 }
3576 }
3577
3578 fn place_key(&mut self, key: &RowKey) -> Option<usize> {
3581 if let Some(found) = self.listed(key) {
3582 return Some(found);
3583 }
3584 if let RowKey::Entry(path) = key
3585 && let Some(root) = self
3586 .sections
3587 .iter()
3588 .find(|s| !s.grouped_by_place && s.rows.iter().any(|r| r.path == *path))
3589 .and_then(|s| s.root.clone())
3590 && self.shown_whole.insert(root.clone())
3591 {
3592 match self.listed(key) {
3593 Some(found) => return Some(found),
3594 None => {
3595 self.shown_whole.remove(&root);
3596 }
3597 }
3598 }
3599 self.behind_recent_cap(key)
3600 }
3601
3602 fn listed(&self, key: &RowKey) -> Option<usize> {
3604 self.position(|row| match (row, key) {
3605 (Row::Entry { entry, .. }, RowKey::Entry(path)) => entry.path == *path,
3606 (Row::Door { entry, .. }, RowKey::Door(path)) => entry.path == *path,
3607 (Row::Place { path, .. }, RowKey::Place(wanted)) => path == wanted,
3608 (Row::Header { section, .. }, RowKey::Header(title))
3609 | (Row::More { section, .. }, RowKey::More(title))
3610 | (Row::Hidden { section, .. }, RowKey::Hidden(title))
3611 | (Row::Up { section }, RowKey::Up(title)) => self
3612 .sections
3613 .get(*section)
3614 .is_some_and(|s| s.title == *title),
3615 _ => false,
3616 })
3617 }
3618
3619 fn behind_recent_cap(&self, key: &RowKey) -> Option<usize> {
3621 let (RowKey::Entry(path) | RowKey::Place(path)) = key else {
3622 return None;
3623 };
3624 self.position(|row| {
3625 matches!(row, Row::More { section, .. }
3626 if self.sections.get(*section).is_some_and(|s| {
3627 s.grouped_by_place
3628 && s.rows
3629 .iter()
3630 .any(|r| r.path == *path || place_of(&r.path) == *path)
3631 }))
3632 })
3633 }
3634
3635 pub fn set_view_height(&mut self, height: usize) {
3638 if height == self.view_height {
3639 return;
3640 }
3641 let key = self.selected_key();
3642 self.view_height = height;
3643 self.reselect(key);
3644 }
3645
3646 pub fn set_list_shape(&mut self, lines: usize, spaced: bool) {
3650 self.view_lines = lines;
3651 self.spaced = spaced;
3652 }
3653
3654 fn follow_selection(&mut self) {
3658 if self.view_lines == 0 {
3659 let rows = self.row_count();
3660 self.scroll = settle_top(self.scroll, self.selected, self.view_height, rows);
3661 return;
3662 }
3663 let lines = self.list_lines(self.spaced);
3664 let rows = lines.rows();
3665 let line_of = |row: usize| lines.line_of(row.min(rows.saturating_sub(1)));
3666 let top = settle_top(
3667 line_of(self.scroll),
3668 line_of(self.selected),
3669 self.view_lines,
3670 lines.total(),
3671 );
3672 self.scroll = lines.first_row_from(top);
3673 }
3674
3675 fn section_folded(&self, section: &Section) -> bool {
3679 if self.browsing.is_some() {
3680 return false;
3681 }
3682 self.folds
3683 .get(§ion.title)
3684 .copied()
3685 .unwrap_or(section.folded_by_default)
3686 }
3687
3688 pub fn is_collapsed(&self, section: usize) -> bool {
3690 self.sections
3691 .get(section)
3692 .is_some_and(|s| self.section_folded(s))
3693 }
3694
3695 pub fn toggle_collapsed(&mut self, section: usize) {
3697 let folded = self.is_collapsed(section);
3698 self.set_collapsed(section, !folded);
3699 }
3700
3701 pub fn set_collapsed(&mut self, section: usize, collapsed: bool) {
3705 if self.browsing.is_some() {
3706 return;
3707 }
3708 let Some(title) = self.sections.get(section).map(|s| s.title.clone()) else {
3709 return;
3710 };
3711 self.folds.insert(title, collapsed);
3712 }
3713
3714 pub fn jump_section(&mut self, delta: isize) {
3716 self.resting = false;
3717 self.returning = None;
3718 self.landing = false;
3719 let headers = self.view().headers.clone();
3720 if headers.is_empty() {
3721 return;
3722 }
3723 let current = self.selected;
3724 self.selected = if delta > 0 {
3725 headers
3726 .iter()
3727 .copied()
3728 .find(|&h| h > current)
3729 .unwrap_or(headers[0])
3730 } else {
3731 headers
3732 .iter()
3733 .rev()
3734 .copied()
3735 .find(|&h| h < current)
3736 .unwrap_or(*headers.last().unwrap())
3737 };
3738 }
3739
3740 pub fn has_any_dataset(&self) -> bool {
3744 self.view().has_dataset
3745 }
3746
3747 pub const SEARCH_SECTION: &'static str = "Found";
3749
3750 pub const CLOUD_SECTION: &'static str = "Cloud";
3752
3753 pub const RECENT_SECTION: &'static str = "Recent";
3755
3756 pub fn cloud_source_of(&self, path: &Path) -> Option<&CloudSource> {
3759 if let Some(id) = cloud_source_id(path) {
3760 return self.cloud.iter().find(|s| s.id == id);
3761 }
3762 if let Some((id, _)) = cloud_account(path) {
3763 return self.cloud.iter().find(|s| s.id == id);
3764 }
3765 let text = path.to_string_lossy();
3766 if let Some((account, _, _)) = crate::cloud::source::azure_parts(&text) {
3767 return self.azure_account_place(&account).and_then(|place| {
3768 cloud_account(&place).and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id))
3769 });
3770 }
3771 if let (Some(id), _) = crate::cloud::source::split_source_id(&text) {
3772 return self.cloud.iter().find(|s| s.id == id);
3773 }
3774 if let Some(project) =
3775 Self::google_bucket_root(path).and_then(|b| self.project_of_bucket(&b))
3776 {
3777 return cloud_account(&project)
3778 .and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id));
3779 }
3780 let (_, plain) = crate::cloud::source::split_source_id(&text);
3781 let (scheme, rest) = plain.split_once("://")?;
3782 let bucket = rest.split('/').next()?;
3783 let root = PathBuf::from(format!("{scheme}://{bucket}"));
3784 self.cloud.iter().find(|s| s.buckets.contains(&root))
3785 }
3786
3787 pub fn parent_of(&self, path: &Path) -> Option<PathBuf> {
3790 if cloud_source_id(path).is_some() {
3791 return None;
3792 }
3793 if let Some((_, dataset)) = self.remote_dataset_of(path) {
3796 let place = &dataset.location;
3797 if same_place(path, place) {
3798 return None;
3799 }
3800 let up = self.parent_within(path)?;
3801 return Some(if same_place(&up, place) {
3803 place.clone()
3804 } else {
3805 up
3806 });
3807 }
3808 if let Some((id, _)) = cloud_account(path) {
3809 return Some(cloud_place(&id));
3810 }
3811 let text = path.to_string_lossy();
3812 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3813 let key = key.trim_matches('/');
3814 if key.is_empty() {
3815 return self.azure_account_place(&account);
3816 }
3817 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3818 let up = if up.is_empty() {
3819 String::new()
3820 } else {
3821 format!("{up}/")
3822 };
3823 return Some(PathBuf::from(crate::cloud::source::azure_url(
3824 &account, &container, &up,
3825 )));
3826 }
3827 if is_bucket_root(path) {
3828 if let Some(project) = self.project_of_bucket(path) {
3830 return Some(project);
3831 }
3832 return self.cloud_source_of(path).map(|s| cloud_place(&s.id));
3833 }
3834 parent_location(path)
3835 }
3836
3837 fn parent_within(&self, path: &Path) -> Option<PathBuf> {
3839 let text = path.to_string_lossy();
3840 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3841 let key = key.trim_matches('/');
3842 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3843 let up = if up.is_empty() {
3844 String::new()
3845 } else {
3846 format!("{up}/")
3847 };
3848 return Some(PathBuf::from(crate::cloud::source::azure_url(
3849 &account, &container, &up,
3850 )));
3851 }
3852 parent_location(path)
3853 }
3854
3855 fn remote_dataset_of(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3858 if !is_object_store_url(path) {
3859 return None;
3860 }
3861 let text = path.to_string_lossy();
3862 self.catalogs
3863 .iter()
3864 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3865 .filter(|(_, d)| {
3866 is_object_store_url(&d.location) && within(&text, &d.location.to_string_lossy())
3867 })
3868 .rev()
3869 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
3870 }
3871
3872 pub fn catalog_dataset(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3874 let places = &self.catalog_places;
3875 if places.indexes(&self.catalogs) {
3876 let key = place_key(path);
3877 let &(c, d) = places.datasets.get(&key)?;
3878 let catalog = self.catalogs.get(c)?;
3879 if let Some(dataset) = catalog.datasets.get(d)
3880 && place_key(&dataset.location) == key
3881 {
3882 return Some((catalog, dataset));
3883 }
3884 }
3885 self.catalogs
3886 .iter()
3887 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3888 .find(|(_, d)| same_place(&d.location, path))
3889 }
3890
3891 pub fn bookmark(&self, path: &Path) -> Option<(&ShownDataset, &str)> {
3893 let places = &self.catalog_places;
3894 if places.indexes(&self.catalogs) {
3895 let key = place_key(path);
3896 let &(c, d, b) = places.bookmarks.get(&key)?;
3897 if let Some(dataset) = self.catalogs.get(c).and_then(|c| c.datasets.get(d))
3898 && let Some((name, place)) = dataset.bookmarks.get(b)
3899 && place_key(place) == key
3900 {
3901 return Some((dataset, name.as_str()));
3902 }
3903 }
3904 self.catalogs
3905 .iter()
3906 .flat_map(|c| c.datasets.iter())
3907 .find_map(|d| {
3908 d.bookmarks
3909 .iter()
3910 .find(|(_, place)| same_place(place, path))
3911 .map(|(name, _)| (d, name.as_str()))
3912 })
3913 }
3914
3915 pub fn size_hint(&self, path: &Path) -> Option<u64> {
3917 self.catalog_dataset(path).and_then(|(_, d)| d.size)
3918 }
3919
3920 fn azure_account_place(&self, account: &str) -> Option<PathBuf> {
3922 self.cloud
3923 .iter()
3924 .flat_map(|s| s.buckets.iter())
3925 .find(|place| cloud_account(place).is_some_and(|(_, a)| a == account))
3926 .cloned()
3927 }
3928
3929 pub fn cloud_look(&self, entry: &Entry) -> Option<CloudLook> {
3932 if entry.kind != EntryKind::Directory
3933 || !entry.holds.is_empty()
3934 || !is_object_store_url(&entry.path)
3935 || is_cloud_place(&entry.path)
3936 || object_place_label(&entry.path).is_some()
3937 {
3938 return None;
3939 }
3940 if self.peeking.contains(&entry.path) {
3941 return Some(CloudLook::Looking);
3942 }
3943 if self.peek_failed.contains(&entry.path) {
3944 return Some(CloudLook::Failed);
3945 }
3946 match self.cloud_kinds.get(&entry.path) {
3947 None => Some(CloudLook::Waiting),
3948 Some((kind, holds)) if *kind != EntryKind::Directory || !holds.is_empty() => {
3951 Some(CloudLook::Looking)
3952 }
3953 Some(_) => None,
3954 }
3955 }
3956
3957 pub fn place_kind(&self, path: &Path) -> Option<&'static str> {
3960 if self.missing.contains(path) {
3961 return Some("missing");
3962 }
3963 if let Some((id, _)) = cloud_account(path) {
3964 return self
3965 .cloud
3966 .iter()
3967 .any(|s| s.id == id && s.api == crate::cloud::source::ProviderKind::Gcs)
3968 .then_some("project");
3969 }
3970 (is_object_store_url(path)
3972 && (self
3973 .catalog_dataset(path)
3974 .is_some_and(|(_, d)| is_object_store_url(&d.location))
3975 || (self.browsing.is_none() && self.bookmark(path).is_some())))
3976 .then_some("dataset")
3977 }
3978
3979 fn project_of_bucket(&self, bucket_root: &Path) -> Option<PathBuf> {
3981 let root = bucket_root.to_string_lossy();
3982 let root = root.trim_end_matches('/');
3983 self.probes
3984 .answered()
3985 .filter(|(place, _)| cloud_account(place).is_some())
3986 .find(|(_, rows)| {
3987 rows.iter()
3988 .any(|row| row.path.to_string_lossy().trim_end_matches('/') == root)
3989 })
3990 .map(|(place, _)| place.clone())
3991 }
3992
3993 fn google_bucket_root(path: &Path) -> Option<PathBuf> {
3995 let text = path.to_string_lossy();
3996 let rest = text
3997 .strip_prefix("gs://")
3998 .or_else(|| text.strip_prefix("gcs://"))?;
3999 let bucket = rest.split('/').next().filter(|b| !b.is_empty())?;
4000 Some(PathBuf::from(format!("gs://{bucket}")))
4001 }
4002
4003 pub fn place_details(&self, path: &Path) -> Option<&[(String, String)]> {
4006 self.cloud
4007 .iter()
4008 .find_map(|s| s.place_details.get(path))
4009 .or_else(|| self.catalog_dataset(path).map(|(_, d)| &d.details))
4010 .or_else(|| self.bookmark(path).map(|(d, _)| &d.details))
4011 .map(Vec::as_slice)
4012 }
4013
4014 pub fn location_label(&self, path: &Path) -> String {
4017 let sep = crate::glyphs::get().trail;
4018 if let Some((catalog, dataset)) = self.remote_dataset_of(path) {
4021 let text = path.to_string_lossy();
4022 let rest = within_rest(&text, &dataset.location.to_string_lossy());
4023 let mut parts = vec![catalog.label.clone(), dataset.name.clone()];
4024 parts.extend(
4025 rest.split('/')
4026 .filter(|p| !p.is_empty())
4027 .map(str::to_string),
4028 );
4029 return parts.join(&format!(" {sep} "));
4030 }
4031 if let Some(source) = self.cloud_source_of(path) {
4032 let mut parts = vec!["cloud".to_string(), source.label.clone()];
4033 let text = path.to_string_lossy();
4034 if let Some((_, account)) = cloud_account(path) {
4035 parts.push(account);
4036 } else if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text)
4037 {
4038 parts.push(account);
4039 parts.push(container);
4040 parts.extend(key.split('/').filter(|p| !p.is_empty()).map(str::to_string));
4041 } else if cloud_source_id(path).is_none() {
4042 if let Some((_, project)) = Self::google_bucket_root(path)
4043 .and_then(|b| self.project_of_bucket(&b))
4044 .as_deref()
4045 .and_then(cloud_account)
4046 {
4047 parts.push(project);
4048 }
4049 let (_, plain) = crate::cloud::source::split_source_id(&text);
4050 if let Some((_, rest)) = plain.split_once("://") {
4051 parts.extend(
4052 rest.split('/')
4053 .filter(|p| !p.is_empty())
4054 .map(str::to_string),
4055 );
4056 }
4057 }
4058 return parts.join(&format!(" {sep} "));
4059 }
4060 display_path(path)
4061 }
4062
4063 pub fn sync_search_section(&mut self) {
4066 let found = (self.sections.iter()).position(|s| s.title == Self::SEARCH_SECTION);
4068 let mut kept = self.take_hits();
4069 self.sections.retain(|s| s.title != Self::SEARCH_SECTION);
4070 self.changed();
4071 if let (Some(hits), Some(at)) = (kept.as_mut(), found)
4072 && at < hits.sections.len()
4073 {
4074 hits.sections.remove(at);
4075 }
4076 *self.rows_cache.rescored.get_mut() = kept;
4077
4078 if self.filter.is_empty() {
4079 return;
4080 }
4081 let cloud_rows: Vec<Entry> = if self.browsing.is_none() {
4083 self.cloud
4084 .iter()
4085 .flat_map(|source| {
4086 source.buckets.iter().map(move |bucket| {
4087 let mut entry = bucket_entry(bucket);
4088 entry.name = format!(
4089 "{} {} {}",
4090 source.label,
4091 crate::glyphs::get().trail,
4092 entry.name
4093 );
4094 entry.cost.source = Some(source.api.name().to_string());
4095 entry
4096 })
4097 })
4098 .filter(|e| match_score(&self.filter, e).is_some())
4099 .collect()
4100 } else {
4101 Vec::new()
4102 };
4103 self.score_search_inline();
4104 let local = self.search.root.is_some()
4105 && (self.search.indexed > 0 || self.search.running || self.search.limited.is_some());
4106 if !local {
4107 if !cloud_rows.is_empty() {
4108 let subtitle =
4109 crate::glyphs::dotted(&format!("cloud · {} names", cloud_rows.len()));
4110 self.sections.push(Section {
4111 subtitle: Some(subtitle),
4112 ..Section::titled(Self::SEARCH_SECTION, cloud_rows)
4113 });
4114 }
4115 return;
4116 }
4117
4118 let matches = self.search.matches.as_ref();
4121 let names: std::collections::HashSet<&std::ffi::OsStr> = (matches.iter())
4125 .flat_map(|m| m.top.iter())
4126 .map(|e| e.path.file_name().unwrap_or(e.path.as_os_str()))
4127 .collect();
4128 let listed: std::collections::HashSet<&PathBuf> = (self.sections.iter())
4129 .flat_map(|s| s.rows.iter().map(|r| &r.path))
4130 .filter(|path| path.file_name().is_none_or(|name| names.contains(name)))
4131 .collect();
4132 let kept: Vec<(&Entry, i32)> = matches
4133 .map(|m| {
4134 let fresh = m.query == self.filter;
4135 m.top
4136 .iter()
4137 .zip(m.scores.iter().copied())
4138 .filter(|(e, _)| !listed.contains(&e.path))
4139 .filter_map(|(e, score)| {
4140 if fresh {
4141 Some((e, score))
4142 } else {
4143 match_score(&self.filter, e).map(|s| (e, s))
4144 }
4145 })
4146 .collect()
4147 })
4148 .unwrap_or_default();
4149 let mut rows: Vec<Entry> = kept.into_iter().map(|(e, _)| e.clone()).collect();
4150 rows.extend(cloud_rows);
4151 for row in &mut rows {
4154 fold_known(row, &self.enriched, &self.stated, &self.stale);
4155 }
4156
4157 let partial = self.search.limited.is_some();
4159 let scored = self.search.scored_for(&self.filter);
4160 if rows.is_empty() && !self.search.running && !partial && scored {
4161 return;
4162 }
4163
4164 let subtitle = self.found_subtitle(rows.is_empty());
4165
4166 self.sections.push(Section {
4167 subtitle: Some(subtitle),
4168 ..Section::titled(Self::SEARCH_SECTION, rows)
4169 });
4170 }
4171
4172 fn found_subtitle(&self, empty: bool) -> String {
4174 let root = self.search.root.clone().unwrap_or_default();
4175 let dot = crate::glyphs::get().middot;
4176 let files = crate::numfmt::group_chrome(self.search.indexed);
4177 let files = if self.search.indexed == 1 {
4178 format!("{files} file")
4179 } else {
4180 format!("{files} files")
4181 };
4182 let subtitle = display_path(&root);
4183 let counted = self
4186 .search
4187 .matches
4188 .as_ref()
4189 .filter(|m| m.query == self.filter && m.ids.len() > m.top.len())
4190 .map(|m| {
4191 format!(
4192 " {dot} {} of {} matches",
4193 crate::numfmt::group_chrome(m.top.len()),
4194 crate::numfmt::group_chrome(m.ids.len())
4195 )
4196 })
4197 .unwrap_or_default();
4198 if self.search.running {
4199 format!(
4200 "{subtitle} {dot} searching {}{counted}",
4201 crate::numfmt::group_chrome(self.search.scanned)
4202 )
4203 } else if !self.search.scored_for(&self.filter) {
4204 format!("{subtitle} {dot} matching {files}")
4205 } else if empty {
4206 match &self.search.limited {
4207 Some(limit) => format!("{subtitle} {dot} no match in {files} {dot} {limit}"),
4208 None => format!("{subtitle} {dot} no match in {files}"),
4209 }
4210 } else {
4211 let searched = crate::numfmt::group_chrome(self.search.scanned);
4212 match &self.search.limited {
4213 Some(limit) => {
4214 format!("{subtitle} {dot} {limit} {dot} {searched} searched{counted}")
4215 }
4216 None => format!("{subtitle} {dot} {searched} searched{counted}"),
4217 }
4218 }
4219 }
4220
4221 pub fn search_batch(&mut self, root: &Path, mut found: Vec<Entry>, scanned: usize) {
4223 if self.search.root.as_deref() != Some(root) {
4225 return;
4226 }
4227 for row in &mut found {
4230 apply_known_facts(row, &self.known, false);
4231 }
4232 self.search.scanned = scanned;
4233 let start = self.search.indexed;
4234 let batch: std::sync::Arc<[Entry]> = found.into();
4235 if !batch.is_empty() {
4236 self.search.indexed += batch.len();
4237 self.search.results.push(batch.clone());
4238 }
4239 let limit = self.search_limit;
4242 let changed = match self.search.matches.as_mut() {
4243 Some(m) if m.query == self.filter && m.upto == start => m.extend(&batch, start, limit),
4244 _ => {
4245 let before = self.search.matches.as_ref().map(|m| m.upto);
4246 self.score_search_inline();
4247 self.search.matches.as_ref().map(|m| m.upto) != before
4248 }
4249 };
4250 let found_listed = self
4252 .sections
4253 .iter()
4254 .position(|s| s.title == Self::SEARCH_SECTION);
4255 match found_listed {
4256 Some(at) if !changed => {
4257 let empty = self.sections[at].rows.is_empty();
4258 self.sections[at].subtitle = Some(self.found_subtitle(empty));
4259 }
4260 _ => self.sync_search_section(),
4261 }
4262 self.settle_return();
4263 }
4264
4265 fn score_search_inline(&mut self) {
4267 if self.filter.is_empty() || self.search.scored_for(&self.filter) {
4268 return;
4269 }
4270 let (base, looks_at) = self.search.base_for(&self.filter);
4271 if looks_at > SCORE_INLINE_MAX {
4272 return;
4273 }
4274 let scored =
4275 crate::home::search::score(&self.search.results, &self.filter, base, self.search_limit);
4276 self.search.matches = Some(scored);
4277 }
4278
4279 pub fn score_job(&mut self) -> Option<ScoreJob> {
4282 if self.filter.is_empty() || self.search.scoring || self.search.scored_for(&self.filter) {
4283 return None;
4284 }
4285 let base = self.search.base_for(&self.filter).0.cloned();
4286 self.search.scoring = true;
4287 Some(ScoreJob {
4288 epoch: self.search.epoch,
4289 results: self.search.results.clone(),
4290 query: self.filter.clone(),
4291 base,
4292 limit: self.search_limit,
4293 })
4294 }
4295
4296 pub fn search_scored(&mut self, epoch: u64, scored: crate::home::search::Matches) {
4298 if epoch != self.search.epoch {
4299 return;
4300 }
4301 self.search.scoring = false;
4302 if self
4304 .search
4305 .matches
4306 .as_ref()
4307 .is_some_and(|m| m.query == scored.query && m.upto >= scored.upto)
4308 {
4309 return;
4310 }
4311 self.search.matches = Some(scored);
4312 self.sync_search_section();
4313 if !matches!(self.selected_row(), Some(Row::Entry { .. })) {
4315 self.select_first_entry();
4316 }
4317 self.settle_return();
4318 }
4319
4320 pub fn search_finished(&mut self, root: &Path, scanned: usize, limited: Option<String>) {
4322 if self.search.root.as_deref() != Some(root) {
4323 return;
4324 }
4325 self.search.running = false;
4326 self.search.done = true;
4327 self.search.scanned = self.search.scanned.max(scanned);
4329 self.search.limited = limited;
4330 self.sync_search_section();
4331 self.settle_return();
4332 }
4333
4334 pub fn visible(&self) -> Vec<Row<'_>> {
4337 let view = self.view();
4338 view.slots.iter().map(|slot| self.row(slot)).collect()
4339 }
4340
4341 pub fn list_lines(&self, spaced: bool) -> ListLines {
4346 let view = self.view();
4347 let rows = view.slots.len();
4348 let headers = (view.headers.iter().copied()).filter(|&h| spaced && h > 0);
4349 let doors = (view.doors.iter().map(|&d| d + 1)).filter(|&r| r < rows);
4350 let mut gaps: Vec<usize> = headers.chain(doors).collect();
4351 gaps.sort_unstable();
4352 gaps.dedup();
4353 ListLines { gaps, rows }
4354 }
4355
4356 pub fn matched(&self) -> usize {
4358 let view = self.view();
4359 (view.headers.iter())
4360 .map(|&i| match &view.slots[i] {
4361 Slot::Plain(Row::Header { matches, .. }) => *matches,
4362 _ => 0,
4363 })
4364 .sum()
4365 }
4366
4367 pub fn position(&self, mut wanted: impl FnMut(&Row<'_>) -> bool) -> Option<usize> {
4370 let view = self.view();
4371 view.slots.iter().position(|slot| wanted(&self.row(slot)))
4372 }
4373
4374 pub fn row_count(&self) -> usize {
4376 self.view().slots.len()
4377 }
4378
4379 pub fn row_at(&self, index: usize) -> Option<Row<'_>> {
4381 self.view().slots.get(index).map(|slot| self.row(slot))
4382 }
4383
4384 pub fn rows_built(&self) -> usize {
4387 self.rows_cache.builds.get()
4388 }
4389
4390 fn changed(&mut self) {
4392 *self.rows_cache.built.get_mut() = None;
4393 *self.rows_cache.rescored.get_mut() = None;
4394 }
4395
4396 fn take_hits(&mut self) -> Option<Hits> {
4399 let built = self.rows_cache.built.get_mut().take();
4400 match self.rows_cache.rescored.get_mut().take() {
4401 Some(hits) => Some(hits),
4402 None => built
4403 .filter(|view| view.key.matches(self))
4404 .map(|view| view.hits),
4405 }
4406 .filter(|hits| hits.filter == self.filter)
4407 }
4408
4409 fn rows_changed(&mut self, touched: &[(usize, usize)]) {
4413 let Some(mut hits) = self.take_hits() else {
4414 return;
4415 };
4416 let needle = Needle::new(&self.filter);
4417 for &(si, i) in touched {
4418 if let Some(Some(section)) = hits.sections.get_mut(si)
4419 && let Some(hit) = section.get_mut(i)
4420 {
4421 *hit = needle.hit(&self.sections[si].rows[i]);
4422 }
4423 }
4424 *self.rows_cache.rescored.get_mut() = Some(hits);
4425 }
4426
4427 pub fn set_catalogs(&mut self, catalogs: Vec<ShownCatalog>) {
4429 self.catalog_places = CatalogPlaces::of(&catalogs);
4430 self.catalogs = catalogs;
4431 self.changed();
4432 }
4433
4434 pub fn set_visits(&mut self, visits: std::collections::HashMap<PathBuf, crate::cache::Visits>) {
4436 self.visits = visits;
4437 self.changed();
4438 }
4439
4440 pub fn show_all(&mut self, section: usize) {
4442 match self.sections.get(section) {
4443 Some(s) if s.grouped_by_place => self.recent_expanded = true,
4444 Some(Section {
4445 root: Some(root), ..
4446 }) => {
4447 self.shown_whole.insert(root.clone());
4448 }
4449 _ => {}
4450 }
4451 }
4452
4453 pub fn cut_again(&mut self, section: usize) -> bool {
4456 let Some(key) = self.selected_key() else {
4457 return false;
4458 };
4459 let Some(s) = self.sections.get(section) else {
4460 return false;
4461 };
4462 let title = s.title.clone();
4463 let whole = if s.grouped_by_place {
4464 None
4465 } else {
4466 match s.root.clone() {
4467 Some(root) => Some(root),
4468 None => return false,
4469 }
4470 };
4471 let was_whole = match &whole {
4472 None => std::mem::replace(&mut self.recent_expanded, false),
4473 Some(root) => self.shown_whole.remove(root),
4474 };
4475 if !was_whole {
4476 return false;
4477 }
4478 if self.listed(&key).is_some() {
4479 match whole {
4480 None => self.recent_expanded = true,
4481 Some(root) => {
4482 self.shown_whole.insert(root);
4483 }
4484 }
4485 return false;
4486 }
4487 self.reselect(Some(RowKey::More(title)));
4488 true
4489 }
4490
4491 pub fn sections_mut(&mut self) -> &mut Vec<Section> {
4493 self.changed();
4494 &mut self.sections
4495 }
4496
4497 fn view(&self) -> std::cell::Ref<'_, View> {
4498 let fresh = self
4499 .rows_cache
4500 .built
4501 .borrow()
4502 .as_ref()
4503 .is_some_and(|view| view.key.matches(self));
4504 if !fresh {
4505 let view = self.build_view();
4506 self.rows_cache.builds.set(self.rows_cache.builds.get() + 1);
4507 *self.rows_cache.built.borrow_mut() = Some(view);
4508 }
4509 std::cell::Ref::map(self.rows_cache.built.borrow(), |view| {
4510 view.as_ref().expect("built above")
4511 })
4512 }
4513
4514 fn row<'a>(&'a self, slot: &Slot) -> Row<'a> {
4515 match slot {
4516 Slot::Plain(row) => row.clone(),
4517 Slot::Entry {
4518 section,
4519 index,
4520 nested,
4521 hit,
4522 } => Row::Entry {
4523 section: *section,
4524 entry: &self.sections[*section].rows[*index],
4525 nested: *nested,
4526 hit: *hit,
4527 },
4528 Slot::Door { section } => Row::Door {
4529 section: *section,
4530 entry: self.sections[*section]
4531 .door
4532 .as_ref()
4533 .expect("the shape says it has a door"),
4534 },
4535 }
4536 }
4537
4538 fn build_view(&self) -> View {
4539 let kept = (self.rows_cache.rescored.take()).filter(|hits| {
4541 hits.filter == self.filter && hits.sections.len() <= self.sections.len()
4542 });
4543 let hits = self.score_rows(kept);
4544 let slots = self.slots(&hits);
4545 View {
4546 key: ViewKey::of(self),
4547 headers: (slots.iter().enumerate())
4548 .filter(|(_, slot)| matches!(slot, Slot::Plain(Row::Header { .. })))
4549 .map(|(i, _)| i)
4550 .collect(),
4551 doors: (slots.iter().enumerate())
4552 .filter(|(_, slot)| matches!(slot, Slot::Door { .. }))
4553 .map(|(i, _)| i)
4554 .collect(),
4555 slots,
4556 hits,
4557 has_dataset: self
4558 .sections
4559 .iter()
4560 .flat_map(|s| s.rows.iter())
4561 .any(|e| e.kind.is_dataset() || e.kind.is_lake_table()),
4562 }
4563 }
4564
4565 fn score_rows(&self, kept: Option<Hits>) -> Hits {
4568 let needle = Needle::new(&self.filter);
4569 let mut kept = kept.map(|hits| hits.sections).unwrap_or_default();
4570 let sections =
4571 (self.sections.iter().enumerate())
4572 .map(|(si, section)| {
4573 let reused = (kept.get_mut(si).and_then(Option::take))
4574 .filter(|hits| hits.len() == section.rows.len());
4575 Some(reused.unwrap_or_else(|| {
4576 (section.rows.iter()).map(|row| needle.hit(row)).collect()
4577 }))
4578 })
4579 .collect();
4580 Hits {
4581 filter: self.filter.clone(),
4582 sections,
4583 }
4584 }
4585
4586 fn slots(&self, hits: &Hits) -> Vec<Slot> {
4587 let mut out: Vec<Slot> = Vec::new();
4588 for (si, section) in self.sections.iter().enumerate() {
4589 let mut matched: Vec<(usize, Hit)> = (section.rows.iter())
4590 .zip(hits.sections[si].iter().flatten())
4591 .enumerate()
4592 .filter(|(_, (row, _))| !(self.hide_unreadable && row.hidden_by_default()))
4593 .filter_map(|(i, (_, hit))| hit.map(|hit| (i, hit)))
4594 .collect();
4595
4596 let keep_empty = section.unavailable || section.waiting || section.origin.is_some();
4600 let has_door = section.door.is_some() && self.filter.is_empty();
4601 let hidden =
4604 if self.browsing.is_some() && self.hide_unreadable && self.filter.is_empty() {
4605 section
4606 .rows
4607 .iter()
4608 .filter(|row| row.hidden_by_default())
4609 .count()
4610 } else {
4611 0
4612 };
4613 let says_why = section.title == Self::SEARCH_SECTION;
4615 if matched.is_empty()
4616 && !has_door
4617 && hidden == 0
4618 && !says_why
4619 && !(keep_empty && self.filter.is_empty())
4620 {
4621 continue;
4622 }
4623
4624 let entry = |i: usize| §ion.rows[i];
4628 if !self.filter.is_empty() {
4629 let now = std::time::SystemTime::now()
4630 .duration_since(std::time::UNIX_EPOCH)
4631 .map(|d| d.as_secs())
4632 .unwrap_or_default();
4633 let lifted = |i: usize, score: i32| {
4634 let frecency = self
4635 .visits
4636 .get(&entry(i).path)
4637 .map_or(0.0, |v| v.frecency(now));
4638 score.saturating_add((frecency.min(10.0) * FRECENCY_LIFT) as i32)
4639 };
4640 matched.sort_by_cached_key(|(i, hit)| {
4641 (
4642 std::cmp::Reverse(lifted(*i, hit.score)),
4643 entry(*i).name.len(),
4644 )
4645 });
4646 }
4647
4648 match self.sort {
4651 SortMode::Natural => {}
4652 SortMode::Size => {
4653 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).size.unwrap_or(0)));
4654 }
4655 SortMode::Rows => {
4656 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).rows.unwrap_or(0)));
4657 }
4658 SortMode::Modified => {
4659 matched.sort_by_key(|(i, _)| {
4660 std::cmp::Reverse(
4661 entry(*i)
4662 .modified
4663 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
4664 .map(|d| d.as_secs())
4665 .unwrap_or(0),
4666 )
4667 });
4668 }
4669 }
4670
4671 let collapsed = self.section_folded(section);
4672 out.push(Slot::Plain(Row::Header {
4673 section: si,
4674 matches: matched.len(),
4676 collapsed,
4677 }));
4678 if collapsed {
4679 continue;
4680 }
4681 if has_door {
4685 out.push(Slot::Door { section: si });
4686 }
4687 let root = section.root.as_deref();
4689 if self.filter.is_empty()
4690 && root
4691 .is_some_and(|root| self.browsing.is_some() || self.parent_of(root).is_some())
4692 {
4693 out.push(Slot::Plain(Row::Up { section: si }));
4694 }
4695 let shown = match root {
4699 Some(_)
4700 if self.browsing.is_none()
4701 && self.filter.is_empty()
4702 && self.view_height > 0
4703 && !root.is_some_and(|root| self.shown_whole.contains(root)) =>
4704 {
4705 (self.view_height * 2 / 5).max(8)
4706 }
4707 _ => usize::MAX,
4708 };
4709 let rest = if matched.len() > shown.saturating_add(1) {
4710 matched.split_off(shown)
4711 } else {
4712 Vec::new()
4713 };
4714 let measuring = rest.iter().any(|(i, _)| self.sort_wants(entry(*i)));
4717 if section.grouped_by_place {
4718 self.slots_by_place(si, section, &matched, &mut out);
4719 } else {
4720 let in_order = self.sort == SortMode::Natural
4722 && self.filter.is_empty()
4723 && section.origin.is_some_and(is_catalog_origin)
4724 && section.root.is_none();
4725 out.extend(matched.into_iter().map(|(index, hit)| Slot::Entry {
4726 section: si,
4727 index,
4728 nested: in_order && self.bookmark(&entry(index).path).is_some(),
4729 hit,
4730 }));
4731 }
4732 if !rest.is_empty() {
4733 out.push(Slot::Plain(Row::More {
4734 section: si,
4735 hidden: rest.len(),
4736 places: 0,
4737 measuring,
4738 }));
4739 }
4740 if hidden > 0 {
4741 out.push(Slot::Plain(Row::Hidden {
4742 section: si,
4743 count: hidden,
4744 }));
4745 }
4746 }
4747 out
4748 }
4749
4750 fn slots_by_place(
4756 &self,
4757 si: usize,
4758 section: &Section,
4759 matched: &[(usize, Hit)],
4760 out: &mut Vec<Slot>,
4761 ) {
4762 let places: Vec<PathBuf> = section.rows.iter().map(|row| place_of(&row.path)).collect();
4763 let mut order: Vec<&PathBuf> = Vec::new();
4764 for place in &places {
4765 if !order.contains(&place) {
4766 order.push(place);
4767 }
4768 }
4769 let groups: Vec<(&PathBuf, Vec<&(usize, Hit)>)> = order
4770 .into_iter()
4771 .filter_map(|place| {
4772 let rows: Vec<&(usize, Hit)> = matched
4773 .iter()
4774 .filter(|(i, _)| places[*i] == *place)
4775 .collect();
4776 (!rows.is_empty()).then_some((place, rows))
4777 })
4778 .collect();
4779
4780 let capped = !self.recent_expanded && self.filter.is_empty() && self.view_height > 0;
4782 let budget = self.view_height / 3;
4783 let mut used = 0usize;
4784 let mut shown = 0usize;
4785 for (place, rows) in &groups {
4786 let cost = 1 + rows.len();
4787 if capped && shown > 0 && used + cost > budget {
4788 break;
4789 }
4790 out.push(Slot::Plain(Row::Place {
4791 section: si,
4792 path: (*place).clone(),
4793 label: section.place_labels.get(*place).cloned(),
4794 source: section.rows[rows[0].0].cost.source.clone(),
4796 held: places.iter().filter(|p| p == place).count(),
4797 }));
4798 out.extend(rows.iter().map(|(index, hit)| Slot::Entry {
4799 section: si,
4800 index: *index,
4801 nested: true,
4802 hit: *hit,
4803 }));
4804 used += cost;
4805 shown += 1;
4806 }
4807 if shown < groups.len() {
4808 out.push(Slot::Plain(Row::More {
4809 section: si,
4810 hidden: groups[shown..].iter().map(|(_, rows)| rows.len()).sum(),
4811 places: groups.len() - shown,
4812 measuring: false,
4813 }));
4814 }
4815 }
4816
4817 pub fn path_candidates(&self) -> Vec<&PathName> {
4820 let Some(listing) = self
4821 .path_listing
4822 .as_ref()
4823 .filter(|l| l.dir == typed_dir(&self.path_input))
4824 else {
4825 return Vec::new();
4826 };
4827 let segment = &self.path_input[listing.dir.len()..];
4828 let mut memo = (listing.matched.0.lock()).unwrap_or_else(|e| e.into_inner());
4829 let at = match memo.as_ref() {
4830 Some((typed, at)) if typed == segment => at.clone(),
4831 _ => {
4832 let at = path_matches(&listing.names, segment);
4833 *memo = Some((segment.to_string(), at.clone()));
4834 at
4835 }
4836 };
4837 drop(memo);
4838 at.iter().map(|&i| &listing.names[i]).collect()
4839 }
4840
4841 pub fn pick_first_path(&mut self) {
4844 self.path_pick = (!self.path_candidates().is_empty()).then_some(0);
4845 }
4846
4847 pub fn picked_path(&self) -> Option<String> {
4849 let pick = self.path_pick?;
4850 let name = *self.path_candidates().get(pick)?;
4851 let dir = typed_dir(&self.path_input);
4852 let mut path = format!("{dir}{}", name.name);
4853 if name.dir {
4854 path.push(separator_in(dir));
4855 }
4856 Some(path)
4857 }
4858
4859 pub fn path_completion(&self) -> Option<String> {
4862 let dir = typed_dir(&self.path_input);
4863 let segment = &self.path_input[dir.len()..];
4864 let candidates = self.path_candidates();
4865 match candidates.as_slice() {
4866 [] => None,
4867 [one] => {
4868 let mut path = format!("{dir}{}", one.name);
4869 if one.dir {
4870 path.push(separator_in(dir));
4871 }
4872 Some(path)
4873 }
4874 many => {
4875 let starting: Vec<&str> = many
4876 .iter()
4877 .map(|n| n.name.as_str())
4878 .filter(|n| n.starts_with(segment))
4879 .collect();
4880 let first = starting.first()?;
4881 let shared = starting
4882 .iter()
4883 .skip(1)
4884 .fold(first.to_string(), |acc, n| common_prefix(&acc, n));
4885 (shared.len() > segment.len()).then(|| format!("{dir}{shared}"))
4886 }
4887 }
4888 }
4889
4890 pub fn known_urls(&self) -> Vec<String> {
4893 let mut urls: Vec<String> = Vec::new();
4894 let mut add = |path: &Path| {
4895 let text = path.to_string_lossy();
4896 if text.contains("://") && !is_cloud_place(path) {
4897 urls.push(text.into_owned());
4898 }
4899 };
4900 for catalog in &self.catalogs {
4901 for dataset in &catalog.datasets {
4902 add(&dataset.location);
4903 }
4904 }
4905 for source in &self.cloud {
4906 for bucket in &source.buckets {
4907 add(bucket);
4908 }
4909 }
4910 for (root, rows) in self.probes.answered() {
4911 add(root);
4912 for row in rows.iter() {
4913 add(&row.path);
4914 }
4915 }
4916 for path in self.known.keys() {
4917 add(path);
4918 }
4919 for section in &self.sections {
4920 for row in §ion.rows {
4921 add(&row.path);
4922 }
4923 }
4924 urls
4925 }
4926
4927 pub fn selected_row(&self) -> Option<Row<'_>> {
4929 if self.resting {
4930 return None;
4931 }
4932 self.row_at(self.selected)
4933 }
4934
4935 pub fn cursor_row(&self) -> Option<Row<'_>> {
4937 self.row_at(self.selected)
4938 }
4939
4940 pub fn selected_entry(&self) -> Option<&Entry> {
4943 match self.selected_row()? {
4944 Row::Entry { entry, .. } | Row::Door { entry, .. } => Some(entry),
4945 _ => None,
4946 }
4947 }
4948
4949 pub fn selection_is_the_door(&self) -> bool {
4951 matches!(self.selected_row(), Some(Row::Door { .. }))
4952 }
4953
4954 pub fn step_past_door(&mut self) -> bool {
4957 let Some(Row::Up { section } | Row::Door { section, .. }) = self.selected_row() else {
4958 return false;
4959 };
4960 let first = (self.selected + 1..self.row_count()).find(
4961 |&i| matches!(self.row_at(i), Some(Row::Entry { section: s, .. }) if s == section),
4962 );
4963 let Some(first) = first else {
4964 return false;
4965 };
4966 self.move_selection((first - self.selected) as isize);
4967 self.follow_selection();
4968 true
4969 }
4970
4971 pub fn recents_in(&self, place: &Path) -> Vec<PathBuf> {
4973 self.sections
4974 .iter()
4975 .filter(|s| s.grouped_by_place)
4976 .flat_map(|s| s.rows.iter())
4977 .filter(|row| place_of(&row.path) == place)
4978 .map(|row| row.path.clone())
4979 .collect()
4980 }
4981
4982 pub fn selected_section(&self) -> Option<usize> {
4984 self.selected_row().map(|r| r.section())
4985 }
4986
4987 pub fn selected_catalog(&self) -> Option<&ShownCatalog> {
4989 if !self.selection_is_header() {
4990 return None;
4991 }
4992 let section = self.sections.get(self.selected_section()?)?;
4993 let origin = section.origin?;
4994 self.catalogs
4995 .iter()
4996 .find(|c| c.label == section.title && c.origin_note() == origin)
4997 }
4998
4999 pub fn selection_is_header(&self) -> bool {
5001 matches!(self.selected_row(), Some(Row::Header { .. }))
5002 }
5003
5004 pub fn pending_probes(&self) -> Vec<PathBuf> {
5007 let check = self.network_check;
5008 let mut out = Vec::new();
5009 for root in self.sections.iter().filter_map(|s| s.remote_root.as_ref()) {
5011 if !self.probes.settled(root) && !out.contains(root) {
5012 out.push(root.clone());
5013 }
5014 }
5015 if let Some(dir) = &self.browsing
5018 && check(dir)
5019 && cloud_source_id(dir).is_none()
5020 && !self.probes.settled(dir)
5021 && !out.contains(dir)
5022 {
5023 out.push(dir.clone());
5024 }
5025 out
5026 }
5027
5028 pub fn below_browse_start(&self) -> bool {
5031 let (Some(dir), Some(start)) = (&self.browsing, &self.browse_start) else {
5032 return false;
5033 };
5034 if dir == start {
5035 return false;
5036 }
5037 let mut current = self.parent_of(dir);
5039 let mut steps = 0;
5040 while let Some(place) = current {
5041 if &place == start {
5042 return true;
5043 }
5044 steps += 1;
5045 if steps > 64 {
5046 break;
5047 }
5048 current = self.parent_of(&place);
5049 }
5050 false
5051 }
5052
5053 pub fn sections_waiting(&self) -> bool {
5055 self.sections.iter().any(|s| s.waiting)
5056 }
5057
5058 pub fn awaiting_listing(&self) -> Option<&Path> {
5060 let dir = self.browsing.as_deref()?;
5061 if cloud_source_id(dir).is_some() {
5062 return self
5063 .cloud_source_of(dir)
5064 .is_some_and(|s| s.status == CloudStatus::Listing && s.buckets.is_empty())
5065 .then_some(dir);
5066 }
5067 ((self.network_check)(dir) && !self.probes.settled(dir)).then_some(dir)
5068 }
5069
5070 pub fn probe_ready(&mut self, root: PathBuf, rows: Vec<Entry>, cut_short: bool) {
5072 self.probes.insert(
5073 root.clone(),
5074 Probe::Listed {
5075 rows: rows.into(),
5076 cut_short,
5077 },
5078 );
5079 self.apply_cloud_kinds(&root);
5080 }
5081
5082 pub fn take_cloud_kinds(&mut self) {
5086 let roots: Vec<PathBuf> = (self.probes.answered())
5087 .map(|(root, _)| root.clone())
5088 .collect();
5089 for root in &roots {
5090 self.apply_cloud_kinds(root);
5091 }
5092 let mut labeled = false;
5093 for section in &mut self.sections {
5094 for row in &mut section.rows {
5095 if row.kind == EntryKind::Directory
5096 && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
5097 {
5098 row.kind = *kind;
5099 if !holds.is_empty() {
5100 row.holds = holds.clone();
5101 }
5102 labeled = true;
5103 }
5104 }
5105 }
5106 if labeled {
5107 self.changed();
5108 }
5109 if let Some(dir) = self.browsing.clone()
5111 && roots.contains(&dir)
5112 {
5113 self.relist_remote(&dir);
5114 }
5115 }
5116
5117 pub fn apply_cloud_kinds(&mut self, root: &Path) {
5119 let Some(rows) = self.probes.listed_mut(root) else {
5120 return;
5121 };
5122 for row in std::sync::Arc::make_mut(rows).iter_mut() {
5124 if row.kind == EntryKind::Directory
5125 && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
5126 {
5127 row.kind = *kind;
5128 if !holds.is_empty() {
5131 row.holds = holds.clone();
5132 }
5133 }
5134 }
5135 }
5136
5137 pub fn cloud_directories_to_peek(&self, limit: usize) -> Vec<PathBuf> {
5141 if limit == 0 {
5142 return Vec::new();
5143 }
5144 let view = self.view();
5145 let mut out: Vec<PathBuf> = Vec::new();
5146 for entry in self.entries_near_cursor(&view, limit) {
5147 if !is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
5150 continue;
5151 }
5152 if !matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown) {
5153 continue;
5154 }
5155 if self.browsing.is_none()
5158 && (self.catalog_dataset(&entry.path).is_some()
5159 || self.bookmark(&entry.path).is_some())
5160 {
5161 continue;
5162 }
5163 if self.cloud_kinds.contains_key(&entry.path)
5165 || self.peeking.contains(&entry.path)
5166 || self.peek_failed.contains(&entry.path)
5167 {
5168 continue;
5169 }
5170 if out.contains(&entry.path) {
5171 continue;
5172 }
5173 out.push(entry.path.clone());
5174 if out.len() >= limit {
5175 break;
5176 }
5177 }
5178 out
5179 }
5180
5181 pub fn probe_failed(&mut self, root: PathBuf, why: Option<String>) {
5183 self.probes.insert(root, Probe::Unreachable(why));
5184 }
5185
5186 pub fn measure_now(&mut self, limit: usize) -> bool {
5190 let wanted = self.unmeasured_visible(limit);
5191 let more = self.unmeasured_visible(limit + 1).len() > wanted.len();
5192 for entry in wanted {
5193 let mut probe = entry.clone();
5194 stat_and_recall(&mut probe, &self.known);
5195 if probe.rows.is_none() && probe.columns.is_empty() {
5196 discover::enrich(&mut probe);
5197 }
5198 self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
5199 }
5200 self.apply_new_measurements();
5201 more
5202 }
5203
5204 pub fn unmeasured_visible(&self, limit: usize) -> Vec<Entry> {
5211 let view = self.view();
5212 let mut out: Vec<Entry> = Vec::new();
5213 for entry in self.entries_on_screen(&view) {
5214 if self.wants_measuring(entry) && !out.iter().any(|e| e.path == entry.path) {
5215 out.push(entry.clone());
5216 if out.len() >= limit {
5217 return out;
5218 }
5219 }
5220 }
5221 if self.sort == SortMode::Rows {
5222 self.sorted_rest(&view, limit, &mut out);
5223 }
5224 out
5225 }
5226
5227 pub fn unstated_for_sort(&self, limit: usize) -> Vec<Entry> {
5231 let view = self.view();
5232 let mut out: Vec<Entry> = Vec::new();
5233 for entry in self.entries_on_screen(&view) {
5234 let stale =
5235 (self.stated.get(&entry.path)).is_some_and(|stated| stated.epoch < self.stat_epoch);
5236 if stale && self.needs_stat(entry) && !out.iter().any(|e| e.path == entry.path) {
5237 out.push(entry.clone());
5238 if out.len() >= limit {
5239 break;
5240 }
5241 }
5242 }
5243 if out.len() < limit && matches!(self.sort, SortMode::Size | SortMode::Modified) {
5244 self.sorted_rest(&view, limit, &mut out);
5245 }
5246 for entry in &mut out {
5247 entry.modified = None;
5248 }
5249 out
5250 }
5251
5252 fn sorted_rest(&self, view: &View, limit: usize, out: &mut Vec<Entry>) {
5254 let mut take = |entry: &Entry| {
5255 if self.sort_wants(entry) && !out.iter().any(|e| e.path == entry.path) {
5256 out.push(entry.clone());
5257 }
5258 out.len() >= limit
5259 };
5260 for entry in view.slots.iter().filter_map(|slot| self.entry_of(slot)) {
5261 if take(entry) {
5262 return;
5263 }
5264 }
5265 for slot in &view.slots {
5266 let Slot::Plain(Row::More {
5267 section,
5268 measuring: true,
5269 ..
5270 }) = slot
5271 else {
5272 continue;
5273 };
5274 for entry in &self.sections[*section].rows {
5275 if take(entry) {
5276 return;
5277 }
5278 }
5279 }
5280 }
5281
5282 fn sort_wants(&self, entry: &Entry) -> bool {
5285 match self.sort {
5286 SortMode::Natural => false,
5287 SortMode::Rows => self.wants_measuring(entry),
5288 SortMode::Size | SortMode::Modified => {
5289 (self.sort == SortMode::Modified || entry.kind != EntryKind::Unknown)
5290 && self.needs_stat(entry)
5291 }
5292 }
5293 }
5294
5295 fn needs_stat(&self, entry: &Entry) -> bool {
5298 let due = match self.stated.get(&entry.path) {
5299 Some(stated) => stated.epoch < self.stat_epoch,
5300 None => entry.modified.is_none(),
5301 };
5302 due && discover::on_disk(entry) && !(self.network_check)(&entry.path)
5303 }
5304
5305 fn wants_measuring(&self, entry: &Entry) -> bool {
5308 if entry.measured || self.enriched.contains_key(&entry.path) {
5309 return false;
5310 }
5311 if entry.kind != EntryKind::Unknown
5313 && discover::unstated(entry)
5314 && !self.stated.contains_key(&entry.path)
5315 && !(self.network_check)(&entry.path)
5316 {
5317 return true;
5318 }
5319 if entry.rows.is_some() {
5320 return false;
5321 }
5322 if matches!(
5325 entry.kind,
5326 EntryKind::Directory | EntryKind::Unknown | EntryKind::Other
5327 ) || entry.kind.is_lake_table()
5328 {
5329 return false;
5330 }
5331 !(self.network_check)(&entry.path)
5334 }
5335
5336 pub fn classify_now(&mut self, limit: usize) -> bool {
5339 let wanted = self.unclassified_visible(limit);
5340 let more = self.unclassified_visible(limit + 1).len() > wanted.len();
5341 for entry in wanted {
5342 let mut stated = entry.clone();
5343 stat_and_recall(&mut stated, &self.known);
5344 let probe = look_into_as(&stated, &Default::default());
5345 self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
5346 }
5347 self.apply_new_measurements();
5348 more
5349 }
5350
5351 pub fn unclassified_visible(&self, limit: usize) -> Vec<Entry> {
5357 self.unclassified_visible_where(limit, |_| true)
5358 }
5359
5360 pub fn unclassified_visible_where(
5362 &self,
5363 limit: usize,
5364 keep: impl Fn(&Entry) -> bool,
5365 ) -> Vec<Entry> {
5366 if limit == 0 {
5367 return Vec::new();
5368 }
5369 let view = self.view();
5370 let mut out: Vec<Entry> = Vec::new();
5371 for entry in self.entries_near_cursor(&view, limit) {
5372 if entry.kind != EntryKind::Unknown
5373 || self.missing.contains(&entry.path)
5374 || !keep(entry)
5375 {
5376 continue;
5377 }
5378 if self.enriched.contains_key(&entry.path) {
5380 continue;
5381 }
5382 if is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
5385 continue;
5386 }
5387 if out.iter().any(|e| e.path == entry.path) {
5389 continue;
5390 }
5391 out.push(entry.clone());
5392 if out.len() >= limit {
5393 break;
5394 }
5395 }
5396 out
5397 }
5398
5399 fn entries_on_screen<'a>(&'a self, view: &'a View) -> impl Iterator<Item = &'a Entry> + 'a {
5402 let height = if self.view_height == 0 {
5405 usize::MAX / 4
5406 } else {
5407 self.view_height
5408 };
5409 let rows = view.slots.len();
5410 let top = self.scroll.min(rows);
5411 let reach = height / 2;
5412 let bottom = top.saturating_add(height + reach).min(rows);
5413 let above = top.saturating_sub(reach);
5414 std::iter::once(self.selected)
5415 .chain(top..bottom)
5416 .chain(above..top)
5417 .filter_map(|i| self.entry_of(view.slots.get(i)?))
5418 }
5419
5420 fn entries_near_cursor<'a>(
5423 &'a self,
5424 view: &'a View,
5425 limit: usize,
5426 ) -> impl Iterator<Item = &'a Entry> + 'a {
5427 let height = if self.view_height == 0 {
5430 limit
5431 } else {
5432 self.view_height
5433 };
5434 let rows = view.slots.len();
5435 let top = self.scroll.min(rows);
5436 let ahead = top.saturating_add(2 * height).min(rows);
5437 let behind = top.saturating_sub(height);
5438 std::iter::once(self.selected)
5439 .chain(top..ahead)
5440 .chain(behind..top)
5441 .filter_map(|i| self.entry_of(view.slots.get(i)?))
5442 }
5443
5444 fn entry_of(&self, slot: &Slot) -> Option<&Entry> {
5446 match slot {
5447 Slot::Entry { section, index, .. } => Some(&self.sections[*section].rows[*index]),
5448 _ => None,
5449 }
5450 }
5451
5452 pub fn record_measurement(&mut self, path: PathBuf, measured: Measured) {
5455 self.unapplied.insert(path.clone());
5456 let before = self.stated.insert(
5457 path.clone(),
5458 Stated {
5459 size: measured.size,
5460 modified: measured.modified,
5461 epoch: self.stat_epoch,
5462 },
5463 );
5464 if !measured.stat_only {
5465 self.stale.remove(&path);
5466 self.enriched.insert(path, measured);
5467 return;
5468 }
5469 let changed = before.is_some_and(|before| {
5472 before.modified.is_some()
5473 && (before.size, before.modified) != (measured.size, measured.modified)
5474 });
5475 if changed && self.enriched.remove(&path).is_some() {
5476 self.stale.insert(path);
5477 }
5478 }
5479
5480 pub fn record_size(&mut self, path: PathBuf, measured: Measured) {
5482 match self.enriched.get_mut(&path) {
5483 Some(known) => {
5484 known.size = measured.size;
5485 self.unapplied.insert(path);
5486 }
5487 None => self.record_measurement(path, measured),
5488 }
5489 }
5490
5491 pub fn apply_measurements(&mut self) {
5494 self.unapplied.clear();
5495 for section in &mut self.sections {
5496 for row in section.rows.iter_mut().chain(section.door.iter_mut()) {
5499 fold_known(row, &self.enriched, &self.stated, &self.stale);
5500 }
5501 if let Some(door) = section.door.as_mut() {
5504 door.name = door_name(door, §ion.rows);
5505 }
5506 }
5507 self.changed();
5508 self.land_again();
5509 }
5510
5511 pub fn apply_new_measurements(&mut self) {
5515 if self.unapplied.is_empty() {
5516 return;
5517 }
5518 let unapplied = std::mem::take(&mut self.unapplied);
5519 let names: std::collections::HashSet<&std::ffi::OsStr> = (unapplied.iter())
5523 .map(|path| path.file_name().unwrap_or(path.as_os_str()))
5524 .collect();
5525 let new = |path: &Path| {
5526 path.file_name().is_none_or(|name| names.contains(name)) && unapplied.contains(path)
5527 };
5528 let mut touched: Vec<(usize, usize)> = Vec::new();
5529 let mut any = false;
5530 let mut moved = false;
5531 let sort = self.sort;
5532 for (si, section) in self.sections.iter_mut().enumerate() {
5533 let mut here = false;
5534 for (i, row) in section.rows.iter_mut().enumerate() {
5535 if !new(&row.path) {
5536 continue;
5537 }
5538 let before = Standing::of(row, sort);
5539 if fold_known(row, &self.enriched, &self.stated, &self.stale) {
5540 moved |= Standing::of(row, sort) != before;
5541 touched.push((si, i));
5542 here = true;
5543 }
5544 }
5545 if let Some(door) = section.door.as_mut()
5546 && new(&door.path)
5547 {
5548 here |= fold_known(door, &self.enriched, &self.stated, &self.stale);
5549 }
5550 if here && let Some(door) = section.door.as_mut() {
5551 door.name = door_name(door, §ion.rows);
5552 }
5553 any |= here;
5554 }
5555 if !any {
5556 return;
5557 }
5558 if moved || sort != SortMode::Natural || !self.hits_hold(&touched) {
5563 self.rows_changed(&touched);
5564 }
5565 self.land_again();
5566 }
5567
5568 fn hits_hold(&self, touched: &[(usize, usize)]) -> bool {
5572 let built = self.rows_cache.built.borrow();
5573 let Some(view) = built.as_ref().filter(|view| view.key.matches(self)) else {
5574 return false;
5575 };
5576 let needle = Needle::new(&self.filter);
5577 touched.iter().all(|&(si, i)| {
5578 let was = view.hits.sections.get(si).and_then(|s| s.as_ref()?.get(i));
5579 was == Some(&needle.hit(&self.sections[si].rows[i]))
5580 })
5581 }
5582
5583 fn land_again(&mut self) {
5586 if self.landing
5587 && let Some(Row::Door { entry, .. }) = self.row_at(self.selected)
5588 && !door_lands(entry)
5589 {
5590 self.selected = self.landing_row();
5591 self.follow_selection();
5592 }
5593 }
5594
5595 pub fn select_first_entry(&mut self) {
5601 self.returning = None;
5602 self.landing = true;
5603 if !self.filter.is_empty() {
5605 self.resting = false;
5606 }
5607 self.selected = self.landing_row();
5608 }
5609
5610 fn landing_row(&self) -> usize {
5612 if self.filter.is_empty()
5614 && let Some(newest) = self.newest_recent.as_ref()
5615 && let Some(at) = self.position(|r| {
5616 matches!(r, Row::Entry { section, entry, .. }
5617 if entry.path == *newest
5618 && self.sections[*section].title == Self::RECENT_SECTION)
5619 })
5620 {
5621 return at;
5622 }
5623 let first = self.position(|r| matches!(r, Row::Entry { .. } | Row::Door { .. }));
5624 let first = match first.and_then(|i| self.row_at(i)) {
5625 Some(Row::Door { entry, .. }) if !door_lands(entry) => self
5626 .position(|r| matches!(r, Row::Entry { .. } | Row::Hidden { .. }))
5627 .or(first),
5628 _ => first,
5629 };
5630 first
5632 .or_else(|| self.position(|r| matches!(r, Row::Hidden { .. })))
5633 .unwrap_or(0)
5634 }
5635
5636 pub fn clamp_selection(&mut self) {
5637 let n = self.row_count();
5638 if n == 0 {
5639 self.selected = 0;
5640 } else if self.selected >= n {
5641 self.selected = n - 1;
5642 }
5643 }
5644
5645 pub fn select(&mut self, index: usize) {
5647 if index < self.row_count() {
5648 self.resting = false;
5649 self.returning = None;
5650 self.landing = false;
5651 self.selected = index;
5652 }
5653 }
5654
5655 pub fn move_selection(&mut self, delta: isize) {
5656 self.resting = false;
5657 self.returning = None;
5658 self.landing = false;
5659 let n = self.row_count();
5660 if n == 0 {
5661 return;
5662 }
5663 let cur = self.selected as isize;
5664 let next = (cur + delta).rem_euclid(n as isize);
5665 self.selected = next as usize;
5666 }
5667
5668 pub fn page_selection(&mut self, delta: isize) {
5671 self.resting = false;
5672 self.returning = None;
5673 self.landing = false;
5674 let n = self.row_count();
5675 if n == 0 {
5676 return;
5677 }
5678 let next = (self.selected as isize)
5680 .saturating_add(delta)
5681 .clamp(0, n as isize - 1);
5682 self.selected = next as usize;
5683 }
5684}
5685
5686fn source_entry(source: &CloudSource) -> Entry {
5688 Entry {
5689 path: cloud_place(&source.id),
5690 kind: EntryKind::Directory,
5691 name: source.label.clone(),
5692 size: None,
5693 modified: source.listed_at,
5694 rows: None,
5695 cols: None,
5696 cols_sampled: false,
5697 columns: Vec::new(),
5698 cost: Default::default(),
5699 holds: Default::default(),
5700 opens_whole_directory: false,
5701 measured: false,
5702 format_spec: None,
5703 table: None,
5704 }
5705}
5706
5707fn bucket_entry(url: &Path) -> Entry {
5709 let mut entry = Entry::directory(url);
5710 let text = url.to_string_lossy();
5712 let (_, plain) = crate::cloud::source::split_source_id(&text);
5713 entry.name = plain
5714 .rsplit('/')
5715 .find(|part| !part.is_empty())
5716 .unwrap_or("")
5717 .to_string();
5718 entry
5719}
5720
5721pub fn names_a_file(path: &Path) -> bool {
5724 let named = path.to_string_lossy();
5725 let dotted = !named.ends_with('/')
5728 && path
5729 .file_name()
5730 .map(|last| last.to_string_lossy())
5731 .is_some_and(|last| last.trim_start_matches('.').contains('.'));
5732 discover::is_data_file(path) || dotted
5733}
5734
5735fn entry_for_path(path: &Path, remote: bool) -> Entry {
5737 if !remote && let Some(table) = discover::table_row(path) {
5739 return table;
5740 }
5741 let mut holds = discover::Holds::default();
5742 let kind = if remote {
5745 if names_a_file(path) {
5750 EntryKind::File
5751 } else {
5752 EntryKind::Unknown
5753 }
5754 } else if path.is_dir() {
5755 let (kind, found) = discover::look_at_directory(path);
5756 holds = found;
5757 kind
5758 } else {
5759 EntryKind::File
5760 };
5761 let mut entry = Entry {
5762 path: path.to_path_buf(),
5763 kind,
5764 name: path
5765 .file_name()
5766 .map(|n| n.to_string_lossy().into_owned())
5767 .unwrap_or_else(|| path.to_string_lossy().into_owned()),
5768 size: None,
5769 modified: None,
5770 rows: None,
5771 cols: None,
5772 cols_sampled: false,
5773 columns: Vec::new(),
5774 cost: Default::default(),
5775 holds,
5776 opens_whole_directory: false,
5777 measured: false,
5778 format_spec: None,
5779 table: None,
5780 };
5781 if !remote && let Ok(meta) = std::fs::metadata(path) {
5782 if meta.is_file() {
5783 entry.size = Some(meta.len());
5784 }
5785 entry.modified = meta.modified().ok();
5786 }
5787 entry
5788}
5789
5790pub fn display_path(path: &Path) -> String {
5792 if let Some(home) = dirs::home_dir()
5793 && let Ok(rest) = path.strip_prefix(&home)
5794 {
5795 if rest.as_os_str().is_empty() {
5796 return "~".to_string();
5797 }
5798 return format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display());
5800 }
5801 path.display().to_string()
5802}
5803
5804pub fn complete_path(typed: &str) -> (String, usize) {
5808 let expanded = expand_user_path(typed);
5809 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5811 let typed_ends_in_sep = typed.ends_with(is_separator);
5812
5813 let (dir, prefix) = if typed_ends_in_sep {
5814 (expanded.clone(), String::new())
5815 } else {
5816 match (expanded.parent(), expanded.file_name()) {
5817 (Some(parent), Some(name)) => {
5818 (parent.to_path_buf(), name.to_string_lossy().into_owned())
5819 }
5820 _ => (expanded.clone(), String::new()),
5821 }
5822 };
5823
5824 let Ok(entries) = std::fs::read_dir(&dir) else {
5825 return (typed.to_string(), 0);
5826 };
5827
5828 let mut names: Vec<String> = entries
5829 .flatten()
5830 .filter_map(|e| {
5831 let name = e.file_name().to_string_lossy().into_owned();
5832 if name.starts_with('.') && !prefix.starts_with('.') {
5834 return None;
5835 }
5836 name.starts_with(&prefix).then_some(name)
5837 })
5838 .collect();
5839 if names.is_empty() {
5840 return (typed.to_string(), 0);
5841 }
5842 names.sort();
5843
5844 let shared = names
5846 .iter()
5847 .skip(1)
5848 .fold(names[0].clone(), |acc, name| common_prefix(&acc, name));
5849
5850 let mut completed = typed.to_string();
5851 completed.truncate(typed.len() - prefix.len());
5852 completed.push_str(&shared);
5853
5854 if names.len() == 1 && dir.join(&shared).is_dir() && !completed.ends_with(is_separator) {
5857 let separator = typed
5858 .chars()
5859 .rev()
5860 .find(|c| is_separator(*c))
5861 .unwrap_or(std::path::MAIN_SEPARATOR);
5862 completed.push(separator);
5863 }
5864 (completed, names.len())
5865}
5866
5867#[derive(Debug, Clone, PartialEq, Eq)]
5869pub struct PathName {
5870 pub name: String,
5871 pub dir: bool,
5873}
5874
5875#[derive(Debug, Clone, Default, PartialEq, Eq)]
5877pub struct PathListing {
5878 pub dir: String,
5880 pub names: Vec<PathName>,
5881 pub failed: bool,
5883 pub matched: SegmentMatches,
5885}
5886
5887#[derive(Debug, Default)]
5891pub struct SegmentMatches(std::sync::Mutex<Option<(String, std::sync::Arc<[usize]>)>>);
5892
5893impl Clone for SegmentMatches {
5894 fn clone(&self) -> Self {
5895 Self::default()
5896 }
5897}
5898
5899impl PartialEq for SegmentMatches {
5900 fn eq(&self, _: &Self) -> bool {
5901 true
5902 }
5903}
5904
5905impl Eq for SegmentMatches {}
5906
5907fn path_matches(names: &[PathName], segment: &str) -> std::sync::Arc<[usize]> {
5910 let mut matched: Vec<(usize, i32)> = (names.iter().enumerate())
5911 .filter(|(_, n)| !n.name.starts_with('.') || segment.starts_with('.'))
5912 .filter_map(|(i, n)| {
5913 if segment.is_empty() {
5914 return Some((i, 0));
5915 }
5916 let prefix = n.name.starts_with(segment) as i32 * 1_000_000;
5917 fuzzy_score(segment, &n.name).map(|score| (i, prefix + score))
5918 })
5919 .collect();
5920 matched
5921 .sort_by(|(a, sa), (b, sb)| sb.cmp(sa).then_with(|| names[*a].name.cmp(&names[*b].name)));
5922 matched.into_iter().map(|(i, _)| i).collect()
5923}
5924
5925const PATH_LISTING_MAX: usize = 5_000;
5927
5928pub fn typed_dir(typed: &str) -> &str {
5931 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5932 let floor = typed.find("://").map_or(0, |at| at + 3);
5933 match typed[floor..].rfind(is_separator) {
5934 Some(at) => &typed[..floor + at + 1],
5935 None => &typed[..floor],
5936 }
5937}
5938
5939fn separator_in(dir: &str) -> char {
5942 if typed_dir_is_url(dir) {
5943 return '/';
5944 }
5945 dir.chars()
5946 .rev()
5947 .find(|c| *c == '/' || (cfg!(windows) && *c == '\\'))
5948 .unwrap_or(std::path::MAIN_SEPARATOR)
5949}
5950
5951pub fn typed_dir_is_url(dir: &str) -> bool {
5953 dir.contains("://")
5954}
5955
5956pub fn list_typed_dir(dir: &str) -> PathListing {
5959 let path = if dir.is_empty() {
5960 PathBuf::from(".")
5961 } else {
5962 expand_user_path(dir)
5963 };
5964 let Ok(entries) = std::fs::read_dir(&path) else {
5965 return PathListing {
5966 dir: dir.to_string(),
5967 names: Vec::new(),
5968 failed: true,
5969 matched: Default::default(),
5970 };
5971 };
5972 let mut names: Vec<PathName> = entries
5973 .flatten()
5974 .take(PATH_LISTING_MAX)
5975 .map(|e| {
5976 let name = e.file_name().to_string_lossy().into_owned();
5977 let dir = e.file_type().is_ok_and(|t| t.is_dir())
5979 || (e.file_type().is_ok_and(|t| t.is_symlink()) && e.path().is_dir());
5980 PathName { name, dir }
5981 })
5982 .collect();
5983 names.sort_by(|a, b| a.name.cmp(&b.name));
5984 PathListing {
5985 dir: dir.to_string(),
5986 names,
5987 failed: false,
5988 matched: Default::default(),
5989 }
5990}
5991
5992pub fn names_under(dir: &str, urls: impl IntoIterator<Item = String>) -> PathListing {
5995 let mut names: Vec<PathName> = Vec::new();
5996 for url in urls {
5997 let forms = match crate::cloud::source::azure_parts(&url) {
5999 Some((_, container, key)) => vec![url.clone(), format!("az://{container}/{key}")],
6000 None => vec![url],
6001 };
6002 for form in forms {
6003 let Some(rest) = form.strip_prefix(dir) else {
6004 continue;
6005 };
6006 let (name, more) = match rest.split_once('/') {
6007 Some((name, more)) => (name, Some(more)),
6008 None => (rest, None),
6009 };
6010 if name.is_empty() {
6011 continue;
6012 }
6013 let is_dir = more.is_some() || !names_a_file(Path::new(&form));
6016 match names.iter_mut().find(|n| n.name == name) {
6017 Some(known) => known.dir |= is_dir,
6018 None => names.push(PathName {
6019 name: name.to_string(),
6020 dir: is_dir,
6021 }),
6022 }
6023 }
6024 }
6025 names.sort_by(|a, b| a.name.cmp(&b.name));
6026 PathListing {
6027 dir: dir.to_string(),
6028 names,
6029 failed: false,
6030 matched: Default::default(),
6031 }
6032}
6033
6034fn common_prefix(a: &str, b: &str) -> String {
6035 a.chars()
6036 .zip(b.chars())
6037 .take_while(|(x, y)| x == y)
6038 .map(|(x, _)| x)
6039 .collect()
6040}
6041
6042pub fn expand_user_path(raw: &str) -> PathBuf {
6044 crate::config::expand_config_path(raw)
6045}
6046
6047#[cfg(test)]
6048mod holds_flow_tests {
6049 use super::*;
6050
6051 #[test]
6054 fn a_rows_label_is_one_decision_for_the_list_and_the_pane() {
6055 let mut directory = Entry::for_test(Path::new("s3://bucket/warehouse"), "warehouse");
6056 directory.kind = EntryKind::Directory;
6057 let said = |look, place_kind| describe(&directory, place_kind, look, 0, None);
6058 let g = crate::glyphs::get();
6059
6060 let waiting = said(Some(CloudLook::Waiting), None);
6061 assert_eq!(
6062 (waiting.short.as_str(), waiting.words.as_str()),
6063 (g.ellipsis, "")
6064 );
6065 assert_eq!(said(Some(CloudLook::Looking), None).words, "");
6066 assert!(
6067 said(Some(CloudLook::Failed), None)
6068 .words
6069 .contains("listing failed")
6070 );
6071 assert_eq!(said(None, None).words, "directory");
6072 let curated = said(None, Some("dataset"));
6073 assert_eq!(
6074 (curated.short.as_str(), curated.words.as_str()),
6075 ("dataset", "dataset")
6076 );
6077 assert!(curated.curated);
6078
6079 directory.holds = crate::home::discover::Holds {
6080 formats: vec![("parquet".to_string(), 12)],
6081 ..Default::default()
6082 };
6083 let counted = describe(&directory, None, None, 0, None);
6084 assert_eq!(counted.short, "12 parquet");
6085 assert_eq!(
6086 counted.words, "directory",
6087 "the count is the pane's `contains` line"
6088 );
6089 let curated = describe(&directory, Some("dataset"), None, 0, None);
6090 assert_eq!(
6091 curated.short, "dataset",
6092 "the curated word wins over the count"
6093 );
6094
6095 directory.opens_whole_directory = true;
6096 assert_eq!(
6097 describe(&directory, Some("dataset"), None, 0, None),
6098 RowLabel::default()
6099 );
6100 }
6101
6102 #[cfg(windows)]
6105 #[test]
6106 fn a_windows_home_path_is_shown_and_typed_with_backslashes() {
6107 let home = dirs::home_dir().unwrap();
6108 let path = home.join("data").join("a.csv");
6109 let shown = display_path(&path);
6110 assert_eq!(shown, r"~\data\a.csv");
6111 assert_eq!(expand_user_path(&shown), path);
6112 }
6113
6114 #[cfg(windows)]
6117 #[test]
6118 fn a_dot_above_a_windows_recent_does_not_make_it_a_file() {
6119 let path = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\orders");
6120 assert_eq!(entry_for_path(path, true).kind, EntryKind::Unknown);
6121 let file = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\a.parquet");
6122 assert_eq!(entry_for_path(file, true).kind, EntryKind::File);
6123 }
6124
6125 fn counted(n: usize) -> crate::home::discover::Holds {
6126 crate::home::discover::Holds {
6127 formats: vec![("parquet".to_string(), n)],
6128 ..Default::default()
6129 }
6130 }
6131
6132 fn in_flight() -> (EntryKind, crate::home::discover::Holds) {
6135 (
6136 EntryKind::Directory,
6137 crate::home::discover::Holds::default(),
6138 )
6139 }
6140
6141 #[test]
6142 fn a_claim_staked_before_a_peek_lands_keeps_the_count_a_row_already_has() {
6143 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6144 let path = root.join("orders");
6145 let mut row = Entry::for_test(&path, "orders");
6146 row.kind = EntryKind::Directory;
6147 row.holds = counted(15);
6150
6151 let mut home = HomeState::default();
6152 home.probe_ready(root.clone(), vec![row], false);
6153 home.cloud_kinds.insert(path, in_flight());
6154 home.apply_cloud_kinds(&root);
6155
6156 assert_eq!(
6157 home.probes.listed(&root).unwrap()[0].holds.label(),
6158 "15 parquet",
6159 "the placeholder erased a count the row already had"
6160 );
6161 }
6162
6163 #[test]
6164 fn a_cloud_directory_waits_then_looks_then_answers() {
6165 let path = std::path::PathBuf::from("gs://pitscope/seasons");
6166 let mut row = Entry::for_test(&path, "seasons");
6167 row.kind = EntryKind::Directory;
6168 let mut home = HomeState::default();
6169
6170 assert_eq!(
6171 home.cloud_look(&row),
6172 Some(CloudLook::Waiting),
6173 "not asked yet"
6174 );
6175 home.peeking.insert(path.clone());
6176 assert_eq!(
6177 home.cloud_look(&row),
6178 Some(CloudLook::Looking),
6179 "being looked into"
6180 );
6181 home.peeking.remove(&path);
6182
6183 home.cloud_kinds
6186 .insert(path.clone(), (EntryKind::Directory, counted(12)));
6187 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
6188 home.cloud_kinds
6189 .insert(path.clone(), (EntryKind::Hive, Default::default()));
6190 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
6191
6192 home.cloud_kinds.insert(path.clone(), in_flight());
6194 assert_eq!(home.cloud_look(&row), None, "answered");
6195
6196 let mut home = HomeState {
6199 network_check: |_| true,
6200 ..Default::default()
6201 };
6202 let root = std::path::PathBuf::from("gs://pitscope");
6203 home.probe_ready(root.clone(), vec![row.clone()], false);
6204 home.browsing = Some(root);
6205 home.rebuild(&[]);
6206 assert_eq!(
6207 home.cloud_directories_to_peek(4),
6208 std::slice::from_ref(&path)
6209 );
6210 home.peek_failed.insert(path.clone());
6211 assert_eq!(home.cloud_look(&row), Some(CloudLook::Failed));
6212 assert!(home.cloud_directories_to_peek(4).is_empty());
6213
6214 let mut counted_row = Entry::for_test(&path.join("x"), "x");
6217 counted_row.kind = EntryKind::Directory;
6218 counted_row.holds = counted(3);
6219 assert_eq!(home.cloud_look(&counted_row), None);
6220 let mut bucket = Entry::for_test(std::path::Path::new("gs://pitscope"), "pitscope");
6221 bucket.kind = EntryKind::Directory;
6222 assert_eq!(home.cloud_look(&bucket), None);
6223 let mut local = Entry::for_test(std::path::Path::new("/data/seasons"), "seasons");
6224 local.kind = EntryKind::Directory;
6225 assert_eq!(home.cloud_look(&local), None);
6226 }
6227
6228 #[test]
6229 fn a_peeks_answer_replaces_the_count_a_row_had() {
6230 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6231 let path = root.join("orders");
6232 let mut row = Entry::for_test(&path, "orders");
6233 row.kind = EntryKind::Directory;
6234 row.holds = counted(15);
6235
6236 let mut home = HomeState::default();
6237 home.probe_ready(root.clone(), vec![row], false);
6238 home.cloud_kinds
6239 .insert(path, (EntryKind::MultiFile, counted(40)));
6240 home.apply_cloud_kinds(&root);
6241
6242 assert_eq!(
6243 home.probes.listed(&root).unwrap()[0].holds.label(),
6244 "40 parquet"
6245 );
6246 assert_eq!(
6247 home.probes.listed(&root).unwrap()[0].kind,
6248 EntryKind::MultiFile
6249 );
6250 }
6251
6252 #[test]
6253 fn a_peek_answers_only_the_rows_that_asked() {
6254 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6255 let settled = root.join("sales");
6259 let mut row = Entry::for_test(&settled, "sales");
6260 row.kind = EntryKind::Hive;
6261 row.holds = counted(40);
6262
6263 let mut home = HomeState::default();
6264 home.probe_ready(root.clone(), vec![row], false);
6265 home.cloud_kinds
6266 .insert(settled, (EntryKind::Directory, counted(1)));
6267 home.apply_cloud_kinds(&root);
6268
6269 assert_eq!(home.probes.listed(&root).unwrap()[0].kind, EntryKind::Hive);
6270 assert_eq!(
6271 home.probes.listed(&root).unwrap()[0].holds.label(),
6272 "40 parquet"
6273 );
6274 }
6275
6276 #[test]
6283 fn a_peek_goes_to_the_row_the_cursor_is_on_and_is_never_asked_twice() {
6284 let root = std::path::PathBuf::from("s3://bucket/warehouse");
6285 let mut home = HomeState {
6286 network_check: |_| true,
6287 ..Default::default()
6288 };
6289 let rows: Vec<Entry> = ["a", "b", "c", "d", "e"]
6290 .iter()
6291 .map(|n| {
6292 let mut row = Entry::for_test(&root.join(n), n);
6293 row.kind = EntryKind::Directory;
6294 row
6295 })
6296 .collect();
6297 home.probe_ready(root.clone(), rows, false);
6298 home.browsing = Some(root.clone());
6299 home.view_height = 10;
6300 home.rebuild(&[]);
6301 home.cloud_kinds
6303 .insert(root.join("b"), (EntryKind::MultiFile, counted(3)));
6304 home.peeking.insert(root.join("c"));
6305
6306 home.selected = home
6309 .visible()
6310 .iter()
6311 .position(|r| matches!(r, Row::Entry { entry, .. } if entry.name == "e"))
6312 .expect("the row is listed");
6313 let asked = home.cloud_directories_to_peek(3);
6314 assert_eq!(
6315 asked.first(),
6316 Some(&root.join("e")),
6317 "the highlighted row is the one about to be acted on"
6318 );
6319 assert_eq!(asked.len(), 3, "the budget is a budget");
6320 assert!(
6321 !asked.contains(&root.join("b")),
6322 "a directory already looked into is not asked again"
6323 );
6324 assert!(
6325 !asked.contains(&root.join("c")),
6326 "nor one with a request already out"
6327 );
6328 }
6329
6330 #[test]
6331 fn a_measurement_that_counted_nothing_keeps_the_count_a_row_already_has() {
6332 let path = std::path::PathBuf::from("/data/warehouse/orders");
6333 let mut row = Entry::for_test(&path, "orders");
6334 row.kind = EntryKind::Directory;
6335 row.holds = counted(15);
6336
6337 let mut home = HomeState::default();
6338 home.sections.push(Section::titled("Here", vec![row]));
6339 home.enriched.insert(
6341 path,
6342 Measured {
6343 kind: Some(EntryKind::Directory),
6344 ..Default::default()
6345 },
6346 );
6347 home.apply_measurements();
6348
6349 assert_eq!(
6350 home.sections[0].rows[0].holds.label(),
6351 "15 parquet",
6352 "a measurement with nothing to say erased the label"
6353 );
6354 }
6355}
6356
6357#[cfg(test)]
6358mod look_into_batch_tests {
6359 use super::*;
6360 use polars::prelude::*;
6361
6362 #[test]
6365 fn every_kind_is_sent_before_any_count() {
6366 let dir = tempfile::tempdir().unwrap();
6367 let cache_dir = tempfile::tempdir().unwrap();
6368 let cache = crate::cache::CacheManager::with_dir(cache_dir.path().to_path_buf());
6369 let mut rows = Vec::new();
6370 for name in ["a", "b"] {
6371 let partition = dir.path().join(name).join("year=2024");
6372 std::fs::create_dir_all(&partition).unwrap();
6373 let mut frame = df!("x" => [1i32, 2, 3]).unwrap();
6374 let file = std::fs::File::create(partition.join("part.parquet")).unwrap();
6375 ParquetWriter::new(file).finish(&mut frame).unwrap();
6376 rows.push(Entry::new(dir.path().join(name), EntryKind::Unknown));
6377 }
6378
6379 let mut sent = Vec::new();
6380 look_into_batch(
6381 rows,
6382 &cache,
6383 &Default::default(),
6384 Reads::Files,
6385 |path, m| {
6386 let name = path.file_name().unwrap().to_string_lossy().into_owned();
6387 sent.push((name, m.kind, m.rows));
6388 },
6389 );
6390
6391 let hive = Some(EntryKind::Hive);
6392 assert_eq!(
6393 sent,
6394 vec![
6395 ("a".to_string(), hive, None),
6396 ("b".to_string(), hive, None),
6397 ("a".to_string(), hive, Some(3)),
6398 ("b".to_string(), hive, Some(3)),
6399 ]
6400 );
6401 }
6402}
6403
6404#[cfg(test)]
6405mod known_facts_tests {
6406 use super::*;
6407 use crate::cache::DatasetFacts;
6408
6409 #[test]
6417 fn what_a_directory_holds_is_restored_beside_its_kind() {
6418 let holds = crate::home::discover::Holds {
6419 formats: vec![("parquet".to_string(), 15)],
6420 ..Default::default()
6421 };
6422 for (path, remote) in [
6423 (
6424 std::path::PathBuf::from("s3://bucket/warehouse/orders"),
6425 true,
6426 ),
6427 (std::path::PathBuf::from("/data/warehouse/orders"), false),
6428 ] {
6429 let facts = DatasetFacts {
6430 mtime: 0,
6431 size: 4096,
6432 rows: Some(999),
6435 cols: Some(72),
6436 cols_sampled: false,
6437 columns: vec!["lat".to_string()],
6438 kind: Some(EntryKind::Directory),
6441 classified_by: crate::home::discover::CLASSIFIER_VERSION,
6442 holds: holds.clone(),
6443 cost: Default::default(),
6444 };
6445 let mut row = Entry::directory(&path);
6446 row.kind = EntryKind::Unknown;
6447 row.modified = Some(std::time::UNIX_EPOCH);
6448 assert_eq!(row.size, None);
6451 let index = std::collections::HashMap::from([(path.clone(), facts)]);
6452
6453 apply_known_facts(&mut row, &index, remote);
6454 assert_eq!(row.kind, EntryKind::Directory, "{path:?}");
6455 assert_eq!(row.label(), "15 parquet", "{path:?}");
6456 if !remote {
6462 assert_eq!(row.rows, None, "{path:?}");
6463 assert_eq!(row.cols, None, "{path:?}");
6464 assert_eq!(row.size, None, "{path:?}");
6465 assert!(row.columns.is_empty(), "{path:?}");
6466 }
6467 }
6468 }
6469
6470 #[test]
6475 fn a_datasets_counts_are_measured_rather_than_restored() {
6476 let path = std::path::PathBuf::from("/data/warehouse/events");
6477 let facts = DatasetFacts {
6478 mtime: 0,
6479 size: 4096,
6480 rows: Some(1_200_000),
6481 cols: Some(58),
6482 cols_sampled: false,
6483 columns: vec!["ts".to_string()],
6484 kind: Some(EntryKind::MultiFile),
6485 classified_by: crate::home::discover::CLASSIFIER_VERSION,
6486 holds: crate::home::discover::Holds {
6487 formats: vec![("parquet".to_string(), 15)],
6488 ..Default::default()
6489 },
6490 cost: Default::default(),
6491 };
6492 let mut row = Entry::directory(&path);
6493 row.kind = EntryKind::Unknown;
6494 row.modified = Some(std::time::UNIX_EPOCH);
6495 let index = std::collections::HashMap::from([(path.clone(), facts)]);
6496
6497 apply_known_facts(&mut row, &index, false);
6498 assert_eq!(row.kind, EntryKind::MultiFile, "the kind comes back");
6499 assert_eq!(row.label(), "15 parquet", "and what it holds");
6500 assert_eq!(
6501 row.rows, None,
6502 "but not the count: the measuring pass skips a row that has one"
6503 );
6504 assert_eq!(row.cols, None);
6505 }
6506
6507 #[test]
6516 fn a_kind_from_an_older_classifier_is_not_restored() {
6517 let remote = std::path::PathBuf::from("s3://bucket/warehouse/orders");
6518 let facts = |classified_by| DatasetFacts {
6519 mtime: 0,
6520 size: 4096,
6521 rows: Some(1_000),
6522 cols: Some(7),
6523 cols_sampled: false,
6524 columns: vec!["id".into(), "amount".into()],
6525 kind: Some(EntryKind::MultiFile),
6526 classified_by,
6527 cost: Default::default(),
6528 holds: Default::default(),
6529 };
6530 let unprobed = || {
6531 let mut row = Entry::directory(&remote);
6532 row.kind = EntryKind::Unknown;
6533 row
6534 };
6535
6536 let index = |classified_by| {
6537 std::collections::HashMap::from([(remote.clone(), facts(classified_by))])
6538 };
6539
6540 let mut row = unprobed();
6541 apply_known_facts(
6542 &mut row,
6543 &index(crate::home::discover::CLASSIFIER_VERSION),
6544 true,
6545 );
6546 assert_eq!(
6547 row.kind,
6548 EntryKind::MultiFile,
6549 "this build's own answer comes back"
6550 );
6551
6552 let mut row = unprobed();
6553 apply_known_facts(&mut row, &index(0), true);
6554 assert_eq!(
6555 row.kind,
6556 EntryKind::Unknown,
6557 "an older build's does not: it may be a lake table this one would recognize"
6558 );
6559 assert_eq!(
6560 row.rows,
6561 Some(1_000),
6562 "but what it measured is still measured"
6563 );
6564 assert_eq!(row.columns, vec!["id".to_string(), "amount".to_string()]);
6565 }
6566}
6567
6568#[cfg(test)]
6569mod build_feature_tests {
6570 use super::*;
6571
6572 #[test]
6575 fn the_builtin_catalog_lists_only_what_this_build_opens() {
6576 let urls: Vec<String> = catalogs(&crate::config::AppConfig::default())
6577 .into_iter()
6578 .filter(|c| c.origin == crate::home::catalog::Origin::Bundled)
6579 .flat_map(|c| c.datasets)
6580 .map(|d| d.location.to_string_lossy().into_owned())
6581 .collect();
6582 let web = urls.iter().filter(|u| u.starts_with("https://")).count();
6583 let stores = urls
6584 .iter()
6585 .filter(|u| is_object_store_url(Path::new(u)))
6586 .count();
6587 assert_eq!(web + stores, urls.len(), "{urls:?}");
6588 assert_eq!(web > 0, cfg!(feature = "http"), "{urls:?}");
6589 assert_eq!(stores > 0, cfg!(feature = "cloud"), "{urls:?}");
6590 }
6591
6592 #[test]
6595 fn an_empty_examples_toml_hides_the_section() {
6596 let mut config = crate::config::AppConfig::default();
6597 assert_eq!(
6599 catalogs(&config)
6600 .iter()
6601 .any(|c| c.origin == crate::home::catalog::Origin::Bundled),
6602 cfg!(any(feature = "http", feature = "cloud"))
6603 );
6604 config.read_catalogs = vec![
6605 crate::home::catalog::parse(
6606 "label = \"Mine\"\n",
6607 crate::home::catalog::EXAMPLES,
6608 crate::home::catalog::Origin::Folder,
6609 None,
6610 )
6611 .unwrap(),
6612 ];
6613 assert!(catalogs(&config).is_empty(), "{:?}", catalogs(&config));
6614 }
6615
6616 #[test]
6619 fn a_users_catalog_is_shown_whole() {
6620 let mut config = crate::config::AppConfig::default();
6621 let mine = crate::home::catalog::parse(
6622 r#"
6623 [bucket]
6624 name = "Bucket"
6625 url = "s3://bucket/prefix/"
6626 [web]
6627 name = "Web"
6628 url = "https://example.com/data.csv"
6629 "#,
6630 crate::home::catalog::MINE,
6631 crate::home::catalog::Origin::Mine,
6632 None,
6633 )
6634 .unwrap();
6635 config.read_catalogs = vec![mine];
6636 let shown = catalogs(&config);
6637 let mine = shown.iter().find(|c| c.id == "mine").unwrap();
6638 assert_eq!(mine.datasets.len(), 2);
6639 assert_eq!(mine.label, crate::home::catalog::MINE_LABEL);
6640 }
6641}
6642
6643#[cfg(test)]
6644mod place_tests {
6645 use super::same_place;
6646 use std::path::Path;
6647
6648 #[test]
6649 fn local_paths_are_one_place_however_spelled() {
6650 assert!(same_place(
6651 Path::new("/data/./sales/"),
6652 Path::new("/data/sales")
6653 ));
6654 assert!(!same_place(
6655 Path::new("/data/sales"),
6656 Path::new("/data/sale")
6657 ));
6658 assert!(same_place(
6659 Path::new("s3://bucket/dir/"),
6660 Path::new("s3://bucket/dir")
6661 ));
6662 if cfg!(windows) {
6663 assert!(same_place(
6664 Path::new("c:/data/sales.csv"),
6665 Path::new(r"C:\data\sales.csv")
6666 ));
6667 }
6668 }
6669}