use std::{cell::RefCell, collections::HashMap, rc::Rc, time::SystemTime};
use gpui_kit::{App, AppContext as _, SharedString, Task};
use smol::stream::StreamExt as _;
use crate::path_browser::{CreateFolderError, Entry, ListError, PageToken, PathStyle, SourcePath};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FileKind {
Folder,
File,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileEntry {
name: SharedString,
kind: FileKind,
hidden: bool,
size: Option<u64>,
modified: Option<SystemTime>,
}
impl FileEntry {
fn of(name: impl Into<SharedString>, kind: FileKind) -> Self {
let name = name.into();
let hidden = name.starts_with('.');
Self {
name,
kind,
hidden,
size: None,
modified: None,
}
}
pub fn file(name: impl Into<SharedString>) -> Self {
Self::of(name, FileKind::File)
}
pub fn folder(name: impl Into<SharedString>) -> Self {
Self::of(name, FileKind::Folder)
}
pub fn with_hidden(mut self, hidden: bool) -> Self {
self.hidden = hidden;
self
}
pub fn with_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
pub fn with_modified(mut self, modified: SystemTime) -> Self {
self.modified = Some(modified);
self
}
pub fn name(&self) -> &SharedString {
&self.name
}
pub fn kind(&self) -> FileKind {
self.kind
}
pub fn is_folder(&self) -> bool {
self.kind == FileKind::Folder
}
pub fn hidden(&self) -> bool {
self.hidden
}
pub fn size(&self) -> Option<u64> {
self.size
}
pub fn modified(&self) -> Option<SystemTime> {
self.modified
}
pub fn extension(&self) -> Option<String> {
if self.is_folder() {
return None;
}
let (stem, extension) = self.name.rsplit_once('.')?;
(!stem.is_empty() && !extension.is_empty()).then(|| extension.to_lowercase())
}
}
impl Entry for FileEntry {
fn name(&self) -> &SharedString {
&self.name
}
fn hidden(&self) -> bool {
self.hidden
}
fn is_folder(&self) -> bool {
self.kind == FileKind::Folder
}
}
pub(crate) fn sort_entries(entries: &mut [FileEntry], style: &PathStyle) {
entries.sort_by(|a, b| {
b.is_folder()
.cmp(&a.is_folder())
.then_with(|| a.hidden.cmp(&b.hidden))
.then_with(|| style.compare_names(&a.name, &b.name))
});
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct FilePage {
pub(super) entries: Vec<FileEntry>,
pub(super) next: Option<PageToken>,
}
impl FilePage {
pub fn new(entries: impl IntoIterator<Item = FileEntry>) -> Self {
Self {
entries: entries.into_iter().collect(),
next: None,
}
}
pub fn with_next(mut self, token: PageToken) -> Self {
self.next = Some(token);
self
}
pub fn entries(&self) -> &[FileEntry] {
&self.entries
}
pub fn next(&self) -> Option<&PageToken> {
self.next.as_ref()
}
}
pub trait FileSource: 'static {
fn list(
&self,
dir: &SourcePath,
page: Option<PageToken>,
cx: &mut App,
) -> Task<Result<FilePage, ListError>>;
fn home(&self) -> Option<SourcePath> {
None
}
fn path_style(&self) -> PathStyle {
PathStyle::host()
}
fn env(&self, name: &str) -> Option<String> {
let _ = name;
None
}
fn can_create_folders(&self) -> bool {
false
}
fn create_folder(
&self,
parent: &SourcePath,
name: &str,
cx: &mut App,
) -> Task<Result<SourcePath, CreateFolderError>> {
let _ = (parent, name, cx);
Task::ready(Err(CreateFolderError::Unsupported))
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct LocalFiles;
impl FileSource for LocalFiles {
fn home(&self) -> Option<SourcePath> {
std::env::home_dir().map(|home| PathStyle::host().path(home.to_string_lossy()))
}
fn env(&self, name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn can_create_folders(&self) -> bool {
true
}
fn create_folder(
&self,
parent: &SourcePath,
name: &str,
cx: &mut App,
) -> Task<Result<SourcePath, CreateFolderError>> {
let path = parent.join(name);
let made = path.to_path_buf();
cx.background_spawn(async move {
smol::fs::create_dir(&made).await?;
Ok(path)
})
}
fn list(
&self,
dir: &SourcePath,
_page: Option<PageToken>,
cx: &mut App,
) -> Task<Result<FilePage, ListError>> {
let dir = dir.to_path_buf();
cx.background_spawn(async move { Ok(list_local(dir).await?) })
}
}
async fn list_local(dir: std::path::PathBuf) -> std::io::Result<FilePage> {
let mut read = smol::fs::read_dir(&dir).await?;
let mut entries = Vec::new();
while let Some(entry) = read.next().await {
let Ok(entry) = entry else { continue };
let name = entry.file_name().to_string_lossy().into_owned();
let Ok(kind) = entry.file_type().await else {
continue;
};
let meta = if kind.is_symlink() {
smol::fs::metadata(entry.path()).await.ok()
} else {
entry.metadata().await.ok()
};
let mut found = match &meta {
Some(meta) if meta.is_dir() => FileEntry::folder(name),
_ => FileEntry::file(name),
};
if let Some(meta) = meta {
if !meta.is_dir() {
found = found.with_size(meta.len());
}
if let Ok(modified) = meta.modified() {
found = found.with_modified(modified);
}
}
entries.push(found);
}
sort_entries(&mut entries, &PathStyle::host());
Ok(FilePage::new(entries))
}
#[derive(Clone, Debug, Default)]
pub struct MemoryFiles {
style: PathStyle,
home: Option<SourcePath>,
dirs: Rc<RefCell<HashMap<SourcePath, Vec<FileEntry>>>>,
}
impl MemoryFiles {
pub fn new() -> Self {
Self::default()
}
pub fn with_style(mut self, style: PathStyle) -> Self {
self.style = style;
self
}
pub fn with_home(mut self, home: impl AsRef<str>) -> Self {
self.home = Some(self.style.path(home));
self
}
pub fn with_dir(
self,
path: impl AsRef<str>,
entries: impl IntoIterator<Item = FileEntry>,
) -> Self {
let mut entries: Vec<FileEntry> = entries.into_iter().collect();
sort_entries(&mut entries, &self.style);
self.dirs
.borrow_mut()
.insert(self.style.path(path), entries);
self
}
}
impl FileSource for MemoryFiles {
fn home(&self) -> Option<SourcePath> {
self.home.clone()
}
fn path_style(&self) -> PathStyle {
self.style
}
fn list(
&self,
dir: &SourcePath,
_page: Option<PageToken>,
cx: &mut App,
) -> Task<Result<FilePage, ListError>> {
let found = self.dirs.borrow().get(dir).cloned();
cx.background_spawn(async move { found.map(FilePage::new).ok_or(ListError::NotFound) })
}
fn can_create_folders(&self) -> bool {
true
}
fn create_folder(
&self,
parent: &SourcePath,
name: &str,
_: &mut App,
) -> Task<Result<SourcePath, CreateFolderError>> {
let mut dirs = self.dirs.borrow_mut();
let style = self.style;
let Some(entries) = dirs.get_mut(parent) else {
return Task::ready(Err(CreateFolderError::Other(
"That directory does not exist".into(),
)));
};
if entries
.iter()
.any(|entry| style.fold(entry.name()) == style.fold(name))
{
return Task::ready(Err(CreateFolderError::Exists));
}
let hidden = style.is_hidden_name(name);
entries.push(FileEntry::folder(name.to_string()).with_hidden(hidden));
sort_entries(entries, &style);
let path = parent.join(name);
dirs.insert(path.clone(), Vec::new());
Task::ready(Ok(path))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn names(entries: &[FileEntry]) -> Vec<&str> {
entries.iter().map(|e| e.name().as_ref()).collect()
}
#[test]
fn folders_come_first_then_files_each_by_name_without_case() {
let mut entries = vec![
FileEntry::file("beta.txt"),
FileEntry::folder("src"),
FileEntry::file("Alpha.txt"),
FileEntry::folder(".git"),
FileEntry::file(".env"),
FileEntry::folder("Docs"),
];
sort_entries(&mut entries, &PathStyle::posix());
assert_eq!(
names(&entries),
["Docs", "src", ".git", "Alpha.txt", "beta.txt", ".env"]
);
}
#[test]
fn a_leading_dot_hides_an_entry_and_can_be_overridden() {
assert!(FileEntry::file(".env").hidden());
assert!(!FileEntry::file(".env").with_hidden(false).hidden());
assert!(FileEntry::folder("src").with_hidden(true).hidden());
}
#[test]
fn the_extension_is_lower_case_and_needs_a_stem() {
assert_eq!(
FileEntry::file("Main.RS").extension().as_deref(),
Some("rs")
);
assert_eq!(
FileEntry::file("a.tar.gz").extension().as_deref(),
Some("gz")
);
assert_eq!(FileEntry::file(".env").extension(), None);
assert_eq!(FileEntry::file("Makefile").extension(), None);
assert_eq!(FileEntry::folder("v1.2").extension(), None);
}
}