1pub mod catalog;
15pub mod codebook;
16pub mod discover;
17pub mod fuzzy;
18pub(crate) mod home_app;
19pub(crate) mod home_keys;
20pub mod home_preview;
21pub mod locality;
22pub mod search;
23
24use crate::home::discover::{Entry, EntryKind};
25use std::path::{Path, PathBuf};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum RootOrigin {
30 Cwd,
31 Desktop,
33}
34
35impl RootOrigin {
36 pub fn note(self) -> &'static str {
37 match self {
38 RootOrigin::Cwd => "current directory",
39 RootOrigin::Desktop => "opened elsewhere",
40 }
41 }
42}
43
44pub fn desktop_recent_dirs() -> Vec<PathBuf> {
49 let Some(data_dir) = dirs::data_dir() else {
50 return Vec::new();
51 };
52 let path = data_dir.join("recently-used.xbel");
53 let Ok(contents) = std::fs::read_to_string(&path) else {
54 return Vec::new();
55 };
56 dirs_from_xbel(&contents)
57}
58
59pub fn dirs_from_xbel(contents: &str) -> Vec<PathBuf> {
62 const PREFIX: &str = "href=\"file://";
63 let mut dirs: Vec<PathBuf> = Vec::new();
64
65 for chunk in contents.split(PREFIX).skip(1) {
66 let Some(end) = chunk.find('"') else { continue };
67 let decoded = percent_decode(&chunk[..end]);
68 let file = PathBuf::from(decoded);
69 if !crate::home::discover::is_data_file(&file) || !file.is_file() {
71 continue;
72 }
73 let Some(parent) = file.parent() else {
74 continue;
75 };
76 if parent.as_os_str().is_empty() {
77 continue;
78 }
79 let parent = parent.to_path_buf();
80 if !dirs.contains(&parent) {
81 dirs.push(parent);
82 }
83 }
84
85 dirs
86}
87
88fn percent_decode(raw: &str) -> String {
90 let bytes = raw.as_bytes();
91 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
92 let mut i = 0;
93 while i < bytes.len() {
94 if bytes[i] == b'%' && i + 2 < bytes.len() {
95 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
96 if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
97 out.push(byte);
98 i += 3;
99 continue;
100 }
101 }
102 out.push(bytes[i]);
103 i += 1;
104 }
105 String::from_utf8_lossy(&out).into_owned()
106}
107
108pub fn object_place_label(path: &Path) -> Option<&'static str> {
112 if cloud_source_id(path).is_some() {
113 return Some("source");
114 }
115 if cloud_account(path).is_some() {
116 return Some("account");
117 }
118 let text = path.to_string_lossy();
119 if let Some((_, _, key)) = crate::cloud::source::azure_parts(&text) {
120 return key.trim_matches('/').is_empty().then_some("container");
121 }
122 let (scheme, rest) = text.split_once("://")?;
123 if !matches!(scheme, "s3" | "s3a" | "gs" | "gcs") {
124 return None;
125 }
126 let rest = rest.trim_end_matches('/');
127 if rest.is_empty() {
128 return None;
129 }
130 (!rest.contains('/')).then_some("bucket")
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum CloudLook {
136 Waiting,
138 Looking,
140 Failed,
142}
143
144#[derive(Debug, Default, PartialEq)]
146pub struct RowLabel {
147 pub short: String,
149 pub words: String,
151 pub curated: bool,
153 pub source: bool,
155 pub missing_source: bool,
157}
158
159pub fn describe(
163 entry: &Entry,
164 place_kind: Option<&'static str>,
165 look: Option<CloudLook>,
166 frame: usize,
167 known_sources: Option<&[crate::config::CloudConnectionConfig]>,
168) -> RowLabel {
169 if entry.opens_whole_directory {
172 return RowLabel::default();
173 }
174 let g = crate::glyphs::get();
175 let curated =
178 place_kind.filter(|_| matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown));
179 let look_glyph = look.map(|look| match look {
180 CloudLook::Waiting => g.ellipsis,
181 CloudLook::Looking => g.spinner[frame % g.spinner.len()],
182 CloudLook::Failed => "?",
183 });
184 let short = match curated {
185 Some(word) => word.to_string(),
186 None if entry.kind == EntryKind::Directory && entry.holds.formats.is_empty() => {
188 object_place_label(&entry.path)
189 .or(look_glyph)
190 .map(str::to_string)
191 .unwrap_or_else(|| entry.label().into_owned())
192 }
193 None => entry.label().into_owned(),
194 };
195 let path_text = entry.path.to_string_lossy();
198 let named = crate::cloud::source::split_source_id(&path_text).0;
199 let missing_source = named
200 .is_some_and(|id| known_sources.is_some_and(|known| !known.iter().any(|k| k.name == id)));
201 let (short, source) = match (named, known_sources) {
202 (Some(id), Some(_)) if short.is_empty() && missing_source => {
203 (format!("source not found: {id}"), true)
204 }
205 (Some(id), Some(_)) if short.is_empty() => (id.to_string(), true),
206 _ if short.is_empty() && entry.kind == EntryKind::Unknown => {
208 (g.ellipsis.to_string(), false)
209 }
210 _ => (short, false),
211 };
212 let words = match (&entry.table, entry.kind) {
213 (Some(table), _) => {
214 let of = match (&entry.format_spec, table.format) {
215 (Some(spec), _) => spec.clone(),
216 (None, Some(format)) => format.name().to_string(),
217 (None, None) => String::new(),
218 };
219 format!("{of} {}", table.kind).trim_start().to_string()
220 }
221 (None, _) if curated.is_some() => curated.unwrap_or_default().to_string(),
222 (None, EntryKind::File) => match (
223 &entry.format_spec,
224 crate::FileFormat::from_path(&entry.path),
225 ) {
226 (Some(spec), _) => format!("{spec} file"),
227 (None, Some(format)) => format!("{} file", format.name()),
228 (None, None) => "data file".to_string(),
230 },
231 (None, EntryKind::Hive) => "hive table".to_string(),
232 (None, EntryKind::MultiFile) => "multi-file table".to_string(),
233 (None, kind) if kind.is_lake_table() => {
234 format!(
235 "{} table",
236 kind.lake_name().unwrap_or_default().to_lowercase()
237 )
238 }
239 (None, EntryKind::Directory) => match look {
240 Some(CloudLook::Failed) => format!("? {} listing failed, Ctrl+R retries", g.middot),
241 Some(_) => String::new(),
243 None => object_place_label(&entry.path)
244 .unwrap_or("directory")
245 .to_string(),
246 },
247 _ => String::new(),
248 };
249 RowLabel {
250 short,
251 words,
252 curated: curated.is_some(),
253 source,
254 missing_source,
255 }
256}
257
258pub const CLOUD_PLACE: &str = "cloud://";
261
262const SCROLL_MARGIN: usize = 2;
264
265pub(crate) fn settle_top(top: usize, selected: usize, height: usize, total: usize) -> usize {
269 if height == 0 {
270 return top.min(selected);
271 }
272 let margin = SCROLL_MARGIN.min((height - 1) / 2);
274 let top = if selected < top + margin {
275 selected.saturating_sub(margin)
276 } else if selected + margin >= top + height {
277 selected + margin + 1 - height
278 } else {
279 top
280 };
281 top.min(total.saturating_sub(height))
282}
283
284pub fn cloud_place(id: &str) -> PathBuf {
286 PathBuf::from(format!("{CLOUD_PLACE}{id}"))
287}
288
289pub fn cloud_source_id(path: &Path) -> Option<String> {
291 let text = path.to_string_lossy();
292 let id = text.strip_prefix(CLOUD_PLACE)?.trim_end_matches('/');
293 (!id.is_empty() && !id.contains('/')).then(|| id.to_string())
294}
295
296pub fn cloud_account(path: &Path) -> Option<(String, String)> {
299 let text = path.to_string_lossy();
300 let rest = text.strip_prefix(CLOUD_PLACE)?.trim_end_matches('/');
301 let (id, account) = rest.split_once('/')?;
302 (!id.is_empty() && !account.is_empty() && !account.contains('/'))
303 .then(|| (id.to_string(), account.to_string()))
304}
305
306fn within(url: &str, root: &str) -> bool {
308 #[cfg(feature = "cloud")]
309 {
310 crate::cloud::cloud_sources::is_within(url, root)
311 }
312 #[cfg(not(feature = "cloud"))]
313 {
314 let (url, root) = (url.trim_end_matches('/'), root.trim_end_matches('/'));
315 url == root || url.strip_prefix(root).is_some_and(|r| r.starts_with('/'))
316 }
317}
318
319fn same_place(a: &Path, b: &Path) -> bool {
322 place_key(a) == place_key(b)
323}
324
325fn place_key(path: &Path) -> String {
327 let text = path.to_string_lossy();
328 if !text.contains("://") {
331 return crate::config::path_place(path).display().to_string();
332 }
333 #[cfg(feature = "cloud")]
334 {
335 crate::cloud::source::canonical_cloud_place(&text)
336 }
337 #[cfg(not(feature = "cloud"))]
338 {
339 text.trim_end_matches('/').to_string()
340 }
341}
342
343#[derive(Debug, Default)]
345pub struct CatalogPlaces {
346 datasets: std::collections::HashMap<String, (usize, usize)>,
348 bookmarks: std::collections::HashMap<String, (usize, usize, usize)>,
350 counted: (usize, usize),
353}
354
355impl CatalogPlaces {
356 fn of(catalogs: &[ShownCatalog]) -> Self {
357 let mut places = CatalogPlaces {
358 counted: Self::count(catalogs),
359 ..Default::default()
360 };
361 for (c, catalog) in catalogs.iter().enumerate() {
362 for (d, dataset) in catalog.datasets.iter().enumerate() {
363 places
364 .datasets
365 .entry(place_key(&dataset.location))
366 .or_insert((c, d));
367 for (b, (_, place)) in dataset.bookmarks.iter().enumerate() {
368 places
369 .bookmarks
370 .entry(place_key(place))
371 .or_insert((c, d, b));
372 }
373 }
374 }
375 places
376 }
377
378 fn count(catalogs: &[ShownCatalog]) -> (usize, usize) {
379 let datasets = catalogs.iter().flat_map(|c| c.datasets.iter());
380 (
381 datasets.clone().count(),
382 datasets.map(|d| d.bookmarks.len()).sum(),
383 )
384 }
385
386 fn indexes(&self, catalogs: &[ShownCatalog]) -> bool {
388 self.counted == Self::count(catalogs)
389 }
390}
391
392fn within_rest(url: &str, root: &str) -> String {
394 let canonical = |u: &str| match crate::cloud::source::azure_parts(u) {
395 Some((account, container, path)) => {
396 crate::cloud::source::azure_url(&account, &container, &path)
397 }
398 None => u.to_string(),
399 };
400 let (url, root) = (canonical(url), canonical(root));
401 url.strip_prefix(root.trim_end_matches('/'))
402 .unwrap_or("")
403 .to_string()
404}
405
406pub fn is_object_store_url(path: &Path) -> bool {
408 let text = path.to_string_lossy();
409 let scheme = text
410 .split_once("://")
411 .map(|(s, _)| s.to_ascii_lowercase())
412 .unwrap_or_default();
413 matches!(scheme.as_str(), "s3" | "s3a" | "gs" | "gcs")
414 || crate::cloud::source::azure_parts(&text).is_some()
415}
416
417pub fn directory_dataset_url(path: &Path) -> PathBuf {
420 let text = path.to_string_lossy();
421 if text.ends_with('/') {
422 path.to_path_buf()
423 } else {
424 PathBuf::from(format!("{text}/"))
425 }
426}
427
428fn whole_directory_row(dir: &Path, rows: &[Entry], remote: bool) -> Option<Entry> {
432 if cloud_account(dir).is_some() {
435 return None;
436 }
437 let directories: Vec<String> = rows
438 .iter()
439 .filter(|r| !matches!(r.kind, EntryKind::File | EntryKind::Other))
440 .map(|r| format!("{}/", r.name))
441 .collect();
442 let objects: Vec<(String, u64)> = rows
443 .iter()
444 .filter(|r| matches!(r.kind, EntryKind::File | EntryKind::Other))
445 .map(|r| (r.path.to_string_lossy().into_owned(), r.size.unwrap_or(1)))
446 .collect();
447 let (kind, holds) = if remote || is_object_store_url(dir) {
452 #[cfg(feature = "cloud")]
453 {
454 crate::cloud::cloud_browse::look_at_listing(
455 &dir.to_string_lossy(),
456 &directories,
457 &objects,
458 )
459 }
460 #[cfg(not(feature = "cloud"))]
464 {
465 let _ = (&directories, &objects);
466 (EntryKind::Unknown, Default::default())
467 }
468 } else {
469 crate::home::discover::look_at_directory(dir)
470 };
471 let openable_row = rows.iter().any(|r| r.kind != EntryKind::Other);
476 if !openable_row && holds_nothing_to_open(&holds) {
477 return None;
478 }
479 let mut entry = Entry::directory(&directory_dataset_url(dir));
480 entry.kind = kind;
481 entry.holds = holds;
484 entry.opens_whole_directory = true;
485 entry.name = door_name(&entry, rows);
486 Some(entry)
487}
488
489fn door_base_name(dir: &Path) -> String {
493 let text = dir.to_string_lossy();
494 if let Some((_, container, key)) = crate::cloud::source::azure_parts(&text) {
495 let leaf = key.trim_matches('/').rsplit('/').next().unwrap_or("");
496 if leaf.is_empty() {
497 container
498 } else {
499 leaf.to_string()
500 }
501 } else {
502 let (_, plain) = crate::cloud::source::split_source_id(&text);
503 std::path::Path::new(plain.as_ref())
504 .file_name()
505 .map(|n| n.to_string_lossy().into_owned())
506 .unwrap_or_else(|| plain.into_owned())
507 }
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub enum DoorKind {
513 Hive,
515 OneSchema,
517 SchemasDiffer,
519 Single,
521 Lake,
523 Mixed,
525 Unknown,
527}
528
529pub fn door_kind(door: &Entry) -> DoorKind {
533 let holds = &door.holds;
534 match door.kind {
535 EntryKind::Hive => DoorKind::Hive,
536 EntryKind::MultiFile => DoorKind::OneSchema,
537 k if k.is_lake_table() => DoorKind::Lake,
538 EntryKind::Unknown => DoorKind::Unknown,
539 _ => {
540 let Some(format) = holds.one_format() else {
541 return DoorKind::Mixed;
542 };
543 if holds.directories > 0 {
544 return DoorKind::Mixed;
545 }
546 let files = holds.data_files();
547 if files == 1 {
548 return DoorKind::Single;
549 }
550 let mostly_data = files * 2 >= files + holds.not_read + holds.unnamed;
551 let reads_many = crate::FileFormat::from_name(format)
552 .is_some_and(crate::FileFormat::reads_many_files);
553 if mostly_data && reads_many {
554 DoorKind::SchemasDiffer
555 } else {
556 DoorKind::Mixed
557 }
558 }
559 }
560}
561
562pub fn door_lands(door: &Entry) -> bool {
566 matches!(door_kind(door), DoorKind::Hive | DoorKind::OneSchema)
567}
568
569fn format_title(name: &str) -> String {
571 crate::FileFormat::from_name(name)
572 .map_or_else(|| name.to_ascii_uppercase(), |f| f.title().to_string())
573}
574
575fn door_keys(door: &Entry, rows: &[Entry]) -> Vec<String> {
578 if let Some(layout) = door.cost.partitions.as_ref()
579 && !layout.keys.is_empty()
580 {
581 return layout.keys.clone();
582 }
583 let mut keys: Vec<String> = Vec::new();
584 for row in rows {
585 if let Some((key, _)) = row.name.split_once('=')
586 && !key.is_empty()
587 && !keys.iter().any(|k| k == key)
588 {
589 keys.push(key.to_string());
590 }
591 }
592 keys
593}
594
595pub fn door_name(door: &Entry, rows: &[Entry]) -> String {
597 let name = door_base_name(&door.path);
598 let holds = &door.holds;
599 let more = if holds.truncated { "+" } else { "" };
600 let files = |format: &str| {
601 let count = holds.data_files();
602 let word = if count == 1 { "file" } else { "files" };
603 format!("{count}{more} {} {word}", format_title(format))
604 };
605 let what = match door_kind(door) {
606 DoorKind::Hive => {
607 let keys = door_keys(door, rows);
608 if keys.is_empty() {
609 "hive table".to_string()
610 } else {
611 format!("hive table: {}", keys.join(", "))
612 }
613 }
614 DoorKind::OneSchema => match (holds.model_weights(), holds.one_format()) {
615 (Some((format, count)), _) => {
616 let word = if count == 1 { "file" } else { "files" };
617 format!("model, {count}{more} {} {word}", format_title(format))
618 }
619 (None, Some(format)) => format!("{}, one schema", files(format)),
620 (None, None) => "one table".to_string(),
621 },
622 DoorKind::SchemasDiffer => {
623 format!(
624 "{}, schemas differ",
625 files(holds.one_format().unwrap_or(""))
626 )
627 }
628 DoorKind::Single => files(holds.one_format().unwrap_or("")),
629 DoorKind::Lake => format!(
630 "{} files, not the table",
631 door.kind.lake_name().unwrap_or_default()
632 ),
633 DoorKind::Mixed => "all files, mixed".to_string(),
634 DoorKind::Unknown => "all files".to_string(),
635 };
636 format!("{name} ({what})")
637}
638
639pub fn door_reads(door: &Entry) -> Option<(String, Option<String>)> {
644 if !matches!(door_kind(door), DoorKind::Mixed | DoorKind::Single) {
645 return None;
646 }
647 let holds = &door.holds;
648 let more = if holds.truncated { "+" } else { "" };
649 let plain = holds.directories.saturating_sub(holds.partitions);
650 let directories = |n: usize| {
651 let word = if n == 1 { "directory" } else { "directories" };
652 format!("{n}{more} {word}")
653 };
654 let Some((format, count)) = holds.formats.first() else {
655 return (holds.directories > 0).then(|| ("every Parquet file below".to_string(), None));
656 };
657 let remote = is_object_store_url(&door.path);
659 let below = remote || holds.partitions > 0 || (holds.formats.len() == 1 && format == "parquet");
660 let below = below && holds.directories > 0;
661 let reads = if below && remote {
662 format!("every {format} file below")
663 } else if below {
664 "every Parquet file below".to_string()
665 } else {
666 format!("{count}{more} {format}")
667 };
668 let mut skips: Vec<String> = holds
669 .formats
670 .iter()
671 .skip(1)
672 .map(|(name, n)| format!("{n}{more} {name}"))
673 .collect();
674 if !below && plain > 0 {
675 skips.push(directories(plain));
676 }
677 Some((reads, (!skips.is_empty()).then(|| skips.join(", "))))
678}
679
680pub fn name_by_spec(formats: &crate::formats::Registry, rows: &mut [Entry]) {
683 if formats.is_empty() {
684 return;
685 }
686 let mut named = false;
687 for row in rows
688 .iter_mut()
689 .filter(|r| r.kind == EntryKind::Other && r.format_spec.is_none())
690 {
691 if let Some(spec) = formats.by_glob(&row.path, false).first() {
692 discover::name_spec_file(row, spec);
693 named = true;
694 }
695 }
696 for row in rows.iter_mut().filter(|r| {
698 r.kind == EntryKind::File
699 && r.format_spec.is_none()
700 && discover::data_format(&r.path).is_some_and(|f| f.separator().is_some())
701 }) {
702 if let Some(spec) = formats
703 .by_glob(&row.path, false)
704 .into_iter()
705 .find(|s| s.is_delimited())
706 {
707 row.format_spec = Some(spec.name.clone());
708 }
709 }
710 if named {
712 discover::sort_entries(rows);
713 }
714}
715
716pub fn holds_nothing_to_open(holds: &discover::Holds) -> bool {
719 holds.formats.is_empty() && holds.directories == 0 && holds.unnamed == 0
720}
721
722pub fn is_cloud_place(path: &Path) -> bool {
724 path.to_string_lossy().starts_with(CLOUD_PLACE)
725}
726
727fn is_bucket_root(path: &Path) -> bool {
730 let text = path.to_string_lossy();
731 if let Some((_, _, key)) = crate::cloud::source::azure_parts(&text) {
732 return key.trim_matches('/').is_empty();
733 }
734 let Some((scheme, rest)) = text.split_once("://") else {
735 return false;
736 };
737 matches!(scheme, "s3" | "s3a" | "gs" | "gcs") && {
738 let rest = rest.trim_end_matches('/');
739 !rest.is_empty() && !rest.contains('/')
740 }
741}
742
743pub fn parent_location(path: &Path) -> Option<PathBuf> {
746 if !matches!(
747 crate::cloud::source::input_source(path),
748 crate::cloud::source::InputSource::Local(_)
749 ) {
750 let s = path.to_string_lossy();
751 let (scheme, rest) = s.split_once("://")?;
752 let (up, _) = rest.trim_end_matches('/').rsplit_once('/')?;
753 return Some(PathBuf::from(format!("{scheme}://{up}")));
754 }
755 path.parent()
756 .filter(|p| !p.as_os_str().is_empty() && *p != path)
757 .map(Path::to_path_buf)
758}
759
760pub fn is_remote_path(path: &Path) -> bool {
764 is_cloud_place(path)
765 || !matches!(
766 crate::cloud::source::input_source(path),
767 crate::cloud::source::InputSource::Local(_)
768 )
769 || is_network_path(path)
770}
771
772pub fn is_network_path(path: &Path) -> bool {
776 crate::home::locality::Mounts::cached().is_network(path)
777}
778
779#[doc(hidden)]
782pub fn network_fs_for_test(mountinfo: &str, path: &Path) -> bool {
783 crate::home::locality::Mounts::parse(mountinfo).is_network(path)
784}
785
786#[derive(Debug, Clone)]
788pub struct Root {
789 pub path: PathBuf,
790 pub origin: RootOrigin,
791 pub network: bool,
793 pub available: bool,
796}
797
798#[derive(Debug, Clone, Default)]
800pub struct Section {
801 pub title: String,
802 pub subtitle: Option<String>,
805 pub origin: Option<&'static str>,
808 pub root: Option<PathBuf>,
811 pub rows: Vec<Entry>,
812 pub door: Option<Entry>,
817 pub unavailable: bool,
819 pub unavailable_note: Option<String>,
822 pub folded_by_default: bool,
825 pub remote_root: Option<PathBuf>,
827 pub waiting: bool,
829 pub grouped_by_place: bool,
832 pub place_labels: std::collections::HashMap<PathBuf, String>,
835}
836
837impl Section {
838 pub fn titled(title: impl Into<String>, rows: Vec<Entry>) -> Self {
840 Section {
841 title: title.into(),
842 rows,
843 ..Default::default()
844 }
845 }
846}
847
848#[derive(Debug, Clone, PartialEq, Eq, Default)]
850pub enum CloudStatus {
851 #[default]
853 Listing,
854 Unlisted,
857 Listed,
859 Failed { short: String, detail: String },
862}
863
864#[derive(Debug, Clone, Default, PartialEq, Eq)]
868pub struct CloudSource {
869 pub id: String,
871 pub label: String,
873 pub api: crate::cloud::source::ProviderKind,
875 pub note: String,
877 pub buckets: Vec<PathBuf>,
879 pub status: CloudStatus,
880 pub listed_at: Option<std::time::SystemTime>,
882 pub refreshing: bool,
884 pub asked: bool,
886 pub details: Vec<(String, String)>,
888 pub place_details: std::collections::HashMap<PathBuf, Vec<(String, String)>>,
891}
892
893impl CloudSource {
894 pub fn count_text(&self) -> String {
896 match &self.status {
897 CloudStatus::Failed { short, .. } if self.buckets.is_empty() => short.clone(),
898 CloudStatus::Listing if self.buckets.is_empty() => String::new(),
899 CloudStatus::Unlisted if self.buckets.is_empty() => "not listed".to_string(),
900 _ => {
901 let (one, many) = match self.api {
902 crate::cloud::source::ProviderKind::Azure => ("account", "accounts"),
903 crate::cloud::source::ProviderKind::Gcs => ("project", "projects"),
904 crate::cloud::source::ProviderKind::S3 => ("bucket", "buckets"),
905 };
906 match self.buckets.len() {
907 0 => format!("no {many}"),
908 1 => format!("1 {one}"),
909 n => format!("{n} {many}"),
910 }
911 }
912 }
913 }
914
915 pub fn begin_listing(&mut self) {
918 self.asked = true;
919 if self.status == CloudStatus::Unlisted && self.buckets.is_empty() {
920 self.status = CloudStatus::Listing;
921 } else {
922 self.refreshing = true;
923 }
924 }
925
926 pub fn busy(&self) -> bool {
928 self.refreshing || (self.status == CloudStatus::Listing && self.buckets.is_empty())
929 }
930
931 pub fn failed(&self) -> bool {
933 matches!(self.status, CloudStatus::Failed { .. })
934 }
935}
936
937#[derive(Debug, Clone, PartialEq)]
940pub struct ShownCatalog {
941 pub id: String,
943 pub label: String,
945 pub origin: crate::home::catalog::Origin,
947 pub description: String,
949 pub file: Option<PathBuf>,
951 pub datasets: Vec<ShownDataset>,
952 pub broken: Option<String>,
954}
955
956#[derive(Debug, Clone, Default, PartialEq)]
958pub struct ShownDataset {
959 pub name: String,
961 pub location: PathBuf,
963 pub details: Vec<(String, String)>,
965 pub size: Option<u64>,
967 pub codebook: Option<std::sync::Arc<crate::home::codebook::Codebook>>,
969 pub bookmarks: Vec<(String, PathBuf)>,
971 pub entry: std::sync::Arc<crate::home::catalog::Dataset>,
973}
974
975impl ShownCatalog {
976 pub fn from_catalog(catalog: &crate::home::catalog::Catalog) -> Self {
978 Self {
979 id: catalog.id.clone(),
980 label: catalog.label.clone(),
981 origin: catalog.origin,
982 description: catalog.description.clone(),
983 file: catalog.file.clone(),
984 datasets: catalog
985 .datasets
986 .iter()
987 .map(|dataset| {
988 let location = dataset.location();
989 let mut details: Vec<(String, String)> = [
990 ("about", &dataset.description),
991 ("publisher", &dataset.publisher),
992 ("license", &dataset.license),
993 ("homepage", &dataset.homepage),
994 ("documentation", &dataset.documentation),
995 ]
996 .into_iter()
997 .filter(|(_, value)| !value.is_empty())
998 .map(|(key, value)| (key.to_string(), value.clone()))
999 .collect();
1000 match &dataset.url {
1001 None => details.push(("path".to_string(), display_path(&location))),
1002 Some(url) => {
1003 details.push(("url".to_string(), url.clone()));
1004 details.push(("login".to_string(), login_of(dataset)));
1005 }
1006 }
1007 ShownDataset {
1008 name: dataset.name.clone(),
1009 location,
1010 details,
1011 size: dataset.size,
1012 codebook: crate::home::codebook::Codebook::of(dataset)
1013 .map(std::sync::Arc::new),
1014 bookmarks: dataset
1015 .bookmarks
1016 .iter()
1017 .map(|(name, path)| (name.clone(), dataset.bookmark_location(path)))
1018 .collect(),
1019 entry: std::sync::Arc::new(dataset.clone()),
1020 }
1021 })
1022 .collect(),
1023 broken: None,
1024 }
1025 }
1026
1027 pub fn from_broken(broken: &crate::home::catalog::Broken) -> Self {
1029 Self {
1030 id: broken.id.clone(),
1031 label: broken.id.clone(),
1032 origin: broken.origin,
1033 description: String::new(),
1034 file: None,
1035 datasets: Vec::new(),
1036 broken: Some(broken.callout()),
1037 }
1038 }
1039
1040 pub fn origin_note(&self) -> &'static str {
1043 match self.origin {
1044 crate::home::catalog::Origin::Mine => "catalog.toml",
1045 crate::home::catalog::Origin::Listed | crate::home::catalog::Origin::Folder => {
1046 "catalog"
1047 }
1048 crate::home::catalog::Origin::Bundled => BUNDLED_ORIGIN,
1049 }
1050 }
1051}
1052
1053pub const BUNDLED_ORIGIN: &str = "comes with datui";
1055
1056pub const CATALOG_ORIGINS: [&str; 3] = ["catalog.toml", "catalog", BUNDLED_ORIGIN];
1058
1059pub fn is_catalog_origin(origin: &str) -> bool {
1061 CATALOG_ORIGINS.contains(&origin)
1062}
1063
1064pub fn login_of(dataset: &crate::home::catalog::Dataset) -> String {
1066 match dataset.object_store_auth() {
1067 Some(crate::config::DatasetAuth::Connection(connection)) => connection,
1068 Some(crate::config::DatasetAuth::Anonymous) | None => "none".to_string(),
1069 Some(crate::config::DatasetAuth::Auto) => "auto".to_string(),
1070 }
1071}
1072
1073pub fn catalogs(config: &crate::config::AppConfig) -> Vec<ShownCatalog> {
1077 let mut out: Vec<ShownCatalog> = config
1078 .shown_catalogs()
1079 .iter()
1080 .filter_map(|catalog| {
1081 let mut shown = ShownCatalog::from_catalog(catalog);
1082 if catalog.origin == crate::home::catalog::Origin::Bundled {
1083 shown
1084 .datasets
1085 .retain(|d| crate::cloud::source::opens_in_this_build(&d.location));
1086 }
1087 (!shown.datasets.is_empty()).then_some(shown)
1088 })
1089 .collect();
1090 let at = out
1092 .iter()
1093 .position(|c| c.origin == crate::home::catalog::Origin::Bundled)
1094 .unwrap_or(out.len());
1095 let broken: Vec<ShownCatalog> = config
1096 .broken_catalogs
1097 .iter()
1098 .filter(|b| !config.home.hide.contains(&b.id))
1099 .map(ShownCatalog::from_broken)
1100 .collect();
1101 out.splice(at..at, broken);
1102 out
1103}
1104
1105fn catalog_entry(
1108 dataset: &ShownDataset,
1109 network_check: fn(&Path) -> bool,
1110 missing: &mut std::collections::HashSet<PathBuf>,
1111) -> Entry {
1112 let path = &dataset.location;
1113 let local = matches!(
1114 crate::cloud::source::input_source(path),
1115 crate::cloud::source::InputSource::Local(_)
1116 );
1117 let mut entry = if is_object_store_url(path) {
1118 if names_a_file(path) {
1119 entry_for_path(path, true)
1120 } else {
1121 Entry::directory(path)
1122 }
1123 } else if !local || network_check(path) {
1124 entry_for_path(path, true)
1125 } else if path.exists() {
1126 entry_for_path(path, false)
1127 } else {
1128 missing.insert(path.clone());
1129 let mut entry = entry_for_path(path, true);
1130 entry.kind = EntryKind::Unknown;
1131 entry
1132 };
1133 entry.name = dataset.name.clone();
1134 entry
1135}
1136
1137pub fn codebook_for(
1140 catalogs: &[ShownCatalog],
1141 path: &Path,
1142) -> Option<std::sync::Arc<crate::home::codebook::Codebook>> {
1143 let text = path.to_string_lossy();
1144 catalogs
1145 .iter()
1146 .flat_map(|c| c.datasets.iter())
1147 .filter(|d| d.codebook.is_some())
1148 .filter(|d| d.location == path || within(&text, &d.location.to_string_lossy()))
1149 .max_by_key(|d| d.location.to_string_lossy().trim_end_matches('/').len())
1150 .and_then(|d| d.codebook.clone())
1151}
1152
1153pub fn catalog_entry_for(
1156 catalogs: &[ShownCatalog],
1157 path: &Path,
1158) -> Option<(String, std::sync::Arc<crate::home::catalog::Dataset>)> {
1159 let text = path.to_string_lossy();
1160 catalogs
1161 .iter()
1162 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
1163 .filter(|(_, d)| {
1164 d.location == path
1165 || same_place(&d.location, path)
1166 || within(&text, &d.location.to_string_lossy())
1167 })
1168 .rev()
1169 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
1170 .map(|(c, d)| (c.label.clone(), d.entry.clone()))
1171}
1172
1173fn bookmark_entry(name: &str, place: &Path, network_check: fn(&Path) -> bool) -> Entry {
1175 let local = matches!(
1176 crate::cloud::source::input_source(place),
1177 crate::cloud::source::InputSource::Local(_)
1178 );
1179 let mut entry = if is_object_store_url(place) && !names_a_file(place) {
1180 Entry::directory(place)
1181 } else {
1182 entry_for_path(place, !local || network_check(place) || !place.exists())
1183 };
1184 entry.name = name.to_string();
1185 entry
1186}
1187
1188fn catalog_section(
1190 catalog: &ShownCatalog,
1191 network_check: fn(&Path) -> bool,
1192 missing: &mut std::collections::HashSet<PathBuf>,
1193) -> Section {
1194 let rows = catalog
1196 .datasets
1197 .iter()
1198 .flat_map(|dataset| {
1199 std::iter::once(catalog_entry(dataset, network_check, missing)).chain(
1200 dataset
1201 .bookmarks
1202 .iter()
1203 .map(|(name, place)| bookmark_entry(name, place, network_check)),
1204 )
1205 })
1206 .collect();
1207 Section {
1208 origin: Some(catalog.origin_note()),
1209 unavailable: catalog.broken.is_some(),
1210 unavailable_note: catalog.broken.clone(),
1211 ..Section::titled(catalog.label.clone(), rows)
1212 }
1213}
1214
1215#[derive(Debug, Clone, Default, PartialEq)]
1218pub struct Measured {
1219 pub rows: Option<usize>,
1220 pub cols: Option<usize>,
1221 pub cols_sampled: bool,
1223 pub size: Option<u64>,
1224 pub columns: Vec<String>,
1226 pub cost: crate::home::discover::Cost,
1229 pub kind: Option<crate::home::discover::EntryKind>,
1232 pub holds: crate::home::discover::Holds,
1235}
1236
1237#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1239pub enum SortMode {
1240 #[default]
1242 Natural,
1243 Size,
1245 Modified,
1247 Rows,
1249}
1250
1251impl SortMode {
1252 pub fn label_in(self, section_is_recency_ordered: bool) -> &'static str {
1255 match self {
1256 SortMode::Natural if section_is_recency_ordered => "recent",
1257 SortMode::Natural => "name",
1258 SortMode::Size => "size",
1259 SortMode::Modified => "modified",
1260 SortMode::Rows => "rows",
1261 }
1262 }
1263
1264 pub fn next(self) -> Self {
1265 match self {
1266 SortMode::Natural => SortMode::Size,
1267 SortMode::Size => SortMode::Modified,
1268 SortMode::Modified => SortMode::Rows,
1269 SortMode::Rows => SortMode::Natural,
1270 }
1271 }
1272}
1273
1274#[derive(Debug, Clone)]
1278pub enum Row<'a> {
1279 Header {
1280 section: usize,
1281 matches: usize,
1283 collapsed: bool,
1284 },
1285 Entry {
1286 section: usize,
1287 entry: &'a Entry,
1288 nested: bool,
1290 hit: Hit,
1292 },
1293 Place {
1295 section: usize,
1296 path: PathBuf,
1297 label: Option<String>,
1300 source: Option<String>,
1302 held: usize,
1304 },
1305 Door { section: usize, entry: &'a Entry },
1308 More {
1311 section: usize,
1312 hidden: usize,
1313 places: usize,
1314 measuring: bool,
1317 },
1318 Hidden { section: usize, count: usize },
1322 Up { section: usize },
1325}
1326
1327impl Row<'_> {
1328 pub fn section(&self) -> usize {
1329 match self {
1330 Row::Header { section, .. }
1331 | Row::Entry { section, .. }
1332 | Row::Door { section, .. }
1333 | Row::Place { section, .. }
1334 | Row::More { section, .. }
1335 | Row::Hidden { section, .. }
1336 | Row::Up { section } => *section,
1337 }
1338 }
1339}
1340
1341#[derive(Debug, Clone, Copy, Default, PartialEq)]
1345pub struct Hit {
1346 pub score: i32,
1347 pub column: Option<usize>,
1350}
1351
1352impl Hit {
1353 pub fn column_of<'a>(&self, entry: &'a Entry) -> Option<&'a str> {
1355 self.column
1356 .and_then(|i| entry.columns.get(i))
1357 .map(String::as_str)
1358 }
1359
1360 pub fn positions(&self, filter: &str, entry: &Entry) -> Vec<usize> {
1363 match self.column_of(entry) {
1364 Some(column) => substring_positions(filter, column),
1365 None => fuzzy_positions(filter, &entry.name),
1366 }
1367 }
1368}
1369
1370#[derive(Debug, Default)]
1375pub struct RowsCache {
1376 built: std::cell::RefCell<Option<View>>,
1377 builds: std::cell::Cell<usize>,
1378 rescored: std::cell::RefCell<Option<Hits>>,
1381}
1382
1383#[derive(Debug, Clone)]
1386struct Hits {
1387 filter: String,
1388 sections: Vec<Option<Vec<Option<Hit>>>>,
1389}
1390
1391#[derive(Debug)]
1393struct View {
1394 key: ViewKey,
1395 slots: Vec<Slot>,
1396 hits: Hits,
1397 has_dataset: bool,
1399}
1400
1401#[derive(Debug, PartialEq)]
1404struct ViewKey {
1405 filter: String,
1406 sort: SortMode,
1407 hide_unreadable: bool,
1408 recent_expanded: bool,
1409 shown_whole: std::collections::HashSet<PathBuf>,
1410 view_height: usize,
1411 browsing: Option<PathBuf>,
1412 folds: std::collections::HashMap<String, bool>,
1413 shape: Vec<(usize, bool)>,
1414}
1415
1416impl ViewKey {
1417 fn of(home: &HomeState) -> Self {
1418 ViewKey {
1419 filter: home.filter.clone(),
1420 sort: home.sort,
1421 hide_unreadable: home.hide_unreadable,
1422 recent_expanded: home.recent_expanded,
1423 shown_whole: home.shown_whole.clone(),
1424 view_height: home.view_height,
1425 browsing: home.browsing.clone(),
1426 folds: home.folds.clone(),
1427 shape: (home.sections.iter())
1428 .map(|s| (s.rows.len(), s.door.is_some()))
1429 .collect(),
1430 }
1431 }
1432
1433 fn matches(&self, home: &HomeState) -> bool {
1435 self.filter == home.filter
1436 && self.sort == home.sort
1437 && self.hide_unreadable == home.hide_unreadable
1438 && self.recent_expanded == home.recent_expanded
1439 && self.shown_whole == home.shown_whole
1440 && self.view_height == home.view_height
1441 && self.browsing == home.browsing
1442 && self.folds == home.folds
1443 && self.shape.iter().copied().eq(home
1444 .sections
1445 .iter()
1446 .map(|s| (s.rows.len(), s.door.is_some())))
1447 }
1448}
1449
1450#[derive(Debug)]
1452enum Slot {
1453 Plain(Row<'static>),
1455 Entry {
1456 section: usize,
1457 index: usize,
1458 nested: bool,
1459 hit: Hit,
1460 },
1461 Door {
1462 section: usize,
1463 },
1464}
1465
1466pub fn place_of(path: &Path) -> PathBuf {
1469 parent_location(path).unwrap_or_else(|| path.to_path_buf())
1470}
1471
1472pub fn place_is_browsable(path: &Path) -> bool {
1475 is_cloud_place(path)
1476 || is_object_store_url(path)
1477 || matches!(
1478 crate::cloud::source::input_source(path),
1479 crate::cloud::source::InputSource::Local(_)
1480 )
1481}
1482
1483#[derive(Debug, Clone, PartialEq, Eq)]
1487pub enum RowKey {
1488 Header(String),
1489 Entry(PathBuf),
1490 Door(PathBuf),
1493 Place(PathBuf),
1494 More(String),
1495 Hidden(String),
1496 Up(String),
1497}
1498
1499#[derive(Debug)]
1501pub struct HomeState {
1502 pub sections: Vec<Section>,
1503 pub filter: String,
1505 pub filter_selected: bool,
1508 pub search_limit: usize,
1510 pub hide_unreadable: bool,
1512 pub formats: std::sync::Arc<crate::formats::Registry>,
1515 pub lake_here: Option<(PathBuf, &'static str)>,
1518 pub selected: usize,
1520 pub scroll: usize,
1525 pub view_height: usize,
1527 pub path_input_active: bool,
1529 pub path_input: String,
1530 pub path_listing: Option<PathListing>,
1532 pub path_pick: Option<usize>,
1534 pub browsing: Option<PathBuf>,
1536 pub browse_start: Option<PathBuf>,
1539 pub status: Option<String>,
1541 pub network_check: fn(&Path) -> bool,
1544 pub visits: std::collections::HashMap<PathBuf, crate::cache::Visits>,
1547 pub newest_recent: Option<PathBuf>,
1549 pub probes: Probes,
1551 pub narrowed: Option<Narrowed>,
1553 pub cloud_kinds: std::collections::HashMap<PathBuf, (EntryKind, crate::home::discover::Holds)>,
1556 pub sort: SortMode,
1558 pub listing_in_flight: bool,
1560 pub measure_in_flight: bool,
1562 pub classify_in_flight: bool,
1565 pub peeking: std::collections::HashSet<PathBuf>,
1568 pub peek_failed: std::collections::HashSet<PathBuf>,
1571 pub enriched: std::collections::HashMap<PathBuf, Measured>,
1574 pub unapplied: std::collections::HashSet<PathBuf>,
1576 pub folds: std::collections::HashMap<String, bool>,
1580 pub folds_owed: bool,
1583 pub search: SearchState,
1585 pub known: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
1588 pub cloud: Vec<CloudSource>,
1591 pub catalogs: Vec<ShownCatalog>,
1594 pub sized: std::collections::HashSet<PathBuf>,
1596 pub web_gone: std::collections::HashMap<PathBuf, crate::error_display::HttpGone>,
1599 pub missing: std::collections::HashSet<PathBuf>,
1601 pub waiting_since: Option<std::time::Instant>,
1603 pub recent_expanded: bool,
1605 pub shown_whole: std::collections::HashSet<PathBuf>,
1608 pub trail: Vec<Mark>,
1611 pub returning: Option<RowKey>,
1614 pub returning_line: Option<usize>,
1616 pub landing: bool,
1619 pub rows_cache: RowsCache,
1621 pub catalog_places: CatalogPlaces,
1623}
1624
1625#[derive(Debug, Clone)]
1628pub struct Mark {
1629 pub place: Option<PathBuf>,
1631 pub key: Option<RowKey>,
1632 pub filter: String,
1634 pub search: Option<SearchState>,
1637 pub line: usize,
1639}
1640
1641#[derive(Debug, Clone, Default)]
1644pub struct SearchState {
1645 pub root: Option<PathBuf>,
1647 pub epoch: u64,
1649 pub results: Vec<std::sync::Arc<[Entry]>>,
1652 pub indexed: usize,
1654 pub matches: Option<crate::home::search::Matches>,
1657 pub scoring: bool,
1659 pub scanned: usize,
1661 pub running: bool,
1663 pub done: bool,
1665 pub limited: Option<String>,
1667}
1668
1669const SCORE_INLINE_MAX: usize = 2_000;
1672
1673const FRECENCY_LIFT: f64 = 3.0;
1676
1677#[derive(Debug, Clone)]
1679pub struct ScoreJob {
1680 pub epoch: u64,
1681 pub results: Vec<std::sync::Arc<[Entry]>>,
1682 pub query: String,
1683 pub base: Option<crate::home::search::Matches>,
1684 pub limit: usize,
1685}
1686
1687impl SearchState {
1688 pub fn reset(&mut self) {
1690 *self = Self::default();
1691 }
1692
1693 pub fn set_results(&mut self, results: Vec<Entry>) {
1695 self.indexed = results.len();
1696 self.results = vec![results.into()];
1697 self.matches = None;
1698 }
1699
1700 pub fn files(&self) -> impl Iterator<Item = &Entry> {
1702 self.results.iter().flat_map(|batch| batch.iter())
1703 }
1704
1705 fn scored_for(&self, query: &str) -> bool {
1707 self.matches
1708 .as_ref()
1709 .is_some_and(|m| m.query == query && m.upto == self.indexed)
1710 }
1711
1712 fn base_for(&self, query: &str) -> (Option<&crate::home::search::Matches>, usize) {
1714 match self.matches.as_ref() {
1715 Some(m) if m.narrows_to(query) && m.upto <= self.indexed => {
1716 (Some(m), m.ids.len() + self.indexed - m.upto)
1717 }
1718 _ => (None, self.indexed),
1719 }
1720 }
1721}
1722
1723impl Default for HomeState {
1724 fn default() -> Self {
1725 Self {
1726 sections: Vec::new(),
1727 cloud: Vec::new(),
1728 catalogs: Vec::new(),
1729 sized: std::collections::HashSet::new(),
1730 web_gone: Default::default(),
1731 missing: Default::default(),
1732 filter: String::new(),
1733 search_limit: crate::config::SearchConfig::default().max_results,
1734 rows_cache: RowsCache::default(),
1735 catalog_places: CatalogPlaces::default(),
1736 hide_unreadable: true,
1737 formats: Default::default(),
1738 lake_here: None,
1739 selected: 0,
1740 scroll: 0,
1741 view_height: 0,
1742 path_input_active: false,
1743 path_input: String::new(),
1744 path_listing: None,
1745 path_pick: None,
1746 filter_selected: false,
1747 browsing: None,
1748 browse_start: None,
1749 status: None,
1750 network_check: is_remote_path,
1751 visits: Default::default(),
1752 newest_recent: None,
1753 sort: SortMode::default(),
1754 listing_in_flight: false,
1755 measure_in_flight: false,
1756 classify_in_flight: false,
1757 peeking: std::collections::HashSet::new(),
1758 probes: Probes::default(),
1759 narrowed: None,
1760 cloud_kinds: std::collections::HashMap::new(),
1761 peek_failed: std::collections::HashSet::new(),
1762 waiting_since: None,
1763 enriched: std::collections::HashMap::new(),
1764 unapplied: Default::default(),
1765 folds: std::collections::HashMap::new(),
1766 folds_owed: false,
1767 search: SearchState::default(),
1768 known: Default::default(),
1769 recent_expanded: false,
1770 shown_whole: Default::default(),
1771 trail: Vec::new(),
1772 returning: None,
1773 returning_line: None,
1774 landing: false,
1775 }
1776 }
1777}
1778
1779#[derive(Debug, Clone)]
1782pub struct ListingRequest {
1783 pub recents: Vec<PathBuf>,
1784 pub desktop_dirs: Vec<PathBuf>,
1785 pub browsing: Option<PathBuf>,
1786 pub probes: Probes,
1788 pub narrowed: Option<Narrowed>,
1790 pub network_check: fn(&Path) -> bool,
1791 pub cloud: Vec<CloudSource>,
1793 pub catalogs: Vec<ShownCatalog>,
1795 pub known: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
1798 pub formats: std::sync::Arc<crate::formats::Registry>,
1801}
1802
1803#[derive(Debug, Clone, Default)]
1805pub struct Listing {
1806 pub sections: Vec<Section>,
1807 pub missing: std::collections::HashSet<PathBuf>,
1809}
1810
1811impl Listing {
1812 pub fn alias_visits(
1818 &self,
1819 visits: &mut std::collections::HashMap<PathBuf, crate::cache::Visits>,
1820 ) {
1821 let names: std::collections::HashSet<std::ffi::OsString> = visits
1822 .keys()
1823 .filter_map(|p| p.file_name().map(|n| n.to_os_string()))
1824 .collect();
1825 let mut aliases = Vec::new();
1826 for row in self.sections.iter().flat_map(|s| &s.rows) {
1827 let path = &row.path;
1828 if visits.contains_key(path)
1829 || row.table.is_some()
1830 || !path.file_name().is_some_and(|n| names.contains(n))
1831 || is_network_path(path)
1832 {
1833 continue;
1834 }
1835 if let Some(v) = crate::canonical::canonicalize(path)
1836 .ok()
1837 .and_then(|canonical| visits.get(&canonical))
1838 {
1839 aliases.push((path.clone(), *v));
1840 }
1841 }
1842 visits.extend(aliases);
1843 }
1844}
1845
1846pub fn look_into_as(entry: &Entry, as_read: &crate::formats::schema_union::ReadAs) -> Entry {
1851 let mut probe = classify_row(entry);
1852 measure_row(&mut probe, entry, as_read, None);
1853 probe
1854}
1855
1856fn classify_row(entry: &Entry) -> Entry {
1858 let mut probe = entry.clone();
1859 if probe.kind == EntryKind::Unknown && probe.path.is_dir() {
1860 let (kind, holds) = discover::look_at_directory(&probe.path);
1861 probe.kind = kind;
1862 probe.holds = holds;
1863 }
1864 probe
1865}
1866
1867fn measure_row(
1870 probe: &mut Entry,
1871 entry: &Entry,
1872 as_read: &crate::formats::schema_union::ReadAs,
1873 remembered: Option<&crate::cache::CacheManager>,
1874) {
1875 discover::enrich_with(probe, as_read, remembered);
1876 probe.size = probe.size.or(entry.size);
1877 probe.modified = probe.modified.or(entry.modified);
1878}
1879
1880pub fn look_into_batch(
1885 rows: Vec<Entry>,
1886 cache: &crate::cache::CacheManager,
1887 mut each: impl FnMut(PathBuf, Measured),
1888) {
1889 let as_read = crate::formats::schema_union::ReadAs::default();
1890 let classified: Vec<(Entry, Entry)> = rows
1891 .into_iter()
1892 .map(|entry| {
1893 let probe = classify_row(&entry);
1894 if probe.kind != entry.kind {
1895 each(entry.path.clone(), measured_from(&probe, &entry));
1896 }
1897 (probe, entry)
1898 })
1899 .collect();
1900
1901 let mut facts = Vec::new();
1902 for (mut probe, entry) in classified {
1903 measure_row(&mut probe, &entry, &as_read, Some(cache));
1904 facts.extend(facts_for(&probe));
1905 each(entry.path.clone(), measured_from(&probe, &entry));
1906 }
1907 cache.record_dataset_facts(&facts);
1910}
1911
1912pub fn measured_from(probe: &Entry, original: &Entry) -> Measured {
1914 Measured {
1915 rows: probe.rows,
1916 cols: probe.cols,
1917 cols_sampled: probe.cols_sampled,
1918 size: probe.size.or(original.size),
1919 columns: probe.columns.clone(),
1920 kind: (probe.kind != original.kind).then_some(probe.kind),
1921 holds: probe.holds.clone(),
1922 cost: crate::home::discover::Cost {
1925 source: None,
1926 ..probe.cost.clone()
1927 },
1928 }
1929}
1930
1931#[derive(Debug, Clone)]
1934pub enum Probe {
1935 Listing(Vec<Entry>),
1937 Listed {
1939 rows: std::sync::Arc<[Entry]>,
1940 cut_short: bool,
1941 },
1942 Unreachable(Option<String>),
1944}
1945
1946#[derive(Debug, Clone, Default)]
1948pub struct Probes(std::collections::HashMap<PathBuf, Probe>);
1949
1950impl Probes {
1951 pub fn listed(&self, place: &Path) -> Option<&[Entry]> {
1953 match self.0.get(place)? {
1954 Probe::Listed { rows, .. } => Some(rows),
1955 _ => None,
1956 }
1957 }
1958
1959 pub fn so_far(&self, place: &Path) -> Option<&[Entry]> {
1961 match self.0.get(place)? {
1962 Probe::Listing(rows) => Some(rows),
1963 _ => None,
1964 }
1965 }
1966
1967 pub fn settled(&self, place: &Path) -> bool {
1969 matches!(
1970 self.0.get(place),
1971 Some(Probe::Listed { .. } | Probe::Unreachable(_))
1972 )
1973 }
1974
1975 pub fn cut_short(&self, place: &Path) -> bool {
1976 matches!(
1977 self.0.get(place),
1978 Some(Probe::Listed {
1979 cut_short: true,
1980 ..
1981 })
1982 )
1983 }
1984
1985 pub fn unreachable(&self, place: &Path) -> bool {
1986 matches!(self.0.get(place), Some(Probe::Unreachable(_)))
1987 }
1988
1989 pub fn error(&self, place: &Path) -> Option<&str> {
1991 match self.0.get(place)? {
1992 Probe::Unreachable(why) => why.as_deref(),
1993 _ => None,
1994 }
1995 }
1996
1997 fn rows(&self, place: &Path) -> Vec<Entry> {
2000 match self.0.get(place) {
2001 Some(Probe::Listed { rows, .. }) => rows.to_vec(),
2002 Some(Probe::Listing(rows)) => {
2003 let mut rows = rows.clone();
2004 if is_object_store_url(place) {
2005 rows.sort_by_key(|row| row.kind != EntryKind::Directory);
2006 } else {
2007 discover::sort_entries(&mut rows);
2008 }
2009 rows
2010 }
2011 _ => Vec::new(),
2012 }
2013 }
2014
2015 pub fn answered(&self) -> impl Iterator<Item = (&PathBuf, &[Entry])> {
2017 self.0.iter().filter_map(|(place, probe)| match probe {
2018 Probe::Listed { rows, .. } => Some((place, &rows[..])),
2019 _ => None,
2020 })
2021 }
2022
2023 fn entry(&self, path: &Path) -> Option<Entry> {
2025 self.answered()
2026 .flat_map(|(_, rows)| rows.iter())
2027 .find(|e| e.path == path)
2028 .cloned()
2029 }
2030
2031 pub fn read(&mut self, place: &Path, rows: &[Entry]) {
2033 if let Probe::Listing(so_far) = self
2034 .0
2035 .entry(place.to_path_buf())
2036 .or_insert(Probe::Listing(Vec::new()))
2037 {
2038 so_far.extend_from_slice(rows);
2039 }
2040 }
2041
2042 pub fn insert(&mut self, place: PathBuf, probe: Probe) {
2043 self.0.insert(place, probe);
2044 }
2045
2046 pub fn forget(&mut self, place: &Path) {
2048 self.0.remove(place);
2049 }
2050
2051 pub fn stopped(&mut self, place: &Path) {
2053 if let Some(Probe::Listing(_)) = self.0.get(place) {
2054 self.0.remove(place);
2055 }
2056 }
2057
2058 fn listed_mut(&mut self, place: &Path) -> Option<&mut std::sync::Arc<[Entry]>> {
2059 match self.0.get_mut(place)? {
2060 Probe::Listed { rows, .. } => Some(rows),
2061 _ => None,
2062 }
2063 }
2064}
2065
2066#[derive(Debug, Clone)]
2069pub struct Narrowed {
2070 pub dir: PathBuf,
2071 pub prefix: String,
2073 pub rows: Vec<Entry>,
2074 pub truncated: bool,
2076}
2077
2078pub fn build_listing(request: &ListingRequest) -> Listing {
2082 let ListingRequest {
2083 recents,
2084 desktop_dirs,
2085 browsing,
2086 probes,
2087 narrowed,
2088 network_check,
2089 cloud,
2090 catalogs,
2091 known,
2092 formats,
2093 } = request;
2094 let network_check = *network_check;
2095 let mounts = crate::home::locality::Mounts::current();
2098 let mut sections: Vec<Section> = Vec::new();
2099
2100 if let Some(id) = browsing.as_deref().and_then(cloud_source_id) {
2102 let source = cloud.iter().find(|s| s.id == id);
2103 let rows = source
2104 .map(|s| s.buckets.iter().map(|b| bucket_entry(b)).collect())
2105 .unwrap_or_default();
2106 let failure = source.and_then(|s| match &s.status {
2107 CloudStatus::Failed { short, .. } => Some(short.clone()),
2108 _ => None,
2109 });
2110 sections.push(Section {
2111 subtitle: source.map(|s| s.note.clone()).filter(|n| !n.is_empty()),
2112 unavailable: source.is_none() || failure.is_some(),
2113 unavailable_note: if source.is_none() {
2114 Some("source not found".to_string())
2115 } else {
2116 failure
2117 },
2118 waiting: source.is_some_and(|s| s.busy()),
2119 ..Section::titled(source.map(|s| s.label.clone()).unwrap_or(id), rows)
2120 });
2121 annotate(&mut sections, known, network_check, &mounts);
2122 return Listing {
2123 sections,
2124 ..Default::default()
2125 };
2126 }
2127
2128 if let Some(dir) = browsing.clone() {
2130 let remote = network_check(&dir);
2133 let so_far = remote && probes.so_far(&dir).is_some();
2135 let database = !remote && dir.is_file();
2137 let (mut rows, truncated) = if remote {
2138 (probes.rows(&dir), probes.cut_short(&dir))
2139 } else if database {
2140 let tables = discover::database_rows(&dir);
2141 let rows = if tables.is_empty() {
2142 discover::variant_rows(&dir, formats)
2143 } else {
2144 tables
2145 };
2146 (rows, false)
2147 } else {
2148 let scan = discover::scan_dir_specs(&dir, formats);
2149 let mut rows = discover::split_rows(&dir);
2151 rows.extend(scan.entries);
2152 (rows, scan.truncated)
2153 };
2154 let narrowed = narrowed
2157 .as_ref()
2158 .filter(|n| remote && truncated && n.dir == dir);
2159 if let Some(narrowed) = narrowed {
2160 let listed: std::collections::HashSet<PathBuf> =
2161 rows.iter().map(|row| row.path.clone()).collect();
2162 rows.extend(
2163 narrowed
2164 .rows
2165 .iter()
2166 .filter(|row| !listed.contains(&row.path))
2167 .cloned(),
2168 );
2169 }
2170 let subtitle = if so_far {
2172 Some(format!(
2173 "{} so far",
2174 crate::numfmt::group_chrome(rows.len())
2175 ))
2176 } else if truncated {
2177 let first = format!(
2178 "first {}",
2179 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2180 );
2181 Some(match narrowed {
2182 Some(n) => format!(
2183 "{first} + {}{} {}*",
2184 crate::numfmt::group_chrome(n.rows.len()),
2185 if n.truncated { "+" } else { "" },
2186 n.prefix
2187 ),
2188 None => first,
2189 })
2190 } else {
2191 None
2192 };
2193 let unavailable = remote && probes.unreachable(&dir);
2194 let mut door = (!database)
2197 .then(|| whole_directory_row(&dir, &rows, remote))
2198 .flatten();
2199 if !remote
2203 && let Some(door) = door.as_mut()
2204 && let Ok(meta) = std::fs::metadata(&dir)
2205 {
2206 door.modified = meta.modified().ok();
2207 apply_known_facts(door, known, false);
2208 door.modified = None;
2209 door.name = door_name(door, &rows);
2210 }
2211 let title = {
2214 let text = dir.to_string_lossy();
2215 if let Some(dataset) = catalogs
2216 .iter()
2217 .flat_map(|c| c.datasets.iter())
2218 .find(|d| is_object_store_url(&d.location) && same_place(&d.location, &dir))
2219 {
2220 dataset.name.clone()
2221 } else if let Some((_, account)) = cloud_account(&dir) {
2222 account
2223 } else if let Some((_, container, key)) = crate::cloud::source::azure_parts(&text) {
2224 format!("{container}/{}", key.trim_matches('/'))
2225 .trim_end_matches('/')
2226 .to_string()
2227 } else {
2228 match crate::cloud::source::split_source_id(&text) {
2229 (Some(_), plain) => plain.into_owned(),
2230 (None, _) => display_path(&dir),
2231 }
2232 }
2233 };
2234 sections.push(Section {
2235 subtitle,
2236 root: Some(dir.clone()),
2237 unavailable,
2238 unavailable_note: probes.error(&dir).map(str::to_string),
2241 waiting: so_far,
2244 door,
2245 ..Section::titled(title, rows)
2246 });
2247 annotate(&mut sections, known, network_check, &mounts);
2248 return Listing {
2249 sections,
2250 ..Default::default()
2251 };
2252 }
2253
2254 let recent_rows: Vec<Entry> = recents
2256 .iter()
2257 .filter(|p| {
2260 network_check(p)
2261 || p.exists()
2262 || crate::formats::members::split(p).is_some()
2263 || crate::formats::members::split_variant(p, formats).is_some()
2264 || crate::formats::hf_splits::split_place(p).is_some()
2265 })
2266 .map(|p| {
2268 if let Some(known) = probes.entry(p) {
2271 return known;
2272 }
2273 if let Some(variant) = discover::variant_row(p, formats) {
2274 return variant;
2275 }
2276 if !network_check(p)
2277 && let Some(split) = discover::split_row(p)
2278 {
2279 return split;
2280 }
2281 let mut entry = entry_for_path(p, network_check(p));
2282 if !network_check(p) {
2283 discover::name_unlisted_file(&mut entry, formats);
2284 }
2285 if let Some(dataset) = catalogs
2288 .iter()
2289 .flat_map(|c| &c.datasets)
2290 .find(|d| d.location == *p)
2291 {
2292 entry.name = dataset.name.clone();
2293 }
2294 entry
2295 })
2296 .collect();
2297
2298 let mut elsewhere: Vec<Entry> = Vec::new();
2300
2301 let roots = HomeState::roots_with(desktop_dirs, network_check);
2302 let mut root_sections: Vec<(RootOrigin, Section)> = Vec::new();
2303 let mut cwd_listing: Option<(PathBuf, std::collections::HashSet<std::ffi::OsString>)> = None;
2306 for root in roots {
2307 if root.origin == RootOrigin::Desktop {
2310 if root.available {
2311 let mut entry = Entry::directory(&root.path);
2312 entry.name = display_path(&root.path);
2314 elsewhere.push(entry);
2315 }
2316 continue;
2317 }
2318
2319 let mut truncated = false;
2322 let rows = if root.network {
2323 truncated = probes.cut_short(&root.path);
2324 probes.rows(&root.path)
2325 } else if root.available {
2326 let scan = discover::scan_dir_specs(&root.path, formats);
2327 truncated = scan.truncated;
2328 scan.entries
2329 } else {
2330 Vec::new()
2331 };
2332 if root.origin == RootOrigin::Cwd {
2333 let key = if root.network {
2336 root.path.clone()
2337 } else {
2338 crate::canonical::canonicalize(&root.path).unwrap_or_else(|_| root.path.clone())
2339 };
2340 let names = rows
2341 .iter()
2342 .filter_map(|row| row.path.file_name().map(|n| n.to_os_string()))
2343 .collect();
2344 cwd_listing = Some((key, names));
2345 }
2346 let unreachable = root.network && probes.unreachable(&root.path);
2348 let waiting = root.network && !unreachable && probes.listed(&root.path).is_none();
2349 let described = mounts.describe(&root.path);
2353 let fstype = if described.network() {
2354 described.fstype
2355 } else {
2356 "network".to_string()
2357 };
2358 let mut state: Vec<String> = Vec::new();
2361 if truncated {
2362 state.push(format!(
2363 "first {}",
2364 crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
2365 ));
2366 }
2367 if root.network {
2368 state.push(fstype);
2369 }
2370 root_sections.push((
2371 root.origin,
2372 Section {
2373 subtitle: (!state.is_empty()).then(|| crate::glyphs::dotted(&state.join(" · "))),
2374 origin: Some(root.origin.note()),
2375 root: Some(root.path.clone()),
2376 unavailable: !root.available || unreachable,
2377 remote_root: root.network.then(|| root.path.clone()),
2378 waiting,
2379 ..Section::titled(display_path(&root.path), rows)
2380 },
2381 ));
2382 }
2383
2384 let recent_rows: Vec<Entry> = match &cwd_listing {
2389 None => recent_rows,
2390 Some((cwd, names)) => recent_rows
2391 .into_iter()
2392 .filter(|row| {
2393 let place = place_of(&row.path);
2394 let place = if network_check(&place) {
2397 place
2398 } else {
2399 crate::canonical::canonicalize(&place).unwrap_or(place)
2400 };
2401 place != *cwd || !row.path.file_name().is_some_and(|n| names.contains(n))
2402 })
2403 .collect(),
2404 };
2405 if !recent_rows.is_empty() {
2406 let place_labels = place_labels(&recent_rows, known, network_check);
2407 sections.push(Section {
2408 grouped_by_place: true,
2410 place_labels,
2411 ..Section::titled(HomeState::RECENT_SECTION, recent_rows)
2412 });
2413 }
2414
2415 sections.extend(root_sections.into_iter().map(|(_, s)| s));
2419
2420 if !cloud.is_empty() {
2423 sections.push(Section::titled(
2424 HomeState::CLOUD_SECTION.to_string(),
2425 cloud.iter().map(source_entry).collect(),
2426 ));
2427 }
2428
2429 let mut missing = std::collections::HashSet::new();
2431 for catalog in catalogs {
2432 let mut section = catalog_section(catalog, network_check, &mut missing);
2433 for row in section.rows.iter_mut().filter(|r| {
2435 r.kind == EntryKind::File
2436 && r.format_spec.is_none()
2437 && r.table.is_none()
2438 && !network_check(&r.path)
2439 && !discover::is_data_file(&r.path)
2440 }) {
2441 if let Some(spec) = formats.by_glob(&row.path, false).first() {
2442 discover::name_spec_file(row, spec);
2443 }
2444 }
2445 sections.push(section);
2446 }
2447
2448 if !elsewhere.is_empty() {
2449 sections.push(Section {
2450 folded_by_default: true,
2452 ..Section::titled("Elsewhere", elsewhere)
2453 });
2454 }
2455
2456 annotate(&mut sections, known, network_check, &mounts);
2459
2460 Listing { sections, missing }
2461}
2462
2463pub fn index_key(path: &Path) -> PathBuf {
2467 let text = path.to_string_lossy();
2468 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
2469 return PathBuf::from(crate::cloud::source::azure_url(&account, &container, &key));
2470 }
2471 match crate::cloud::source::split_source_id(&text) {
2472 (Some(_), plain) => PathBuf::from(plain.into_owned()),
2473 (None, _) => path.to_path_buf(),
2474 }
2475}
2476
2477fn known_facts<'a>(
2479 known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2480 path: &Path,
2481) -> Option<&'a crate::cache::DatasetFacts> {
2482 known.get(path).or_else(|| known.get(&index_key(path)))
2483}
2484
2485fn place_labels(
2490 rows: &[Entry],
2491 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2492 network_check: fn(&Path) -> bool,
2493) -> std::collections::HashMap<PathBuf, String> {
2494 let mut labels = std::collections::HashMap::new();
2495 for row in rows {
2496 let place = place_of(&row.path);
2497 if labels.contains_key(&place) {
2498 continue;
2499 }
2500 let Some(facts) = known_facts(known, &place) else {
2501 continue;
2502 };
2503 if facts.classified_by != crate::home::discover::CLASSIFIER_VERSION {
2504 continue;
2505 }
2506 if !network_check(&place) {
2507 let same_mtime = std::fs::metadata(&place)
2508 .and_then(|m| m.modified())
2509 .ok()
2510 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2511 .is_some_and(|d| d.as_secs() == facts.mtime);
2512 if !same_mtime {
2513 continue;
2514 }
2515 }
2516 let Some(kind) = facts.kind else {
2517 continue;
2518 };
2519 let mut probe = Entry::directory(&place);
2520 probe.kind = kind;
2521 probe.holds = facts.holds.clone();
2522 let label = probe.label();
2523 if !label.is_empty() && !label.starts_with("dir") {
2524 labels.insert(place, label.into_owned());
2525 }
2526 }
2527 labels
2528}
2529
2530fn annotate(
2533 sections: &mut [Section],
2534 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2535 network_check: fn(&Path) -> bool,
2536 mounts: &crate::home::locality::Mounts,
2537) {
2538 for section in sections {
2539 for row in &mut section.rows {
2540 if is_cloud_place(&row.path) {
2541 row.cost.source = Some("cloud".to_string());
2542 continue;
2543 }
2544 apply_known_facts(row, known, network_check(&row.path));
2545 row.cost.source = Some(mounts.describe(&row.path).fstype);
2546 }
2547 if let Some(door) = section.door.as_mut()
2549 && !is_cloud_place(&door.path)
2550 {
2551 door.cost.source = Some(mounts.describe(&door.path).fstype);
2552 }
2553 }
2554}
2555
2556fn fold_measured(row: &mut Entry, m: &Measured) {
2558 row.measured = true;
2559 row.rows = m.rows;
2560 row.cols = m.cols;
2561 row.cols_sampled = m.cols_sampled;
2562 if let Some(kind) = m.kind {
2563 row.kind = kind;
2564 }
2565 if m.size.is_some() {
2566 row.size = m.size;
2567 }
2568 if !m.columns.is_empty() && row.columns != m.columns {
2569 row.columns.clone_from(&m.columns);
2570 }
2571 if !m.holds.is_empty() && row.holds != m.holds {
2572 row.holds.clone_from(&m.holds);
2573 }
2574 take_cost(row, &m.cost);
2576}
2577
2578fn take_cost(row: &mut Entry, cost: &discover::Cost) {
2582 let source = row.cost.source.take();
2583 let variants = row.cost.tables.filter(|_| row.format_spec.is_some());
2584 row.cost = cost.clone();
2585 row.cost.source = source;
2586 if variants.is_some() {
2587 row.cost.tables = variants;
2588 }
2589}
2590
2591fn apply_known_facts(
2595 row: &mut Entry,
2596 known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
2597 remote: bool,
2598) {
2599 let Some(facts) = known_facts(known, &row.path) else {
2600 return;
2601 };
2602
2603 if !remote
2610 && matches!(row.kind, EntryKind::Unknown | EntryKind::MultiFile)
2611 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
2612 && let Some(kind) = facts.kind
2613 {
2614 let same_mtime = row
2615 .modified
2616 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2617 .is_some_and(|d| d.as_secs() == facts.mtime);
2618 if same_mtime {
2619 row.kind = kind;
2620 if row.holds.is_empty() {
2623 row.holds = facts.holds.clone();
2624 }
2625 }
2629 }
2630
2631 if !remote {
2632 let same_bytes = row.size.map(|s| s == facts.size).unwrap_or(false)
2633 && row
2634 .modified
2635 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
2636 .map(|d| d.as_secs() == facts.mtime)
2637 .unwrap_or(false);
2638 if !same_bytes {
2639 return;
2640 }
2641 }
2642
2643 row.rows = facts.rows;
2644 row.cols = facts.cols;
2645 row.cols_sampled = facts.cols_sampled;
2646 if !facts.columns.is_empty() {
2647 row.columns = facts.columns.clone();
2648 }
2649 take_cost(row, &facts.cost);
2652 if remote {
2653 if facts.size > 0 {
2656 row.size = row.size.or(Some(facts.size));
2657 }
2658 if row.kind == EntryKind::Unknown
2662 && facts.classified_by == crate::home::discover::CLASSIFIER_VERSION
2663 && let Some(kind) = facts.kind
2664 {
2665 row.kind = kind;
2666 if row.holds.is_empty() {
2668 row.holds = facts.holds.clone();
2669 }
2670 }
2671 }
2672}
2673
2674pub fn facts_for(entry: &Entry) -> Option<(PathBuf, crate::cache::DatasetFacts)> {
2676 let size = entry.size?;
2677 let mtime = entry
2678 .modified?
2679 .duration_since(std::time::UNIX_EPOCH)
2680 .ok()?
2681 .as_secs();
2682 if entry.rows.is_none() && entry.columns.is_empty() && entry.cost == Default::default() {
2683 return None; }
2685 Some((
2686 entry.path.clone(),
2687 crate::cache::DatasetFacts {
2688 mtime,
2689 size,
2690 rows: entry.rows,
2691 cols: entry.cols,
2692 cols_sampled: entry.cols_sampled,
2693 columns: entry.columns.clone(),
2694 kind: Some(entry.kind),
2695 holds: entry.holds.clone(),
2696 classified_by: crate::home::discover::CLASSIFIER_VERSION,
2697 cost: crate::home::discover::Cost {
2699 source: None,
2700 ..entry.cost.clone()
2701 },
2702 },
2703 ))
2704}
2705
2706pub fn match_score(filter: &str, entry: &Entry) -> Option<i32> {
2710 match crate::home::fuzzy::best_match(filter, &entry.name) {
2711 Some(m) => Some(m.score),
2712 None => matching_column(filter, entry).map(|_| -COLUMN_MATCH_PENALTY),
2715 }
2716}
2717
2718pub fn match_hit(filter: &str, entry: &Entry) -> Option<Hit> {
2720 Needle::new(filter).hit(entry)
2721}
2722
2723struct Needle<'a> {
2726 filter: &'a str,
2727 lower: String,
2728}
2729
2730impl<'a> Needle<'a> {
2731 fn new(filter: &'a str) -> Self {
2732 Needle {
2733 filter,
2734 lower: filter.to_lowercase(),
2735 }
2736 }
2737
2738 fn hit(&self, entry: &Entry) -> Option<Hit> {
2739 let named = crate::home::fuzzy::best_match_with(self.filter, &entry.name, |score, _| Hit {
2740 score,
2741 column: None,
2742 });
2743 if named.is_some() {
2744 return named;
2745 }
2746 Some(Hit {
2747 score: -COLUMN_MATCH_PENALTY,
2748 column: Some(self.column(entry)?),
2749 })
2750 }
2751
2752 fn column(&self, entry: &Entry) -> Option<usize> {
2754 if self.filter.is_empty() {
2755 return None;
2756 }
2757 (entry.columns.iter()).position(|c| contains_folded(c, &self.lower))
2758 }
2759}
2760
2761fn contains_folded(haystack: &str, lower: &str) -> bool {
2764 if haystack.is_ascii() && lower.is_ascii() {
2765 let (hay, needle) = (haystack.as_bytes(), lower.as_bytes());
2766 return needle.is_empty()
2767 || hay
2768 .windows(needle.len())
2769 .any(|w| w.eq_ignore_ascii_case(needle));
2770 }
2771 haystack.to_lowercase().contains(lower)
2772}
2773
2774const COLUMN_MATCH_PENALTY: i32 = 1_000_000;
2777
2778pub fn matching_column<'a>(filter: &str, entry: &'a Entry) -> Option<&'a str> {
2781 (Needle::new(filter).column(entry)).map(|i| entry.columns[i].as_str())
2782}
2783
2784pub fn fuzzy_positions(needle: &str, haystack: &str) -> Vec<usize> {
2787 crate::home::fuzzy::best_match(needle, haystack)
2788 .map(|m| m.positions)
2789 .unwrap_or_default()
2790}
2791
2792pub fn substring_positions(needle: &str, haystack: &str) -> Vec<usize> {
2795 if needle.is_empty() {
2796 return Vec::new();
2797 }
2798 let hay: Vec<char> = haystack.to_lowercase().chars().collect();
2799 let need: Vec<char> = needle.to_lowercase().chars().collect();
2800 if need.len() > hay.len() {
2801 return Vec::new();
2802 }
2803 for start in 0..=(hay.len() - need.len()) {
2804 if hay[start..start + need.len()] == need[..] {
2805 return (start..start + need.len()).collect();
2806 }
2807 }
2808 Vec::new()
2809}
2810
2811pub fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
2814 crate::home::fuzzy::best_match(needle, haystack).map(|m| m.score)
2815}
2816
2817impl HomeState {
2818 pub fn roots(desktop_dirs: &[PathBuf]) -> Vec<Root> {
2821 Self::roots_with(desktop_dirs, is_remote_path)
2822 }
2823
2824 pub fn roots_with(desktop_dirs: &[PathBuf], is_network: fn(&Path) -> bool) -> Vec<Root> {
2826 let mut roots: Vec<Root> = Vec::new();
2827 let mut seen: Vec<PathBuf> = Vec::new();
2828
2829 let push =
2830 |path: PathBuf, origin: RootOrigin, roots: &mut Vec<Root>, seen: &mut Vec<PathBuf>| {
2831 let network = is_network(&path);
2833
2834 let key = if network {
2838 path.clone()
2839 } else {
2840 crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone())
2841 };
2842 if seen.contains(&key) {
2843 return;
2844 }
2845 seen.push(key);
2846
2847 let available = if network {
2848 true } else {
2850 std::fs::read_dir(&path).is_ok()
2851 };
2852 roots.push(Root {
2853 path,
2854 origin,
2855 available,
2856 network,
2857 });
2858 };
2859
2860 if let Ok(cwd) = std::env::current_dir() {
2864 push(cwd, RootOrigin::Cwd, &mut roots, &mut seen);
2865 }
2866
2867 for dir in desktop_dirs {
2870 push(dir.clone(), RootOrigin::Desktop, &mut roots, &mut seen);
2871 }
2872
2873 roots
2874 }
2875
2876 pub fn rebuild(&mut self, recents: &[PathBuf]) {
2879 self.rebuild_with(recents, &[])
2880 }
2881
2882 pub fn rebuild_with(&mut self, recents: &[PathBuf], desktop_dirs: &[PathBuf]) {
2885 let request = ListingRequest {
2886 recents: recents.to_vec(),
2887 desktop_dirs: desktop_dirs.to_vec(),
2888 browsing: self.browsing.clone(),
2889 probes: self.probes.clone(),
2890 narrowed: self.narrowed.clone(),
2891 network_check: self.network_check,
2892 cloud: self.cloud.clone(),
2893 catalogs: self.catalogs.clone(),
2894 known: Default::default(),
2897 formats: self.formats.clone(),
2898 };
2899 let listing = build_listing(&request);
2900 self.apply_listing(listing);
2901 }
2902
2903 pub fn apply_listing(&mut self, listing: Listing) {
2905 let returning = self.returning.take();
2906 let previous = returning.clone().or_else(|| self.selected_key());
2907 let line = self.selected.saturating_sub(self.scroll);
2909 let mut listing = listing;
2910 for section in &mut listing.sections {
2911 name_by_spec(&self.formats, &mut section.rows);
2912 }
2913 self.sections = listing.sections;
2914 self.changed();
2915 self.missing = listing.missing;
2916 if let (Some(browsing), Some((dir, format))) = (&self.browsing, &self.lake_here)
2918 && browsing == dir
2919 && let Some(section) = self.sections.first_mut()
2920 {
2921 let note = crate::glyphs::dotted(&format!(
2922 "{} · not read as a table",
2923 format.to_ascii_lowercase()
2924 ));
2925 section.subtitle = Some(match section.subtitle.take() {
2926 Some(state) => crate::glyphs::dotted(&format!("{note} · {state}")),
2927 None => note,
2928 });
2929 }
2930 self.sync_search_section();
2932 self.apply_measurements();
2935
2936 let placed = self.reselect(previous);
2938 if placed && returning.is_some() {
2941 self.landing = false;
2942 }
2943 if !placed {
2944 self.select_first_entry();
2945 if self.rows_still_arriving() {
2948 self.returning = returning;
2949 }
2950 } else if returning.is_some() {
2951 self.scroll_to_returning_line();
2952 } else {
2953 self.scroll = self.selected.saturating_sub(line);
2954 }
2955 self.follow_selection();
2956 }
2957
2958 pub fn leave_mark(&mut self) {
2961 let mark = Mark {
2962 place: self.browsing.clone(),
2963 key: self.selected_key(),
2964 filter: self.filter.clone(),
2965 search: (!self.search.running).then(|| SearchState {
2968 scoring: false,
2969 ..self.search.clone()
2970 }),
2971 line: self.selected.saturating_sub(self.scroll),
2972 };
2973 self.trail.retain(|m| m.place != mark.place);
2976 self.trail.push(mark);
2977 }
2978
2979 pub fn come_back(&mut self, from: Option<PathBuf>) {
2984 let to = self.browsing.clone();
2985 let mark = self
2986 .trail
2987 .iter()
2988 .rposition(|m| m.place == to)
2989 .map(|at| self.trail.split_off(at).remove(0));
2990 match mark {
2991 Some(mark) => {
2992 self.filter = mark.filter;
2993 self.search = mark.search.unwrap_or_default();
2994 self.returning = mark.key;
2995 self.returning_line = Some(mark.line);
2996 }
2997 None => {
2998 self.filter.clear();
2999 self.search.reset();
3000 self.returning = from.map(RowKey::Entry);
3001 self.returning_line = None;
3002 }
3003 }
3004 }
3005
3006 fn rows_still_arriving(&self) -> bool {
3009 self.listing_in_flight
3010 || self.sections_waiting()
3011 || self.awaiting_listing().is_some()
3012 || self.search.running
3013 }
3014
3015 fn settle_return(&mut self) {
3017 let Some(key) = self.returning.clone() else {
3018 return;
3019 };
3020 if let Some(idx) = self.place_key(&key) {
3021 self.selected = idx;
3022 self.returning = None;
3023 self.landing = false;
3024 self.scroll_to_returning_line();
3025 self.follow_selection();
3026 } else if !self.rows_still_arriving() {
3027 self.returning = None;
3028 }
3029 }
3030
3031 fn scroll_to_returning_line(&mut self) {
3033 if let Some(line) = self.returning_line.take() {
3034 self.scroll = self.selected.saturating_sub(line);
3035 }
3036 }
3037
3038 pub fn selected_key(&self) -> Option<RowKey> {
3040 let title = |section: usize| self.sections.get(section).map(|s| s.title.clone());
3041 Some(match self.selected_row()? {
3042 Row::Header { section, .. } => RowKey::Header(title(section)?),
3043 Row::More { section, .. } => RowKey::More(title(section)?),
3044 Row::Hidden { section, .. } => RowKey::Hidden(title(section)?),
3045 Row::Up { section } => RowKey::Up(title(section)?),
3046 Row::Entry { entry, .. } => RowKey::Entry(entry.path.clone()),
3047 Row::Door { entry, .. } => RowKey::Door(entry.path.clone()),
3048 Row::Place { path, .. } => RowKey::Place(path),
3049 })
3050 }
3051
3052 pub fn reselect(&mut self, key: Option<RowKey>) -> bool {
3056 let Some(key) = key else {
3057 self.clamp_selection();
3058 return false;
3059 };
3060 match self.place_key(&key) {
3061 Some(idx) => {
3062 self.selected = idx;
3063 true
3064 }
3065 None => {
3066 self.clamp_selection();
3067 false
3068 }
3069 }
3070 }
3071
3072 fn place_key(&mut self, key: &RowKey) -> Option<usize> {
3075 if let Some(found) = self.listed(key) {
3076 return Some(found);
3077 }
3078 if let RowKey::Entry(path) = key
3079 && let Some(root) = self
3080 .sections
3081 .iter()
3082 .find(|s| !s.grouped_by_place && s.rows.iter().any(|r| r.path == *path))
3083 .and_then(|s| s.root.clone())
3084 && self.shown_whole.insert(root.clone())
3085 {
3086 match self.listed(key) {
3087 Some(found) => return Some(found),
3088 None => {
3089 self.shown_whole.remove(&root);
3090 }
3091 }
3092 }
3093 self.behind_recent_cap(key)
3094 }
3095
3096 fn listed(&self, key: &RowKey) -> Option<usize> {
3098 self.visible().iter().position(|row| match (row, key) {
3099 (Row::Entry { entry, .. }, RowKey::Entry(path)) => entry.path == *path,
3100 (Row::Door { entry, .. }, RowKey::Door(path)) => entry.path == *path,
3101 (Row::Place { path, .. }, RowKey::Place(wanted)) => path == wanted,
3102 (Row::Header { section, .. }, RowKey::Header(title))
3103 | (Row::More { section, .. }, RowKey::More(title))
3104 | (Row::Hidden { section, .. }, RowKey::Hidden(title))
3105 | (Row::Up { section }, RowKey::Up(title)) => self
3106 .sections
3107 .get(*section)
3108 .is_some_and(|s| s.title == *title),
3109 _ => false,
3110 })
3111 }
3112
3113 fn behind_recent_cap(&self, key: &RowKey) -> Option<usize> {
3115 let (RowKey::Entry(path) | RowKey::Place(path)) = key else {
3116 return None;
3117 };
3118 self.visible().iter().position(|row| {
3119 matches!(row, Row::More { section, .. }
3120 if self.sections.get(*section).is_some_and(|s| {
3121 s.grouped_by_place
3122 && s.rows
3123 .iter()
3124 .any(|r| r.path == *path || place_of(&r.path) == *path)
3125 }))
3126 })
3127 }
3128
3129 pub fn set_view_height(&mut self, height: usize) {
3132 if height == self.view_height {
3133 return;
3134 }
3135 let key = self.selected_key();
3136 self.view_height = height;
3137 self.reselect(key);
3138 }
3139
3140 fn follow_selection(&mut self) {
3144 let rows = self.row_count();
3145 self.scroll = settle_top(self.scroll, self.selected, self.view_height, rows);
3146 }
3147
3148 fn section_folded(&self, section: &Section) -> bool {
3152 if self.browsing.is_some() {
3153 return false;
3154 }
3155 self.folds
3156 .get(§ion.title)
3157 .copied()
3158 .unwrap_or(section.folded_by_default)
3159 }
3160
3161 pub fn is_collapsed(&self, section: usize) -> bool {
3163 self.sections
3164 .get(section)
3165 .is_some_and(|s| self.section_folded(s))
3166 }
3167
3168 pub fn toggle_collapsed(&mut self, section: usize) {
3170 let folded = self.is_collapsed(section);
3171 self.set_collapsed(section, !folded);
3172 }
3173
3174 pub fn set_collapsed(&mut self, section: usize, collapsed: bool) {
3178 if self.browsing.is_some() {
3179 return;
3180 }
3181 let Some(title) = self.sections.get(section).map(|s| s.title.clone()) else {
3182 return;
3183 };
3184 self.folds.insert(title, collapsed);
3185 }
3186
3187 pub fn jump_section(&mut self, delta: isize) {
3189 self.returning = None;
3190 self.landing = false;
3191 let rows = self.visible();
3192 let headers: Vec<usize> = rows
3193 .iter()
3194 .enumerate()
3195 .filter(|(_, r)| matches!(r, Row::Header { .. }))
3196 .map(|(i, _)| i)
3197 .collect();
3198 if headers.is_empty() {
3199 return;
3200 }
3201 let current = self.selected;
3202 self.selected = if delta > 0 {
3203 headers
3204 .iter()
3205 .copied()
3206 .find(|&h| h > current)
3207 .unwrap_or(headers[0])
3208 } else {
3209 headers
3210 .iter()
3211 .rev()
3212 .copied()
3213 .find(|&h| h < current)
3214 .unwrap_or(*headers.last().unwrap())
3215 };
3216 }
3217
3218 pub fn has_any_dataset(&self) -> bool {
3222 self.view().has_dataset
3223 }
3224
3225 pub const SEARCH_SECTION: &'static str = "Found";
3227
3228 pub const CLOUD_SECTION: &'static str = "Cloud";
3230
3231 pub const RECENT_SECTION: &'static str = "Recent";
3233
3234 pub fn cloud_source_of(&self, path: &Path) -> Option<&CloudSource> {
3237 if let Some(id) = cloud_source_id(path) {
3238 return self.cloud.iter().find(|s| s.id == id);
3239 }
3240 if let Some((id, _)) = cloud_account(path) {
3241 return self.cloud.iter().find(|s| s.id == id);
3242 }
3243 let text = path.to_string_lossy();
3244 if let Some((account, _, _)) = crate::cloud::source::azure_parts(&text) {
3245 return self.azure_account_place(&account).and_then(|place| {
3246 cloud_account(&place).and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id))
3247 });
3248 }
3249 if let (Some(id), _) = crate::cloud::source::split_source_id(&text) {
3250 return self.cloud.iter().find(|s| s.id == id);
3251 }
3252 if let Some(project) =
3253 Self::google_bucket_root(path).and_then(|b| self.project_of_bucket(&b))
3254 {
3255 return cloud_account(&project)
3256 .and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id));
3257 }
3258 let (_, plain) = crate::cloud::source::split_source_id(&text);
3259 let (scheme, rest) = plain.split_once("://")?;
3260 let bucket = rest.split('/').next()?;
3261 let root = PathBuf::from(format!("{scheme}://{bucket}"));
3262 self.cloud.iter().find(|s| s.buckets.contains(&root))
3263 }
3264
3265 pub fn parent_of(&self, path: &Path) -> Option<PathBuf> {
3268 if cloud_source_id(path).is_some() {
3269 return None;
3270 }
3271 if let Some((_, dataset)) = self.remote_dataset_of(path) {
3274 let place = &dataset.location;
3275 if same_place(path, place) {
3276 return None;
3277 }
3278 let up = self.parent_within(path)?;
3279 return Some(if same_place(&up, place) {
3281 place.clone()
3282 } else {
3283 up
3284 });
3285 }
3286 if let Some((id, _)) = cloud_account(path) {
3287 return Some(cloud_place(&id));
3288 }
3289 let text = path.to_string_lossy();
3290 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3291 let key = key.trim_matches('/');
3292 if key.is_empty() {
3293 return self.azure_account_place(&account);
3294 }
3295 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3296 let up = if up.is_empty() {
3297 String::new()
3298 } else {
3299 format!("{up}/")
3300 };
3301 return Some(PathBuf::from(crate::cloud::source::azure_url(
3302 &account, &container, &up,
3303 )));
3304 }
3305 if is_bucket_root(path) {
3306 if let Some(project) = self.project_of_bucket(path) {
3308 return Some(project);
3309 }
3310 return self.cloud_source_of(path).map(|s| cloud_place(&s.id));
3311 }
3312 parent_location(path)
3313 }
3314
3315 fn parent_within(&self, path: &Path) -> Option<PathBuf> {
3317 let text = path.to_string_lossy();
3318 if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text) {
3319 let key = key.trim_matches('/');
3320 let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
3321 let up = if up.is_empty() {
3322 String::new()
3323 } else {
3324 format!("{up}/")
3325 };
3326 return Some(PathBuf::from(crate::cloud::source::azure_url(
3327 &account, &container, &up,
3328 )));
3329 }
3330 parent_location(path)
3331 }
3332
3333 fn remote_dataset_of(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3336 if !is_object_store_url(path) {
3337 return None;
3338 }
3339 let text = path.to_string_lossy();
3340 self.catalogs
3341 .iter()
3342 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3343 .filter(|(_, d)| {
3344 is_object_store_url(&d.location) && within(&text, &d.location.to_string_lossy())
3345 })
3346 .rev()
3347 .max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
3348 }
3349
3350 pub fn catalog_dataset(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
3352 let places = &self.catalog_places;
3353 if places.indexes(&self.catalogs) {
3354 let key = place_key(path);
3355 let &(c, d) = places.datasets.get(&key)?;
3356 let catalog = self.catalogs.get(c)?;
3357 if let Some(dataset) = catalog.datasets.get(d)
3358 && place_key(&dataset.location) == key
3359 {
3360 return Some((catalog, dataset));
3361 }
3362 }
3363 self.catalogs
3364 .iter()
3365 .flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
3366 .find(|(_, d)| same_place(&d.location, path))
3367 }
3368
3369 pub fn bookmark(&self, path: &Path) -> Option<(&ShownDataset, &str)> {
3371 let places = &self.catalog_places;
3372 if places.indexes(&self.catalogs) {
3373 let key = place_key(path);
3374 let &(c, d, b) = places.bookmarks.get(&key)?;
3375 if let Some(dataset) = self.catalogs.get(c).and_then(|c| c.datasets.get(d))
3376 && let Some((name, place)) = dataset.bookmarks.get(b)
3377 && place_key(place) == key
3378 {
3379 return Some((dataset, name.as_str()));
3380 }
3381 }
3382 self.catalogs
3383 .iter()
3384 .flat_map(|c| c.datasets.iter())
3385 .find_map(|d| {
3386 d.bookmarks
3387 .iter()
3388 .find(|(_, place)| same_place(place, path))
3389 .map(|(name, _)| (d, name.as_str()))
3390 })
3391 }
3392
3393 pub fn size_hint(&self, path: &Path) -> Option<u64> {
3395 self.catalog_dataset(path).and_then(|(_, d)| d.size)
3396 }
3397
3398 fn azure_account_place(&self, account: &str) -> Option<PathBuf> {
3400 self.cloud
3401 .iter()
3402 .flat_map(|s| s.buckets.iter())
3403 .find(|place| cloud_account(place).is_some_and(|(_, a)| a == account))
3404 .cloned()
3405 }
3406
3407 pub fn cloud_look(&self, entry: &Entry) -> Option<CloudLook> {
3410 if entry.kind != EntryKind::Directory
3411 || !entry.holds.is_empty()
3412 || !is_object_store_url(&entry.path)
3413 || is_cloud_place(&entry.path)
3414 || object_place_label(&entry.path).is_some()
3415 {
3416 return None;
3417 }
3418 if self.peeking.contains(&entry.path) {
3419 return Some(CloudLook::Looking);
3420 }
3421 if self.peek_failed.contains(&entry.path) {
3422 return Some(CloudLook::Failed);
3423 }
3424 match self.cloud_kinds.get(&entry.path) {
3425 None => Some(CloudLook::Waiting),
3426 Some((kind, holds)) if *kind != EntryKind::Directory || !holds.is_empty() => {
3429 Some(CloudLook::Looking)
3430 }
3431 Some(_) => None,
3432 }
3433 }
3434
3435 pub fn place_kind(&self, path: &Path) -> Option<&'static str> {
3438 if self.missing.contains(path) {
3439 return Some("missing");
3440 }
3441 if let Some((id, _)) = cloud_account(path) {
3442 return self
3443 .cloud
3444 .iter()
3445 .any(|s| s.id == id && s.api == crate::cloud::source::ProviderKind::Gcs)
3446 .then_some("project");
3447 }
3448 (is_object_store_url(path)
3450 && (self
3451 .catalog_dataset(path)
3452 .is_some_and(|(_, d)| is_object_store_url(&d.location))
3453 || (self.browsing.is_none() && self.bookmark(path).is_some())))
3454 .then_some("dataset")
3455 }
3456
3457 fn project_of_bucket(&self, bucket_root: &Path) -> Option<PathBuf> {
3459 let root = bucket_root.to_string_lossy();
3460 let root = root.trim_end_matches('/');
3461 self.probes
3462 .answered()
3463 .filter(|(place, _)| cloud_account(place).is_some())
3464 .find(|(_, rows)| {
3465 rows.iter()
3466 .any(|row| row.path.to_string_lossy().trim_end_matches('/') == root)
3467 })
3468 .map(|(place, _)| place.clone())
3469 }
3470
3471 fn google_bucket_root(path: &Path) -> Option<PathBuf> {
3473 let text = path.to_string_lossy();
3474 let rest = text
3475 .strip_prefix("gs://")
3476 .or_else(|| text.strip_prefix("gcs://"))?;
3477 let bucket = rest.split('/').next().filter(|b| !b.is_empty())?;
3478 Some(PathBuf::from(format!("gs://{bucket}")))
3479 }
3480
3481 pub fn place_details(&self, path: &Path) -> Option<&[(String, String)]> {
3484 self.cloud
3485 .iter()
3486 .find_map(|s| s.place_details.get(path))
3487 .or_else(|| self.catalog_dataset(path).map(|(_, d)| &d.details))
3488 .or_else(|| self.bookmark(path).map(|(d, _)| &d.details))
3489 .map(Vec::as_slice)
3490 }
3491
3492 pub fn location_label(&self, path: &Path) -> String {
3495 let sep = crate::glyphs::get().trail;
3496 if let Some((catalog, dataset)) = self.remote_dataset_of(path) {
3499 let text = path.to_string_lossy();
3500 let rest = within_rest(&text, &dataset.location.to_string_lossy());
3501 let mut parts = vec![catalog.label.clone(), dataset.name.clone()];
3502 parts.extend(
3503 rest.split('/')
3504 .filter(|p| !p.is_empty())
3505 .map(str::to_string),
3506 );
3507 return parts.join(&format!(" {sep} "));
3508 }
3509 if let Some(source) = self.cloud_source_of(path) {
3510 let mut parts = vec!["cloud".to_string(), source.label.clone()];
3511 let text = path.to_string_lossy();
3512 if let Some((_, account)) = cloud_account(path) {
3513 parts.push(account);
3514 } else if let Some((account, container, key)) = crate::cloud::source::azure_parts(&text)
3515 {
3516 parts.push(account);
3517 parts.push(container);
3518 parts.extend(key.split('/').filter(|p| !p.is_empty()).map(str::to_string));
3519 } else if cloud_source_id(path).is_none() {
3520 if let Some((_, project)) = Self::google_bucket_root(path)
3521 .and_then(|b| self.project_of_bucket(&b))
3522 .as_deref()
3523 .and_then(cloud_account)
3524 {
3525 parts.push(project);
3526 }
3527 let (_, plain) = crate::cloud::source::split_source_id(&text);
3528 if let Some((_, rest)) = plain.split_once("://") {
3529 parts.extend(
3530 rest.split('/')
3531 .filter(|p| !p.is_empty())
3532 .map(str::to_string),
3533 );
3534 }
3535 }
3536 return parts.join(&format!(" {sep} "));
3537 }
3538 display_path(path)
3539 }
3540
3541 pub fn sync_search_section(&mut self) {
3544 let found = (self.sections.iter()).position(|s| s.title == Self::SEARCH_SECTION);
3546 let mut kept = self.take_hits();
3547 self.sections.retain(|s| s.title != Self::SEARCH_SECTION);
3548 self.changed();
3549 if let (Some(hits), Some(at)) = (kept.as_mut(), found)
3550 && at < hits.sections.len()
3551 {
3552 hits.sections.remove(at);
3553 }
3554 *self.rows_cache.rescored.get_mut() = kept;
3555
3556 if self.filter.is_empty() {
3557 return;
3558 }
3559 let cloud_rows: Vec<Entry> = if self.browsing.is_none() {
3561 self.cloud
3562 .iter()
3563 .flat_map(|source| {
3564 source.buckets.iter().map(move |bucket| {
3565 let mut entry = bucket_entry(bucket);
3566 entry.name = format!(
3567 "{} {} {}",
3568 source.label,
3569 crate::glyphs::get().trail,
3570 entry.name
3571 );
3572 entry.cost.source = Some(source.api.name().to_string());
3573 entry
3574 })
3575 })
3576 .filter(|e| match_score(&self.filter, e).is_some())
3577 .collect()
3578 } else {
3579 Vec::new()
3580 };
3581 self.score_search_inline();
3582 let local = self.search.root.is_some()
3583 && (self.search.indexed > 0 || self.search.running || self.search.limited.is_some());
3584 if !local {
3585 if !cloud_rows.is_empty() {
3586 let subtitle =
3587 crate::glyphs::dotted(&format!("cloud · {} names", cloud_rows.len()));
3588 self.sections.push(Section {
3589 subtitle: Some(subtitle),
3590 ..Section::titled(Self::SEARCH_SECTION, cloud_rows)
3591 });
3592 }
3593 return;
3594 }
3595
3596 let matches = self.search.matches.as_ref();
3599 let names: std::collections::HashSet<&std::ffi::OsStr> = (matches.iter())
3603 .flat_map(|m| m.top.iter())
3604 .map(|e| e.path.file_name().unwrap_or(e.path.as_os_str()))
3605 .collect();
3606 let listed: std::collections::HashSet<&PathBuf> = (self.sections.iter())
3607 .flat_map(|s| s.rows.iter().map(|r| &r.path))
3608 .filter(|path| path.file_name().is_none_or(|name| names.contains(name)))
3609 .collect();
3610 let kept: Vec<(&Entry, i32)> = matches
3611 .map(|m| {
3612 let fresh = m.query == self.filter;
3613 m.top
3614 .iter()
3615 .zip(m.scores.iter().copied())
3616 .filter(|(e, _)| !listed.contains(&e.path))
3617 .filter_map(|(e, score)| {
3618 if fresh {
3619 Some((e, score))
3620 } else {
3621 match_score(&self.filter, e).map(|s| (e, s))
3622 }
3623 })
3624 .collect()
3625 })
3626 .unwrap_or_default();
3627 let mut rows: Vec<Entry> = kept.into_iter().map(|(e, _)| e.clone()).collect();
3628 rows.extend(cloud_rows);
3629 for row in &mut rows {
3632 if let Some(m) = self.enriched.get(&row.path) {
3633 fold_measured(row, m);
3634 }
3635 }
3636
3637 let partial = self.search.limited.is_some();
3639 let scored = self.search.scored_for(&self.filter);
3640 if rows.is_empty() && !self.search.running && !partial && scored {
3641 return;
3642 }
3643
3644 let subtitle = self.found_subtitle(rows.is_empty());
3645
3646 self.sections.push(Section {
3647 subtitle: Some(subtitle),
3648 ..Section::titled(Self::SEARCH_SECTION, rows)
3649 });
3650 }
3651
3652 fn found_subtitle(&self, empty: bool) -> String {
3654 let root = self.search.root.clone().unwrap_or_default();
3655 let dot = crate::glyphs::get().middot;
3656 let files = crate::numfmt::group_chrome(self.search.indexed);
3657 let files = if self.search.indexed == 1 {
3658 format!("{files} file")
3659 } else {
3660 format!("{files} files")
3661 };
3662 let subtitle = display_path(&root);
3663 let counted = self
3666 .search
3667 .matches
3668 .as_ref()
3669 .filter(|m| m.query == self.filter && m.ids.len() > m.top.len())
3670 .map(|m| {
3671 format!(
3672 " {dot} {} of {} matches",
3673 crate::numfmt::group_chrome(m.top.len()),
3674 crate::numfmt::group_chrome(m.ids.len())
3675 )
3676 })
3677 .unwrap_or_default();
3678 if self.search.running {
3679 format!(
3680 "{subtitle} {dot} searching {}{counted}",
3681 crate::numfmt::group_chrome(self.search.scanned)
3682 )
3683 } else if !self.search.scored_for(&self.filter) {
3684 format!("{subtitle} {dot} matching {files}")
3685 } else if empty {
3686 match &self.search.limited {
3687 Some(limit) => format!("{subtitle} {dot} no match in {files} {dot} {limit}"),
3688 None => format!("{subtitle} {dot} no match in {files}"),
3689 }
3690 } else {
3691 let searched = crate::numfmt::group_chrome(self.search.scanned);
3692 match &self.search.limited {
3693 Some(limit) => {
3694 format!("{subtitle} {dot} {limit} {dot} {searched} searched{counted}")
3695 }
3696 None => format!("{subtitle} {dot} {searched} searched{counted}"),
3697 }
3698 }
3699 }
3700
3701 pub fn search_batch(&mut self, root: &Path, mut found: Vec<Entry>, scanned: usize) {
3703 if self.search.root.as_deref() != Some(root) {
3705 return;
3706 }
3707 for row in &mut found {
3710 apply_known_facts(row, &self.known, false);
3711 }
3712 self.search.scanned = scanned;
3713 let start = self.search.indexed;
3714 let batch: std::sync::Arc<[Entry]> = found.into();
3715 if !batch.is_empty() {
3716 self.search.indexed += batch.len();
3717 self.search.results.push(batch.clone());
3718 }
3719 let limit = self.search_limit;
3722 let changed = match self.search.matches.as_mut() {
3723 Some(m) if m.query == self.filter && m.upto == start => m.extend(&batch, start, limit),
3724 _ => {
3725 let before = self.search.matches.as_ref().map(|m| m.upto);
3726 self.score_search_inline();
3727 self.search.matches.as_ref().map(|m| m.upto) != before
3728 }
3729 };
3730 let found_listed = self
3732 .sections
3733 .iter()
3734 .position(|s| s.title == Self::SEARCH_SECTION);
3735 match found_listed {
3736 Some(at) if !changed => {
3737 let empty = self.sections[at].rows.is_empty();
3738 self.sections[at].subtitle = Some(self.found_subtitle(empty));
3739 }
3740 _ => self.sync_search_section(),
3741 }
3742 self.settle_return();
3743 }
3744
3745 fn score_search_inline(&mut self) {
3747 if self.filter.is_empty() || self.search.scored_for(&self.filter) {
3748 return;
3749 }
3750 let (base, looks_at) = self.search.base_for(&self.filter);
3751 if looks_at > SCORE_INLINE_MAX {
3752 return;
3753 }
3754 let scored =
3755 crate::home::search::score(&self.search.results, &self.filter, base, self.search_limit);
3756 self.search.matches = Some(scored);
3757 }
3758
3759 pub fn score_job(&mut self) -> Option<ScoreJob> {
3762 if self.filter.is_empty() || self.search.scoring || self.search.scored_for(&self.filter) {
3763 return None;
3764 }
3765 let base = self.search.base_for(&self.filter).0.cloned();
3766 self.search.scoring = true;
3767 Some(ScoreJob {
3768 epoch: self.search.epoch,
3769 results: self.search.results.clone(),
3770 query: self.filter.clone(),
3771 base,
3772 limit: self.search_limit,
3773 })
3774 }
3775
3776 pub fn search_scored(&mut self, epoch: u64, scored: crate::home::search::Matches) {
3778 if epoch != self.search.epoch {
3779 return;
3780 }
3781 self.search.scoring = false;
3782 if self
3784 .search
3785 .matches
3786 .as_ref()
3787 .is_some_and(|m| m.query == scored.query && m.upto >= scored.upto)
3788 {
3789 return;
3790 }
3791 self.search.matches = Some(scored);
3792 self.sync_search_section();
3793 if !matches!(self.selected_row(), Some(Row::Entry { .. })) {
3795 self.select_first_entry();
3796 }
3797 self.settle_return();
3798 }
3799
3800 pub fn search_finished(&mut self, root: &Path, scanned: usize, limited: Option<String>) {
3802 if self.search.root.as_deref() != Some(root) {
3803 return;
3804 }
3805 self.search.running = false;
3806 self.search.done = true;
3807 self.search.scanned = self.search.scanned.max(scanned);
3809 self.search.limited = limited;
3810 self.sync_search_section();
3811 self.settle_return();
3812 }
3813
3814 pub fn visible(&self) -> Vec<Row<'_>> {
3817 let view = self.view();
3818 view.slots.iter().map(|slot| self.row(slot)).collect()
3819 }
3820
3821 pub fn header_rows(&self) -> Vec<bool> {
3823 (self.view().slots.iter())
3824 .map(|slot| matches!(slot, Slot::Plain(Row::Header { .. })))
3825 .collect()
3826 }
3827
3828 pub fn matched(&self) -> usize {
3830 (self.view().slots.iter())
3831 .map(|slot| match slot {
3832 Slot::Plain(Row::Header { matches, .. }) => *matches,
3833 _ => 0,
3834 })
3835 .sum()
3836 }
3837
3838 pub fn row_count(&self) -> usize {
3840 self.view().slots.len()
3841 }
3842
3843 pub fn row_at(&self, index: usize) -> Option<Row<'_>> {
3845 self.view().slots.get(index).map(|slot| self.row(slot))
3846 }
3847
3848 pub fn rows_built(&self) -> usize {
3851 self.rows_cache.builds.get()
3852 }
3853
3854 fn changed(&mut self) {
3856 *self.rows_cache.built.get_mut() = None;
3857 *self.rows_cache.rescored.get_mut() = None;
3858 }
3859
3860 fn take_hits(&mut self) -> Option<Hits> {
3863 let built = self.rows_cache.built.get_mut().take();
3864 match self.rows_cache.rescored.get_mut().take() {
3865 Some(hits) => Some(hits),
3866 None => built
3867 .filter(|view| view.key.matches(self))
3868 .map(|view| view.hits),
3869 }
3870 .filter(|hits| hits.filter == self.filter)
3871 }
3872
3873 fn rows_changed(&mut self, touched: &[(usize, usize)]) {
3877 let Some(mut hits) = self.take_hits() else {
3878 return;
3879 };
3880 let needle = Needle::new(&self.filter);
3881 for &(si, i) in touched {
3882 if let Some(Some(section)) = hits.sections.get_mut(si)
3883 && let Some(hit) = section.get_mut(i)
3884 {
3885 *hit = needle.hit(&self.sections[si].rows[i]);
3886 }
3887 }
3888 *self.rows_cache.rescored.get_mut() = Some(hits);
3889 }
3890
3891 pub fn set_catalogs(&mut self, catalogs: Vec<ShownCatalog>) {
3893 self.catalog_places = CatalogPlaces::of(&catalogs);
3894 self.catalogs = catalogs;
3895 self.changed();
3896 }
3897
3898 pub fn set_visits(&mut self, visits: std::collections::HashMap<PathBuf, crate::cache::Visits>) {
3900 self.visits = visits;
3901 self.changed();
3902 }
3903
3904 pub fn show_all(&mut self, section: usize) {
3906 match self.sections.get(section) {
3907 Some(s) if s.grouped_by_place => self.recent_expanded = true,
3908 Some(Section {
3909 root: Some(root), ..
3910 }) => {
3911 self.shown_whole.insert(root.clone());
3912 }
3913 _ => {}
3914 }
3915 }
3916
3917 pub fn cut_again(&mut self, section: usize) -> bool {
3920 let Some(key) = self.selected_key() else {
3921 return false;
3922 };
3923 let Some(s) = self.sections.get(section) else {
3924 return false;
3925 };
3926 let title = s.title.clone();
3927 let whole = if s.grouped_by_place {
3928 None
3929 } else {
3930 match s.root.clone() {
3931 Some(root) => Some(root),
3932 None => return false,
3933 }
3934 };
3935 let was_whole = match &whole {
3936 None => std::mem::replace(&mut self.recent_expanded, false),
3937 Some(root) => self.shown_whole.remove(root),
3938 };
3939 if !was_whole {
3940 return false;
3941 }
3942 if self.listed(&key).is_some() {
3943 match whole {
3944 None => self.recent_expanded = true,
3945 Some(root) => {
3946 self.shown_whole.insert(root);
3947 }
3948 }
3949 return false;
3950 }
3951 self.reselect(Some(RowKey::More(title)));
3952 true
3953 }
3954
3955 pub fn sections_mut(&mut self) -> &mut Vec<Section> {
3957 self.changed();
3958 &mut self.sections
3959 }
3960
3961 fn view(&self) -> std::cell::Ref<'_, View> {
3962 let fresh = self
3963 .rows_cache
3964 .built
3965 .borrow()
3966 .as_ref()
3967 .is_some_and(|view| view.key.matches(self));
3968 if !fresh {
3969 let view = self.build_view();
3970 self.rows_cache.builds.set(self.rows_cache.builds.get() + 1);
3971 *self.rows_cache.built.borrow_mut() = Some(view);
3972 }
3973 std::cell::Ref::map(self.rows_cache.built.borrow(), |view| {
3974 view.as_ref().expect("built above")
3975 })
3976 }
3977
3978 fn row<'a>(&'a self, slot: &Slot) -> Row<'a> {
3979 match slot {
3980 Slot::Plain(row) => row.clone(),
3981 Slot::Entry {
3982 section,
3983 index,
3984 nested,
3985 hit,
3986 } => Row::Entry {
3987 section: *section,
3988 entry: &self.sections[*section].rows[*index],
3989 nested: *nested,
3990 hit: *hit,
3991 },
3992 Slot::Door { section } => Row::Door {
3993 section: *section,
3994 entry: self.sections[*section]
3995 .door
3996 .as_ref()
3997 .expect("the shape says it has a door"),
3998 },
3999 }
4000 }
4001
4002 fn build_view(&self) -> View {
4003 let kept = (self.rows_cache.rescored.take()).filter(|hits| {
4005 hits.filter == self.filter && hits.sections.len() <= self.sections.len()
4006 });
4007 let hits = self.score_rows(kept);
4008 View {
4009 key: ViewKey::of(self),
4010 slots: self.slots(&hits),
4011 hits,
4012 has_dataset: self
4013 .sections
4014 .iter()
4015 .flat_map(|s| s.rows.iter())
4016 .any(|e| e.kind.is_dataset() || e.kind.is_lake_table()),
4017 }
4018 }
4019
4020 fn score_rows(&self, kept: Option<Hits>) -> Hits {
4023 let needle = Needle::new(&self.filter);
4024 let mut kept = kept.map(|hits| hits.sections).unwrap_or_default();
4025 let sections =
4026 (self.sections.iter().enumerate())
4027 .map(|(si, section)| {
4028 let reused = (kept.get_mut(si).and_then(Option::take))
4029 .filter(|hits| hits.len() == section.rows.len());
4030 Some(reused.unwrap_or_else(|| {
4031 (section.rows.iter()).map(|row| needle.hit(row)).collect()
4032 }))
4033 })
4034 .collect();
4035 Hits {
4036 filter: self.filter.clone(),
4037 sections,
4038 }
4039 }
4040
4041 fn slots(&self, hits: &Hits) -> Vec<Slot> {
4042 let mut out: Vec<Slot> = Vec::new();
4043 for (si, section) in self.sections.iter().enumerate() {
4044 let mut matched: Vec<(usize, Hit)> = (section.rows.iter())
4045 .zip(hits.sections[si].iter().flatten())
4046 .enumerate()
4047 .filter(|(_, (row, _))| !(self.hide_unreadable && row.hidden_by_default()))
4048 .filter_map(|(i, (_, hit))| hit.map(|hit| (i, hit)))
4049 .collect();
4050
4051 let keep_empty = section.unavailable || section.waiting || section.origin.is_some();
4055 let has_door = section.door.is_some() && self.filter.is_empty();
4056 let hidden =
4059 if self.browsing.is_some() && self.hide_unreadable && self.filter.is_empty() {
4060 section
4061 .rows
4062 .iter()
4063 .filter(|row| row.hidden_by_default())
4064 .count()
4065 } else {
4066 0
4067 };
4068 let says_why = section.title == Self::SEARCH_SECTION;
4070 if matched.is_empty()
4071 && !has_door
4072 && hidden == 0
4073 && !says_why
4074 && !(keep_empty && self.filter.is_empty())
4075 {
4076 continue;
4077 }
4078
4079 let entry = |i: usize| §ion.rows[i];
4083 if !self.filter.is_empty() {
4084 let now = std::time::SystemTime::now()
4085 .duration_since(std::time::UNIX_EPOCH)
4086 .map(|d| d.as_secs())
4087 .unwrap_or_default();
4088 let lifted = |i: usize, score: i32| {
4089 let frecency = self
4090 .visits
4091 .get(&entry(i).path)
4092 .map_or(0.0, |v| v.frecency(now));
4093 score.saturating_add((frecency.min(10.0) * FRECENCY_LIFT) as i32)
4094 };
4095 matched.sort_by_cached_key(|(i, hit)| {
4096 (
4097 std::cmp::Reverse(lifted(*i, hit.score)),
4098 entry(*i).name.len(),
4099 )
4100 });
4101 }
4102
4103 match self.sort {
4106 SortMode::Natural => {}
4107 SortMode::Size => {
4108 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).size.unwrap_or(0)));
4109 }
4110 SortMode::Rows => {
4111 matched.sort_by_key(|(i, _)| std::cmp::Reverse(entry(*i).rows.unwrap_or(0)));
4112 }
4113 SortMode::Modified => {
4114 matched.sort_by_key(|(i, _)| {
4115 std::cmp::Reverse(
4116 entry(*i)
4117 .modified
4118 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
4119 .map(|d| d.as_secs())
4120 .unwrap_or(0),
4121 )
4122 });
4123 }
4124 }
4125
4126 let collapsed = self.section_folded(section);
4127 out.push(Slot::Plain(Row::Header {
4128 section: si,
4129 matches: matched.len(),
4131 collapsed,
4132 }));
4133 if collapsed {
4134 continue;
4135 }
4136 let root = section.root.as_deref();
4138 if self.filter.is_empty()
4139 && root
4140 .is_some_and(|root| self.browsing.is_some() || self.parent_of(root).is_some())
4141 {
4142 out.push(Slot::Plain(Row::Up { section: si }));
4143 }
4144 if has_door {
4147 out.push(Slot::Door { section: si });
4148 }
4149 let shown = match root {
4153 Some(_)
4154 if self.browsing.is_none()
4155 && self.filter.is_empty()
4156 && self.view_height > 0
4157 && !root.is_some_and(|root| self.shown_whole.contains(root)) =>
4158 {
4159 (self.view_height * 2 / 5).max(8)
4160 }
4161 _ => usize::MAX,
4162 };
4163 let rest = if matched.len() > shown.saturating_add(1) {
4164 matched.split_off(shown)
4165 } else {
4166 Vec::new()
4167 };
4168 let measuring = self.sort == SortMode::Rows
4171 && rest.iter().any(|(i, _)| self.wants_measuring(entry(*i)));
4172 if section.grouped_by_place {
4173 self.slots_by_place(si, section, &matched, &mut out);
4174 } else {
4175 let in_order = self.sort == SortMode::Natural
4177 && self.filter.is_empty()
4178 && section.origin.is_some_and(is_catalog_origin)
4179 && section.root.is_none();
4180 out.extend(matched.into_iter().map(|(index, hit)| Slot::Entry {
4181 section: si,
4182 index,
4183 nested: in_order && self.bookmark(&entry(index).path).is_some(),
4184 hit,
4185 }));
4186 }
4187 if !rest.is_empty() {
4188 out.push(Slot::Plain(Row::More {
4189 section: si,
4190 hidden: rest.len(),
4191 places: 0,
4192 measuring,
4193 }));
4194 }
4195 if hidden > 0 {
4196 out.push(Slot::Plain(Row::Hidden {
4197 section: si,
4198 count: hidden,
4199 }));
4200 }
4201 }
4202 out
4203 }
4204
4205 fn slots_by_place(
4211 &self,
4212 si: usize,
4213 section: &Section,
4214 matched: &[(usize, Hit)],
4215 out: &mut Vec<Slot>,
4216 ) {
4217 let places: Vec<PathBuf> = section.rows.iter().map(|row| place_of(&row.path)).collect();
4218 let mut order: Vec<&PathBuf> = Vec::new();
4219 for place in &places {
4220 if !order.contains(&place) {
4221 order.push(place);
4222 }
4223 }
4224 let groups: Vec<(&PathBuf, Vec<&(usize, Hit)>)> = order
4225 .into_iter()
4226 .filter_map(|place| {
4227 let rows: Vec<&(usize, Hit)> = matched
4228 .iter()
4229 .filter(|(i, _)| places[*i] == *place)
4230 .collect();
4231 (!rows.is_empty()).then_some((place, rows))
4232 })
4233 .collect();
4234
4235 let capped = !self.recent_expanded && self.filter.is_empty() && self.view_height > 0;
4237 let budget = self.view_height / 3;
4238 let mut used = 0usize;
4239 let mut shown = 0usize;
4240 for (place, rows) in &groups {
4241 let cost = 1 + rows.len();
4242 if capped && shown > 0 && used + cost > budget {
4243 break;
4244 }
4245 out.push(Slot::Plain(Row::Place {
4246 section: si,
4247 path: (*place).clone(),
4248 label: section.place_labels.get(*place).cloned(),
4249 source: section.rows[rows[0].0].cost.source.clone(),
4251 held: places.iter().filter(|p| p == place).count(),
4252 }));
4253 out.extend(rows.iter().map(|(index, hit)| Slot::Entry {
4254 section: si,
4255 index: *index,
4256 nested: true,
4257 hit: *hit,
4258 }));
4259 used += cost;
4260 shown += 1;
4261 }
4262 if shown < groups.len() {
4263 out.push(Slot::Plain(Row::More {
4264 section: si,
4265 hidden: groups[shown..].iter().map(|(_, rows)| rows.len()).sum(),
4266 places: groups.len() - shown,
4267 measuring: false,
4268 }));
4269 }
4270 }
4271
4272 pub fn path_candidates(&self) -> Vec<&PathName> {
4275 let Some(listing) = self
4276 .path_listing
4277 .as_ref()
4278 .filter(|l| l.dir == typed_dir(&self.path_input))
4279 else {
4280 return Vec::new();
4281 };
4282 let segment = &self.path_input[listing.dir.len()..];
4283 let mut matched: Vec<(&PathName, i32)> = listing
4284 .names
4285 .iter()
4286 .filter(|n| !n.name.starts_with('.') || segment.starts_with('.'))
4287 .filter_map(|n| {
4288 if segment.is_empty() {
4289 return Some((n, 0));
4290 }
4291 let prefix = n.name.starts_with(segment) as i32 * 1_000_000;
4293 fuzzy_score(segment, &n.name).map(|score| (n, prefix + score))
4294 })
4295 .collect();
4296 matched.sort_by(|(a, sa), (b, sb)| sb.cmp(sa).then_with(|| a.name.cmp(&b.name)));
4297 matched.into_iter().map(|(n, _)| n).collect()
4298 }
4299
4300 pub fn pick_first_path(&mut self) {
4303 self.path_pick = (!self.path_candidates().is_empty()).then_some(0);
4304 }
4305
4306 pub fn picked_path(&self) -> Option<String> {
4308 let pick = self.path_pick?;
4309 let name = *self.path_candidates().get(pick)?;
4310 let dir = typed_dir(&self.path_input);
4311 let mut path = format!("{dir}{}", name.name);
4312 if name.dir {
4313 path.push(separator_in(dir));
4314 }
4315 Some(path)
4316 }
4317
4318 pub fn path_completion(&self) -> Option<String> {
4321 let dir = typed_dir(&self.path_input);
4322 let segment = &self.path_input[dir.len()..];
4323 let candidates = self.path_candidates();
4324 match candidates.as_slice() {
4325 [] => None,
4326 [one] => {
4327 let mut path = format!("{dir}{}", one.name);
4328 if one.dir {
4329 path.push(separator_in(dir));
4330 }
4331 Some(path)
4332 }
4333 many => {
4334 let starting: Vec<&str> = many
4335 .iter()
4336 .map(|n| n.name.as_str())
4337 .filter(|n| n.starts_with(segment))
4338 .collect();
4339 let first = starting.first()?;
4340 let shared = starting
4341 .iter()
4342 .skip(1)
4343 .fold(first.to_string(), |acc, n| common_prefix(&acc, n));
4344 (shared.len() > segment.len()).then(|| format!("{dir}{shared}"))
4345 }
4346 }
4347 }
4348
4349 pub fn known_urls(&self) -> Vec<String> {
4352 let mut urls: Vec<String> = Vec::new();
4353 let mut add = |path: &Path| {
4354 let text = path.to_string_lossy();
4355 if text.contains("://") && !is_cloud_place(path) {
4356 urls.push(text.into_owned());
4357 }
4358 };
4359 for catalog in &self.catalogs {
4360 for dataset in &catalog.datasets {
4361 add(&dataset.location);
4362 }
4363 }
4364 for source in &self.cloud {
4365 for bucket in &source.buckets {
4366 add(bucket);
4367 }
4368 }
4369 for (root, rows) in self.probes.answered() {
4370 add(root);
4371 for row in rows.iter() {
4372 add(&row.path);
4373 }
4374 }
4375 for path in self.known.keys() {
4376 add(path);
4377 }
4378 for section in &self.sections {
4379 for row in §ion.rows {
4380 add(&row.path);
4381 }
4382 }
4383 urls
4384 }
4385
4386 pub fn selected_row(&self) -> Option<Row<'_>> {
4388 self.row_at(self.selected)
4389 }
4390
4391 pub fn selected_entry(&self) -> Option<&Entry> {
4394 match self.selected_row()? {
4395 Row::Entry { entry, .. } | Row::Door { entry, .. } => Some(entry),
4396 _ => None,
4397 }
4398 }
4399
4400 pub fn selection_is_the_door(&self) -> bool {
4402 matches!(self.selected_row(), Some(Row::Door { .. }))
4403 }
4404
4405 pub fn recents_in(&self, place: &Path) -> Vec<PathBuf> {
4407 self.sections
4408 .iter()
4409 .filter(|s| s.grouped_by_place)
4410 .flat_map(|s| s.rows.iter())
4411 .filter(|row| place_of(&row.path) == place)
4412 .map(|row| row.path.clone())
4413 .collect()
4414 }
4415
4416 pub fn selected_section(&self) -> Option<usize> {
4418 self.selected_row().map(|r| r.section())
4419 }
4420
4421 pub fn selected_catalog(&self) -> Option<&ShownCatalog> {
4423 if !self.selection_is_header() {
4424 return None;
4425 }
4426 let section = self.sections.get(self.selected_section()?)?;
4427 let origin = section.origin?;
4428 self.catalogs
4429 .iter()
4430 .find(|c| c.label == section.title && c.origin_note() == origin)
4431 }
4432
4433 pub fn selection_is_header(&self) -> bool {
4435 matches!(self.selected_row(), Some(Row::Header { .. }))
4436 }
4437
4438 pub fn pending_probes(&self) -> Vec<PathBuf> {
4441 let check = self.network_check;
4442 let mut out = Vec::new();
4443 for root in self.sections.iter().filter_map(|s| s.remote_root.as_ref()) {
4445 if !self.probes.settled(root) && !out.contains(root) {
4446 out.push(root.clone());
4447 }
4448 }
4449 if let Some(dir) = &self.browsing
4452 && check(dir)
4453 && cloud_source_id(dir).is_none()
4454 && !self.probes.settled(dir)
4455 && !out.contains(dir)
4456 {
4457 out.push(dir.clone());
4458 }
4459 out
4460 }
4461
4462 pub fn below_browse_start(&self) -> bool {
4465 let (Some(dir), Some(start)) = (&self.browsing, &self.browse_start) else {
4466 return false;
4467 };
4468 if dir == start {
4469 return false;
4470 }
4471 let mut current = self.parent_of(dir);
4473 let mut steps = 0;
4474 while let Some(place) = current {
4475 if &place == start {
4476 return true;
4477 }
4478 steps += 1;
4479 if steps > 64 {
4480 break;
4481 }
4482 current = self.parent_of(&place);
4483 }
4484 false
4485 }
4486
4487 pub fn sections_waiting(&self) -> bool {
4489 self.sections.iter().any(|s| s.waiting)
4490 }
4491
4492 pub fn awaiting_listing(&self) -> Option<&Path> {
4494 let dir = self.browsing.as_deref()?;
4495 if cloud_source_id(dir).is_some() {
4496 return self
4497 .cloud_source_of(dir)
4498 .is_some_and(|s| s.status == CloudStatus::Listing && s.buckets.is_empty())
4499 .then_some(dir);
4500 }
4501 ((self.network_check)(dir) && !self.probes.settled(dir)).then_some(dir)
4502 }
4503
4504 pub fn probe_ready(&mut self, root: PathBuf, rows: Vec<Entry>, cut_short: bool) {
4506 self.probes.insert(
4507 root.clone(),
4508 Probe::Listed {
4509 rows: rows.into(),
4510 cut_short,
4511 },
4512 );
4513 self.apply_cloud_kinds(&root);
4514 }
4515
4516 pub fn apply_cloud_kinds(&mut self, root: &Path) {
4518 let Some(rows) = self.probes.listed_mut(root) else {
4519 return;
4520 };
4521 for row in std::sync::Arc::make_mut(rows).iter_mut() {
4523 if row.kind == EntryKind::Directory
4524 && let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
4525 {
4526 row.kind = *kind;
4527 if !holds.is_empty() {
4530 row.holds = holds.clone();
4531 }
4532 }
4533 }
4534 }
4535
4536 pub fn cloud_directories_to_peek(&self, limit: usize) -> Vec<PathBuf> {
4540 if limit == 0 {
4541 return Vec::new();
4542 }
4543 let view = self.view();
4544 let mut out: Vec<PathBuf> = Vec::new();
4545 for entry in self.entries_near_cursor(&view, limit) {
4546 if !is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
4549 continue;
4550 }
4551 if !matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown) {
4552 continue;
4553 }
4554 if self.browsing.is_none()
4557 && (self.catalog_dataset(&entry.path).is_some()
4558 || self.bookmark(&entry.path).is_some())
4559 {
4560 continue;
4561 }
4562 if self.cloud_kinds.contains_key(&entry.path)
4564 || self.peeking.contains(&entry.path)
4565 || self.peek_failed.contains(&entry.path)
4566 {
4567 continue;
4568 }
4569 if out.contains(&entry.path) {
4570 continue;
4571 }
4572 out.push(entry.path.clone());
4573 if out.len() >= limit {
4574 break;
4575 }
4576 }
4577 out
4578 }
4579
4580 pub fn probe_failed(&mut self, root: PathBuf, why: Option<String>) {
4582 self.probes.insert(root, Probe::Unreachable(why));
4583 }
4584
4585 pub fn measure_now(&mut self, limit: usize) -> bool {
4589 let wanted = self.unmeasured_visible(limit);
4590 let more = self.unmeasured_visible(limit + 1).len() > wanted.len();
4591 for entry in wanted {
4592 let mut probe = entry.clone();
4593 discover::enrich(&mut probe);
4594 self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
4595 }
4596 self.apply_new_measurements();
4597 more
4598 }
4599
4600 pub fn unmeasured_visible(&self, limit: usize) -> Vec<Entry> {
4605 let view = self.view();
4606 let mut out: Vec<Entry> = Vec::new();
4607 let listed = view.slots.iter().filter_map(|slot| self.entry_of(slot));
4608 let near: Box<dyn Iterator<Item = &Entry>> = if self.view_height == 0 {
4610 Box::new(std::iter::empty())
4611 } else {
4612 Box::new(self.entries_near_cursor(&view, limit))
4613 };
4614 for entry in near.chain(listed) {
4615 if self.wants_measuring(entry) && !out.iter().any(|e| e.path == entry.path) {
4616 out.push(entry.clone());
4617 if out.len() >= limit {
4618 return out;
4619 }
4620 }
4621 }
4622 for slot in &view.slots {
4625 let Slot::Plain(Row::More {
4626 section,
4627 measuring: true,
4628 ..
4629 }) = slot
4630 else {
4631 continue;
4632 };
4633 for entry in &self.sections[*section].rows {
4634 if self.wants_measuring(entry) && !out.iter().any(|e| e.path == entry.path) {
4635 out.push(entry.clone());
4636 if out.len() >= limit {
4637 return out;
4638 }
4639 }
4640 }
4641 }
4642 out
4643 }
4644
4645 fn wants_measuring(&self, entry: &Entry) -> bool {
4647 if entry.rows.is_some() || entry.measured || self.enriched.contains_key(&entry.path) {
4648 return false;
4649 }
4650 if matches!(
4653 entry.kind,
4654 EntryKind::Directory | EntryKind::Unknown | EntryKind::Other
4655 ) || entry.kind.is_lake_table()
4656 {
4657 return false;
4658 }
4659 !(self.network_check)(&entry.path)
4662 }
4663
4664 pub fn classify_now(&mut self, limit: usize) -> bool {
4667 let wanted = self.unclassified_visible(limit);
4668 let more = self.unclassified_visible(limit + 1).len() > wanted.len();
4669 for entry in wanted {
4670 let probe = look_into_as(&entry, &Default::default());
4671 self.record_measurement(entry.path.clone(), measured_from(&probe, &entry));
4672 }
4673 self.apply_new_measurements();
4674 more
4675 }
4676
4677 pub fn unclassified_visible(&self, limit: usize) -> Vec<Entry> {
4683 if limit == 0 {
4684 return Vec::new();
4685 }
4686 let view = self.view();
4687 let mut out: Vec<Entry> = Vec::new();
4688 for entry in self.entries_near_cursor(&view, limit) {
4689 if entry.kind != EntryKind::Unknown || self.missing.contains(&entry.path) {
4690 continue;
4691 }
4692 if self.enriched.contains_key(&entry.path) {
4694 continue;
4695 }
4696 if is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
4699 continue;
4700 }
4701 if out.iter().any(|e| e.path == entry.path) {
4703 continue;
4704 }
4705 out.push(entry.clone());
4706 if out.len() >= limit {
4707 break;
4708 }
4709 }
4710 out
4711 }
4712
4713 fn entries_near_cursor<'a>(
4716 &'a self,
4717 view: &'a View,
4718 limit: usize,
4719 ) -> impl Iterator<Item = &'a Entry> + 'a {
4720 let height = if self.view_height == 0 {
4723 limit
4724 } else {
4725 self.view_height
4726 };
4727 let rows = view.slots.len();
4728 let top = self.scroll.min(rows);
4729 let ahead = top.saturating_add(2 * height).min(rows);
4730 let behind = top.saturating_sub(height);
4731 std::iter::once(self.selected)
4732 .chain(top..ahead)
4733 .chain(behind..top)
4734 .filter_map(|i| self.entry_of(view.slots.get(i)?))
4735 }
4736
4737 fn entry_of(&self, slot: &Slot) -> Option<&Entry> {
4739 match slot {
4740 Slot::Entry { section, index, .. } => Some(&self.sections[*section].rows[*index]),
4741 _ => None,
4742 }
4743 }
4744
4745 pub fn record_measurement(&mut self, path: PathBuf, measured: Measured) {
4748 self.unapplied.insert(path.clone());
4749 self.enriched.insert(path, measured);
4750 }
4751
4752 pub fn record_size(&mut self, path: PathBuf, measured: Measured) {
4754 match self.enriched.get_mut(&path) {
4755 Some(known) => {
4756 known.size = measured.size;
4757 self.unapplied.insert(path);
4758 }
4759 None => self.record_measurement(path, measured),
4760 }
4761 }
4762
4763 pub fn apply_measurements(&mut self) {
4766 self.unapplied.clear();
4767 for section in &mut self.sections {
4768 for row in section.rows.iter_mut().chain(section.door.iter_mut()) {
4771 if let Some(m) = self.enriched.get(&row.path) {
4772 fold_measured(row, m);
4773 }
4774 }
4775 if let Some(door) = section.door.as_mut() {
4778 door.name = door_name(door, §ion.rows);
4779 }
4780 }
4781 self.changed();
4782 self.land_again();
4783 }
4784
4785 pub fn apply_new_measurements(&mut self) {
4789 if self.unapplied.is_empty() {
4790 return;
4791 }
4792 let unapplied = std::mem::take(&mut self.unapplied);
4793 let names: std::collections::HashSet<&std::ffi::OsStr> = (unapplied.iter())
4797 .map(|path| path.file_name().unwrap_or(path.as_os_str()))
4798 .collect();
4799 let new = |path: &Path| {
4800 path.file_name().is_none_or(|name| names.contains(name)) && unapplied.contains(path)
4801 };
4802 let mut touched: Vec<(usize, usize)> = Vec::new();
4803 let mut any = false;
4804 for (si, section) in self.sections.iter_mut().enumerate() {
4805 let mut here = false;
4806 for (i, row) in section.rows.iter_mut().enumerate() {
4807 if new(&row.path)
4808 && let Some(m) = self.enriched.get(&row.path)
4809 {
4810 fold_measured(row, m);
4811 touched.push((si, i));
4812 here = true;
4813 }
4814 }
4815 if let Some(door) = section.door.as_mut()
4816 && new(&door.path)
4817 && let Some(m) = self.enriched.get(&door.path)
4818 {
4819 fold_measured(door, m);
4820 here = true;
4821 }
4822 if here && let Some(door) = section.door.as_mut() {
4823 door.name = door_name(door, §ion.rows);
4824 }
4825 any |= here;
4826 }
4827 if any {
4828 self.rows_changed(&touched);
4829 self.land_again();
4830 }
4831 }
4832
4833 fn land_again(&mut self) {
4836 if self.landing
4837 && let Some(Row::Door { entry, .. }) = self.row_at(self.selected)
4838 && !door_lands(entry)
4839 {
4840 self.selected = self.landing_row();
4841 self.follow_selection();
4842 }
4843 }
4844
4845 pub fn select_first_entry(&mut self) {
4851 self.returning = None;
4852 self.landing = true;
4853 self.selected = self.landing_row();
4854 }
4855
4856 fn landing_row(&self) -> usize {
4858 let rows = self.visible();
4859 if self.filter.is_empty()
4861 && let Some(newest) = self.newest_recent.as_ref()
4862 && let Some(at) = rows.iter().position(|r| {
4863 matches!(r, Row::Entry { section, entry, .. }
4864 if entry.path == *newest
4865 && self.sections[*section].title == Self::RECENT_SECTION)
4866 })
4867 {
4868 return at;
4869 }
4870 let first = rows
4871 .iter()
4872 .position(|r| matches!(r, Row::Entry { .. } | Row::Door { .. }));
4873 let first = match first.and_then(|i| rows.get(i)) {
4874 Some(Row::Door { entry, .. }) if !door_lands(entry) => rows
4875 .iter()
4876 .position(|r| matches!(r, Row::Entry { .. } | Row::Hidden { .. }))
4877 .or(first),
4878 _ => first,
4879 };
4880 first
4882 .or_else(|| rows.iter().position(|r| matches!(r, Row::Hidden { .. })))
4883 .unwrap_or(0)
4884 }
4885
4886 pub fn clamp_selection(&mut self) {
4887 let n = self.row_count();
4888 if n == 0 {
4889 self.selected = 0;
4890 } else if self.selected >= n {
4891 self.selected = n - 1;
4892 }
4893 }
4894
4895 pub fn select(&mut self, index: usize) {
4897 if index < self.row_count() {
4898 self.returning = None;
4899 self.landing = false;
4900 self.selected = index;
4901 }
4902 }
4903
4904 pub fn move_selection(&mut self, delta: isize) {
4905 self.returning = None;
4906 self.landing = false;
4907 let n = self.row_count();
4908 if n == 0 {
4909 return;
4910 }
4911 let cur = self.selected as isize;
4912 let next = (cur + delta).rem_euclid(n as isize);
4913 self.selected = next as usize;
4914 }
4915
4916 pub fn page_selection(&mut self, delta: isize) {
4919 self.returning = None;
4920 self.landing = false;
4921 let n = self.row_count();
4922 if n == 0 {
4923 return;
4924 }
4925 let next = (self.selected as isize)
4927 .saturating_add(delta)
4928 .clamp(0, n as isize - 1);
4929 self.selected = next as usize;
4930 }
4931}
4932
4933fn source_entry(source: &CloudSource) -> Entry {
4935 Entry {
4936 path: cloud_place(&source.id),
4937 kind: EntryKind::Directory,
4938 name: source.label.clone(),
4939 size: None,
4940 modified: source.listed_at,
4941 rows: None,
4942 cols: None,
4943 cols_sampled: false,
4944 columns: Vec::new(),
4945 cost: Default::default(),
4946 holds: Default::default(),
4947 opens_whole_directory: false,
4948 measured: false,
4949 format_spec: None,
4950 table: None,
4951 }
4952}
4953
4954fn bucket_entry(url: &Path) -> Entry {
4956 let mut entry = Entry::directory(url);
4957 let text = url.to_string_lossy();
4959 let (_, plain) = crate::cloud::source::split_source_id(&text);
4960 entry.name = plain
4961 .rsplit('/')
4962 .find(|part| !part.is_empty())
4963 .unwrap_or("")
4964 .to_string();
4965 entry
4966}
4967
4968pub fn names_a_file(path: &Path) -> bool {
4971 let named = path.to_string_lossy();
4972 let dotted = !named.ends_with('/')
4975 && path
4976 .file_name()
4977 .map(|last| last.to_string_lossy())
4978 .is_some_and(|last| last.trim_start_matches('.').contains('.'));
4979 discover::is_data_file(path) || dotted
4980}
4981
4982fn entry_for_path(path: &Path, remote: bool) -> Entry {
4984 if !remote && let Some(table) = discover::table_row(path) {
4986 return table;
4987 }
4988 let mut holds = discover::Holds::default();
4989 let kind = if remote {
4992 if names_a_file(path) {
4997 EntryKind::File
4998 } else {
4999 EntryKind::Unknown
5000 }
5001 } else if path.is_dir() {
5002 let (kind, found) = discover::look_at_directory(path);
5003 holds = found;
5004 kind
5005 } else {
5006 EntryKind::File
5007 };
5008 let mut entry = Entry {
5009 path: path.to_path_buf(),
5010 kind,
5011 name: path
5012 .file_name()
5013 .map(|n| n.to_string_lossy().into_owned())
5014 .unwrap_or_else(|| path.to_string_lossy().into_owned()),
5015 size: None,
5016 modified: None,
5017 rows: None,
5018 cols: None,
5019 cols_sampled: false,
5020 columns: Vec::new(),
5021 cost: Default::default(),
5022 holds,
5023 opens_whole_directory: false,
5024 measured: false,
5025 format_spec: None,
5026 table: None,
5027 };
5028 if !remote && let Ok(meta) = std::fs::metadata(path) {
5029 if meta.is_file() {
5030 entry.size = Some(meta.len());
5031 }
5032 entry.modified = meta.modified().ok();
5033 }
5034 entry
5035}
5036
5037pub fn display_path(path: &Path) -> String {
5039 if let Some(home) = dirs::home_dir()
5040 && let Ok(rest) = path.strip_prefix(&home)
5041 {
5042 if rest.as_os_str().is_empty() {
5043 return "~".to_string();
5044 }
5045 return format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display());
5047 }
5048 path.display().to_string()
5049}
5050
5051pub fn complete_path(typed: &str) -> (String, usize) {
5055 let expanded = expand_user_path(typed);
5056 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5058 let typed_ends_in_sep = typed.ends_with(is_separator);
5059
5060 let (dir, prefix) = if typed_ends_in_sep {
5061 (expanded.clone(), String::new())
5062 } else {
5063 match (expanded.parent(), expanded.file_name()) {
5064 (Some(parent), Some(name)) => {
5065 (parent.to_path_buf(), name.to_string_lossy().into_owned())
5066 }
5067 _ => (expanded.clone(), String::new()),
5068 }
5069 };
5070
5071 let Ok(entries) = std::fs::read_dir(&dir) else {
5072 return (typed.to_string(), 0);
5073 };
5074
5075 let mut names: Vec<String> = entries
5076 .flatten()
5077 .filter_map(|e| {
5078 let name = e.file_name().to_string_lossy().into_owned();
5079 if name.starts_with('.') && !prefix.starts_with('.') {
5081 return None;
5082 }
5083 name.starts_with(&prefix).then_some(name)
5084 })
5085 .collect();
5086 if names.is_empty() {
5087 return (typed.to_string(), 0);
5088 }
5089 names.sort();
5090
5091 let shared = names
5093 .iter()
5094 .skip(1)
5095 .fold(names[0].clone(), |acc, name| common_prefix(&acc, name));
5096
5097 let mut completed = typed.to_string();
5098 completed.truncate(typed.len() - prefix.len());
5099 completed.push_str(&shared);
5100
5101 if names.len() == 1 && dir.join(&shared).is_dir() && !completed.ends_with(is_separator) {
5104 let separator = typed
5105 .chars()
5106 .rev()
5107 .find(|c| is_separator(*c))
5108 .unwrap_or(std::path::MAIN_SEPARATOR);
5109 completed.push(separator);
5110 }
5111 (completed, names.len())
5112}
5113
5114#[derive(Debug, Clone, PartialEq, Eq)]
5116pub struct PathName {
5117 pub name: String,
5118 pub dir: bool,
5120}
5121
5122#[derive(Debug, Clone, Default, PartialEq, Eq)]
5124pub struct PathListing {
5125 pub dir: String,
5127 pub names: Vec<PathName>,
5128 pub failed: bool,
5130}
5131
5132const PATH_LISTING_MAX: usize = 5_000;
5134
5135pub fn typed_dir(typed: &str) -> &str {
5138 let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
5139 let floor = typed.find("://").map_or(0, |at| at + 3);
5140 match typed[floor..].rfind(is_separator) {
5141 Some(at) => &typed[..floor + at + 1],
5142 None => &typed[..floor],
5143 }
5144}
5145
5146fn separator_in(dir: &str) -> char {
5149 if typed_dir_is_url(dir) {
5150 return '/';
5151 }
5152 dir.chars()
5153 .rev()
5154 .find(|c| *c == '/' || (cfg!(windows) && *c == '\\'))
5155 .unwrap_or(std::path::MAIN_SEPARATOR)
5156}
5157
5158pub fn typed_dir_is_url(dir: &str) -> bool {
5160 dir.contains("://")
5161}
5162
5163pub fn list_typed_dir(dir: &str) -> PathListing {
5166 let path = if dir.is_empty() {
5167 PathBuf::from(".")
5168 } else {
5169 expand_user_path(dir)
5170 };
5171 let Ok(entries) = std::fs::read_dir(&path) else {
5172 return PathListing {
5173 dir: dir.to_string(),
5174 names: Vec::new(),
5175 failed: true,
5176 };
5177 };
5178 let mut names: Vec<PathName> = entries
5179 .flatten()
5180 .take(PATH_LISTING_MAX)
5181 .map(|e| {
5182 let name = e.file_name().to_string_lossy().into_owned();
5183 let dir = e.file_type().is_ok_and(|t| t.is_dir())
5185 || (e.file_type().is_ok_and(|t| t.is_symlink()) && e.path().is_dir());
5186 PathName { name, dir }
5187 })
5188 .collect();
5189 names.sort_by(|a, b| a.name.cmp(&b.name));
5190 PathListing {
5191 dir: dir.to_string(),
5192 names,
5193 failed: false,
5194 }
5195}
5196
5197pub fn names_under(dir: &str, urls: impl IntoIterator<Item = String>) -> PathListing {
5200 let mut names: Vec<PathName> = Vec::new();
5201 for url in urls {
5202 let forms = match crate::cloud::source::azure_parts(&url) {
5204 Some((_, container, key)) => vec![url.clone(), format!("az://{container}/{key}")],
5205 None => vec![url],
5206 };
5207 for form in forms {
5208 let Some(rest) = form.strip_prefix(dir) else {
5209 continue;
5210 };
5211 let (name, more) = match rest.split_once('/') {
5212 Some((name, more)) => (name, Some(more)),
5213 None => (rest, None),
5214 };
5215 if name.is_empty() {
5216 continue;
5217 }
5218 let is_dir = more.is_some() || !names_a_file(Path::new(&form));
5221 match names.iter_mut().find(|n| n.name == name) {
5222 Some(known) => known.dir |= is_dir,
5223 None => names.push(PathName {
5224 name: name.to_string(),
5225 dir: is_dir,
5226 }),
5227 }
5228 }
5229 }
5230 names.sort_by(|a, b| a.name.cmp(&b.name));
5231 PathListing {
5232 dir: dir.to_string(),
5233 names,
5234 failed: false,
5235 }
5236}
5237
5238fn common_prefix(a: &str, b: &str) -> String {
5239 a.chars()
5240 .zip(b.chars())
5241 .take_while(|(x, y)| x == y)
5242 .map(|(x, _)| x)
5243 .collect()
5244}
5245
5246pub fn expand_user_path(raw: &str) -> PathBuf {
5248 crate::config::expand_config_path(raw)
5249}
5250
5251#[cfg(test)]
5252mod holds_flow_tests {
5253 use super::*;
5254
5255 #[test]
5258 fn a_rows_label_is_one_decision_for_the_list_and_the_pane() {
5259 let mut directory = Entry::for_test(Path::new("s3://bucket/warehouse"), "warehouse");
5260 directory.kind = EntryKind::Directory;
5261 let said = |look, place_kind| describe(&directory, place_kind, look, 0, None);
5262 let g = crate::glyphs::get();
5263
5264 let waiting = said(Some(CloudLook::Waiting), None);
5265 assert_eq!(
5266 (waiting.short.as_str(), waiting.words.as_str()),
5267 (g.ellipsis, "")
5268 );
5269 assert_eq!(said(Some(CloudLook::Looking), None).words, "");
5270 assert!(
5271 said(Some(CloudLook::Failed), None)
5272 .words
5273 .contains("listing failed")
5274 );
5275 assert_eq!(said(None, None).words, "directory");
5276 let curated = said(None, Some("dataset"));
5277 assert_eq!(
5278 (curated.short.as_str(), curated.words.as_str()),
5279 ("dataset", "dataset")
5280 );
5281 assert!(curated.curated);
5282
5283 directory.holds = crate::home::discover::Holds {
5284 formats: vec![("parquet".to_string(), 12)],
5285 ..Default::default()
5286 };
5287 let counted = describe(&directory, None, None, 0, None);
5288 assert_eq!(counted.short, "12 parquet");
5289 assert_eq!(
5290 counted.words, "directory",
5291 "the count is the pane's `contains` line"
5292 );
5293 let curated = describe(&directory, Some("dataset"), None, 0, None);
5294 assert_eq!(
5295 curated.short, "dataset",
5296 "the curated word wins over the count"
5297 );
5298
5299 directory.opens_whole_directory = true;
5300 assert_eq!(
5301 describe(&directory, Some("dataset"), None, 0, None),
5302 RowLabel::default()
5303 );
5304 }
5305
5306 #[cfg(windows)]
5309 #[test]
5310 fn a_windows_home_path_is_shown_and_typed_with_backslashes() {
5311 let home = dirs::home_dir().unwrap();
5312 let path = home.join("data").join("a.csv");
5313 let shown = display_path(&path);
5314 assert_eq!(shown, r"~\data\a.csv");
5315 assert_eq!(expand_user_path(&shown), path);
5316 }
5317
5318 #[cfg(windows)]
5321 #[test]
5322 fn a_dot_above_a_windows_recent_does_not_make_it_a_file() {
5323 let path = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\orders");
5324 assert_eq!(entry_for_path(path, true).kind, EntryKind::Unknown);
5325 let file = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\a.parquet");
5326 assert_eq!(entry_for_path(file, true).kind, EntryKind::File);
5327 }
5328
5329 fn counted(n: usize) -> crate::home::discover::Holds {
5330 crate::home::discover::Holds {
5331 formats: vec![("parquet".to_string(), n)],
5332 ..Default::default()
5333 }
5334 }
5335
5336 fn in_flight() -> (EntryKind, crate::home::discover::Holds) {
5339 (
5340 EntryKind::Directory,
5341 crate::home::discover::Holds::default(),
5342 )
5343 }
5344
5345 #[test]
5346 fn a_claim_staked_before_a_peek_lands_keeps_the_count_a_row_already_has() {
5347 let root = std::path::PathBuf::from("s3://bucket/warehouse");
5348 let path = root.join("orders");
5349 let mut row = Entry::for_test(&path, "orders");
5350 row.kind = EntryKind::Directory;
5351 row.holds = counted(15);
5354
5355 let mut home = HomeState::default();
5356 home.probe_ready(root.clone(), vec![row], false);
5357 home.cloud_kinds.insert(path, in_flight());
5358 home.apply_cloud_kinds(&root);
5359
5360 assert_eq!(
5361 home.probes.listed(&root).unwrap()[0].holds.label(),
5362 "15 parquet",
5363 "the placeholder erased a count the row already had"
5364 );
5365 }
5366
5367 #[test]
5368 fn a_cloud_directory_waits_then_looks_then_answers() {
5369 let path = std::path::PathBuf::from("gs://pitscope/seasons");
5370 let mut row = Entry::for_test(&path, "seasons");
5371 row.kind = EntryKind::Directory;
5372 let mut home = HomeState::default();
5373
5374 assert_eq!(
5375 home.cloud_look(&row),
5376 Some(CloudLook::Waiting),
5377 "not asked yet"
5378 );
5379 home.peeking.insert(path.clone());
5380 assert_eq!(
5381 home.cloud_look(&row),
5382 Some(CloudLook::Looking),
5383 "being looked into"
5384 );
5385 home.peeking.remove(&path);
5386
5387 home.cloud_kinds
5390 .insert(path.clone(), (EntryKind::Directory, counted(12)));
5391 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
5392 home.cloud_kinds
5393 .insert(path.clone(), (EntryKind::Hive, Default::default()));
5394 assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
5395
5396 home.cloud_kinds.insert(path.clone(), in_flight());
5398 assert_eq!(home.cloud_look(&row), None, "answered");
5399
5400 let mut home = HomeState {
5403 network_check: |_| true,
5404 ..Default::default()
5405 };
5406 let root = std::path::PathBuf::from("gs://pitscope");
5407 home.probe_ready(root.clone(), vec![row.clone()], false);
5408 home.browsing = Some(root);
5409 home.rebuild(&[]);
5410 assert_eq!(
5411 home.cloud_directories_to_peek(4),
5412 std::slice::from_ref(&path)
5413 );
5414 home.peek_failed.insert(path.clone());
5415 assert_eq!(home.cloud_look(&row), Some(CloudLook::Failed));
5416 assert!(home.cloud_directories_to_peek(4).is_empty());
5417
5418 let mut counted_row = Entry::for_test(&path.join("x"), "x");
5421 counted_row.kind = EntryKind::Directory;
5422 counted_row.holds = counted(3);
5423 assert_eq!(home.cloud_look(&counted_row), None);
5424 let mut bucket = Entry::for_test(std::path::Path::new("gs://pitscope"), "pitscope");
5425 bucket.kind = EntryKind::Directory;
5426 assert_eq!(home.cloud_look(&bucket), None);
5427 let mut local = Entry::for_test(std::path::Path::new("/data/seasons"), "seasons");
5428 local.kind = EntryKind::Directory;
5429 assert_eq!(home.cloud_look(&local), None);
5430 }
5431
5432 #[test]
5433 fn a_peeks_answer_replaces_the_count_a_row_had() {
5434 let root = std::path::PathBuf::from("s3://bucket/warehouse");
5435 let path = root.join("orders");
5436 let mut row = Entry::for_test(&path, "orders");
5437 row.kind = EntryKind::Directory;
5438 row.holds = counted(15);
5439
5440 let mut home = HomeState::default();
5441 home.probe_ready(root.clone(), vec![row], false);
5442 home.cloud_kinds
5443 .insert(path, (EntryKind::MultiFile, counted(40)));
5444 home.apply_cloud_kinds(&root);
5445
5446 assert_eq!(
5447 home.probes.listed(&root).unwrap()[0].holds.label(),
5448 "40 parquet"
5449 );
5450 assert_eq!(
5451 home.probes.listed(&root).unwrap()[0].kind,
5452 EntryKind::MultiFile
5453 );
5454 }
5455
5456 #[test]
5457 fn a_peek_answers_only_the_rows_that_asked() {
5458 let root = std::path::PathBuf::from("s3://bucket/warehouse");
5459 let settled = root.join("sales");
5463 let mut row = Entry::for_test(&settled, "sales");
5464 row.kind = EntryKind::Hive;
5465 row.holds = counted(40);
5466
5467 let mut home = HomeState::default();
5468 home.probe_ready(root.clone(), vec![row], false);
5469 home.cloud_kinds
5470 .insert(settled, (EntryKind::Directory, counted(1)));
5471 home.apply_cloud_kinds(&root);
5472
5473 assert_eq!(home.probes.listed(&root).unwrap()[0].kind, EntryKind::Hive);
5474 assert_eq!(
5475 home.probes.listed(&root).unwrap()[0].holds.label(),
5476 "40 parquet"
5477 );
5478 }
5479
5480 #[test]
5487 fn a_peek_goes_to_the_row_the_cursor_is_on_and_is_never_asked_twice() {
5488 let root = std::path::PathBuf::from("s3://bucket/warehouse");
5489 let mut home = HomeState {
5490 network_check: |_| true,
5491 ..Default::default()
5492 };
5493 let rows: Vec<Entry> = ["a", "b", "c", "d", "e"]
5494 .iter()
5495 .map(|n| {
5496 let mut row = Entry::for_test(&root.join(n), n);
5497 row.kind = EntryKind::Directory;
5498 row
5499 })
5500 .collect();
5501 home.probe_ready(root.clone(), rows, false);
5502 home.browsing = Some(root.clone());
5503 home.view_height = 10;
5504 home.rebuild(&[]);
5505 home.cloud_kinds
5507 .insert(root.join("b"), (EntryKind::MultiFile, counted(3)));
5508 home.peeking.insert(root.join("c"));
5509
5510 home.selected = home
5513 .visible()
5514 .iter()
5515 .position(|r| matches!(r, Row::Entry { entry, .. } if entry.name == "e"))
5516 .expect("the row is listed");
5517 let asked = home.cloud_directories_to_peek(3);
5518 assert_eq!(
5519 asked.first(),
5520 Some(&root.join("e")),
5521 "the highlighted row is the one about to be acted on"
5522 );
5523 assert_eq!(asked.len(), 3, "the budget is a budget");
5524 assert!(
5525 !asked.contains(&root.join("b")),
5526 "a directory already looked into is not asked again"
5527 );
5528 assert!(
5529 !asked.contains(&root.join("c")),
5530 "nor one with a request already out"
5531 );
5532 }
5533
5534 #[test]
5535 fn a_measurement_that_counted_nothing_keeps_the_count_a_row_already_has() {
5536 let path = std::path::PathBuf::from("/data/warehouse/orders");
5537 let mut row = Entry::for_test(&path, "orders");
5538 row.kind = EntryKind::Directory;
5539 row.holds = counted(15);
5540
5541 let mut home = HomeState::default();
5542 home.sections.push(Section::titled("Here", vec![row]));
5543 home.enriched.insert(
5545 path,
5546 Measured {
5547 kind: Some(EntryKind::Directory),
5548 ..Default::default()
5549 },
5550 );
5551 home.apply_measurements();
5552
5553 assert_eq!(
5554 home.sections[0].rows[0].holds.label(),
5555 "15 parquet",
5556 "a measurement with nothing to say erased the label"
5557 );
5558 }
5559}
5560
5561#[cfg(test)]
5562mod look_into_batch_tests {
5563 use super::*;
5564 use polars::prelude::*;
5565
5566 #[test]
5569 fn every_kind_is_sent_before_any_count() {
5570 let dir = tempfile::tempdir().unwrap();
5571 let cache_dir = tempfile::tempdir().unwrap();
5572 let cache = crate::cache::CacheManager::with_dir(cache_dir.path().to_path_buf());
5573 let mut rows = Vec::new();
5574 for name in ["a", "b"] {
5575 let partition = dir.path().join(name).join("year=2024");
5576 std::fs::create_dir_all(&partition).unwrap();
5577 let mut frame = df!("x" => [1i32, 2, 3]).unwrap();
5578 let file = std::fs::File::create(partition.join("part.parquet")).unwrap();
5579 ParquetWriter::new(file).finish(&mut frame).unwrap();
5580 rows.push(Entry::new(dir.path().join(name), EntryKind::Unknown));
5581 }
5582
5583 let mut sent = Vec::new();
5584 look_into_batch(rows, &cache, |path, m| {
5585 let name = path.file_name().unwrap().to_string_lossy().into_owned();
5586 sent.push((name, m.kind, m.rows));
5587 });
5588
5589 let hive = Some(EntryKind::Hive);
5590 assert_eq!(
5591 sent,
5592 vec![
5593 ("a".to_string(), hive, None),
5594 ("b".to_string(), hive, None),
5595 ("a".to_string(), hive, Some(3)),
5596 ("b".to_string(), hive, Some(3)),
5597 ]
5598 );
5599 }
5600}
5601
5602#[cfg(test)]
5603mod known_facts_tests {
5604 use super::*;
5605 use crate::cache::DatasetFacts;
5606
5607 #[test]
5615 fn what_a_directory_holds_is_restored_beside_its_kind() {
5616 let holds = crate::home::discover::Holds {
5617 formats: vec![("parquet".to_string(), 15)],
5618 ..Default::default()
5619 };
5620 for (path, remote) in [
5621 (
5622 std::path::PathBuf::from("s3://bucket/warehouse/orders"),
5623 true,
5624 ),
5625 (std::path::PathBuf::from("/data/warehouse/orders"), false),
5626 ] {
5627 let facts = DatasetFacts {
5628 mtime: 0,
5629 size: 4096,
5630 rows: Some(999),
5633 cols: Some(72),
5634 cols_sampled: false,
5635 columns: vec!["lat".to_string()],
5636 kind: Some(EntryKind::Directory),
5639 classified_by: crate::home::discover::CLASSIFIER_VERSION,
5640 holds: holds.clone(),
5641 cost: Default::default(),
5642 };
5643 let mut row = Entry::directory(&path);
5644 row.kind = EntryKind::Unknown;
5645 row.modified = Some(std::time::UNIX_EPOCH);
5646 assert_eq!(row.size, None);
5649 let index = std::collections::HashMap::from([(path.clone(), facts)]);
5650
5651 apply_known_facts(&mut row, &index, remote);
5652 assert_eq!(row.kind, EntryKind::Directory, "{path:?}");
5653 assert_eq!(row.label(), "15 parquet", "{path:?}");
5654 if !remote {
5660 assert_eq!(row.rows, None, "{path:?}");
5661 assert_eq!(row.cols, None, "{path:?}");
5662 assert_eq!(row.size, None, "{path:?}");
5663 assert!(row.columns.is_empty(), "{path:?}");
5664 }
5665 }
5666 }
5667
5668 #[test]
5673 fn a_datasets_counts_are_measured_rather_than_restored() {
5674 let path = std::path::PathBuf::from("/data/warehouse/events");
5675 let facts = DatasetFacts {
5676 mtime: 0,
5677 size: 4096,
5678 rows: Some(1_200_000),
5679 cols: Some(58),
5680 cols_sampled: false,
5681 columns: vec!["ts".to_string()],
5682 kind: Some(EntryKind::MultiFile),
5683 classified_by: crate::home::discover::CLASSIFIER_VERSION,
5684 holds: crate::home::discover::Holds {
5685 formats: vec![("parquet".to_string(), 15)],
5686 ..Default::default()
5687 },
5688 cost: Default::default(),
5689 };
5690 let mut row = Entry::directory(&path);
5691 row.kind = EntryKind::Unknown;
5692 row.modified = Some(std::time::UNIX_EPOCH);
5693 let index = std::collections::HashMap::from([(path.clone(), facts)]);
5694
5695 apply_known_facts(&mut row, &index, false);
5696 assert_eq!(row.kind, EntryKind::MultiFile, "the kind comes back");
5697 assert_eq!(row.label(), "15 parquet", "and what it holds");
5698 assert_eq!(
5699 row.rows, None,
5700 "but not the count: the measuring pass skips a row that has one"
5701 );
5702 assert_eq!(row.cols, None);
5703 }
5704
5705 #[test]
5714 fn a_kind_from_an_older_classifier_is_not_restored() {
5715 let remote = std::path::PathBuf::from("s3://bucket/warehouse/orders");
5716 let facts = |classified_by| DatasetFacts {
5717 mtime: 0,
5718 size: 4096,
5719 rows: Some(1_000),
5720 cols: Some(7),
5721 cols_sampled: false,
5722 columns: vec!["id".into(), "amount".into()],
5723 kind: Some(EntryKind::MultiFile),
5724 classified_by,
5725 cost: Default::default(),
5726 holds: Default::default(),
5727 };
5728 let unprobed = || {
5729 let mut row = Entry::directory(&remote);
5730 row.kind = EntryKind::Unknown;
5731 row
5732 };
5733
5734 let index = |classified_by| {
5735 std::collections::HashMap::from([(remote.clone(), facts(classified_by))])
5736 };
5737
5738 let mut row = unprobed();
5739 apply_known_facts(
5740 &mut row,
5741 &index(crate::home::discover::CLASSIFIER_VERSION),
5742 true,
5743 );
5744 assert_eq!(
5745 row.kind,
5746 EntryKind::MultiFile,
5747 "this build's own answer comes back"
5748 );
5749
5750 let mut row = unprobed();
5751 apply_known_facts(&mut row, &index(0), true);
5752 assert_eq!(
5753 row.kind,
5754 EntryKind::Unknown,
5755 "an older build's does not: it may be a lake table this one would recognize"
5756 );
5757 assert_eq!(
5758 row.rows,
5759 Some(1_000),
5760 "but what it measured is still measured"
5761 );
5762 assert_eq!(row.columns, vec!["id".to_string(), "amount".to_string()]);
5763 }
5764}
5765
5766#[cfg(test)]
5767mod build_feature_tests {
5768 use super::*;
5769
5770 #[test]
5773 fn the_builtin_catalog_lists_only_what_this_build_opens() {
5774 let urls: Vec<String> = catalogs(&crate::config::AppConfig::default())
5775 .into_iter()
5776 .filter(|c| c.origin == crate::home::catalog::Origin::Bundled)
5777 .flat_map(|c| c.datasets)
5778 .map(|d| d.location.to_string_lossy().into_owned())
5779 .collect();
5780 let web = urls.iter().filter(|u| u.starts_with("https://")).count();
5781 let stores = urls
5782 .iter()
5783 .filter(|u| is_object_store_url(Path::new(u)))
5784 .count();
5785 assert_eq!(web + stores, urls.len(), "{urls:?}");
5786 assert_eq!(web > 0, cfg!(feature = "http"), "{urls:?}");
5787 assert_eq!(stores > 0, cfg!(feature = "cloud"), "{urls:?}");
5788 }
5789
5790 #[test]
5793 fn an_empty_examples_toml_hides_the_section() {
5794 let mut config = crate::config::AppConfig::default();
5795 assert_eq!(
5797 catalogs(&config)
5798 .iter()
5799 .any(|c| c.origin == crate::home::catalog::Origin::Bundled),
5800 cfg!(any(feature = "http", feature = "cloud"))
5801 );
5802 config.read_catalogs = vec![
5803 crate::home::catalog::parse(
5804 "label = \"Mine\"\n",
5805 crate::home::catalog::EXAMPLES,
5806 crate::home::catalog::Origin::Folder,
5807 None,
5808 )
5809 .unwrap(),
5810 ];
5811 assert!(catalogs(&config).is_empty(), "{:?}", catalogs(&config));
5812 }
5813
5814 #[test]
5817 fn a_users_catalog_is_shown_whole() {
5818 let mut config = crate::config::AppConfig::default();
5819 let mine = crate::home::catalog::parse(
5820 r#"
5821 [bucket]
5822 name = "Bucket"
5823 url = "s3://bucket/prefix/"
5824 [web]
5825 name = "Web"
5826 url = "https://example.com/data.csv"
5827 "#,
5828 crate::home::catalog::MINE,
5829 crate::home::catalog::Origin::Mine,
5830 None,
5831 )
5832 .unwrap();
5833 config.read_catalogs = vec![mine];
5834 let shown = catalogs(&config);
5835 let mine = shown.iter().find(|c| c.id == "mine").unwrap();
5836 assert_eq!(mine.datasets.len(), 2);
5837 assert_eq!(mine.label, crate::home::catalog::MINE_LABEL);
5838 }
5839}
5840
5841#[cfg(test)]
5842mod place_tests {
5843 use super::same_place;
5844 use std::path::Path;
5845
5846 #[test]
5847 fn local_paths_are_one_place_however_spelled() {
5848 assert!(same_place(
5849 Path::new("/data/./sales/"),
5850 Path::new("/data/sales")
5851 ));
5852 assert!(!same_place(
5853 Path::new("/data/sales"),
5854 Path::new("/data/sale")
5855 ));
5856 assert!(same_place(
5857 Path::new("s3://bucket/dir/"),
5858 Path::new("s3://bucket/dir")
5859 ));
5860 if cfg!(windows) {
5861 assert!(same_place(
5862 Path::new("c:/data/sales.csv"),
5863 Path::new(r"C:\data\sales.csv")
5864 ));
5865 }
5866 }
5867}