use crate::discover::{self, Entry, EntryKind};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RootOrigin {
Cwd,
Desktop,
}
impl RootOrigin {
pub fn note(self) -> &'static str {
match self {
RootOrigin::Cwd => "current directory",
RootOrigin::Desktop => "opened elsewhere",
}
}
}
pub fn desktop_recent_dirs() -> Vec<PathBuf> {
let Some(data_dir) = dirs::data_dir() else {
return Vec::new();
};
let path = data_dir.join("recently-used.xbel");
let Ok(contents) = std::fs::read_to_string(&path) else {
return Vec::new();
};
dirs_from_xbel(&contents)
}
pub fn dirs_from_xbel(contents: &str) -> Vec<PathBuf> {
const PREFIX: &str = "href=\"file://";
let mut dirs: Vec<PathBuf> = Vec::new();
for chunk in contents.split(PREFIX).skip(1) {
let Some(end) = chunk.find('"') else { continue };
let decoded = percent_decode(&chunk[..end]);
let file = PathBuf::from(decoded);
if !crate::discover::is_data_file(&file) || !file.is_file() {
continue;
}
let Some(parent) = file.parent() else {
continue;
};
if parent.as_os_str().is_empty() {
continue;
}
let parent = parent.to_path_buf();
if !dirs.contains(&parent) {
dirs.push(parent);
}
}
dirs
}
fn percent_decode(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn object_place_label(path: &Path) -> Option<&'static str> {
if cloud_source_id(path).is_some() {
return Some("source");
}
if cloud_account(path).is_some() {
return Some("account");
}
let text = path.to_string_lossy();
if let Some((_, _, key)) = crate::source::azure_parts(&text) {
return key.trim_matches('/').is_empty().then_some("container");
}
let (scheme, rest) = text.split_once("://")?;
if !matches!(scheme, "s3" | "s3a" | "gs" | "gcs") {
return None;
}
let rest = rest.trim_end_matches('/');
if rest.is_empty() {
return None;
}
(!rest.contains('/')).then_some("bucket")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloudLook {
Waiting,
Looking,
Failed,
}
pub const CLOUD_PLACE: &str = "cloud://";
const SCROLL_MARGIN: usize = 2;
pub(crate) fn settle_top(top: usize, selected: usize, height: usize, total: usize) -> usize {
if height == 0 {
return top.min(selected);
}
let margin = SCROLL_MARGIN.min((height - 1) / 2);
let top = if selected < top + margin {
selected.saturating_sub(margin)
} else if selected + margin >= top + height {
selected + margin + 1 - height
} else {
top
};
top.min(total.saturating_sub(height))
}
pub fn cloud_place(id: &str) -> PathBuf {
PathBuf::from(format!("{CLOUD_PLACE}{id}"))
}
pub fn cloud_source_id(path: &Path) -> Option<String> {
let text = path.to_string_lossy();
let id = text.strip_prefix(CLOUD_PLACE)?.trim_end_matches('/');
(!id.is_empty() && !id.contains('/')).then(|| id.to_string())
}
pub fn cloud_account(path: &Path) -> Option<(String, String)> {
let text = path.to_string_lossy();
let rest = text.strip_prefix(CLOUD_PLACE)?.trim_end_matches('/');
let (id, account) = rest.split_once('/')?;
(!id.is_empty() && !account.is_empty() && !account.contains('/'))
.then(|| (id.to_string(), account.to_string()))
}
fn within(url: &str, root: &str) -> bool {
#[cfg(feature = "cloud")]
{
crate::cloud_sources::is_within(url, root)
}
#[cfg(not(feature = "cloud"))]
{
let (url, root) = (url.trim_end_matches('/'), root.trim_end_matches('/'));
url == root || url.strip_prefix(root).is_some_and(|r| r.starts_with('/'))
}
}
fn same_place(a: &Path, b: &Path) -> bool {
let (a, b) = (a.to_string_lossy(), b.to_string_lossy());
within(&a, &b) && within(&b, &a)
}
fn within_rest(url: &str, root: &str) -> String {
let canonical = |u: &str| match crate::source::azure_parts(u) {
Some((account, container, path)) => crate::source::azure_url(&account, &container, &path),
None => u.to_string(),
};
let (url, root) = (canonical(url), canonical(root));
url.strip_prefix(root.trim_end_matches('/'))
.unwrap_or("")
.to_string()
}
pub fn is_object_store_url(path: &Path) -> bool {
let text = path.to_string_lossy();
let scheme = text
.split_once("://")
.map(|(s, _)| s.to_ascii_lowercase())
.unwrap_or_default();
matches!(scheme.as_str(), "s3" | "s3a" | "gs" | "gcs")
|| crate::source::azure_parts(&text).is_some()
}
pub fn directory_dataset_url(path: &Path) -> PathBuf {
let text = path.to_string_lossy();
if text.ends_with('/') {
path.to_path_buf()
} else {
PathBuf::from(format!("{text}/"))
}
}
fn whole_directory_row(dir: &Path, rows: &[Entry], remote: bool) -> Option<Entry> {
if cloud_account(dir).is_some() {
return None;
}
let directories: Vec<String> = rows
.iter()
.filter(|r| !matches!(r.kind, EntryKind::File | EntryKind::Other))
.map(|r| format!("{}/", r.name))
.collect();
let objects: Vec<(String, u64)> = rows
.iter()
.filter(|r| matches!(r.kind, EntryKind::File | EntryKind::Other))
.map(|r| (r.path.to_string_lossy().into_owned(), r.size.unwrap_or(1)))
.collect();
let (kind, holds) = if remote || is_object_store_url(dir) {
#[cfg(feature = "cloud")]
{
crate::cloud_browse::look_at_listing(&dir.to_string_lossy(), &directories, &objects)
}
#[cfg(not(feature = "cloud"))]
{
let _ = (&directories, &objects);
(EntryKind::Unknown, Default::default())
}
} else {
crate::discover::look_at_directory(dir)
};
let openable_row = rows.iter().any(|r| r.kind != EntryKind::Other);
if !openable_row && holds_nothing_to_open(&holds) {
return None;
}
let mut entry = Entry::directory(&directory_dataset_url(dir));
entry.kind = kind;
entry.holds = holds;
entry.opens_whole_directory = true;
entry.name = door_name(&entry, rows);
Some(entry)
}
fn door_base_name(dir: &Path) -> String {
let text = dir.to_string_lossy();
if let Some((_, container, key)) = crate::source::azure_parts(&text) {
let leaf = key.trim_matches('/').rsplit('/').next().unwrap_or("");
if leaf.is_empty() {
container
} else {
leaf.to_string()
}
} else {
let (_, plain) = crate::source::split_source_id(&text);
std::path::Path::new(plain.as_ref())
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| plain.into_owned())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DoorKind {
Hive,
OneSchema,
SchemasDiffer,
Single,
Lake,
Mixed,
Unknown,
}
pub fn door_kind(door: &Entry) -> DoorKind {
let holds = &door.holds;
match door.kind {
EntryKind::Hive => DoorKind::Hive,
EntryKind::MultiFile => DoorKind::OneSchema,
k if k.is_lake_table() => DoorKind::Lake,
EntryKind::Unknown => DoorKind::Unknown,
_ => {
let Some(format) = holds.one_format() else {
return DoorKind::Mixed;
};
if holds.directories > 0 {
return DoorKind::Mixed;
}
let files = holds.data_files();
if files == 1 {
return DoorKind::Single;
}
let mostly_data = files * 2 >= files + holds.not_read + holds.unnamed;
let reads_many = crate::FileFormat::from_name(format)
.is_some_and(crate::FileFormat::reads_many_files);
if mostly_data && reads_many {
DoorKind::SchemasDiffer
} else {
DoorKind::Mixed
}
}
}
}
pub fn door_lands(door: &Entry) -> bool {
matches!(door_kind(door), DoorKind::Hive | DoorKind::OneSchema)
}
fn format_title(name: &str) -> String {
crate::FileFormat::from_name(name)
.map_or_else(|| name.to_ascii_uppercase(), |f| f.title().to_string())
}
fn door_keys(door: &Entry, rows: &[Entry]) -> Vec<String> {
if let Some(layout) = door.cost.partitions.as_ref()
&& !layout.keys.is_empty()
{
return layout.keys.clone();
}
let mut keys: Vec<String> = Vec::new();
for row in rows {
if let Some((key, _)) = row.name.split_once('=')
&& !key.is_empty()
&& !keys.iter().any(|k| k == key)
{
keys.push(key.to_string());
}
}
keys
}
pub fn door_name(door: &Entry, rows: &[Entry]) -> String {
let name = door_base_name(&door.path);
let holds = &door.holds;
let more = if holds.truncated { "+" } else { "" };
let files = |format: &str| {
let count = holds.data_files();
let word = if count == 1 { "file" } else { "files" };
format!("{count}{more} {} {word}", format_title(format))
};
let what = match door_kind(door) {
DoorKind::Hive => {
let keys = door_keys(door, rows);
if keys.is_empty() {
"hive table".to_string()
} else {
format!("hive table: {}", keys.join(", "))
}
}
DoorKind::OneSchema => match (holds.model_weights(), holds.one_format()) {
(Some((format, count)), _) => {
let word = if count == 1 { "file" } else { "files" };
format!("model, {count}{more} {} {word}", format_title(format))
}
(None, Some(format)) => format!("{}, one schema", files(format)),
(None, None) => "one table".to_string(),
},
DoorKind::SchemasDiffer => {
format!(
"{}, schemas differ",
files(holds.one_format().unwrap_or(""))
)
}
DoorKind::Single => files(holds.one_format().unwrap_or("")),
DoorKind::Lake => format!(
"{} files, not the table",
door.kind.lake_name().unwrap_or_default()
),
DoorKind::Mixed => "all files, mixed".to_string(),
DoorKind::Unknown => "all files".to_string(),
};
format!("{name} ({what})")
}
pub fn door_reads(door: &Entry) -> Option<(String, Option<String>)> {
if !matches!(door_kind(door), DoorKind::Mixed | DoorKind::Single) {
return None;
}
let holds = &door.holds;
let more = if holds.truncated { "+" } else { "" };
let plain = holds.directories.saturating_sub(holds.partitions);
let directories = |n: usize| {
let word = if n == 1 { "directory" } else { "directories" };
format!("{n}{more} {word}")
};
let Some((format, count)) = holds.formats.first() else {
return (holds.directories > 0).then(|| ("every Parquet file below".to_string(), None));
};
let remote = is_object_store_url(&door.path);
let below = remote || holds.partitions > 0 || (holds.formats.len() == 1 && format == "parquet");
let below = below && holds.directories > 0;
let reads = if below && remote {
format!("every {format} file below")
} else if below {
"every Parquet file below".to_string()
} else {
format!("{count}{more} {format}")
};
let mut skips: Vec<String> = holds
.formats
.iter()
.skip(1)
.map(|(name, n)| format!("{n}{more} {name}"))
.collect();
if !below && plain > 0 {
skips.push(directories(plain));
}
Some((reads, (!skips.is_empty()).then(|| skips.join(", "))))
}
pub fn name_by_spec(formats: &crate::formats::Registry, rows: &mut [Entry]) {
if formats.is_empty() {
return;
}
let mut named = false;
for row in rows
.iter_mut()
.filter(|r| r.kind == EntryKind::Other && r.format_spec.is_none())
{
if let Some(spec) = formats.by_glob(&row.path, false).first() {
discover::name_spec_file(row, spec);
named = true;
}
}
for row in rows.iter_mut().filter(|r| {
r.kind == EntryKind::File
&& r.format_spec.is_none()
&& discover::data_format(&r.path).is_some_and(|f| f.separator().is_some())
}) {
if let Some(spec) = formats
.by_glob(&row.path, false)
.into_iter()
.find(|s| s.is_delimited())
{
row.format_spec = Some(spec.name.clone());
}
}
if named {
discover::sort_entries(rows);
}
}
pub fn holds_nothing_to_open(holds: &discover::Holds) -> bool {
holds.formats.is_empty() && holds.directories == 0 && holds.unnamed == 0
}
pub fn is_cloud_place(path: &Path) -> bool {
path.to_string_lossy().starts_with(CLOUD_PLACE)
}
fn is_bucket_root(path: &Path) -> bool {
let text = path.to_string_lossy();
if let Some((_, _, key)) = crate::source::azure_parts(&text) {
return key.trim_matches('/').is_empty();
}
let Some((scheme, rest)) = text.split_once("://") else {
return false;
};
matches!(scheme, "s3" | "s3a" | "gs" | "gcs") && {
let rest = rest.trim_end_matches('/');
!rest.is_empty() && !rest.contains('/')
}
}
pub fn parent_location(path: &Path) -> Option<PathBuf> {
if !matches!(
crate::source::input_source(path),
crate::source::InputSource::Local(_)
) {
let s = path.to_string_lossy();
let (scheme, rest) = s.split_once("://")?;
let (up, _) = rest.trim_end_matches('/').rsplit_once('/')?;
return Some(PathBuf::from(format!("{scheme}://{up}")));
}
path.parent()
.filter(|p| !p.as_os_str().is_empty() && *p != path)
.map(Path::to_path_buf)
}
pub fn is_remote_path(path: &Path) -> bool {
is_cloud_place(path)
|| !matches!(
crate::source::input_source(path),
crate::source::InputSource::Local(_)
)
|| is_network_path(path)
}
pub fn is_network_path(path: &Path) -> bool {
crate::locality::Mounts::cached().is_network(path)
}
#[doc(hidden)]
pub fn network_fs_for_test(mountinfo: &str, path: &Path) -> bool {
crate::locality::Mounts::parse(mountinfo).is_network(path)
}
#[derive(Debug, Clone)]
pub struct Root {
pub path: PathBuf,
pub origin: RootOrigin,
pub network: bool,
pub available: bool,
}
#[derive(Debug, Clone)]
pub struct Section {
pub title: String,
pub subtitle: Option<String>,
pub origin: Option<&'static str>,
pub root: Option<PathBuf>,
pub rows: Vec<Entry>,
pub door: Option<Entry>,
pub unavailable: bool,
pub unavailable_note: Option<String>,
pub folded_by_default: bool,
pub remote_root: Option<PathBuf>,
pub waiting: bool,
pub grouped_by_place: bool,
pub place_labels: std::collections::HashMap<PathBuf, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum CloudStatus {
#[default]
Listing,
Unlisted,
Listed,
Failed { short: String, detail: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CloudSource {
pub id: String,
pub label: String,
pub api: String,
pub note: String,
pub buckets: Vec<PathBuf>,
pub status: CloudStatus,
pub listed_at: Option<std::time::SystemTime>,
pub refreshing: bool,
pub asked: bool,
pub details: Vec<(String, String)>,
pub place_details: std::collections::HashMap<PathBuf, Vec<(String, String)>>,
}
impl CloudSource {
pub fn count_text(&self) -> String {
match &self.status {
CloudStatus::Failed { short, .. } if self.buckets.is_empty() => short.clone(),
CloudStatus::Listing if self.buckets.is_empty() => String::new(),
CloudStatus::Unlisted if self.buckets.is_empty() => "not listed".to_string(),
_ => {
let (one, many) = if self.api == "azure" {
("account", "accounts")
} else if self.api == "gcs" {
("project", "projects")
} else {
("bucket", "buckets")
};
match self.buckets.len() {
0 => format!("no {many}"),
1 => format!("1 {one}"),
n => format!("{n} {many}"),
}
}
}
}
pub fn begin_listing(&mut self) {
self.asked = true;
if self.status == CloudStatus::Unlisted && self.buckets.is_empty() {
self.status = CloudStatus::Listing;
} else {
self.refreshing = true;
}
}
pub fn busy(&self) -> bool {
self.refreshing || (self.status == CloudStatus::Listing && self.buckets.is_empty())
}
pub fn failed(&self) -> bool {
matches!(self.status, CloudStatus::Failed { .. })
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ShownCatalog {
pub id: String,
pub label: String,
pub origin: crate::catalog::Origin,
pub description: String,
pub file: Option<PathBuf>,
pub datasets: Vec<ShownDataset>,
pub broken: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ShownDataset {
pub name: String,
pub location: PathBuf,
pub details: Vec<(String, String)>,
pub size: Option<u64>,
pub codebook: Option<std::sync::Arc<crate::codebook::Codebook>>,
pub bookmarks: Vec<(String, PathBuf)>,
pub entry: std::sync::Arc<crate::catalog::Dataset>,
}
impl ShownCatalog {
pub fn from_catalog(catalog: &crate::catalog::Catalog) -> Self {
Self {
id: catalog.id.clone(),
label: catalog.label.clone(),
origin: catalog.origin,
description: catalog.description.clone(),
file: catalog.file.clone(),
datasets: catalog
.datasets
.iter()
.map(|dataset| {
let location = dataset.location();
let mut details: Vec<(String, String)> = [
("about", &dataset.description),
("publisher", &dataset.publisher),
("license", &dataset.license),
("homepage", &dataset.homepage),
("documentation", &dataset.documentation),
]
.into_iter()
.filter(|(_, value)| !value.is_empty())
.map(|(key, value)| (key.to_string(), value.clone()))
.collect();
match &dataset.url {
None => details.push(("path".to_string(), display_path(&location))),
Some(url) => {
details.push(("url".to_string(), url.clone()));
details.push(("login".to_string(), login_of(dataset)));
}
}
ShownDataset {
name: dataset.name.clone(),
location,
details,
size: dataset.size,
codebook: crate::codebook::Codebook::of(dataset).map(std::sync::Arc::new),
bookmarks: dataset
.bookmarks
.iter()
.map(|(name, path)| (name.clone(), dataset.bookmark_location(path)))
.collect(),
entry: std::sync::Arc::new(dataset.clone()),
}
})
.collect(),
broken: None,
}
}
pub fn from_broken(broken: &crate::catalog::Broken) -> Self {
Self {
id: broken.id.clone(),
label: broken.id.clone(),
origin: broken.origin,
description: String::new(),
file: None,
datasets: Vec::new(),
broken: Some(broken.callout()),
}
}
pub fn origin_note(&self) -> &'static str {
match self.origin {
crate::catalog::Origin::Mine => "catalog.toml",
crate::catalog::Origin::Listed | crate::catalog::Origin::Folder => "catalog",
crate::catalog::Origin::Bundled => BUNDLED_ORIGIN,
}
}
}
pub const BUNDLED_ORIGIN: &str = "comes with datui";
pub const CATALOG_ORIGINS: [&str; 3] = ["catalog.toml", "catalog", BUNDLED_ORIGIN];
pub fn is_catalog_origin(origin: &str) -> bool {
CATALOG_ORIGINS.contains(&origin)
}
pub fn login_of(dataset: &crate::catalog::Dataset) -> String {
match dataset.object_store_auth() {
Some(crate::config::DatasetAuth::Connection(connection)) => connection,
Some(crate::config::DatasetAuth::Anonymous) | None => "none".to_string(),
Some(crate::config::DatasetAuth::Auto) => "auto".to_string(),
}
}
pub fn catalogs(config: &crate::config::AppConfig) -> Vec<ShownCatalog> {
let mut out: Vec<ShownCatalog> = config
.shown_catalogs()
.iter()
.filter_map(|catalog| {
let mut shown = ShownCatalog::from_catalog(catalog);
if catalog.origin == crate::catalog::Origin::Bundled {
shown
.datasets
.retain(|d| crate::source::opens_in_this_build(&d.location));
}
(!shown.datasets.is_empty()).then_some(shown)
})
.collect();
let at = out
.iter()
.position(|c| c.origin == crate::catalog::Origin::Bundled)
.unwrap_or(out.len());
let broken: Vec<ShownCatalog> = config
.broken_catalogs
.iter()
.filter(|b| !config.home.hide.contains(&b.id))
.map(ShownCatalog::from_broken)
.collect();
out.splice(at..at, broken);
out
}
fn catalog_entry(
dataset: &ShownDataset,
network_check: fn(&Path) -> bool,
missing: &mut std::collections::HashSet<PathBuf>,
) -> Entry {
let path = &dataset.location;
let local = matches!(
crate::source::input_source(path),
crate::source::InputSource::Local(_)
);
let mut entry = if is_object_store_url(path) {
if names_a_file(path) {
entry_for_path(path, true)
} else {
Entry::directory(path)
}
} else if !local || network_check(path) {
entry_for_path(path, true)
} else if path.exists() {
entry_for_path(path, false)
} else {
missing.insert(path.clone());
let mut entry = entry_for_path(path, true);
entry.kind = EntryKind::Unknown;
entry
};
entry.name = dataset.name.clone();
entry
}
pub fn codebook_for(
catalogs: &[ShownCatalog],
path: &Path,
) -> Option<std::sync::Arc<crate::codebook::Codebook>> {
let text = path.to_string_lossy();
catalogs
.iter()
.flat_map(|c| c.datasets.iter())
.filter(|d| d.codebook.is_some())
.filter(|d| d.location == path || within(&text, &d.location.to_string_lossy()))
.max_by_key(|d| d.location.to_string_lossy().trim_end_matches('/').len())
.and_then(|d| d.codebook.clone())
}
pub fn catalog_entry_for(
catalogs: &[ShownCatalog],
path: &Path,
) -> Option<(String, std::sync::Arc<crate::catalog::Dataset>)> {
let text = path.to_string_lossy();
catalogs
.iter()
.flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
.filter(|(_, d)| {
d.location == path
|| same_place(&d.location, path)
|| within(&text, &d.location.to_string_lossy())
})
.rev()
.max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
.map(|(c, d)| (c.label.clone(), d.entry.clone()))
}
fn bookmark_entry(name: &str, place: &Path, network_check: fn(&Path) -> bool) -> Entry {
let local = matches!(
crate::source::input_source(place),
crate::source::InputSource::Local(_)
);
let mut entry = if is_object_store_url(place) && !names_a_file(place) {
Entry::directory(place)
} else {
entry_for_path(place, !local || network_check(place) || !place.exists())
};
entry.name = name.to_string();
entry
}
fn catalog_section(
catalog: &ShownCatalog,
network_check: fn(&Path) -> bool,
missing: &mut std::collections::HashSet<PathBuf>,
) -> Section {
Section {
title: catalog.label.clone(),
subtitle: None,
origin: Some(catalog.origin_note()),
root: None,
unavailable: catalog.broken.is_some(),
unavailable_note: catalog.broken.clone(),
rows: catalog
.datasets
.iter()
.flat_map(|dataset| {
std::iter::once(catalog_entry(dataset, network_check, missing)).chain(
dataset
.bookmarks
.iter()
.map(|(name, place)| bookmark_entry(name, place, network_check)),
)
})
.collect(),
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: false,
door: None,
place_labels: Default::default(),
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Measured {
pub rows: Option<usize>,
pub cols: Option<usize>,
pub cols_sampled: bool,
pub size: Option<u64>,
pub columns: Vec<String>,
pub cost: crate::discover::Cost,
pub kind: Option<crate::discover::EntryKind>,
pub holds: crate::discover::Holds,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SortMode {
#[default]
Natural,
Size,
Modified,
Rows,
}
impl SortMode {
pub fn label_in(self, section_is_recency_ordered: bool) -> &'static str {
match self {
SortMode::Natural if section_is_recency_ordered => "recent",
SortMode::Natural => "name",
SortMode::Size => "size",
SortMode::Modified => "modified",
SortMode::Rows => "rows",
}
}
pub fn next(self) -> Self {
match self {
SortMode::Natural => SortMode::Size,
SortMode::Size => SortMode::Modified,
SortMode::Modified => SortMode::Rows,
SortMode::Rows => SortMode::Natural,
}
}
}
#[derive(Debug, Clone)]
pub enum Row<'a> {
Header {
section: usize,
matches: usize,
collapsed: bool,
},
Entry {
section: usize,
entry: &'a Entry,
nested: bool,
},
Place {
section: usize,
path: PathBuf,
label: Option<String>,
source: Option<String>,
held: usize,
},
Door { section: usize, entry: &'a Entry },
More {
section: usize,
hidden: usize,
places: usize,
},
Hidden { section: usize, count: usize },
}
impl Row<'_> {
pub fn section(&self) -> usize {
match self {
Row::Header { section, .. }
| Row::Entry { section, .. }
| Row::Door { section, .. }
| Row::Place { section, .. }
| Row::More { section, .. }
| Row::Hidden { section, .. } => *section,
}
}
}
pub fn place_of(path: &Path) -> PathBuf {
parent_location(path).unwrap_or_else(|| path.to_path_buf())
}
pub fn place_is_browsable(path: &Path) -> bool {
is_cloud_place(path)
|| is_object_store_url(path)
|| matches!(
crate::source::input_source(path),
crate::source::InputSource::Local(_)
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RowKey {
Header(String),
Entry(PathBuf),
Door(PathBuf),
Place(PathBuf),
More(String),
Hidden(String),
}
#[derive(Debug)]
pub struct HomeState {
pub sections: Vec<Section>,
pub filter: String,
pub filter_selected: bool,
pub search_limit: usize,
pub found_scores: Option<(String, Vec<i32>)>,
pub hide_unreadable: bool,
pub formats: std::sync::Arc<crate::formats::Registry>,
pub lake_here: Option<(PathBuf, &'static str)>,
pub selected: usize,
pub scroll: usize,
pub view_height: usize,
pub path_input_active: bool,
pub path_input: String,
pub path_listing: Option<PathListing>,
pub path_pick: Option<usize>,
pub browsing: Option<PathBuf>,
pub browse_start: Option<PathBuf>,
pub status: Option<String>,
pub network_check: fn(&Path) -> bool,
pub visits: std::collections::HashMap<PathBuf, crate::cache::Visits>,
pub newest_recent: Option<PathBuf>,
pub probed: std::collections::HashMap<PathBuf, Vec<Entry>>,
pub unreachable: std::collections::HashSet<PathBuf>,
pub listing_so_far: std::collections::HashMap<PathBuf, Vec<Entry>>,
pub cut_short: std::collections::HashSet<PathBuf>,
pub narrowed: Option<Narrowed>,
pub probe_errors: std::collections::HashMap<PathBuf, String>,
pub cloud_kinds: std::collections::HashMap<PathBuf, (EntryKind, crate::discover::Holds)>,
pub sort: SortMode,
pub listing_in_flight: bool,
pub measure_in_flight: bool,
pub classify_in_flight: bool,
pub pending_enrich: bool,
pub pending_classify: bool,
pub pending_peek: bool,
pub peeking: std::collections::HashSet<PathBuf>,
pub peek_failed: std::collections::HashSet<PathBuf>,
pub enriched: std::collections::HashMap<PathBuf, Measured>,
pub folds: std::collections::HashMap<String, bool>,
pub folds_owed: bool,
pub search: SearchState,
pub known: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
pub cloud: Vec<CloudSource>,
pub catalogs: Vec<ShownCatalog>,
pub sized: std::collections::HashSet<PathBuf>,
pub web_gone: std::collections::HashMap<PathBuf, crate::error_display::HttpGone>,
pub missing: std::collections::HashSet<PathBuf>,
pub waiting_since: Option<std::time::Instant>,
pub recent_expanded: bool,
pub trail: Vec<Mark>,
pub returning: Option<RowKey>,
pub returning_line: Option<usize>,
pub landing: bool,
}
#[derive(Debug, Clone)]
pub struct Mark {
pub place: Option<PathBuf>,
pub key: Option<RowKey>,
pub filter: String,
pub search: Option<SearchState>,
pub line: usize,
}
#[derive(Debug, Clone, Default)]
pub struct SearchState {
pub root: Option<PathBuf>,
pub epoch: u64,
pub results: Vec<std::sync::Arc<[Entry]>>,
pub indexed: usize,
pub matches: Option<crate::search::Matches>,
pub scoring: bool,
pub scanned: usize,
pub running: bool,
pub done: bool,
pub limited: Option<String>,
}
const SCORE_INLINE_MAX: usize = 2_000;
const FRECENCY_LIFT: f64 = 3.0;
#[derive(Debug, Clone)]
pub struct ScoreJob {
pub epoch: u64,
pub results: Vec<std::sync::Arc<[Entry]>>,
pub query: String,
pub base: Option<crate::search::Matches>,
pub limit: usize,
}
impl SearchState {
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn set_results(&mut self, results: Vec<Entry>) {
self.indexed = results.len();
self.results = vec![results.into()];
self.matches = None;
}
pub fn files(&self) -> impl Iterator<Item = &Entry> {
self.results.iter().flat_map(|batch| batch.iter())
}
fn scored_for(&self, query: &str) -> bool {
self.matches
.as_ref()
.is_some_and(|m| m.query == query && m.upto == self.indexed)
}
fn base_for(&self, query: &str) -> (Option<&crate::search::Matches>, usize) {
match self.matches.as_ref() {
Some(m) if m.narrows_to(query) && m.upto <= self.indexed => {
(Some(m), m.ids.len() + self.indexed - m.upto)
}
_ => (None, self.indexed),
}
}
}
impl Default for HomeState {
fn default() -> Self {
Self {
sections: Vec::new(),
cloud: Vec::new(),
catalogs: Vec::new(),
sized: std::collections::HashSet::new(),
web_gone: Default::default(),
missing: Default::default(),
filter: String::new(),
search_limit: crate::config::SearchConfig::default().max_results,
found_scores: None,
hide_unreadable: true,
formats: Default::default(),
lake_here: None,
selected: 0,
scroll: 0,
view_height: 0,
path_input_active: false,
path_input: String::new(),
path_listing: None,
path_pick: None,
filter_selected: false,
browsing: None,
browse_start: None,
status: None,
network_check: is_remote_path,
visits: Default::default(),
newest_recent: None,
sort: SortMode::default(),
listing_in_flight: false,
measure_in_flight: false,
classify_in_flight: false,
pending_classify: false,
pending_peek: false,
peeking: std::collections::HashSet::new(),
probed: std::collections::HashMap::new(),
unreachable: std::collections::HashSet::new(),
listing_so_far: std::collections::HashMap::new(),
cut_short: std::collections::HashSet::new(),
narrowed: None,
probe_errors: std::collections::HashMap::new(),
cloud_kinds: std::collections::HashMap::new(),
peek_failed: std::collections::HashSet::new(),
pending_enrich: false,
waiting_since: None,
enriched: std::collections::HashMap::new(),
folds: std::collections::HashMap::new(),
folds_owed: false,
search: SearchState::default(),
known: Default::default(),
recent_expanded: false,
trail: Vec::new(),
returning: None,
returning_line: None,
landing: false,
}
}
}
#[derive(Debug, Clone)]
pub struct ListingRequest {
pub recents: Vec<PathBuf>,
pub desktop_dirs: Vec<PathBuf>,
pub browsing: Option<PathBuf>,
pub probed: std::collections::HashMap<PathBuf, Vec<Entry>>,
pub unreachable: std::collections::HashSet<PathBuf>,
pub listing_so_far: std::collections::HashMap<PathBuf, Vec<Entry>>,
pub cut_short: std::collections::HashSet<PathBuf>,
pub narrowed: Option<Narrowed>,
pub probe_errors: std::collections::HashMap<PathBuf, String>,
pub network_check: fn(&Path) -> bool,
pub cloud: Vec<CloudSource>,
pub catalogs: Vec<ShownCatalog>,
pub known: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
pub formats: std::sync::Arc<crate::formats::Registry>,
}
#[derive(Debug, Clone, Default)]
pub struct Listing {
pub sections: Vec<Section>,
pub missing: std::collections::HashSet<PathBuf>,
}
impl Listing {
pub fn alias_visits(
&self,
visits: &mut std::collections::HashMap<PathBuf, crate::cache::Visits>,
) {
let names: std::collections::HashSet<std::ffi::OsString> = visits
.keys()
.filter_map(|p| p.file_name().map(|n| n.to_os_string()))
.collect();
let mut aliases = Vec::new();
for row in self.sections.iter().flat_map(|s| &s.rows) {
let path = &row.path;
if visits.contains_key(path)
|| row.table.is_some()
|| !path.file_name().is_some_and(|n| names.contains(n))
|| is_network_path(path)
{
continue;
}
if let Some(v) = crate::canonical::canonicalize(path)
.ok()
.and_then(|canonical| visits.get(&canonical))
{
aliases.push((path.clone(), *v));
}
}
visits.extend(aliases);
}
}
pub fn look_into(entry: &Entry) -> Entry {
look_into_as(entry, &crate::schema_union::ReadAs::default())
}
pub fn look_into_as(entry: &Entry, as_read: &crate::schema_union::ReadAs) -> Entry {
let mut probe = classify_row(entry);
measure_row(&mut probe, entry, as_read, None);
probe
}
fn classify_row(entry: &Entry) -> Entry {
let mut probe = entry.clone();
if probe.kind == EntryKind::Unknown && probe.path.is_dir() {
let (kind, holds) = discover::look_at_directory(&probe.path);
probe.kind = kind;
probe.holds = holds;
}
probe
}
fn measure_row(
probe: &mut Entry,
entry: &Entry,
as_read: &crate::schema_union::ReadAs,
remembered: Option<&crate::cache::CacheManager>,
) {
discover::enrich_with(probe, as_read, remembered);
probe.size = probe.size.or(entry.size);
probe.modified = probe.modified.or(entry.modified);
}
pub fn look_into_batch(
rows: Vec<Entry>,
cache: &crate::cache::CacheManager,
mut each: impl FnMut(PathBuf, Measured),
) {
let as_read = crate::schema_union::ReadAs::default();
let classified: Vec<(Entry, Entry)> = rows
.into_iter()
.map(|entry| {
let probe = classify_row(&entry);
if probe.kind != entry.kind {
each(entry.path.clone(), measured_from(&probe, &entry));
}
(probe, entry)
})
.collect();
let mut facts = Vec::new();
for (mut probe, entry) in classified {
measure_row(&mut probe, &entry, &as_read, Some(cache));
facts.extend(facts_for(&probe));
each(entry.path.clone(), measured_from(&probe, &entry));
}
cache.record_dataset_facts(&facts);
}
pub fn measured_from(probe: &Entry, original: &Entry) -> Measured {
Measured {
rows: probe.rows,
cols: probe.cols,
cols_sampled: probe.cols_sampled,
size: probe.size.or(original.size),
columns: probe.columns.clone(),
kind: (probe.kind != original.kind).then_some(probe.kind),
holds: probe.holds.clone(),
cost: crate::discover::Cost {
source: None,
..probe.cost.clone()
},
}
}
fn probed_entry(
probed: &std::collections::HashMap<PathBuf, Vec<Entry>>,
path: &Path,
) -> Option<Entry> {
probed.values().flatten().find(|e| e.path == path).cloned()
}
#[derive(Debug, Clone)]
pub struct Narrowed {
pub dir: PathBuf,
pub prefix: String,
pub rows: Vec<Entry>,
pub truncated: bool,
}
pub fn build_listing(request: &ListingRequest) -> Listing {
let ListingRequest {
recents,
desktop_dirs,
browsing,
probed,
unreachable,
listing_so_far,
cut_short,
narrowed,
probe_errors,
network_check,
cloud,
catalogs,
known,
formats,
} = request;
let network_check = *network_check;
let mounts = crate::locality::Mounts::current();
let mut sections: Vec<Section> = Vec::new();
if let Some(id) = browsing.as_deref().and_then(cloud_source_id) {
let source = cloud.iter().find(|s| s.id == id);
let rows = source
.map(|s| s.buckets.iter().map(|b| bucket_entry(b)).collect())
.unwrap_or_default();
let failure = source.and_then(|s| match &s.status {
CloudStatus::Failed { short, .. } => Some(short.clone()),
_ => None,
});
sections.push(Section {
title: source.map(|s| s.label.clone()).unwrap_or(id),
subtitle: source.map(|s| s.note.clone()).filter(|n| !n.is_empty()),
origin: None,
rows,
unavailable: source.is_none() || failure.is_some(),
unavailable_note: if source.is_none() {
Some("source not found".to_string())
} else {
failure
},
folded_by_default: false,
remote_root: None,
waiting: source.is_some_and(|s| s.busy()),
grouped_by_place: false,
door: None,
place_labels: Default::default(),
root: None,
});
annotate(&mut sections, known, network_check, &mounts);
return Listing {
sections,
..Default::default()
};
}
if let Some(dir) = browsing.clone() {
let remote = network_check(&dir);
let so_far = remote && !probed.contains_key(&dir) && listing_so_far.contains_key(&dir);
let database = !remote && dir.is_file();
let (mut rows, truncated) = if remote {
let rows = probed
.get(&dir)
.or_else(|| listing_so_far.get(&dir))
.cloned()
.unwrap_or_default();
(rows, cut_short.contains(&dir))
} else if database {
let tables = discover::database_rows(&dir);
let rows = if tables.is_empty() {
discover::variant_rows(&dir, formats)
} else {
tables
};
(rows, false)
} else {
let scan = discover::scan_dir_specs(&dir, formats);
let mut rows = discover::split_rows(&dir);
rows.extend(scan.entries);
(rows, scan.truncated)
};
let narrowed = narrowed
.as_ref()
.filter(|n| remote && truncated && n.dir == dir);
if let Some(narrowed) = narrowed {
let listed: std::collections::HashSet<PathBuf> =
rows.iter().map(|row| row.path.clone()).collect();
rows.extend(
narrowed
.rows
.iter()
.filter(|row| !listed.contains(&row.path))
.cloned(),
);
}
let subtitle = if so_far {
Some(format!(
"{} so far",
crate::numfmt::group_chrome(rows.len())
))
} else if truncated {
let first = format!(
"first {}",
crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
);
Some(match narrowed {
Some(n) => format!(
"{first} + {}{} {}*",
crate::numfmt::group_chrome(n.rows.len()),
if n.truncated { "+" } else { "" },
n.prefix
),
None => first,
})
} else {
None
};
let unavailable = remote && unreachable.contains(&dir);
let mut door = (!database)
.then(|| whole_directory_row(&dir, &rows, remote))
.flatten();
if !remote
&& let Some(door) = door.as_mut()
&& let Ok(meta) = std::fs::metadata(&dir)
{
door.modified = meta.modified().ok();
apply_known_facts(door, known, false);
door.modified = None;
door.name = door_name(door, &rows);
}
sections.push(Section {
title: {
let text = dir.to_string_lossy();
if let Some(dataset) = catalogs
.iter()
.flat_map(|c| c.datasets.iter())
.find(|d| is_object_store_url(&d.location) && same_place(&d.location, &dir))
{
dataset.name.clone()
} else if let Some((_, account)) = cloud_account(&dir) {
account
} else if let Some((_, container, key)) = crate::source::azure_parts(&text) {
format!("{container}/{}", key.trim_matches('/'))
.trim_end_matches('/')
.to_string()
} else {
match crate::source::split_source_id(&text) {
(Some(_), plain) => plain.into_owned(),
(None, _) => display_path(&dir),
}
}
},
subtitle,
origin: None,
root: Some(dir.clone()),
rows,
unavailable,
unavailable_note: probe_errors.get(&dir).cloned(),
folded_by_default: false,
remote_root: None,
waiting: so_far,
grouped_by_place: false,
door,
place_labels: Default::default(),
});
annotate(&mut sections, known, network_check, &mounts);
return Listing {
sections,
..Default::default()
};
}
let recent_rows: Vec<Entry> = recents
.iter()
.filter(|p| {
network_check(p)
|| p.exists()
|| crate::members::split(p).is_some()
|| crate::members::split_variant(p, formats).is_some()
|| crate::hf_splits::split_place(p).is_some()
})
.map(|p| {
if let Some(known) = probed_entry(probed, p) {
return known;
}
if let Some(variant) = discover::variant_row(p, formats) {
return variant;
}
if !network_check(p)
&& let Some(split) = discover::split_row(p)
{
return split;
}
let mut entry = entry_for_path(p, network_check(p));
if !network_check(p) {
discover::name_unlisted_file(&mut entry, formats);
}
if let Some(dataset) = catalogs
.iter()
.flat_map(|c| &c.datasets)
.find(|d| d.location == *p)
{
entry.name = dataset.name.clone();
}
entry
})
.collect();
let mut elsewhere: Vec<Entry> = Vec::new();
let roots = HomeState::roots_with(desktop_dirs, network_check);
let mut root_sections: Vec<(RootOrigin, Section)> = Vec::new();
let mut cwd_listing: Option<(PathBuf, std::collections::HashSet<std::ffi::OsString>)> = None;
for root in roots {
if root.origin == RootOrigin::Desktop {
if root.available {
let mut entry = Entry::directory(&root.path);
entry.name = display_path(&root.path);
elsewhere.push(entry);
}
continue;
}
let mut truncated = false;
let rows = if root.network {
truncated = cut_short.contains(&root.path);
probed
.get(&root.path)
.or_else(|| listing_so_far.get(&root.path))
.cloned()
.unwrap_or_default()
} else if root.available {
let scan = discover::scan_dir_specs(&root.path, formats);
truncated = scan.truncated;
scan.entries
} else {
Vec::new()
};
if root.origin == RootOrigin::Cwd {
let key = if root.network {
root.path.clone()
} else {
crate::canonical::canonicalize(&root.path).unwrap_or_else(|_| root.path.clone())
};
let names = rows
.iter()
.filter_map(|row| row.path.file_name().map(|n| n.to_os_string()))
.collect();
cwd_listing = Some((key, names));
}
let unreachable = root.network && unreachable.contains(&root.path);
let waiting = root.network && !unreachable && !probed.contains_key(&root.path);
let described = mounts.describe(&root.path);
let fstype = if described.network() {
described.fstype
} else {
"network".to_string()
};
let mut state: Vec<String> = Vec::new();
if truncated {
state.push(format!(
"first {}",
crate::numfmt::group_chrome(discover::MAX_ENTRIES_PER_DIR)
));
}
if root.network {
state.push(fstype);
}
root_sections.push((
root.origin,
Section {
title: display_path(&root.path),
subtitle: (!state.is_empty()).then(|| state.join(" · ")),
origin: Some(root.origin.note()),
root: Some(root.path.clone()),
rows,
unavailable: !root.available || unreachable,
unavailable_note: None,
folded_by_default: false,
remote_root: root.network.then(|| root.path.clone()),
waiting,
grouped_by_place: false,
door: None,
place_labels: Default::default(),
},
));
}
let recent_rows: Vec<Entry> = match &cwd_listing {
None => recent_rows,
Some((cwd, names)) => recent_rows
.into_iter()
.filter(|row| {
let place = place_of(&row.path);
let place = if network_check(&place) {
place
} else {
crate::canonical::canonicalize(&place).unwrap_or(place)
};
place != *cwd || !row.path.file_name().is_some_and(|n| names.contains(n))
})
.collect(),
};
if !recent_rows.is_empty() {
let place_labels = place_labels(&recent_rows, known, network_check);
sections.push(Section {
title: HomeState::RECENT_SECTION.to_string(),
subtitle: None,
origin: None,
root: None,
rows: recent_rows,
unavailable: false,
unavailable_note: None,
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: true,
door: None,
place_labels,
});
}
sections.extend(root_sections.into_iter().map(|(_, s)| s));
if !cloud.is_empty() {
sections.push(Section {
title: HomeState::CLOUD_SECTION.to_string(),
subtitle: None,
origin: None,
rows: cloud.iter().map(source_entry).collect(),
unavailable: false,
unavailable_note: None,
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: false,
door: None,
place_labels: Default::default(),
root: None,
});
}
let mut missing = std::collections::HashSet::new();
for catalog in catalogs {
let mut section = catalog_section(catalog, network_check, &mut missing);
for row in section.rows.iter_mut().filter(|r| {
r.kind == EntryKind::File
&& r.format_spec.is_none()
&& r.table.is_none()
&& !network_check(&r.path)
&& !discover::is_data_file(&r.path)
}) {
if let Some(spec) = formats.by_glob(&row.path, false).first() {
discover::name_spec_file(row, spec);
}
}
sections.push(section);
}
if !elsewhere.is_empty() {
sections.push(Section {
title: "Elsewhere".to_string(),
subtitle: None,
origin: None,
rows: elsewhere,
unavailable: false,
unavailable_note: None,
folded_by_default: true,
remote_root: None,
waiting: false,
grouped_by_place: false,
door: None,
place_labels: Default::default(),
root: None,
});
}
annotate(&mut sections, known, network_check, &mounts);
Listing { sections, missing }
}
pub fn index_key(path: &Path) -> PathBuf {
let text = path.to_string_lossy();
if let Some((account, container, key)) = crate::source::azure_parts(&text) {
return PathBuf::from(crate::source::azure_url(&account, &container, &key));
}
match crate::source::split_source_id(&text) {
(Some(_), plain) => PathBuf::from(plain.into_owned()),
(None, _) => path.to_path_buf(),
}
}
fn known_facts<'a>(
known: &'a std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
path: &Path,
) -> Option<&'a crate::cache::DatasetFacts> {
known.get(path).or_else(|| known.get(&index_key(path)))
}
fn place_labels(
rows: &[Entry],
known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
network_check: fn(&Path) -> bool,
) -> std::collections::HashMap<PathBuf, String> {
let mut labels = std::collections::HashMap::new();
for row in rows {
let place = place_of(&row.path);
if labels.contains_key(&place) {
continue;
}
let Some(facts) = known_facts(known, &place) else {
continue;
};
if facts.classified_by != crate::discover::CLASSIFIER_VERSION {
continue;
}
if !network_check(&place) {
let same_mtime = std::fs::metadata(&place)
.and_then(|m| m.modified())
.ok()
.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
.is_some_and(|d| d.as_secs() == facts.mtime);
if !same_mtime {
continue;
}
}
let Some(kind) = facts.kind else {
continue;
};
let mut probe = Entry::directory(&place);
probe.kind = kind;
probe.holds = facts.holds.clone();
let label = probe.label();
if !label.is_empty() && !label.starts_with("dir") {
labels.insert(place, label.into_owned());
}
}
labels
}
fn annotate(
sections: &mut [Section],
known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
network_check: fn(&Path) -> bool,
mounts: &crate::locality::Mounts,
) {
for section in sections {
for row in &mut section.rows {
if is_cloud_place(&row.path) {
row.cost.source = Some("cloud".to_string());
continue;
}
apply_known_facts(row, known, network_check(&row.path));
row.cost.source = Some(mounts.describe(&row.path).fstype);
}
if let Some(door) = section.door.as_mut()
&& !is_cloud_place(&door.path)
{
door.cost.source = Some(mounts.describe(&door.path).fstype);
}
}
}
fn take_cost(row: &mut Entry, cost: &discover::Cost) {
let source = row.cost.source.take();
let variants = row.cost.tables.filter(|_| row.format_spec.is_some());
row.cost = cost.clone();
row.cost.source = source;
if variants.is_some() {
row.cost.tables = variants;
}
}
fn apply_known_facts(
row: &mut Entry,
known: &std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
remote: bool,
) {
let Some(facts) = known_facts(known, &row.path) else {
return;
};
if !remote
&& matches!(row.kind, EntryKind::Unknown | EntryKind::MultiFile)
&& facts.classified_by == crate::discover::CLASSIFIER_VERSION
&& let Some(kind) = facts.kind
{
let same_mtime = row
.modified
.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
.is_some_and(|d| d.as_secs() == facts.mtime);
if same_mtime {
row.kind = kind;
if row.holds.is_empty() {
row.holds = facts.holds.clone();
}
}
}
if !remote {
let same_bytes = row.size.map(|s| s == facts.size).unwrap_or(false)
&& row
.modified
.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() == facts.mtime)
.unwrap_or(false);
if !same_bytes {
return;
}
}
row.rows = facts.rows;
row.cols = facts.cols;
row.cols_sampled = facts.cols_sampled;
if !facts.columns.is_empty() {
row.columns = facts.columns.clone();
}
take_cost(row, &facts.cost);
if remote {
if facts.size > 0 {
row.size = row.size.or(Some(facts.size));
}
if row.kind == EntryKind::Unknown
&& facts.classified_by == crate::discover::CLASSIFIER_VERSION
&& let Some(kind) = facts.kind
{
row.kind = kind;
if row.holds.is_empty() {
row.holds = facts.holds.clone();
}
}
}
}
pub fn facts_for(entry: &Entry) -> Option<(PathBuf, crate::cache::DatasetFacts)> {
let size = entry.size?;
let mtime = entry
.modified?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
if entry.rows.is_none() && entry.columns.is_empty() && entry.cost == Default::default() {
return None; }
Some((
entry.path.clone(),
crate::cache::DatasetFacts {
mtime,
size,
rows: entry.rows,
cols: entry.cols,
cols_sampled: entry.cols_sampled,
columns: entry.columns.clone(),
kind: Some(entry.kind),
holds: entry.holds.clone(),
classified_by: crate::discover::CLASSIFIER_VERSION,
cost: crate::discover::Cost {
source: None,
..entry.cost.clone()
},
},
))
}
pub fn match_score(filter: &str, entry: &Entry) -> Option<i32> {
if let Some(m) = crate::fuzzy::best_match(filter, &entry.name) {
return Some(m.score);
}
if filter.is_empty() {
return Some(0);
}
matching_column(filter, entry).map(|_| -COLUMN_MATCH_PENALTY)
}
const COLUMN_MATCH_PENALTY: i32 = 1_000_000;
pub fn matching_column<'a>(filter: &str, entry: &'a Entry) -> Option<&'a str> {
if filter.is_empty() {
return None;
}
let needle = filter.to_lowercase();
entry
.columns
.iter()
.find(|c| c.to_lowercase().contains(&needle))
.map(|c| c.as_str())
}
pub fn fuzzy_positions(needle: &str, haystack: &str) -> Vec<usize> {
crate::fuzzy::best_match(needle, haystack)
.map(|m| m.positions)
.unwrap_or_default()
}
pub fn substring_positions(needle: &str, haystack: &str) -> Vec<usize> {
if needle.is_empty() {
return Vec::new();
}
let hay: Vec<char> = haystack.to_lowercase().chars().collect();
let need: Vec<char> = needle.to_lowercase().chars().collect();
if need.len() > hay.len() {
return Vec::new();
}
for start in 0..=(hay.len() - need.len()) {
if hay[start..start + need.len()] == need[..] {
return (start..start + need.len()).collect();
}
}
Vec::new()
}
pub fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
crate::fuzzy::best_match(needle, haystack).map(|m| m.score)
}
impl HomeState {
pub fn roots(desktop_dirs: &[PathBuf]) -> Vec<Root> {
Self::roots_with(desktop_dirs, is_remote_path)
}
pub fn roots_with(desktop_dirs: &[PathBuf], is_network: fn(&Path) -> bool) -> Vec<Root> {
let mut roots: Vec<Root> = Vec::new();
let mut seen: Vec<PathBuf> = Vec::new();
let push =
|path: PathBuf, origin: RootOrigin, roots: &mut Vec<Root>, seen: &mut Vec<PathBuf>| {
let network = is_network(&path);
let key = if network {
path.clone()
} else {
crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone())
};
if seen.contains(&key) {
return;
}
seen.push(key);
let available = if network {
true } else {
std::fs::read_dir(&path).is_ok()
};
roots.push(Root {
path,
origin,
available,
network,
});
};
if let Ok(cwd) = std::env::current_dir() {
push(cwd, RootOrigin::Cwd, &mut roots, &mut seen);
}
for dir in desktop_dirs {
push(dir.clone(), RootOrigin::Desktop, &mut roots, &mut seen);
}
roots
}
pub fn rebuild(&mut self, recents: &[PathBuf]) {
self.rebuild_with(recents, &[])
}
pub fn rebuild_with(&mut self, recents: &[PathBuf], desktop_dirs: &[PathBuf]) {
let request = ListingRequest {
recents: recents.to_vec(),
desktop_dirs: desktop_dirs.to_vec(),
browsing: self.browsing.clone(),
probed: self.probed.clone(),
unreachable: self.unreachable.clone(),
listing_so_far: self.listing_so_far.clone(),
cut_short: self.cut_short.clone(),
narrowed: self.narrowed.clone(),
probe_errors: self.probe_errors.clone(),
network_check: self.network_check,
cloud: self.cloud.clone(),
catalogs: self.catalogs.clone(),
known: Default::default(),
formats: self.formats.clone(),
};
let listing = build_listing(&request);
self.apply_listing(listing);
}
pub fn apply_listing(&mut self, listing: Listing) {
let returning = self.returning.take();
let previous = returning.clone().or_else(|| self.selected_key());
let line = self.selected.saturating_sub(self.scroll);
let mut listing = listing;
for section in &mut listing.sections {
name_by_spec(&self.formats, &mut section.rows);
}
self.sections = listing.sections;
self.missing = listing.missing;
if let (Some(browsing), Some((dir, format))) = (&self.browsing, &self.lake_here)
&& browsing == dir
&& let Some(section) = self.sections.first_mut()
{
let note = format!("{} · not read as a table", format.to_ascii_lowercase());
section.subtitle = Some(match section.subtitle.take() {
Some(state) => format!("{note} · {state}"),
None => note,
});
}
self.sync_search_section();
self.apply_measurements();
let placed = self.reselect(previous);
if placed && returning.is_some() {
self.landing = false;
}
if !placed {
self.select_first_entry();
if self.rows_still_arriving() {
self.returning = returning;
}
} else if returning.is_some() {
self.scroll_to_returning_line();
} else {
self.scroll = self.selected.saturating_sub(line);
}
self.follow_selection();
}
pub fn leave_mark(&mut self) {
let mark = Mark {
place: self.browsing.clone(),
key: self.selected_key(),
filter: self.filter.clone(),
search: (!self.search.running).then(|| SearchState {
scoring: false,
..self.search.clone()
}),
line: self.selected.saturating_sub(self.scroll),
};
self.trail.retain(|m| m.place != mark.place);
self.trail.push(mark);
}
pub fn come_back(&mut self, from: Option<PathBuf>) {
let to = self.browsing.clone();
let mark = self
.trail
.iter()
.rposition(|m| m.place == to)
.map(|at| self.trail.split_off(at).remove(0));
match mark {
Some(mark) => {
self.filter = mark.filter;
self.search = mark.search.unwrap_or_default();
self.returning = mark.key;
self.returning_line = Some(mark.line);
}
None => {
self.filter.clear();
self.search.reset();
self.returning = from.map(RowKey::Entry);
self.returning_line = None;
}
}
}
fn rows_still_arriving(&self) -> bool {
self.listing_in_flight
|| self.sections_waiting()
|| self.awaiting_listing().is_some()
|| self.search.running
}
fn settle_return(&mut self) {
let Some(key) = self.returning.clone() else {
return;
};
if let Some(idx) = self.row_of(&key) {
self.selected = idx;
self.returning = None;
self.landing = false;
self.scroll_to_returning_line();
self.follow_selection();
} else if !self.rows_still_arriving() {
self.returning = None;
}
}
fn scroll_to_returning_line(&mut self) {
if let Some(line) = self.returning_line.take() {
self.scroll = self.selected.saturating_sub(line);
}
}
pub fn selected_key(&self) -> Option<RowKey> {
let title = |section: usize| self.sections.get(section).map(|s| s.title.clone());
Some(match self.selected_row()? {
Row::Header { section, .. } => RowKey::Header(title(section)?),
Row::More { section, .. } => RowKey::More(title(section)?),
Row::Hidden { section, .. } => RowKey::Hidden(title(section)?),
Row::Entry { entry, .. } => RowKey::Entry(entry.path.clone()),
Row::Door { entry, .. } => RowKey::Door(entry.path.clone()),
Row::Place { path, .. } => RowKey::Place(path),
})
}
pub fn reselect(&mut self, key: Option<RowKey>) -> bool {
let Some(key) = key else {
self.clamp_selection();
return false;
};
match self.row_of(&key) {
Some(idx) => {
self.selected = idx;
true
}
None => {
self.clamp_selection();
false
}
}
}
fn row_of(&self, key: &RowKey) -> Option<usize> {
let rows = self.visible();
let found = rows.iter().position(|row| match (row, key) {
(Row::Entry { entry, .. }, RowKey::Entry(path)) => entry.path == *path,
(Row::Door { entry, .. }, RowKey::Door(path)) => entry.path == *path,
(Row::Place { path, .. }, RowKey::Place(wanted)) => path == wanted,
(Row::Header { section, .. }, RowKey::Header(title))
| (Row::More { section, .. }, RowKey::More(title))
| (Row::Hidden { section, .. }, RowKey::Hidden(title)) => self
.sections
.get(*section)
.is_some_and(|s| s.title == *title),
_ => false,
});
if found.is_some() {
return found;
}
match key {
RowKey::Entry(path) | RowKey::Place(path) => rows.iter().position(|row| {
matches!(row, Row::More { section, .. }
if self.sections.get(*section).is_some_and(|s| {
s.grouped_by_place
&& s.rows.iter().any(|r| r.path == *path || place_of(&r.path) == *path)
}))
}),
_ => None,
}
}
pub fn set_view_height(&mut self, height: usize) {
if height == self.view_height {
return;
}
let key = self.selected_key();
self.view_height = height;
self.reselect(key);
}
fn follow_selection(&mut self) {
let rows = self.visible().len();
self.scroll = settle_top(self.scroll, self.selected, self.view_height, rows);
}
fn section_folded(&self, section: &Section) -> bool {
if self.browsing.is_some() {
return false;
}
self.folds
.get(§ion.title)
.copied()
.unwrap_or(section.folded_by_default)
}
pub fn is_collapsed(&self, section: usize) -> bool {
self.sections
.get(section)
.is_some_and(|s| self.section_folded(s))
}
pub fn toggle_collapsed(&mut self, section: usize) {
let folded = self.is_collapsed(section);
self.set_collapsed(section, !folded);
}
pub fn set_collapsed(&mut self, section: usize, collapsed: bool) {
if self.browsing.is_some() {
return;
}
let Some(title) = self.sections.get(section).map(|s| s.title.clone()) else {
return;
};
self.folds.insert(title, collapsed);
}
pub fn jump_section(&mut self, delta: isize) {
self.returning = None;
self.landing = false;
let rows = self.visible();
let headers: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, r)| matches!(r, Row::Header { .. }))
.map(|(i, _)| i)
.collect();
if headers.is_empty() {
return;
}
let current = self.selected;
self.selected = if delta > 0 {
headers
.iter()
.copied()
.find(|&h| h > current)
.unwrap_or(headers[0])
} else {
headers
.iter()
.rev()
.copied()
.find(|&h| h < current)
.unwrap_or(*headers.last().unwrap())
};
}
pub fn has_any_dataset(&self) -> bool {
self.sections
.iter()
.flat_map(|s| s.rows.iter())
.any(|e| e.kind.is_dataset() || e.kind.is_lake_table())
}
pub const SEARCH_SECTION: &'static str = "Found";
pub const CLOUD_SECTION: &'static str = "Cloud";
pub const RECENT_SECTION: &'static str = "Recent";
pub fn cloud_source_of(&self, path: &Path) -> Option<&CloudSource> {
if let Some(id) = cloud_source_id(path) {
return self.cloud.iter().find(|s| s.id == id);
}
if let Some((id, _)) = cloud_account(path) {
return self.cloud.iter().find(|s| s.id == id);
}
let text = path.to_string_lossy();
if let Some((account, _, _)) = crate::source::azure_parts(&text) {
return self.azure_account_place(&account).and_then(|place| {
cloud_account(&place).and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id))
});
}
if let (Some(id), _) = crate::source::split_source_id(&text) {
return self.cloud.iter().find(|s| s.id == id);
}
if let Some(project) =
Self::google_bucket_root(path).and_then(|b| self.project_of_bucket(&b))
{
return cloud_account(&project)
.and_then(|(id, _)| self.cloud.iter().find(|s| s.id == id));
}
let (_, plain) = crate::source::split_source_id(&text);
let (scheme, rest) = plain.split_once("://")?;
let bucket = rest.split('/').next()?;
let root = PathBuf::from(format!("{scheme}://{bucket}"));
self.cloud.iter().find(|s| s.buckets.contains(&root))
}
pub fn parent_of(&self, path: &Path) -> Option<PathBuf> {
if cloud_source_id(path).is_some() {
return None;
}
if let Some((_, dataset)) = self.remote_dataset_of(path) {
let place = &dataset.location;
if same_place(path, place) {
return None;
}
let up = self.parent_within(path)?;
return Some(if same_place(&up, place) {
place.clone()
} else {
up
});
}
if let Some((id, _)) = cloud_account(path) {
return Some(cloud_place(&id));
}
let text = path.to_string_lossy();
if let Some((account, container, key)) = crate::source::azure_parts(&text) {
let key = key.trim_matches('/');
if key.is_empty() {
return self.azure_account_place(&account);
}
let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
let up = if up.is_empty() {
String::new()
} else {
format!("{up}/")
};
return Some(PathBuf::from(crate::source::azure_url(
&account, &container, &up,
)));
}
if is_bucket_root(path) {
if let Some(project) = self.project_of_bucket(path) {
return Some(project);
}
return self.cloud_source_of(path).map(|s| cloud_place(&s.id));
}
parent_location(path)
}
fn parent_within(&self, path: &Path) -> Option<PathBuf> {
let text = path.to_string_lossy();
if let Some((account, container, key)) = crate::source::azure_parts(&text) {
let key = key.trim_matches('/');
let up = key.rsplit_once('/').map(|(up, _)| up).unwrap_or("");
let up = if up.is_empty() {
String::new()
} else {
format!("{up}/")
};
return Some(PathBuf::from(crate::source::azure_url(
&account, &container, &up,
)));
}
parent_location(path)
}
fn remote_dataset_of(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
if !is_object_store_url(path) {
return None;
}
let text = path.to_string_lossy();
self.catalogs
.iter()
.flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
.filter(|(_, d)| {
is_object_store_url(&d.location) && within(&text, &d.location.to_string_lossy())
})
.rev()
.max_by_key(|(_, d)| d.location.to_string_lossy().trim_end_matches('/').len())
}
pub fn catalog_dataset(&self, path: &Path) -> Option<(&ShownCatalog, &ShownDataset)> {
self.catalogs
.iter()
.flat_map(|c| c.datasets.iter().map(move |d| (c, d)))
.find(|(_, d)| d.location == path || same_place(&d.location, path))
}
pub fn bookmark(&self, path: &Path) -> Option<(&ShownDataset, &str)> {
self.catalogs
.iter()
.flat_map(|c| c.datasets.iter())
.find_map(|d| {
d.bookmarks
.iter()
.find(|(_, place)| place == path || same_place(place, path))
.map(|(name, _)| (d, name.as_str()))
})
}
pub fn size_hint(&self, path: &Path) -> Option<u64> {
self.catalog_dataset(path).and_then(|(_, d)| d.size)
}
fn azure_account_place(&self, account: &str) -> Option<PathBuf> {
self.cloud
.iter()
.flat_map(|s| s.buckets.iter())
.find(|place| cloud_account(place).is_some_and(|(_, a)| a == account))
.cloned()
}
pub fn cloud_look(&self, entry: &Entry) -> Option<CloudLook> {
if entry.kind != EntryKind::Directory
|| !entry.holds.is_empty()
|| !is_object_store_url(&entry.path)
|| is_cloud_place(&entry.path)
|| object_place_label(&entry.path).is_some()
{
return None;
}
if self.peeking.contains(&entry.path) {
return Some(CloudLook::Looking);
}
if self.peek_failed.contains(&entry.path) {
return Some(CloudLook::Failed);
}
match self.cloud_kinds.get(&entry.path) {
None => Some(CloudLook::Waiting),
Some((kind, holds)) if *kind != EntryKind::Directory || !holds.is_empty() => {
Some(CloudLook::Looking)
}
Some(_) => None,
}
}
pub fn place_kind(&self, path: &Path) -> Option<&'static str> {
if self.missing.contains(path) {
return Some("missing");
}
if let Some((id, _)) = cloud_account(path) {
return self
.cloud
.iter()
.any(|s| s.id == id && s.api == "gcs")
.then_some("project");
}
(is_object_store_url(path)
&& (self
.catalog_dataset(path)
.is_some_and(|(_, d)| is_object_store_url(&d.location))
|| (self.browsing.is_none() && self.bookmark(path).is_some())))
.then_some("dataset")
}
fn project_of_bucket(&self, bucket_root: &Path) -> Option<PathBuf> {
let root = bucket_root.to_string_lossy();
let root = root.trim_end_matches('/');
self.probed
.iter()
.filter(|(place, _)| cloud_account(place).is_some())
.find(|(_, rows)| {
rows.iter()
.any(|row| row.path.to_string_lossy().trim_end_matches('/') == root)
})
.map(|(place, _)| place.clone())
}
fn google_bucket_root(path: &Path) -> Option<PathBuf> {
let text = path.to_string_lossy();
let rest = text
.strip_prefix("gs://")
.or_else(|| text.strip_prefix("gcs://"))?;
let bucket = rest.split('/').next().filter(|b| !b.is_empty())?;
Some(PathBuf::from(format!("gs://{bucket}")))
}
pub fn place_details(&self, path: &Path) -> Option<&[(String, String)]> {
self.cloud
.iter()
.find_map(|s| s.place_details.get(path))
.or_else(|| self.catalog_dataset(path).map(|(_, d)| &d.details))
.or_else(|| self.bookmark(path).map(|(d, _)| &d.details))
.map(Vec::as_slice)
}
pub fn location_label(&self, path: &Path) -> String {
let sep = crate::glyphs::get().trail;
if let Some((catalog, dataset)) = self.remote_dataset_of(path) {
let text = path.to_string_lossy();
let rest = within_rest(&text, &dataset.location.to_string_lossy());
let mut parts = vec![catalog.label.clone(), dataset.name.clone()];
parts.extend(
rest.split('/')
.filter(|p| !p.is_empty())
.map(str::to_string),
);
return parts.join(&format!(" {sep} "));
}
if let Some(source) = self.cloud_source_of(path) {
let mut parts = vec!["cloud".to_string(), source.label.clone()];
let text = path.to_string_lossy();
if let Some((_, account)) = cloud_account(path) {
parts.push(account);
} else if let Some((account, container, key)) = crate::source::azure_parts(&text) {
parts.push(account);
parts.push(container);
parts.extend(key.split('/').filter(|p| !p.is_empty()).map(str::to_string));
} else if cloud_source_id(path).is_none() {
if let Some((_, project)) = Self::google_bucket_root(path)
.and_then(|b| self.project_of_bucket(&b))
.as_deref()
.and_then(cloud_account)
{
parts.push(project);
}
let (_, plain) = crate::source::split_source_id(&text);
if let Some((_, rest)) = plain.split_once("://") {
parts.extend(
rest.split('/')
.filter(|p| !p.is_empty())
.map(str::to_string),
);
}
}
return parts.join(&format!(" {sep} "));
}
display_path(path)
}
pub fn sync_search_section(&mut self) {
self.sections.retain(|s| s.title != Self::SEARCH_SECTION);
self.found_scores = None;
if self.filter.is_empty() {
return;
}
let cloud_rows: Vec<Entry> = if self.browsing.is_none() {
self.cloud
.iter()
.flat_map(|source| {
source.buckets.iter().map(move |bucket| {
let mut entry = bucket_entry(bucket);
entry.name = format!(
"{} {} {}",
source.label,
crate::glyphs::get().trail,
entry.name
);
entry.cost.source = Some(source.api.clone());
entry
})
})
.filter(|e| match_score(&self.filter, e).is_some())
.collect()
} else {
Vec::new()
};
self.score_search_inline();
let local = self.search.root.is_some()
&& (self.search.indexed > 0 || self.search.running || self.search.limited.is_some());
if !local {
if !cloud_rows.is_empty() {
let subtitle = format!("cloud · {} names", cloud_rows.len());
self.sections.push(Section {
title: Self::SEARCH_SECTION.to_string(),
subtitle: Some(subtitle),
origin: None,
rows: cloud_rows,
unavailable: false,
unavailable_note: None,
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: false,
door: None,
place_labels: Default::default(),
root: None,
});
}
return;
}
let listed: std::collections::HashSet<&PathBuf> = self
.sections
.iter()
.flat_map(|s| s.rows.iter().map(|r| &r.path))
.collect();
let matches = self.search.matches.as_ref();
let kept: Vec<(&Entry, i32)> = matches
.map(|m| {
let fresh = m.query == self.filter;
m.top
.iter()
.zip(m.scores.iter().copied())
.filter(|(e, _)| !listed.contains(&e.path))
.filter_map(|(e, score)| {
if fresh {
Some((e, score))
} else {
match_score(&self.filter, e).map(|s| (e, s))
}
})
.collect()
})
.unwrap_or_default();
let found_scores = Some((
self.filter.clone(),
kept.iter().map(|&(_, s)| s).collect::<Vec<i32>>(),
));
let mut rows: Vec<Entry> = kept.into_iter().map(|(e, _)| e.clone()).collect();
rows.extend(cloud_rows);
let partial = self.search.limited.is_some();
let scored = self.search.scored_for(&self.filter);
if rows.is_empty() && !self.search.running && !partial && scored {
return;
}
let subtitle = self.found_subtitle(rows.is_empty());
self.found_scores = found_scores;
self.sections.push(Section {
title: Self::SEARCH_SECTION.to_string(),
subtitle: Some(subtitle),
origin: None,
rows,
unavailable: false,
unavailable_note: None,
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: false,
door: None,
place_labels: Default::default(),
root: None,
});
}
fn found_subtitle(&self, empty: bool) -> String {
let root = self.search.root.clone().unwrap_or_default();
let dot = crate::glyphs::get().middot;
let files = crate::numfmt::group_chrome(self.search.indexed);
let files = if self.search.indexed == 1 {
format!("{files} file")
} else {
format!("{files} files")
};
let subtitle = display_path(&root);
let counted = self
.search
.matches
.as_ref()
.filter(|m| m.query == self.filter && m.ids.len() > m.top.len())
.map(|m| {
format!(
" {dot} {} of {} matches",
crate::numfmt::group_chrome(m.top.len()),
crate::numfmt::group_chrome(m.ids.len())
)
})
.unwrap_or_default();
if self.search.running {
format!(
"{subtitle} {dot} searching {}{counted}",
crate::numfmt::group_chrome(self.search.scanned)
)
} else if !self.search.scored_for(&self.filter) {
format!("{subtitle} {dot} matching {files}")
} else if empty {
match &self.search.limited {
Some(limit) => format!("{subtitle} {dot} no match in {files} {dot} {limit}"),
None => format!("{subtitle} {dot} no match in {files}"),
}
} else {
let searched = crate::numfmt::group_chrome(self.search.scanned);
match &self.search.limited {
Some(limit) => {
format!("{subtitle} {dot} {limit} {dot} {searched} searched{counted}")
}
None => format!("{subtitle} {dot} {searched} searched{counted}"),
}
}
}
pub fn search_batch(&mut self, root: &Path, mut found: Vec<Entry>, scanned: usize) {
if self.search.root.as_deref() != Some(root) {
return;
}
for row in &mut found {
apply_known_facts(row, &self.known, false);
}
self.search.scanned = scanned;
let start = self.search.indexed;
let batch: std::sync::Arc<[Entry]> = found.into();
if !batch.is_empty() {
self.search.indexed += batch.len();
self.search.results.push(batch.clone());
}
let limit = self.search_limit;
let changed = match self.search.matches.as_mut() {
Some(m) if m.query == self.filter && m.upto == start => m.extend(&batch, start, limit),
_ => {
let before = self.search.matches.as_ref().map(|m| m.upto);
self.score_search_inline();
self.search.matches.as_ref().map(|m| m.upto) != before
}
};
let found_listed = self
.sections
.iter()
.position(|s| s.title == Self::SEARCH_SECTION);
match found_listed {
Some(at) if !changed => {
let empty = self.sections[at].rows.is_empty();
self.sections[at].subtitle = Some(self.found_subtitle(empty));
}
_ => self.sync_search_section(),
}
self.settle_return();
}
fn score_search_inline(&mut self) {
if self.filter.is_empty() || self.search.scored_for(&self.filter) {
return;
}
let (base, looks_at) = self.search.base_for(&self.filter);
if looks_at > SCORE_INLINE_MAX {
return;
}
let scored =
crate::search::score(&self.search.results, &self.filter, base, self.search_limit);
self.search.matches = Some(scored);
}
pub fn score_job(&mut self) -> Option<ScoreJob> {
if self.filter.is_empty() || self.search.scoring || self.search.scored_for(&self.filter) {
return None;
}
let base = self.search.base_for(&self.filter).0.cloned();
self.search.scoring = true;
Some(ScoreJob {
epoch: self.search.epoch,
results: self.search.results.clone(),
query: self.filter.clone(),
base,
limit: self.search_limit,
})
}
pub fn search_scored(&mut self, epoch: u64, scored: crate::search::Matches) {
if epoch != self.search.epoch {
return;
}
self.search.scoring = false;
if self
.search
.matches
.as_ref()
.is_some_and(|m| m.query == scored.query && m.upto >= scored.upto)
{
return;
}
self.search.matches = Some(scored);
self.sync_search_section();
if !matches!(self.selected_row(), Some(Row::Entry { .. })) {
self.select_first_entry();
}
self.settle_return();
}
pub fn search_finished(&mut self, root: &Path, scanned: usize, limited: Option<String>) {
if self.search.root.as_deref() != Some(root) {
return;
}
self.search.running = false;
self.search.done = true;
self.search.scanned = self.search.scanned.max(scanned);
self.search.limited = limited;
self.sync_search_section();
self.settle_return();
}
pub fn visible(&self) -> Vec<Row<'_>> {
self.rows(true)
}
pub fn listed(&self) -> Vec<Row<'_>> {
self.rows(false)
}
fn rows(&self, capped: bool) -> Vec<Row<'_>> {
let mut out: Vec<Row<'_>> = Vec::new();
for (si, section) in self.sections.iter().enumerate() {
let scored = self
.found_scores
.as_ref()
.filter(|(query, _)| section.title == Self::SEARCH_SECTION && *query == self.filter)
.map(|(_, scores)| scores.as_slice())
.unwrap_or_default();
let mut matched: Vec<(&Entry, i32)> = section
.rows
.iter()
.enumerate()
.filter(|(_, row)| !(self.hide_unreadable && row.hidden_by_default()))
.filter_map(|(i, row)| match scored.get(i) {
Some(&score) => Some((row, score)),
None => match_score(&self.filter, row).map(|s| (row, s)),
})
.collect();
let keep_empty = section.unavailable || section.waiting || section.origin.is_some();
let has_door = section.door.is_some() && self.filter.is_empty();
let hidden =
if self.browsing.is_some() && self.hide_unreadable && self.filter.is_empty() {
section
.rows
.iter()
.filter(|row| row.hidden_by_default())
.count()
} else {
0
};
let says_why = section.title == Self::SEARCH_SECTION;
if matched.is_empty()
&& !has_door
&& hidden == 0
&& !says_why
&& !(keep_empty && self.filter.is_empty())
{
continue;
}
if !self.filter.is_empty() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let lifted = |entry: &Entry, score: i32| {
let frecency = self
.visits
.get(&entry.path)
.map_or(0.0, |v| v.frecency(now));
score.saturating_add((frecency.min(10.0) * FRECENCY_LIFT) as i32)
};
matched.sort_by(|(a, sa), (b, sb)| {
lifted(b, *sb)
.cmp(&lifted(a, *sa))
.then_with(|| a.name.len().cmp(&b.name.len()))
});
}
match self.sort {
SortMode::Natural => {}
SortMode::Size => {
matched.sort_by_key(|(e, _)| std::cmp::Reverse(e.size.unwrap_or(0)));
}
SortMode::Rows => {
matched.sort_by_key(|(e, _)| std::cmp::Reverse(e.rows.unwrap_or(0)));
}
SortMode::Modified => {
matched.sort_by_key(|(e, _)| {
std::cmp::Reverse(
e.modified
.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0),
)
});
}
}
let collapsed = self.section_folded(section);
out.push(Row::Header {
section: si,
matches: matched.len(),
collapsed,
});
if collapsed {
continue;
}
if let Some(door) = section.door.as_ref()
&& self.filter.is_empty()
{
out.push(Row::Door {
section: si,
entry: door,
});
}
if section.grouped_by_place {
out.extend(self.rows_by_place(si, section, &matched, capped));
} else {
let in_order = self.sort == SortMode::Natural && self.filter.is_empty();
out.extend(matched.into_iter().map(|(entry, _)| Row::Entry {
section: si,
entry,
nested: in_order
&& section.origin.is_some_and(is_catalog_origin)
&& section.root.is_none()
&& self.bookmark(&entry.path).is_some(),
}));
}
if hidden > 0 {
out.push(Row::Hidden {
section: si,
count: hidden,
});
}
}
out
}
fn rows_by_place<'a>(
&self,
si: usize,
section: &'a Section,
matched: &[(&'a Entry, i32)],
capped: bool,
) -> Vec<Row<'a>> {
let mut order: Vec<PathBuf> = Vec::new();
for row in §ion.rows {
let place = place_of(&row.path);
if !order.contains(&place) {
order.push(place);
}
}
let groups: Vec<(PathBuf, Vec<&'a Entry>)> = order
.into_iter()
.filter_map(|place| {
let rows: Vec<&'a Entry> = matched
.iter()
.filter(|(entry, _)| place_of(&entry.path) == place)
.map(|(entry, _)| *entry)
.collect();
(!rows.is_empty()).then_some((place, rows))
})
.collect();
let capped =
capped && !self.recent_expanded && self.filter.is_empty() && self.view_height > 0;
let budget = self.view_height / 3;
let mut out: Vec<Row<'a>> = Vec::new();
let mut used = 0usize;
let mut shown = 0usize;
for (place, rows) in &groups {
let cost = 1 + rows.len();
if capped && shown > 0 && used + cost > budget {
break;
}
out.push(Row::Place {
section: si,
path: place.clone(),
label: section.place_labels.get(place).cloned(),
source: rows[0].cost.source.clone(),
held: section
.rows
.iter()
.filter(|row| place_of(&row.path) == *place)
.count(),
});
out.extend(rows.iter().map(|entry| Row::Entry {
section: si,
entry,
nested: true,
}));
used += cost;
shown += 1;
}
if shown < groups.len() {
out.push(Row::More {
section: si,
hidden: groups[shown..].iter().map(|(_, rows)| rows.len()).sum(),
places: groups.len() - shown,
});
}
out
}
pub fn path_candidates(&self) -> Vec<&PathName> {
let Some(listing) = self
.path_listing
.as_ref()
.filter(|l| l.dir == typed_dir(&self.path_input))
else {
return Vec::new();
};
let segment = &self.path_input[listing.dir.len()..];
let mut matched: Vec<(&PathName, i32)> = listing
.names
.iter()
.filter(|n| !n.name.starts_with('.') || segment.starts_with('.'))
.filter_map(|n| {
if segment.is_empty() {
return Some((n, 0));
}
let prefix = n.name.starts_with(segment) as i32 * 1_000_000;
fuzzy_score(segment, &n.name).map(|score| (n, prefix + score))
})
.collect();
matched.sort_by(|(a, sa), (b, sb)| sb.cmp(sa).then_with(|| a.name.cmp(&b.name)));
matched.into_iter().map(|(n, _)| n).collect()
}
pub fn pick_first_path(&mut self) {
self.path_pick = (!self.path_candidates().is_empty()).then_some(0);
}
pub fn picked_path(&self) -> Option<String> {
let pick = self.path_pick?;
let name = *self.path_candidates().get(pick)?;
let dir = typed_dir(&self.path_input);
let mut path = format!("{dir}{}", name.name);
if name.dir {
path.push(separator_in(dir));
}
Some(path)
}
pub fn path_completion(&self) -> Option<String> {
let dir = typed_dir(&self.path_input);
let segment = &self.path_input[dir.len()..];
let candidates = self.path_candidates();
match candidates.as_slice() {
[] => None,
[one] => {
let mut path = format!("{dir}{}", one.name);
if one.dir {
path.push(separator_in(dir));
}
Some(path)
}
many => {
let starting: Vec<&str> = many
.iter()
.map(|n| n.name.as_str())
.filter(|n| n.starts_with(segment))
.collect();
let first = starting.first()?;
let shared = starting
.iter()
.skip(1)
.fold(first.to_string(), |acc, n| common_prefix(&acc, n));
(shared.len() > segment.len()).then(|| format!("{dir}{shared}"))
}
}
}
pub fn known_urls(&self) -> Vec<String> {
let mut urls: Vec<String> = Vec::new();
let mut add = |path: &Path| {
let text = path.to_string_lossy();
if text.contains("://") && !is_cloud_place(path) {
urls.push(text.into_owned());
}
};
for catalog in &self.catalogs {
for dataset in &catalog.datasets {
add(&dataset.location);
}
}
for source in &self.cloud {
for bucket in &source.buckets {
add(bucket);
}
}
for (root, rows) in &self.probed {
add(root);
for row in rows {
add(&row.path);
}
}
for path in self.known.keys() {
add(path);
}
for section in &self.sections {
for row in §ion.rows {
add(&row.path);
}
}
urls
}
pub fn selected_row(&self) -> Option<Row<'_>> {
self.visible().into_iter().nth(self.selected)
}
pub fn selected_entry(&self) -> Option<Entry> {
match self.visible().get(self.selected) {
Some(Row::Entry { entry, .. }) | Some(Row::Door { entry, .. }) => {
Some((*entry).clone())
}
_ => None,
}
}
pub fn selection_is_the_door(&self) -> bool {
matches!(self.visible().get(self.selected), Some(Row::Door { .. }))
}
pub fn recents_in(&self, place: &Path) -> Vec<PathBuf> {
self.sections
.iter()
.filter(|s| s.grouped_by_place)
.flat_map(|s| s.rows.iter())
.filter(|row| place_of(&row.path) == place)
.map(|row| row.path.clone())
.collect()
}
pub fn selected_section(&self) -> Option<usize> {
self.visible().get(self.selected).map(|r| r.section())
}
pub fn selected_catalog(&self) -> Option<&ShownCatalog> {
if !self.selection_is_header() {
return None;
}
let section = self.sections.get(self.selected_section()?)?;
let origin = section.origin?;
self.catalogs
.iter()
.find(|c| c.label == section.title && c.origin_note() == origin)
}
pub fn selection_is_header(&self) -> bool {
matches!(self.visible().get(self.selected), Some(Row::Header { .. }))
}
pub fn pending_probes(&self) -> Vec<PathBuf> {
let check = self.network_check;
let mut out = Vec::new();
for root in self.sections.iter().filter_map(|s| s.remote_root.as_ref()) {
if !self.probed.contains_key(root)
&& !self.unreachable.contains(root)
&& !out.contains(root)
{
out.push(root.clone());
}
}
if let Some(dir) = &self.browsing
&& check(dir)
&& cloud_source_id(dir).is_none()
&& !self.probed.contains_key(dir)
&& !self.unreachable.contains(dir)
&& !out.contains(dir)
{
out.push(dir.clone());
}
out
}
pub fn below_browse_start(&self) -> bool {
let (Some(dir), Some(start)) = (&self.browsing, &self.browse_start) else {
return false;
};
if dir == start {
return false;
}
let mut current = self.parent_of(dir);
let mut steps = 0;
while let Some(place) = current {
if &place == start {
return true;
}
steps += 1;
if steps > 64 {
break;
}
current = self.parent_of(&place);
}
false
}
pub fn sections_waiting(&self) -> bool {
self.sections.iter().any(|s| s.waiting)
}
pub fn awaiting_listing(&self) -> Option<&Path> {
let dir = self.browsing.as_deref()?;
if cloud_source_id(dir).is_some() {
return self
.cloud_source_of(dir)
.is_some_and(|s| s.status == CloudStatus::Listing && s.buckets.is_empty())
.then_some(dir);
}
((self.network_check)(dir)
&& !self.probed.contains_key(dir)
&& !self.unreachable.contains(dir))
.then_some(dir)
}
pub fn probe_ready(&mut self, root: PathBuf, rows: Vec<Entry>) {
self.unreachable.remove(&root);
self.probe_errors.remove(&root);
self.listing_so_far.remove(&root);
self.cut_short.remove(&root);
self.probed.insert(root.clone(), rows);
self.apply_cloud_kinds(&root);
}
pub fn apply_cloud_kinds(&mut self, root: &Path) {
let Some(rows) = self.probed.get_mut(root) else {
return;
};
for row in rows.iter_mut() {
if row.kind == EntryKind::Directory
&& let Some((kind, holds)) = self.cloud_kinds.get(&row.path)
{
row.kind = *kind;
if !holds.is_empty() {
row.holds = holds.clone();
}
}
}
}
pub fn cloud_directories_to_peek(&self, limit: usize) -> Vec<PathBuf> {
if limit == 0 {
return Vec::new();
}
let rows = self.visible();
let height = if self.view_height == 0 {
limit
} else {
self.view_height
};
let top = self.scroll.min(rows.len());
let ahead = top.saturating_add(2 * height).min(rows.len());
let behind = top.saturating_sub(height);
let mut out: Vec<PathBuf> = Vec::new();
let order = std::iter::once(self.selected)
.chain(top..ahead)
.chain(behind..top);
for row in order.filter_map(|i| rows.get(i)) {
let Row::Entry { entry, .. } = row else {
continue;
};
if !is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
continue;
}
if !matches!(entry.kind, EntryKind::Directory | EntryKind::Unknown) {
continue;
}
if self.browsing.is_none()
&& (self.catalog_dataset(&entry.path).is_some()
|| self.bookmark(&entry.path).is_some())
{
continue;
}
if self.cloud_kinds.contains_key(&entry.path)
|| self.peeking.contains(&entry.path)
|| self.peek_failed.contains(&entry.path)
{
continue;
}
if out.contains(&entry.path) {
continue;
}
out.push(entry.path.clone());
if out.len() >= limit {
break;
}
}
out
}
pub fn probe_failed(&mut self, root: PathBuf) {
self.probed.remove(&root);
self.listing_so_far.remove(&root);
self.unreachable.insert(root);
}
pub fn measure_now(&mut self, limit: usize) -> bool {
let wanted = self.unmeasured_visible(limit);
let more = self.unmeasured_visible(limit + 1).len() > wanted.len();
for entry in wanted {
let mut probe = entry.clone();
discover::enrich(&mut probe);
self.enriched
.insert(entry.path.clone(), measured_from(&probe, &entry));
}
self.apply_measurements();
more
}
pub fn unmeasured_visible(&self, limit: usize) -> Vec<Entry> {
let mut out = Vec::new();
for row in self.visible() {
let Row::Entry { entry, .. } = row else {
continue;
};
if entry.rows.is_some() || self.enriched.contains_key(&entry.path) {
continue;
}
if matches!(
entry.kind,
EntryKind::Directory | EntryKind::Unknown | EntryKind::Other
) || entry.kind.is_lake_table()
{
continue;
}
if (self.network_check)(&entry.path) {
continue;
}
out.push(entry.clone());
if out.len() >= limit {
break;
}
}
out
}
pub fn classify_now(&mut self, limit: usize) -> bool {
let wanted = self.unclassified_visible(limit);
let more = self.unclassified_visible(limit + 1).len() > wanted.len();
for entry in wanted {
let probe = look_into(&entry);
self.enriched
.insert(entry.path.clone(), measured_from(&probe, &entry));
}
self.apply_measurements();
more
}
pub fn unclassified_visible(&self, limit: usize) -> Vec<Entry> {
if limit == 0 {
return Vec::new();
}
let rows = self.visible();
let height = if self.view_height == 0 {
limit
} else {
self.view_height
};
let top = self.scroll.min(rows.len());
let ahead = top.saturating_add(2 * height).min(rows.len());
let behind = top.saturating_sub(height);
let mut out: Vec<Entry> = Vec::new();
let order = std::iter::once(self.selected)
.chain(top..ahead)
.chain(behind..top);
for row in order.filter_map(|i| rows.get(i)) {
let Row::Entry { entry, .. } = row else {
continue;
};
if entry.kind != EntryKind::Unknown || self.missing.contains(&entry.path) {
continue;
}
if self.enriched.contains_key(&entry.path) {
continue;
}
if is_object_store_url(&entry.path) || is_cloud_place(&entry.path) {
continue;
}
if out.iter().any(|e| e.path == entry.path) {
continue;
}
out.push((*entry).clone());
if out.len() >= limit {
break;
}
}
out
}
pub fn apply_measurements(&mut self) {
for section in &mut self.sections {
for row in section.rows.iter_mut().chain(section.door.iter_mut()) {
if let Some(m) = self.enriched.get(&row.path) {
row.rows = m.rows;
row.cols = m.cols;
row.cols_sampled = m.cols_sampled;
if let Some(kind) = m.kind {
row.kind = kind;
}
if m.size.is_some() {
row.size = m.size;
}
if !m.columns.is_empty() {
row.columns = m.columns.clone();
}
if !m.holds.is_empty() {
row.holds = m.holds.clone();
}
take_cost(row, &m.cost);
}
}
if let Some(door) = section.door.as_mut() {
door.name = door_name(door, §ion.rows);
}
}
if self.landing
&& let Some(Row::Door { entry, .. }) = self.visible().get(self.selected)
&& !door_lands(entry)
{
self.selected = self.landing_row();
self.follow_selection();
}
}
pub fn select_first_entry(&mut self) {
self.returning = None;
self.landing = true;
self.selected = self.landing_row();
}
fn landing_row(&self) -> usize {
let rows = self.visible();
if self.filter.is_empty()
&& let Some(newest) = self.newest_recent.as_ref()
&& let Some(at) = rows.iter().position(|r| {
matches!(r, Row::Entry { section, entry, .. }
if entry.path == *newest
&& self.sections[*section].title == Self::RECENT_SECTION)
})
{
return at;
}
let first = rows
.iter()
.position(|r| matches!(r, Row::Entry { .. } | Row::Door { .. }));
let first = match first.and_then(|i| rows.get(i)) {
Some(Row::Door { entry, .. }) if !door_lands(entry) => rows
.iter()
.position(|r| matches!(r, Row::Entry { .. } | Row::Hidden { .. }))
.or(first),
_ => first,
};
first
.or_else(|| rows.iter().position(|r| matches!(r, Row::Hidden { .. })))
.unwrap_or(0)
}
pub fn clamp_selection(&mut self) {
let n = self.visible().len();
if n == 0 {
self.selected = 0;
} else if self.selected >= n {
self.selected = n - 1;
}
}
pub fn select(&mut self, index: usize) {
if index < self.visible().len() {
self.returning = None;
self.landing = false;
self.selected = index;
}
}
pub fn move_selection(&mut self, delta: isize) {
self.returning = None;
self.landing = false;
let n = self.visible().len();
if n == 0 {
return;
}
let cur = self.selected as isize;
let next = (cur + delta).rem_euclid(n as isize);
self.selected = next as usize;
}
pub fn page_selection(&mut self, delta: isize) {
self.returning = None;
self.landing = false;
let n = self.visible().len();
if n == 0 {
return;
}
let next = (self.selected as isize)
.saturating_add(delta)
.clamp(0, n as isize - 1);
self.selected = next as usize;
}
}
fn source_entry(source: &CloudSource) -> Entry {
Entry {
path: cloud_place(&source.id),
kind: EntryKind::Directory,
name: source.label.clone(),
size: None,
modified: source.listed_at,
rows: None,
cols: None,
cols_sampled: false,
columns: Vec::new(),
cost: Default::default(),
holds: Default::default(),
opens_whole_directory: false,
format_spec: None,
table: None,
}
}
fn bucket_entry(url: &Path) -> Entry {
let mut entry = Entry::directory(url);
let text = url.to_string_lossy();
let (_, plain) = crate::source::split_source_id(&text);
entry.name = plain
.rsplit('/')
.find(|part| !part.is_empty())
.unwrap_or("")
.to_string();
entry
}
pub fn names_a_file(path: &Path) -> bool {
let named = path.to_string_lossy();
let dotted = !named.ends_with('/')
&& path
.file_name()
.map(|last| last.to_string_lossy())
.is_some_and(|last| last.trim_start_matches('.').contains('.'));
discover::is_data_file(path) || dotted
}
fn entry_for_path(path: &Path, remote: bool) -> Entry {
if !remote && let Some(table) = discover::table_row(path) {
return table;
}
let mut holds = discover::Holds::default();
let kind = if remote {
if names_a_file(path) {
EntryKind::File
} else {
EntryKind::Unknown
}
} else if path.is_dir() {
let (kind, found) = discover::look_at_directory(path);
holds = found;
kind
} else {
EntryKind::File
};
let mut entry = Entry {
path: path.to_path_buf(),
kind,
name: path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned()),
size: None,
modified: None,
rows: None,
cols: None,
cols_sampled: false,
columns: Vec::new(),
cost: Default::default(),
holds,
opens_whole_directory: false,
format_spec: None,
table: None,
};
if !remote && let Ok(meta) = std::fs::metadata(path) {
if meta.is_file() {
entry.size = Some(meta.len());
}
entry.modified = meta.modified().ok();
}
entry
}
pub fn display_path(path: &Path) -> String {
if let Some(home) = dirs::home_dir()
&& let Ok(rest) = path.strip_prefix(&home)
{
if rest.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display());
}
path.display().to_string()
}
pub fn complete_path(typed: &str) -> (String, usize) {
let expanded = expand_user_path(typed);
let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
let typed_ends_in_sep = typed.ends_with(is_separator);
let (dir, prefix) = if typed_ends_in_sep {
(expanded.clone(), String::new())
} else {
match (expanded.parent(), expanded.file_name()) {
(Some(parent), Some(name)) => {
(parent.to_path_buf(), name.to_string_lossy().into_owned())
}
_ => (expanded.clone(), String::new()),
}
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return (typed.to_string(), 0);
};
let mut names: Vec<String> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') && !prefix.starts_with('.') {
return None;
}
name.starts_with(&prefix).then_some(name)
})
.collect();
if names.is_empty() {
return (typed.to_string(), 0);
}
names.sort();
let shared = names
.iter()
.skip(1)
.fold(names[0].clone(), |acc, name| common_prefix(&acc, name));
let mut completed = typed.to_string();
completed.truncate(typed.len() - prefix.len());
completed.push_str(&shared);
if names.len() == 1 && dir.join(&shared).is_dir() && !completed.ends_with(is_separator) {
let separator = typed
.chars()
.rev()
.find(|c| is_separator(*c))
.unwrap_or(std::path::MAIN_SEPARATOR);
completed.push(separator);
}
(completed, names.len())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathName {
pub name: String,
pub dir: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PathListing {
pub dir: String,
pub names: Vec<PathName>,
pub failed: bool,
}
const PATH_LISTING_MAX: usize = 5_000;
pub fn typed_dir(typed: &str) -> &str {
let is_separator = |c: char| c == '/' || (cfg!(windows) && c == '\\');
let floor = typed.find("://").map_or(0, |at| at + 3);
match typed[floor..].rfind(is_separator) {
Some(at) => &typed[..floor + at + 1],
None => &typed[..floor],
}
}
fn separator_in(dir: &str) -> char {
if typed_dir_is_url(dir) {
return '/';
}
dir.chars()
.rev()
.find(|c| *c == '/' || (cfg!(windows) && *c == '\\'))
.unwrap_or(std::path::MAIN_SEPARATOR)
}
pub fn typed_dir_is_url(dir: &str) -> bool {
dir.contains("://")
}
pub fn list_typed_dir(dir: &str) -> PathListing {
let path = if dir.is_empty() {
PathBuf::from(".")
} else {
expand_user_path(dir)
};
let Ok(entries) = std::fs::read_dir(&path) else {
return PathListing {
dir: dir.to_string(),
names: Vec::new(),
failed: true,
};
};
let mut names: Vec<PathName> = entries
.flatten()
.take(PATH_LISTING_MAX)
.map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let dir = e.file_type().is_ok_and(|t| t.is_dir())
|| (e.file_type().is_ok_and(|t| t.is_symlink()) && e.path().is_dir());
PathName { name, dir }
})
.collect();
names.sort_by(|a, b| a.name.cmp(&b.name));
PathListing {
dir: dir.to_string(),
names,
failed: false,
}
}
pub fn names_under(dir: &str, urls: impl IntoIterator<Item = String>) -> PathListing {
let mut names: Vec<PathName> = Vec::new();
for url in urls {
let forms = match crate::source::azure_parts(&url) {
Some((_, container, key)) => vec![url.clone(), format!("az://{container}/{key}")],
None => vec![url],
};
for form in forms {
let Some(rest) = form.strip_prefix(dir) else {
continue;
};
let (name, more) = match rest.split_once('/') {
Some((name, more)) => (name, Some(more)),
None => (rest, None),
};
if name.is_empty() {
continue;
}
let is_dir = more.is_some() || !names_a_file(Path::new(&form));
match names.iter_mut().find(|n| n.name == name) {
Some(known) => known.dir |= is_dir,
None => names.push(PathName {
name: name.to_string(),
dir: is_dir,
}),
}
}
}
names.sort_by(|a, b| a.name.cmp(&b.name));
PathListing {
dir: dir.to_string(),
names,
failed: false,
}
}
fn common_prefix(a: &str, b: &str) -> String {
a.chars()
.zip(b.chars())
.take_while(|(x, y)| x == y)
.map(|(x, _)| x)
.collect()
}
pub fn expand_user_path(raw: &str) -> PathBuf {
crate::config::expand_config_path(raw)
}
#[cfg(test)]
mod holds_flow_tests {
use super::*;
#[cfg(windows)]
#[test]
fn a_windows_home_path_is_shown_and_typed_with_backslashes() {
let home = dirs::home_dir().unwrap();
let path = home.join("data").join("a.csv");
let shown = display_path(&path);
assert_eq!(shown, r"~\data\a.csv");
assert_eq!(expand_user_path(&shown), path);
}
#[cfg(windows)]
#[test]
fn a_dot_above_a_windows_recent_does_not_make_it_a_file() {
let path = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\orders");
assert_eq!(entry_for_path(path, true).kind, EntryKind::Unknown);
let file = Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpAzMMTE\a.parquet");
assert_eq!(entry_for_path(file, true).kind, EntryKind::File);
}
fn counted(n: usize) -> crate::discover::Holds {
crate::discover::Holds {
formats: vec![("parquet".to_string(), n)],
..Default::default()
}
}
fn in_flight() -> (EntryKind, crate::discover::Holds) {
(EntryKind::Directory, crate::discover::Holds::default())
}
#[test]
fn a_claim_staked_before_a_peek_lands_keeps_the_count_a_row_already_has() {
let root = std::path::PathBuf::from("s3://bucket/warehouse");
let path = root.join("orders");
let mut row = Entry::for_test(&path, "orders");
row.kind = EntryKind::Directory;
row.holds = counted(15);
let mut home = HomeState::default();
home.probed.insert(root.clone(), vec![row]);
home.cloud_kinds.insert(path, in_flight());
home.apply_cloud_kinds(&root);
assert_eq!(
home.probed[&root][0].holds.label(),
"15 parquet",
"the placeholder erased a count the row already had"
);
}
#[test]
fn a_cloud_directory_waits_then_looks_then_answers() {
let path = std::path::PathBuf::from("gs://pitscope/seasons");
let mut row = Entry::for_test(&path, "seasons");
row.kind = EntryKind::Directory;
let mut home = HomeState::default();
assert_eq!(
home.cloud_look(&row),
Some(CloudLook::Waiting),
"not asked yet"
);
home.peeking.insert(path.clone());
assert_eq!(
home.cloud_look(&row),
Some(CloudLook::Looking),
"being looked into"
);
home.peeking.remove(&path);
home.cloud_kinds
.insert(path.clone(), (EntryKind::Directory, counted(12)));
assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
home.cloud_kinds
.insert(path.clone(), (EntryKind::Hive, Default::default()));
assert_eq!(home.cloud_look(&row), Some(CloudLook::Looking));
home.cloud_kinds.insert(path.clone(), in_flight());
assert_eq!(home.cloud_look(&row), None, "answered");
let mut home = HomeState {
network_check: |_| true,
..Default::default()
};
let root = std::path::PathBuf::from("gs://pitscope");
home.probe_ready(root.clone(), vec![row.clone()]);
home.browsing = Some(root);
home.rebuild(&[]);
assert_eq!(
home.cloud_directories_to_peek(4),
std::slice::from_ref(&path)
);
home.peek_failed.insert(path.clone());
assert_eq!(home.cloud_look(&row), Some(CloudLook::Failed));
assert!(home.cloud_directories_to_peek(4).is_empty());
let mut counted_row = Entry::for_test(&path.join("x"), "x");
counted_row.kind = EntryKind::Directory;
counted_row.holds = counted(3);
assert_eq!(home.cloud_look(&counted_row), None);
let mut bucket = Entry::for_test(std::path::Path::new("gs://pitscope"), "pitscope");
bucket.kind = EntryKind::Directory;
assert_eq!(home.cloud_look(&bucket), None);
let mut local = Entry::for_test(std::path::Path::new("/data/seasons"), "seasons");
local.kind = EntryKind::Directory;
assert_eq!(home.cloud_look(&local), None);
}
#[test]
fn a_peeks_answer_replaces_the_count_a_row_had() {
let root = std::path::PathBuf::from("s3://bucket/warehouse");
let path = root.join("orders");
let mut row = Entry::for_test(&path, "orders");
row.kind = EntryKind::Directory;
row.holds = counted(15);
let mut home = HomeState::default();
home.probed.insert(root.clone(), vec![row]);
home.cloud_kinds
.insert(path, (EntryKind::MultiFile, counted(40)));
home.apply_cloud_kinds(&root);
assert_eq!(home.probed[&root][0].holds.label(), "40 parquet");
assert_eq!(home.probed[&root][0].kind, EntryKind::MultiFile);
}
#[test]
fn a_peek_answers_only_the_rows_that_asked() {
let root = std::path::PathBuf::from("s3://bucket/warehouse");
let settled = root.join("sales");
let mut row = Entry::for_test(&settled, "sales");
row.kind = EntryKind::Hive;
row.holds = counted(40);
let mut home = HomeState::default();
home.probed.insert(root.clone(), vec![row]);
home.cloud_kinds
.insert(settled, (EntryKind::Directory, counted(1)));
home.apply_cloud_kinds(&root);
assert_eq!(home.probed[&root][0].kind, EntryKind::Hive);
assert_eq!(home.probed[&root][0].holds.label(), "40 parquet");
}
#[test]
fn a_peek_goes_to_the_row_the_cursor_is_on_and_is_never_asked_twice() {
let root = std::path::PathBuf::from("s3://bucket/warehouse");
let mut home = HomeState {
network_check: |_| true,
..Default::default()
};
let rows: Vec<Entry> = ["a", "b", "c", "d", "e"]
.iter()
.map(|n| {
let mut row = Entry::for_test(&root.join(n), n);
row.kind = EntryKind::Directory;
row
})
.collect();
home.probe_ready(root.clone(), rows);
home.browsing = Some(root.clone());
home.rebuild(&[]);
home.cloud_kinds
.insert(root.join("b"), (EntryKind::MultiFile, counted(3)));
home.peeking.insert(root.join("c"));
home.selected = home
.visible()
.iter()
.position(|r| matches!(r, Row::Entry { entry, .. } if entry.name == "e"))
.expect("the row is listed");
let asked = home.cloud_directories_to_peek(3);
assert_eq!(
asked.first(),
Some(&root.join("e")),
"the highlighted row is the one about to be acted on"
);
assert_eq!(asked.len(), 3, "the budget is a budget");
assert!(
!asked.contains(&root.join("b")),
"a directory already looked into is not asked again"
);
assert!(
!asked.contains(&root.join("c")),
"nor one with a request already out"
);
}
#[test]
fn a_measurement_that_counted_nothing_keeps_the_count_a_row_already_has() {
let path = std::path::PathBuf::from("/data/warehouse/orders");
let mut row = Entry::for_test(&path, "orders");
row.kind = EntryKind::Directory;
row.holds = counted(15);
let mut home = HomeState::default();
home.sections.push(Section {
title: "Here".to_string(),
subtitle: None,
origin: None,
rows: vec![row],
unavailable: false,
unavailable_note: None,
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: false,
door: None,
place_labels: Default::default(),
root: None,
});
home.enriched.insert(
path,
Measured {
kind: Some(EntryKind::Directory),
..Default::default()
},
);
home.apply_measurements();
assert_eq!(
home.sections[0].rows[0].holds.label(),
"15 parquet",
"a measurement with nothing to say erased the label"
);
}
}
#[cfg(test)]
mod look_into_batch_tests {
use super::*;
use polars::prelude::*;
#[test]
fn every_kind_is_sent_before_any_count() {
let dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let cache = crate::cache::CacheManager::with_dir(cache_dir.path().to_path_buf());
let mut rows = Vec::new();
for name in ["a", "b"] {
let partition = dir.path().join(name).join("year=2024");
std::fs::create_dir_all(&partition).unwrap();
let mut frame = df!("x" => [1i32, 2, 3]).unwrap();
let file = std::fs::File::create(partition.join("part.parquet")).unwrap();
ParquetWriter::new(file).finish(&mut frame).unwrap();
rows.push(Entry::new(dir.path().join(name), EntryKind::Unknown));
}
let mut sent = Vec::new();
look_into_batch(rows, &cache, |path, m| {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
sent.push((name, m.kind, m.rows));
});
let hive = Some(EntryKind::Hive);
assert_eq!(
sent,
vec![
("a".to_string(), hive, None),
("b".to_string(), hive, None),
("a".to_string(), hive, Some(3)),
("b".to_string(), hive, Some(3)),
]
);
}
}
#[cfg(test)]
mod known_facts_tests {
use super::*;
use crate::cache::DatasetFacts;
#[test]
fn what_a_directory_holds_is_restored_beside_its_kind() {
let holds = crate::discover::Holds {
formats: vec![("parquet".to_string(), 15)],
..Default::default()
};
for (path, remote) in [
(
std::path::PathBuf::from("s3://bucket/warehouse/orders"),
true,
),
(std::path::PathBuf::from("/data/warehouse/orders"), false),
] {
let facts = DatasetFacts {
mtime: 0,
size: 4096,
rows: Some(999),
cols: Some(72),
cols_sampled: false,
columns: vec!["lat".to_string()],
kind: Some(EntryKind::Directory),
classified_by: crate::discover::CLASSIFIER_VERSION,
holds: holds.clone(),
cost: Default::default(),
};
let mut row = Entry::directory(&path);
row.kind = EntryKind::Unknown;
row.modified = Some(std::time::UNIX_EPOCH);
assert_eq!(row.size, None);
let index = std::collections::HashMap::from([(path.clone(), facts)]);
apply_known_facts(&mut row, &index, remote);
assert_eq!(row.kind, EntryKind::Directory, "{path:?}");
assert_eq!(row.label(), "15 parquet", "{path:?}");
if !remote {
assert_eq!(row.rows, None, "{path:?}");
assert_eq!(row.cols, None, "{path:?}");
assert_eq!(row.size, None, "{path:?}");
assert!(row.columns.is_empty(), "{path:?}");
}
}
}
#[test]
fn a_datasets_counts_are_measured_rather_than_restored() {
let path = std::path::PathBuf::from("/data/warehouse/events");
let facts = DatasetFacts {
mtime: 0,
size: 4096,
rows: Some(1_200_000),
cols: Some(58),
cols_sampled: false,
columns: vec!["ts".to_string()],
kind: Some(EntryKind::MultiFile),
classified_by: crate::discover::CLASSIFIER_VERSION,
holds: crate::discover::Holds {
formats: vec![("parquet".to_string(), 15)],
..Default::default()
},
cost: Default::default(),
};
let mut row = Entry::directory(&path);
row.kind = EntryKind::Unknown;
row.modified = Some(std::time::UNIX_EPOCH);
let index = std::collections::HashMap::from([(path.clone(), facts)]);
apply_known_facts(&mut row, &index, false);
assert_eq!(row.kind, EntryKind::MultiFile, "the kind comes back");
assert_eq!(row.label(), "15 parquet", "and what it holds");
assert_eq!(
row.rows, None,
"but not the count: the measuring pass skips a row that has one"
);
assert_eq!(row.cols, None);
}
#[test]
fn a_kind_from_an_older_classifier_is_not_restored() {
let remote = std::path::PathBuf::from("s3://bucket/warehouse/orders");
let facts = |classified_by| DatasetFacts {
mtime: 0,
size: 4096,
rows: Some(1_000),
cols: Some(7),
cols_sampled: false,
columns: vec!["id".into(), "amount".into()],
kind: Some(EntryKind::MultiFile),
classified_by,
cost: Default::default(),
holds: Default::default(),
};
let unprobed = || {
let mut row = Entry::directory(&remote);
row.kind = EntryKind::Unknown;
row
};
let index = |classified_by| {
std::collections::HashMap::from([(remote.clone(), facts(classified_by))])
};
let mut row = unprobed();
apply_known_facts(&mut row, &index(crate::discover::CLASSIFIER_VERSION), true);
assert_eq!(
row.kind,
EntryKind::MultiFile,
"this build's own answer comes back"
);
let mut row = unprobed();
apply_known_facts(&mut row, &index(0), true);
assert_eq!(
row.kind,
EntryKind::Unknown,
"an older build's does not: it may be a lake table this one would recognize"
);
assert_eq!(
row.rows,
Some(1_000),
"but what it measured is still measured"
);
assert_eq!(row.columns, vec!["id".to_string(), "amount".to_string()]);
}
}
#[cfg(test)]
mod build_feature_tests {
use super::*;
#[test]
fn the_builtin_catalog_lists_only_what_this_build_opens() {
let urls: Vec<String> = catalogs(&crate::config::AppConfig::default())
.into_iter()
.filter(|c| c.origin == crate::catalog::Origin::Bundled)
.flat_map(|c| c.datasets)
.map(|d| d.location.to_string_lossy().into_owned())
.collect();
let web = urls.iter().filter(|u| u.starts_with("https://")).count();
let stores = urls
.iter()
.filter(|u| is_object_store_url(Path::new(u)))
.count();
assert_eq!(web + stores, urls.len(), "{urls:?}");
assert_eq!(web > 0, cfg!(feature = "http"), "{urls:?}");
assert_eq!(stores > 0, cfg!(feature = "cloud"), "{urls:?}");
}
#[test]
fn an_empty_examples_toml_hides_the_section() {
let mut config = crate::config::AppConfig::default();
assert_eq!(
catalogs(&config)
.iter()
.any(|c| c.origin == crate::catalog::Origin::Bundled),
cfg!(any(feature = "http", feature = "cloud"))
);
config.read_catalogs = vec![
crate::catalog::parse(
"label = \"Mine\"\n",
crate::catalog::EXAMPLES,
crate::catalog::Origin::Folder,
None,
)
.unwrap(),
];
assert!(catalogs(&config).is_empty(), "{:?}", catalogs(&config));
}
#[test]
fn a_users_catalog_is_shown_whole() {
let mut config = crate::config::AppConfig::default();
let mine = crate::catalog::parse(
r#"
[bucket]
name = "Bucket"
url = "s3://bucket/prefix/"
[web]
name = "Web"
url = "https://example.com/data.csv"
"#,
crate::catalog::MINE,
crate::catalog::Origin::Mine,
None,
)
.unwrap();
config.read_catalogs = vec![mine];
let shown = catalogs(&config);
let mine = shown.iter().find(|c| c.id == "mine").unwrap();
assert_eq!(mine.datasets.len(), 2);
assert_eq!(mine.label, crate::catalog::MINE_LABEL);
}
}