mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
fs: Add support for restoring trashed files (#52014)
Introduce a new `fs::Fs::restore` method which, given a `fs::TrashedEntry` should attempt to restore the file or directory back to its original path.
This commit is contained in:
parent
b6562e8afa
commit
fd285c8ec7
2 changed files with 224 additions and 1 deletions
|
|
@ -170,6 +170,13 @@ pub trait Fs: Send + Sync {
|
|||
async fn is_case_sensitive(&self) -> bool;
|
||||
fn subscribe_to_jobs(&self) -> JobEventReceiver;
|
||||
|
||||
/// Restores a given `TrashedEntry`, moving it from the system's trash back
|
||||
/// to the original path.
|
||||
async fn restore(
|
||||
&self,
|
||||
trashed_entry: TrashedEntry,
|
||||
) -> std::result::Result<(), TrashRestoreError>;
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
fn as_fake(&self) -> Arc<FakeFs> {
|
||||
panic!("called as_fake on a real fs");
|
||||
|
|
@ -181,7 +188,7 @@ pub trait Fs: Send + Sync {
|
|||
// tests from changes to that crate's API surface.
|
||||
/// Represents a file or directory that has been moved to the system trash,
|
||||
/// retaining enough information to restore it to its original location.
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct TrashedEntry {
|
||||
/// Platform-specific identifier for the file/directory in the trash.
|
||||
///
|
||||
|
|
@ -205,6 +212,41 @@ impl From<trash::TrashItem> for TrashedEntry {
|
|||
}
|
||||
}
|
||||
|
||||
impl TrashedEntry {
|
||||
fn into_trash_item(self) -> trash::TrashItem {
|
||||
trash::TrashItem {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
original_parent: self.original_parent,
|
||||
// `TrashedEntry` doesn't preserve `time_deleted` as we don't
|
||||
// currently need it for restore, so we default it to 0 here.
|
||||
time_deleted: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TrashRestoreError {
|
||||
/// The specified `path` was not found in the system's trash.
|
||||
NotFound { path: PathBuf },
|
||||
/// A file or directory already exists at the restore destination.
|
||||
Collision { path: PathBuf },
|
||||
/// Any other platform-specific error.
|
||||
Unknown { description: String },
|
||||
}
|
||||
|
||||
impl From<trash::Error> for TrashRestoreError {
|
||||
fn from(err: trash::Error) -> Self {
|
||||
match err {
|
||||
trash::Error::RestoreCollision { path, .. } => Self::Collision { path },
|
||||
trash::Error::Unknown { description } => Self::Unknown { description },
|
||||
other => Self::Unknown {
|
||||
description: other.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GlobalFs(Arc<dyn Fs>);
|
||||
|
||||
impl Global for GlobalFs {}
|
||||
|
|
@ -1212,6 +1254,13 @@ impl Fs for RealFs {
|
|||
);
|
||||
res
|
||||
}
|
||||
|
||||
async fn restore(
|
||||
&self,
|
||||
trashed_entry: TrashedEntry,
|
||||
) -> std::result::Result<(), TrashRestoreError> {
|
||||
trash::restore_all([trashed_entry.into_trash_item()]).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
|
||||
|
|
@ -3043,6 +3092,51 @@ impl Fs for FakeFs {
|
|||
receiver
|
||||
}
|
||||
|
||||
async fn restore(
|
||||
&self,
|
||||
trashed_entry: TrashedEntry,
|
||||
) -> std::result::Result<(), TrashRestoreError> {
|
||||
let mut state = self.state.lock();
|
||||
|
||||
let Some((trashed_entry, fake_entry)) = state
|
||||
.trash
|
||||
.iter()
|
||||
.find(|(entry, _)| *entry == trashed_entry)
|
||||
.cloned()
|
||||
else {
|
||||
return Err(TrashRestoreError::NotFound {
|
||||
path: PathBuf::from(trashed_entry.id),
|
||||
});
|
||||
};
|
||||
|
||||
let path = trashed_entry
|
||||
.original_parent
|
||||
.join(trashed_entry.name.clone());
|
||||
|
||||
let result = state.write_path(&path, |entry| match entry {
|
||||
btree_map::Entry::Vacant(entry) => {
|
||||
entry.insert(fake_entry);
|
||||
Ok(())
|
||||
}
|
||||
btree_map::Entry::Occupied(_) => {
|
||||
anyhow::bail!("Failed to restore {:?}", path);
|
||||
}
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
state.trash.retain(|(entry, _)| *entry != trashed_entry);
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => {
|
||||
// For now we'll just assume that this failed because it was a
|
||||
// collision error, which I think that, for the time being, is
|
||||
// the only case where this could fail?
|
||||
Err(TrashRestoreError::Collision { path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
fn as_fake(&self) -> Arc<FakeFs> {
|
||||
self.this.upgrade().unwrap()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::{
|
||||
collections::BTreeSet,
|
||||
ffi::OsString,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
|
|
@ -687,6 +688,134 @@ async fn test_fake_fs_trash_dir(executor: BackgroundExecutor) {
|
|||
assert_eq!(trash_entries[0].original_parent, root_path);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_fake_fs_restore(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"src": {
|
||||
"file_a.txt": "File A",
|
||||
"file_b.txt": "File B",
|
||||
},
|
||||
"file_c.txt": "File C",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Providing a non-existent `TrashedEntry` should result in an error.
|
||||
let id: OsString = "/trash/file_c.txt".into();
|
||||
let name: OsString = "file_c.txt".into();
|
||||
let original_parent = PathBuf::from(path!("/root"));
|
||||
let trashed_entry = TrashedEntry {
|
||||
id,
|
||||
name,
|
||||
original_parent,
|
||||
};
|
||||
let result = fs.restore(trashed_entry).await;
|
||||
assert!(matches!(result, Err(TrashRestoreError::NotFound { .. })));
|
||||
|
||||
// Attempt deleting a file, asserting that the filesystem no longer reports
|
||||
// it as part of its list of files, restore it and verify that the list of
|
||||
// files and trash has been updated accordingly.
|
||||
let path = path!("/root/src/file_a.txt").as_ref();
|
||||
let trashed_entry = fs.trash_file(path).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 1);
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_b.txt"))
|
||||
]
|
||||
);
|
||||
|
||||
fs.restore(trashed_entry).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 0);
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_a.txt")),
|
||||
PathBuf::from(path!("/root/src/file_b.txt"))
|
||||
]
|
||||
);
|
||||
|
||||
// Deleting and restoring a directory should also remove all of its files
|
||||
// but create a single trashed entry, which should be removed after
|
||||
// restoration.
|
||||
let path = path!("/root/src/").as_ref();
|
||||
let trashed_entry = fs.trash_dir(path).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 1);
|
||||
assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
|
||||
|
||||
fs.restore(trashed_entry).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_a.txt")),
|
||||
PathBuf::from(path!("/root/src/file_b.txt"))
|
||||
]
|
||||
);
|
||||
assert_eq!(fs.trash_entries().len(), 0);
|
||||
|
||||
// A collision error should be returned in case a file is being restored to
|
||||
// a path where a file already exists.
|
||||
let path = path!("/root/src/file_a.txt").as_ref();
|
||||
let trashed_entry = fs.trash_file(path).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 1);
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_b.txt"))
|
||||
]
|
||||
);
|
||||
|
||||
fs.write(path, "New File A".as_bytes()).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 1);
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_a.txt")),
|
||||
PathBuf::from(path!("/root/src/file_b.txt"))
|
||||
]
|
||||
);
|
||||
|
||||
let file_contents = fs.files_with_contents(path);
|
||||
assert!(fs.restore(trashed_entry).await.is_err());
|
||||
assert_eq!(
|
||||
file_contents,
|
||||
vec![(PathBuf::from(path), b"New File A".to_vec())]
|
||||
);
|
||||
|
||||
// A collision error should be returned in case a directory is being
|
||||
// restored to a path where a directory already exists.
|
||||
let path = path!("/root/src/").as_ref();
|
||||
let trashed_entry = fs.trash_dir(path).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 2);
|
||||
assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
|
||||
|
||||
fs.create_dir(path).await.unwrap();
|
||||
|
||||
assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
|
||||
assert_eq!(fs.trash_entries().len(), 2);
|
||||
|
||||
let result = fs.restore(trashed_entry).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
|
||||
assert_eq!(fs.trash_entries().len(), 2);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore = "stress test; run explicitly when needed"]
|
||||
async fn test_realfs_watch_stress_reports_missed_paths(
|
||||
|
|
|
|||
Loading…
Reference in a new issue