use anyhow::{anyhow, Result};
use gpui::Context;
use gpui::TaskExt as _;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct File {
path: PathBuf,
contents: String,
original_contents: Option<String>,
exists_on_disk: bool,
}
impl File {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
contents: String::new(),
original_contents: None,
exists_on_disk: false,
}
}
pub fn load(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
let metadata = std::fs::metadata(&path)?;
if metadata.is_dir() {
return Err(anyhow!("Path is a directory, not a file"));
}
let contents = std::fs::read(&path)?;
let contents = String::from_utf8(contents)
.map_err(|_| anyhow!("File is not a valid UTF-8 text file"))?;
Ok(Self {
path,
contents: contents.clone(),
original_contents: Some(contents),
exists_on_disk: true,
})
}
pub fn new_with_contents(path: impl Into<PathBuf>, contents: impl Into<String>) -> Self {
Self {
path: path.into(),
contents: contents.into(),
original_contents: None,
exists_on_disk: false,
}
}
pub fn save(&mut self) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.path, &self.contents)?;
self.original_contents = Some(self.contents.clone());
self.exists_on_disk = true;
Ok(())
}
pub fn save_as(&mut self, new_path: impl Into<PathBuf>) -> Result<()> {
self.path = new_path.into();
self.save()
}
pub fn load_async(path: impl Into<PathBuf>) -> impl std::future::Future<Output = Result<Self>> {
let path = path.into();
async move { File::load(path) }
}
pub fn save_async(&mut self) -> impl std::future::Future<Output = Result<()>> {
let path = self.path.clone();
let contents = self.contents.clone();
self.original_contents = Some(self.contents.clone());
self.exists_on_disk = true;
async move {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &contents)?;
Ok(())
}
}
pub fn reload_async(&self) -> impl std::future::Future<Output = Result<String>> {
let path = self.path.clone();
let exists = self.exists_on_disk;
async move {
if !exists {
return Err(anyhow!("File does not exist on disk"));
}
let contents = std::fs::read(&path)?;
let contents = String::from_utf8(contents)
.map_err(|_| anyhow!("File is not a valid UTF-8 text file"))?;
Ok(contents)
}
}
pub fn revert(&mut self) -> Result<()> {
match &self.original_contents {
Some(original) => {
self.contents = original.clone();
Ok(())
}
None => Err(anyhow!("No original contents to revert to")),
}
}
pub fn reload(&mut self) -> Result<()> {
if !self.exists_on_disk {
return Err(anyhow!("File does not exist on disk"));
}
let contents = std::fs::read(&self.path)?;
let contents = String::from_utf8(contents)
.map_err(|_| anyhow!("File is not a valid UTF-8 text file"))?;
self.contents = contents.clone();
self.original_contents = Some(contents);
Ok(())
}
pub fn file_name(&self) -> Option<&str> {
self.path.file_name().and_then(|name| name.to_str())
}
pub fn file_stem(&self) -> Option<&str> {
self.path.file_stem().and_then(|stem| stem.to_str())
}
pub fn extension(&self) -> Option<&str> {
self.path.extension().and_then(|ext| ext.to_str())
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn parent(&self) -> Option<&Path> {
self.path.parent()
}
pub fn contents_mut(&mut self) -> &mut String {
&mut self.contents
}
pub fn contents(&self) -> &str {
&self.contents
}
pub fn set_contents(&mut self, contents: impl Into<String>) {
self.contents = contents.into();
}
pub fn has_changes(&self) -> bool {
match &self.original_contents {
Some(original) => &self.contents != original,
None => !self.contents.is_empty(),
}
}
pub fn exists_on_disk(&self) -> bool {
self.exists_on_disk
}
pub fn is_new(&self) -> bool {
!self.exists_on_disk
}
pub fn exists_at_path(path: impl AsRef<Path>) -> bool {
path.as_ref().exists() && path.as_ref().is_file()
}
pub fn size(&self) -> usize {
self.contents.len()
}
pub fn line_count(&self) -> usize {
if self.contents.is_empty() {
0
} else {
self.contents.lines().count()
}
}
pub fn is_empty(&self) -> bool {
self.contents.is_empty()
}
pub fn create_async(
path: impl Into<PathBuf>,
contents: impl Into<String>,
) -> impl std::future::Future<Output = Result<Self>> {
let path = path.into();
let contents = contents.into();
async move {
let mut file = File::new_with_contents(path, contents);
file.save()?;
Ok(file)
}
}
pub fn exists_async(path: impl Into<PathBuf>) -> impl std::future::Future<Output = bool> {
let path = path.into();
async move { path.exists() && path.is_file() }
}
}
pub struct FileHandle {
file: Option<File>,
}
impl FileHandle {
pub fn new() -> Self {
Self { file: None }
}
pub fn load(&mut self, path: impl Into<PathBuf>, cx: &mut Context<Self>) {
let path = path.into();
cx.spawn(async move |this, cx| {
let file = cx
.background_executor()
.spawn(File::load_async(path))
.await?;
this.update(cx, |this, cx| {
this.file = Some(file);
cx.notify();
})?;
Ok::<(), anyhow::Error>(())
})
.detach_and_log_err(cx);
}
pub fn save(&mut self, cx: &mut Context<Self>) {
if let Some(file) = &mut self.file {
let save_future = file.save_async();
cx.spawn(async move |_this, cx| {
cx.background_executor().spawn(save_future).await?;
Ok::<(), anyhow::Error>(())
})
.detach_and_log_err(cx);
}
}
pub fn file(&self) -> Option<&File> {
self.file.as_ref()
}
pub fn file_mut(&mut self) -> Option<&mut File> {
self.file.as_mut()
}
pub fn reload(&mut self, cx: &mut Context<Self>) {
if let Some(file) = &self.file {
let reload_future = file.reload_async();
cx.spawn(async move |this, cx| {
let new_contents = cx.background_executor().spawn(reload_future).await?;
this.update(cx, |this, cx| {
if let Some(file) = &mut this.file {
file.contents = new_contents.clone();
file.original_contents = Some(new_contents);
}
cx.notify();
})?;
Ok::<(), anyhow::Error>(())
})
.detach_and_log_err(cx);
}
}
}
impl Default for FileHandle {
fn default() -> Self {
Self::new()
}
}
pub mod dialog {
use super::*;
use gpui::{AsyncApp, AsyncWindowContext, PathPromptOptions};
#[derive(Debug, Clone)]
pub struct OpenOptions {
pub files: bool,
pub directories: bool,
pub multiple: bool,
pub filters: Vec<(&'static str, Vec<&'static str>)>,
}
impl Default for OpenOptions {
fn default() -> Self {
Self {
files: true,
directories: false,
multiple: false,
filters: Vec::new(),
}
}
}
impl OpenOptions {
pub fn single_file() -> Self {
Self::default()
}
pub fn multiple_files() -> Self {
Self {
files: true,
directories: false,
multiple: true,
filters: Vec::new(),
}
}
pub fn directory() -> Self {
Self {
files: false,
directories: true,
multiple: false,
filters: Vec::new(),
}
}
pub fn with_filter(mut self, name: &'static str, extensions: Vec<&'static str>) -> Self {
self.filters.push((name, extensions));
self
}
}
pub async fn prompt_for_open(
cx: &AsyncApp,
options: OpenOptions,
) -> Result<Option<Vec<PathBuf>>> {
let paths = cx
.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: options.files,
directories: options.directories,
multiple: options.multiple,
prompt: None,
})
})
.await??;
Ok(paths)
}
pub async fn prompt_for_save(
cx: &AsyncApp,
default_path: Option<PathBuf>,
) -> Result<Option<PathBuf>> {
let default_path = default_path.unwrap_or_else(|| {
std::env::var("HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"))
});
let path = cx
.update(|cx| cx.prompt_for_new_path(&default_path, None))
.await??;
Ok(path)
}
pub async fn prompt_for_save_with_extension(
cx: &AsyncApp,
default_path: Option<PathBuf>,
extension: &str,
) -> Result<Option<PathBuf>> {
let path = prompt_for_save(cx, default_path).await?;
Ok(path.map(|mut p| {
if p.extension().is_none() || p.extension() != Some(std::ffi::OsStr::new(extension)) {
p.set_extension(extension);
}
p
}))
}
pub async fn open_file(cx: &AsyncApp) -> Result<Option<File>> {
let paths = prompt_for_open(cx, OpenOptions::single_file()).await?;
match paths.and_then(|p| p.into_iter().next()) {
Some(path) => {
let file = File::load(path)?;
Ok(Some(file))
}
None => Ok(None),
}
}
pub async fn open_files(cx: &AsyncApp) -> Result<Option<Vec<File>>> {
let paths = prompt_for_open(cx, OpenOptions::multiple_files()).await?;
match paths {
Some(paths) => {
let mut files = Vec::new();
for path in paths {
files.push(File::load(path)?);
}
Ok(Some(files))
}
None => Ok(None),
}
}
pub async fn save_file(
cx: &AsyncApp,
file: &mut File,
default_path: Option<PathBuf>,
) -> Result<bool> {
let path =
prompt_for_save(cx, default_path.or_else(|| Some(file.path().to_path_buf()))).await?;
match path {
Some(path) => {
file.save_as(path)?;
Ok(true)
}
None => Ok(false),
}
}
pub trait FileDialogExt {
fn prompt_open_file(&mut self, cx: &mut Context<Self>)
where
Self: Sized;
fn prompt_save_file(&mut self, cx: &mut Context<Self>)
where
Self: Sized;
}
impl FileDialogExt for FileHandle {
fn prompt_open_file(&mut self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
if let Some(file) = open_file(&cx).await? {
this.update(cx, |this, cx| {
this.file = Some(file);
cx.notify();
})?;
}
Ok::<(), anyhow::Error>(())
})
.detach_and_log_err(cx);
}
fn prompt_save_file(&mut self, cx: &mut Context<Self>) {
if let Some(file) = self.file.as_ref() {
let current_path = file.path().to_path_buf();
cx.spawn(async move |this, cx| {
let path = prompt_for_save(&cx, Some(current_path)).await?;
if let Some(path) = path {
this.update(cx, |this, cx| {
if let Some(file) = &mut this.file {
if let Err(e) = file.save_as(path) {
log::error!("Failed to save file: {}", e);
}
cx.notify();
}
})?;
}
Ok::<(), anyhow::Error>(())
})
.detach_and_log_err(cx);
}
}
}
pub async fn prompt_for_open_in_window(
cx: &mut AsyncWindowContext,
options: OpenOptions,
) -> Result<Option<Vec<PathBuf>>> {
let paths = cx
.update(|_window, cx| {
cx.prompt_for_paths(PathPromptOptions {
files: options.files,
directories: options.directories,
multiple: options.multiple,
prompt: None,
})
})?
.await??;
Ok(paths)
}
pub async fn prompt_for_save_in_window(
cx: &mut AsyncWindowContext,
default_path: Option<PathBuf>,
) -> Result<Option<PathBuf>> {
let default_path = default_path.unwrap_or_else(|| {
std::env::var("HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"))
});
let path = cx
.update(|_window, cx| cx.prompt_for_new_path(&default_path, None))?
.await??;
Ok(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_metadata() {
let file = File::new("/path/to/test.txt");
assert_eq!(file.file_name(), Some("test.txt"));
assert_eq!(file.file_stem(), Some("test"));
assert_eq!(file.extension(), Some("txt"));
}
#[test]
fn test_file_changes() {
let mut file = File::new("/path/to/test.txt");
assert!(!file.has_changes());
file.set_contents("Hello, world!");
assert!(file.has_changes());
file.original_contents = Some("Hello, world!".to_string());
assert!(!file.has_changes());
file.set_contents("Modified content");
assert!(file.has_changes());
}
#[test]
fn test_revert() {
let mut file = File::new("/path/to/test.txt");
file.original_contents = Some("Original content".to_string());
file.set_contents("Modified content");
assert_eq!(file.contents(), "Modified content");
file.revert().unwrap();
assert_eq!(file.contents(), "Original content");
}
#[test]
fn test_new_file() {
let file = File::new("/path/to/new.txt");
assert!(file.is_new());
assert!(!file.exists_on_disk());
assert_eq!(file.contents(), "");
}
#[test]
fn test_file_with_contents() {
let file = File::new_with_contents("/path/to/test.txt", "Initial content");
assert_eq!(file.contents(), "Initial content");
assert!(file.is_new());
assert!(file.has_changes());
}
#[test]
fn test_line_count() {
let mut file = File::new("/path/to/test.txt");
assert_eq!(file.line_count(), 0);
file.set_contents("Line 1");
assert_eq!(file.line_count(), 1);
file.set_contents("Line 1\nLine 2\nLine 3");
assert_eq!(file.line_count(), 3);
}
}