Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e58f34e
chore(fs): add trash dependency
dinocosta Mar 1, 2026
e513dea
Merge branch 'main' into 5039-fs-update
dinocosta Mar 1, 2026
fdbd61d
fix: explicit type annotation and unsafe block removal
dinocosta Mar 1, 2026
9c0411d
refactor(fs): replace platform-specific trash implementations
dinocosta Mar 1, 2026
5661584
refactor(fs): replace platform-specific trash_dir implementations
dinocosta Mar 2, 2026
fac34ca
feat(fs): keep track of trashed files and directories
dinocosta Mar 2, 2026
fc108de
feat(fs): add restore trait method
dinocosta Mar 3, 2026
311417e
chore: remove duplicate removed event emission
dinocosta Mar 3, 2026
1cebdf8
feat(fs): introduce trash restoration error
dinocosta Mar 3, 2026
b459a08
refactor(fs): remove unused dependencies and improve test
dinocosta Mar 4, 2026
f0bafd5
Merge branch 'main' into 5039-fs-update
dinocosta Mar 20, 2026
34d8c6d
Merge branch '5039-fs-update' into 5039-fs-restore
dinocosta Mar 20, 2026
2a94100
fix(fs): fix missing test attribute
dinocosta Mar 20, 2026
b414892
fix(fs): fix import name conflict on linux
dinocosta Mar 20, 2026
f6d1619
Merge branch '5039-fs-update' into 5039-fs-restore
dinocosta Mar 20, 2026
ebfb355
docs: improve trashed entry documentation
dinocosta Mar 25, 2026
bbe30a4
refactor(fs): remove unused dependencies
dinocosta Mar 25, 2026
fe3b2c6
Merge branch '5039-fs-update' into 5039-fs-restore
dinocosta Mar 25, 2026
88bfddc
chore: clean up todo comment
dinocosta Mar 25, 2026
794b4e7
refactor(fs): remove unused options argument
dinocosta Mar 26, 2026
500e08c
chore: update trash dependency
dinocosta Apr 8, 2026
4f29a26
Merge branch 'main' into 5039-fs-update
dinocosta Apr 8, 2026
625c3cf
Merge branch '5039-fs-update' into 5039-fs-restore
dinocosta Apr 8, 2026
46de14f
test: fix failing test
dinocosta Apr 8, 2026
4417fe5
Merge branch 'main' into 5039-fs-restore
dinocosta Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion crates/fs/src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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.
///
Expand All @@ -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 {}
Expand Down Expand Up @@ -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")))]
Expand Down Expand Up @@ -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()
Expand Down
129 changes: 129 additions & 0 deletions crates/fs/tests/integration/fs.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{
collections::BTreeSet,
ffi::OsString,
io::Write,
path::{Path, PathBuf},
time::Duration,
Expand Down Expand Up @@ -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(
Expand Down
Loading