logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
63
crates/project_panel/Cargo.toml
Normal file
63
crates/project_panel/Cargo.toml
Normal file
@@ -0,0 +1,63 @@
|
||||
[package]
|
||||
name = "project_panel"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "sorting"
|
||||
harness = false
|
||||
|
||||
[lib]
|
||||
path = "src/project_panel.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
collections.workspace = true
|
||||
command_palette_hooks.workspace = true
|
||||
editor.workspace = true
|
||||
file_icons.workspace = true
|
||||
futures.workspace = true
|
||||
git_ui.workspace = true
|
||||
git.workspace = true
|
||||
gpui.workspace = true
|
||||
itertools.workspace = true
|
||||
menu.workspace = true
|
||||
pretty_assertions.workspace = true
|
||||
project.workspace = true
|
||||
schemars.workspace = true
|
||||
search.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
smallvec.workspace = true
|
||||
theme.workspace = true
|
||||
theme_settings.workspace = true
|
||||
rayon.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
client.workspace = true
|
||||
worktree.workspace = true
|
||||
workspace.workspace = true
|
||||
language.workspace = true
|
||||
zed_actions.workspace = true
|
||||
telemetry.workspace = true
|
||||
notifications.workspace = true
|
||||
feature_flags.workspace = true
|
||||
fs.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
client = { workspace = true, features = ["test-support"] }
|
||||
criterion.workspace = true
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
remote_connection = { workspace = true, features = ["test-support"] }
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
workspace = { workspace = true, features = ["test-support"] }
|
||||
1
crates/project_panel/LICENSE-GPL
Symbolic link
1
crates/project_panel/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
96349
crates/project_panel/benches/linux_repo_snapshot.txt
Normal file
96349
crates/project_panel/benches/linux_repo_snapshot.txt
Normal file
File diff suppressed because it is too large
Load Diff
77
crates/project_panel/benches/sorting.rs
Normal file
77
crates/project_panel/benches/sorting.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use project::{Entry, EntryKind, GitEntry, ProjectEntryId};
|
||||
use project_panel::par_sort_worktree_entries;
|
||||
use settings::{ProjectPanelSortMode, ProjectPanelSortOrder};
|
||||
use std::sync::Arc;
|
||||
use util::rel_path::RelPath;
|
||||
|
||||
fn load_linux_repo_snapshot() -> Vec<GitEntry> {
|
||||
let file = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/benches/linux_repo_snapshot.txt"
|
||||
))
|
||||
.expect("Failed to read file");
|
||||
file.lines()
|
||||
.filter_map(|line| {
|
||||
let kind = match line.chars().next() {
|
||||
Some('f') => EntryKind::File,
|
||||
Some('d') => EntryKind::Dir,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let entry = Entry {
|
||||
kind,
|
||||
path: Arc::from(RelPath::unix(&(line.trim_end()[2..])).unwrap()),
|
||||
id: ProjectEntryId::default(),
|
||||
size: 0,
|
||||
inode: 0,
|
||||
mtime: None,
|
||||
canonical_path: None,
|
||||
is_ignored: false,
|
||||
is_always_included: false,
|
||||
is_external: false,
|
||||
is_private: false,
|
||||
is_hidden: false,
|
||||
char_bag: Default::default(),
|
||||
is_fifo: false,
|
||||
};
|
||||
Some(GitEntry {
|
||||
entry,
|
||||
git_summary: Default::default(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn criterion_benchmark(c: &mut Criterion) {
|
||||
let snapshot = load_linux_repo_snapshot();
|
||||
|
||||
let modes = [
|
||||
("DirectoriesFirst", ProjectPanelSortMode::DirectoriesFirst),
|
||||
("Mixed", ProjectPanelSortMode::Mixed),
|
||||
("FilesFirst", ProjectPanelSortMode::FilesFirst),
|
||||
];
|
||||
let orders = [
|
||||
("Default", ProjectPanelSortOrder::Default),
|
||||
("Upper", ProjectPanelSortOrder::Upper),
|
||||
("Lower", ProjectPanelSortOrder::Lower),
|
||||
("Unicode", ProjectPanelSortOrder::Unicode),
|
||||
];
|
||||
|
||||
for (mode_name, mode) in &modes {
|
||||
for (order_name, order) in &orders {
|
||||
c.bench_function(
|
||||
&format!("Sort linux worktree snapshot ({mode_name}, {order_name})"),
|
||||
|b| {
|
||||
b.iter_batched(
|
||||
|| snapshot.clone(),
|
||||
|mut snapshot| par_sort_worktree_entries(&mut snapshot, *mode, *order),
|
||||
criterion::BatchSize::LargeInput,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
criterion_group!(benches, criterion_benchmark);
|
||||
criterion_main!(benches);
|
||||
7401
crates/project_panel/src/project_panel.rs
Normal file
7401
crates/project_panel/src/project_panel.rs
Normal file
File diff suppressed because it is too large
Load Diff
150
crates/project_panel/src/project_panel_settings.rs
Normal file
150
crates/project_panel/src/project_panel_settings.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use editor::{EditorSettings, ui_scrollbar_settings_from_raw};
|
||||
use gpui::Pixels;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{
|
||||
DockSide, ProjectPanelEntrySpacing, ProjectPanelSortMode, ProjectPanelSortOrder,
|
||||
RegisterSetting, Settings, ShowDiagnostics, ShowIndentGuides,
|
||||
};
|
||||
use ui::{
|
||||
px,
|
||||
scrollbars::{ScrollbarVisibility, ShowScrollbar},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, RegisterSetting)]
|
||||
pub struct ProjectPanelSettings {
|
||||
pub button: bool,
|
||||
pub hide_gitignore: bool,
|
||||
pub default_width: Pixels,
|
||||
pub dock: DockSide,
|
||||
pub entry_spacing: ProjectPanelEntrySpacing,
|
||||
pub file_icons: bool,
|
||||
pub folder_icons: bool,
|
||||
pub git_status: bool,
|
||||
pub indent_size: f32,
|
||||
pub indent_guides: IndentGuidesSettings,
|
||||
pub sticky_scroll: bool,
|
||||
pub auto_reveal_entries: bool,
|
||||
pub auto_fold_dirs: bool,
|
||||
pub bold_folder_labels: bool,
|
||||
pub starts_open: bool,
|
||||
pub scrollbar: ScrollbarSettings,
|
||||
pub show_diagnostics: ShowDiagnostics,
|
||||
pub hide_root: bool,
|
||||
pub hide_hidden: bool,
|
||||
pub drag_and_drop: bool,
|
||||
pub auto_open: AutoOpenSettings,
|
||||
pub sort_mode: ProjectPanelSortMode,
|
||||
pub sort_order: ProjectPanelSortOrder,
|
||||
pub diagnostic_badges: bool,
|
||||
pub git_status_indicator: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct IndentGuidesSettings {
|
||||
pub show: ShowIndentGuides,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct ScrollbarSettings {
|
||||
/// When to show the scrollbar in the project panel.
|
||||
///
|
||||
/// Default: inherits editor scrollbar settings
|
||||
pub show: Option<ShowScrollbar>,
|
||||
/// Whether to allow horizontal scrolling in the project panel.
|
||||
/// When false, the view is locked to the leftmost position and long file names are clipped.
|
||||
///
|
||||
/// Default: true
|
||||
pub horizontal_scroll: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct AutoOpenSettings {
|
||||
pub on_create: bool,
|
||||
pub on_paste: bool,
|
||||
pub on_drop: bool,
|
||||
}
|
||||
|
||||
impl AutoOpenSettings {
|
||||
#[inline]
|
||||
pub fn should_open_on_create(self) -> bool {
|
||||
self.on_create
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn should_open_on_paste(self) -> bool {
|
||||
self.on_paste
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn should_open_on_drop(self) -> bool {
|
||||
self.on_drop
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ProjectPanelScrollbarProxy;
|
||||
|
||||
impl ScrollbarVisibility for ProjectPanelScrollbarProxy {
|
||||
fn visibility(&self, cx: &ui::App) -> ShowScrollbar {
|
||||
ProjectPanelSettings::get_global(cx)
|
||||
.scrollbar
|
||||
.show
|
||||
.unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings for ProjectPanelSettings {
|
||||
fn from_settings(content: &settings::SettingsContent) -> Self {
|
||||
let project_panel = content.project_panel.clone().unwrap();
|
||||
Self {
|
||||
button: project_panel.button.unwrap(),
|
||||
hide_gitignore: project_panel.hide_gitignore.unwrap(),
|
||||
default_width: px(project_panel.default_width.unwrap()),
|
||||
dock: project_panel.dock.unwrap(),
|
||||
entry_spacing: project_panel.entry_spacing.unwrap(),
|
||||
file_icons: project_panel.file_icons.unwrap(),
|
||||
folder_icons: project_panel.folder_icons.unwrap(),
|
||||
git_status: project_panel.git_status.unwrap()
|
||||
&& content
|
||||
.git
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.enabled
|
||||
.unwrap()
|
||||
.is_git_status_enabled(),
|
||||
indent_size: project_panel.indent_size.unwrap(),
|
||||
indent_guides: IndentGuidesSettings {
|
||||
show: project_panel.indent_guides.unwrap().show.unwrap(),
|
||||
},
|
||||
sticky_scroll: project_panel.sticky_scroll.unwrap(),
|
||||
auto_reveal_entries: project_panel.auto_reveal_entries.unwrap(),
|
||||
auto_fold_dirs: project_panel.auto_fold_dirs.unwrap(),
|
||||
bold_folder_labels: project_panel.bold_folder_labels.unwrap(),
|
||||
starts_open: project_panel.starts_open.unwrap(),
|
||||
scrollbar: {
|
||||
let scrollbar = project_panel.scrollbar.unwrap();
|
||||
ScrollbarSettings {
|
||||
show: scrollbar.show.map(ui_scrollbar_settings_from_raw),
|
||||
horizontal_scroll: scrollbar.horizontal_scroll.unwrap(),
|
||||
}
|
||||
},
|
||||
show_diagnostics: project_panel.show_diagnostics.unwrap(),
|
||||
hide_root: project_panel.hide_root.unwrap(),
|
||||
hide_hidden: project_panel.hide_hidden.unwrap(),
|
||||
drag_and_drop: project_panel.drag_and_drop.unwrap(),
|
||||
auto_open: {
|
||||
let auto_open = project_panel.auto_open.unwrap();
|
||||
AutoOpenSettings {
|
||||
on_create: auto_open.on_create.unwrap(),
|
||||
on_paste: auto_open.on_paste.unwrap(),
|
||||
on_drop: auto_open.on_drop.unwrap(),
|
||||
}
|
||||
},
|
||||
sort_mode: project_panel.sort_mode.unwrap(),
|
||||
sort_order: project_panel.sort_order.unwrap(),
|
||||
diagnostic_badges: project_panel.diagnostic_badges.unwrap(),
|
||||
git_status_indicator: project_panel.git_status_indicator.unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
10377
crates/project_panel/src/project_panel_tests.rs
Normal file
10377
crates/project_panel/src/project_panel_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
1
crates/project_panel/src/tests.rs
Normal file
1
crates/project_panel/src/tests.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(crate) mod undo;
|
||||
384
crates/project_panel/src/tests/undo.rs
Normal file
384
crates/project_panel/src/tests/undo.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use collections::HashSet;
|
||||
use fs::{FakeFs, Fs};
|
||||
use gpui::{Entity, VisualTestContext};
|
||||
use project::Project;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use workspace::MultiWorkspace;
|
||||
|
||||
use crate::project_panel_tests::{self, find_project_entry, select_path};
|
||||
use crate::{NewDirectory, NewFile, ProjectPanel, Redo, Rename, Trash, Undo};
|
||||
|
||||
struct TestContext {
|
||||
panel: Entity<ProjectPanel>,
|
||||
fs: Arc<FakeFs>,
|
||||
cx: VisualTestContext,
|
||||
}
|
||||
|
||||
// Using the `util::path` macro requires a string literal, which would mean that
|
||||
// callers of, for example, `rename`, would now need to know about `/` and
|
||||
// use `path!` in tests.
|
||||
//
|
||||
// As such, we define it as a function here to make the helper methods more
|
||||
// ergonomic for our use case.
|
||||
fn path(path: impl AsRef<str>) -> String {
|
||||
let path = path.as_ref();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut path = path.replace("/", "\\");
|
||||
if path.starts_with("\\") {
|
||||
path = format!("C:{}", &path);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl TestContext {
|
||||
async fn undo(&mut self) {
|
||||
self.panel.update_in(&mut self.cx, |panel, window, cx| {
|
||||
panel.undo(&Undo, window, cx);
|
||||
});
|
||||
self.cx.run_until_parked();
|
||||
}
|
||||
async fn redo(&mut self) {
|
||||
self.panel.update_in(&mut self.cx, |panel, window, cx| {
|
||||
panel.redo(&Redo, window, cx);
|
||||
});
|
||||
self.cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Note this only works when every file has an extension
|
||||
fn assert_fs_state_is(&mut self, state: &[&str]) {
|
||||
let state: HashSet<_> = state
|
||||
.into_iter()
|
||||
.map(|s| path(format!("/workspace/{s}")))
|
||||
.chain([path("/workspace"), path("/")])
|
||||
.map(|s| Path::new(&s).to_path_buf())
|
||||
.collect();
|
||||
|
||||
let dirs: HashSet<_> = state
|
||||
.iter()
|
||||
.map(|p| match p.extension() {
|
||||
Some(_) => p.parent().unwrap_or(Path::new(&path("/"))).to_owned(),
|
||||
None => p.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
self.fs
|
||||
.directories(true)
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>(),
|
||||
dirs
|
||||
);
|
||||
assert_eq!(
|
||||
self.fs.paths(true).into_iter().collect::<HashSet<_>>(),
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_exists(&mut self, file: &str) {
|
||||
assert!(
|
||||
find_project_entry(&self.panel, &format!("workspace/{file}"), &mut self.cx).is_some(),
|
||||
"{file} should exist"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_not_exists(&mut self, file: &str) {
|
||||
assert_eq!(
|
||||
find_project_entry(&self.panel, &format!("workspace/{file}"), &mut self.cx),
|
||||
None,
|
||||
"{file} should not exist"
|
||||
);
|
||||
}
|
||||
|
||||
async fn rename(&mut self, from: &str, to: &str) {
|
||||
let from = format!("workspace/{from}");
|
||||
let Self { panel, cx, .. } = self;
|
||||
select_path(&panel, &from, cx);
|
||||
panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
|
||||
cx.run_until_parked();
|
||||
|
||||
let confirm = panel.update_in(cx, |panel, window, cx| {
|
||||
panel
|
||||
.filename_editor
|
||||
.update(cx, |editor, cx| editor.set_text(to, window, cx));
|
||||
panel.confirm_edit(true, window, cx).unwrap()
|
||||
});
|
||||
confirm.await.unwrap();
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
async fn create_file(&mut self, path: &str) {
|
||||
let Self { panel, cx, .. } = self;
|
||||
select_path(&panel, "workspace", cx);
|
||||
panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
|
||||
cx.run_until_parked();
|
||||
|
||||
let confirm = panel.update_in(cx, |panel, window, cx| {
|
||||
panel
|
||||
.filename_editor
|
||||
.update(cx, |editor, cx| editor.set_text(path, window, cx));
|
||||
panel.confirm_edit(true, window, cx).unwrap()
|
||||
});
|
||||
confirm.await.unwrap();
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
async fn create_directory(&mut self, path: &str) {
|
||||
let Self { panel, cx, .. } = self;
|
||||
|
||||
select_path(&panel, "workspace", cx);
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.new_directory(&NewDirectory, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let confirm = panel.update_in(cx, |panel, window, cx| {
|
||||
panel
|
||||
.filename_editor
|
||||
.update(cx, |editor, cx| editor.set_text(path, window, cx));
|
||||
panel.confirm_edit(true, window, cx).unwrap()
|
||||
});
|
||||
confirm.await.unwrap();
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Drags the `files` to the provided `directory`.
|
||||
fn drag(&mut self, files: &[&str], directory: &str) {
|
||||
self.panel
|
||||
.update(&mut self.cx, |panel, _| panel.marked_entries.clear());
|
||||
files.into_iter().for_each(|file| {
|
||||
project_panel_tests::select_path_with_mark(
|
||||
&self.panel,
|
||||
&format!("workspace/{file}"),
|
||||
&mut self.cx,
|
||||
)
|
||||
});
|
||||
project_panel_tests::drag_selection_to(
|
||||
&self.panel,
|
||||
&format!("workspace/{directory}"),
|
||||
false,
|
||||
&mut self.cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Only supports files in root (otherwise would need toggle_expand_dir).
|
||||
/// For undo redo the paths themselves do not matter so this is fine
|
||||
async fn cut(&mut self, file: &str) {
|
||||
project_panel_tests::select_path_with_mark(
|
||||
&self.panel,
|
||||
&format!("workspace/{file}"),
|
||||
&mut self.cx,
|
||||
);
|
||||
self.panel.update_in(&mut self.cx, |panel, window, cx| {
|
||||
panel.cut(&Default::default(), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Only supports files in root (otherwise would need toggle_expand_dir).
|
||||
/// For undo redo the paths themselves do not matter so this is fine
|
||||
async fn paste(&mut self, directory: &str) {
|
||||
select_path(&self.panel, &format!("workspace/{directory}"), &mut self.cx);
|
||||
self.panel.update_in(&mut self.cx, |panel, window, cx| {
|
||||
panel.paste(&Default::default(), window, cx);
|
||||
});
|
||||
self.cx.run_until_parked();
|
||||
}
|
||||
|
||||
async fn trash(&mut self, paths: &[&str]) {
|
||||
paths.iter().for_each(|p| {
|
||||
project_panel_tests::select_path_with_mark(
|
||||
&self.panel,
|
||||
&format!("workspace/{p}"),
|
||||
&mut self.cx,
|
||||
)
|
||||
});
|
||||
|
||||
self.panel.update_in(&mut self.cx, |panel, window, cx| {
|
||||
panel.trash(&Trash { skip_prompt: true }, window, cx);
|
||||
});
|
||||
|
||||
self.cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// The test tree is:
|
||||
/// ```txt
|
||||
/// a.txt
|
||||
/// b.txt
|
||||
/// ```
|
||||
/// a and b are empty, x has the text "content" inside
|
||||
async fn new(cx: &mut gpui::TestAppContext) -> TestContext {
|
||||
Self::new_with_tree(
|
||||
cx,
|
||||
json!({
|
||||
"a.txt": "",
|
||||
"b.txt": "",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn new_with_tree(cx: &mut gpui::TestAppContext, tree: Value) -> TestContext {
|
||||
project_panel_tests::init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/workspace", tree).await;
|
||||
let project = Project::test(fs.clone(), ["/workspace".as_ref()], cx).await;
|
||||
let window =
|
||||
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let workspace = window
|
||||
.read_with(cx, |mw, _| mw.workspace().clone())
|
||||
.unwrap();
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
let panel = workspace.update_in(&mut cx, ProjectPanel::new);
|
||||
cx.run_until_parked();
|
||||
|
||||
TestContext { panel, fs, cx }
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn rename_undo_redo(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.rename("a.txt", "renamed.txt").await;
|
||||
cx.assert_fs_state_is(&["b.txt", "renamed.txt"]);
|
||||
|
||||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt"]);
|
||||
|
||||
cx.redo().await;
|
||||
cx.assert_fs_state_is(&["b.txt", "renamed.txt"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn create_undo_redo(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
let path = path("/workspace/c.txt");
|
||||
|
||||
cx.create_file("c.txt").await;
|
||||
cx.assert_exists("c.txt");
|
||||
|
||||
// We'll now insert some content into `c.txt` in order to ensure that, after
|
||||
// undoing the trash operation, i.e., when the file is restored, the actual
|
||||
// file's contents are preserved instead of a new one with the same path
|
||||
// being created.
|
||||
cx.fs.write(Path::new(&path), b"Hello!").await.unwrap();
|
||||
|
||||
cx.undo().await;
|
||||
cx.assert_not_exists("c.txt");
|
||||
|
||||
cx.redo().await;
|
||||
cx.assert_exists("c.txt");
|
||||
assert_eq!(cx.fs.load(Path::new(&path)).await.unwrap(), "Hello!");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn create_dir_undo(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.create_directory("new_dir").await;
|
||||
cx.assert_exists("new_dir");
|
||||
cx.undo().await;
|
||||
cx.assert_not_exists("new_dir");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn cut_paste_undo(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.create_directory("files").await;
|
||||
cx.cut("a.txt").await;
|
||||
cx.paste("files").await;
|
||||
cx.assert_fs_state_is(&["b.txt", "files/", "files/a.txt"]);
|
||||
|
||||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt", "files/"]);
|
||||
|
||||
cx.redo().await;
|
||||
cx.assert_fs_state_is(&["b.txt", "files/", "files/a.txt"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn drag_undo_redo(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.create_directory("src").await;
|
||||
cx.create_file("src/a.rs").await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt", "src/", "src/a.rs"]);
|
||||
|
||||
cx.drag(&["src/a.rs"], "");
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt", "a.rs", "src/"]);
|
||||
|
||||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt", "src/", "src/a.rs"]);
|
||||
|
||||
cx.redo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt", "a.rs", "src/"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn drag_multiple_undo_redo(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.create_directory("src").await;
|
||||
cx.create_file("src/x.rs").await;
|
||||
cx.create_file("src/y.rs").await;
|
||||
|
||||
cx.drag(&["src/x.rs", "src/y.rs"], "");
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt", "x.rs", "y.rs", "src/"]);
|
||||
|
||||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt", "src/", "src/x.rs", "src/y.rs"]);
|
||||
|
||||
cx.redo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt", "x.rs", "y.rs", "src/"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn two_sequential_undos(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.rename("a.txt", "x.txt").await;
|
||||
cx.create_file("y.txt").await;
|
||||
cx.assert_fs_state_is(&["b.txt", "x.txt", "y.txt"]);
|
||||
|
||||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["b.txt", "x.txt"]);
|
||||
|
||||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn undo_without_history(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
// Undoing without any history should just result in the filesystem state
|
||||
// remaining unchanged.
|
||||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt"])
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn trash_undo_redo(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.trash(&["a.txt", "b.txt"]).await;
|
||||
cx.assert_fs_state_is(&[]);
|
||||
|
||||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt"]);
|
||||
|
||||
cx.redo().await;
|
||||
cx.assert_fs_state_is(&[]);
|
||||
}
|
||||
558
crates/project_panel/src/undo.rs
Normal file
558
crates/project_panel/src/undo.rs
Normal file
@@ -0,0 +1,558 @@
|
||||
//! # Undo Manager
|
||||
//!
|
||||
//! ## Operations and Results
|
||||
//!
|
||||
//! Undo and Redo actions execute an operation against the filesystem, producing
|
||||
//! a result that is recorded back into the history in place of the original
|
||||
//! entry. Each result is the semantic inverse of its paired operation, so the
|
||||
//! cycle can repeat for continued undo and redo.
|
||||
//!
|
||||
//! Operations Results
|
||||
//! ───────────────────────────────── ──────────────────────────────────────
|
||||
//! Create(ProjectPath) → Created(ProjectPath)
|
||||
//! Trash(ProjectPath) → Trashed(TrashedEntry)
|
||||
//! Rename(ProjectPath, ProjectPath) → Renamed(ProjectPath, ProjectPath)
|
||||
//! Restore(TrashedEntry) → Restored(ProjectPath)
|
||||
//! Batch(Vec<Operation>) → Batch(Vec<Result>)
|
||||
//!
|
||||
//!
|
||||
//! ## History and Cursor
|
||||
//!
|
||||
//! The undo manager maintains an operation history with a cursor position (↑).
|
||||
//! Recording an operation appends it to the history and advances the cursor to
|
||||
//! the end. The cursor separates past entries (left of ↑) from future entries
|
||||
//! (right of ↑).
|
||||
//!
|
||||
//! ─ **Undo**: Takes the history entry just *before* ↑, executes its inverse,
|
||||
//! records the result back in its place, and moves ↑ one step to the left.
|
||||
//! ─ **Redo**: Takes the history entry just *at* ↑, executes its inverse,
|
||||
//! records the result back in its place, and advances ↑ one step to the right.
|
||||
//!
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! User Operation Create(src/main.rs)
|
||||
//! History
|
||||
//! 0 Created(src/main.rs)
|
||||
//! 1 +++cursor+++
|
||||
//!
|
||||
//! User Operation Rename(README.md, readme.md)
|
||||
//! History
|
||||
//! 0 Created(src/main.rs)
|
||||
//! 1 Renamed(README.md, readme.md)
|
||||
//! 2 +++cursor+++
|
||||
//!
|
||||
//! User Operation Create(CONTRIBUTING.md)
|
||||
//! History
|
||||
//! 0 Created(src/main.rs)
|
||||
//! 1 Renamed(README.md, readme.md)
|
||||
//! 2 Created(CONTRIBUTING.md) ──┐
|
||||
//! 3 +++cursor+++ │(before the cursor)
|
||||
//! │
|
||||
//! ┌──────────────────────────────┴─────────────────────────────────────────────┐
|
||||
//! Redoing will take the result at the cursor position, convert that into the
|
||||
//! operation that can revert that result, execute that operation and replace
|
||||
//! the result in the history with the new result, obtained from running the
|
||||
//! inverse operation, advancing the cursor position.
|
||||
//! └──────────────────────────────┬─────────────────────────────────────────────┘
|
||||
//! │
|
||||
//! │
|
||||
//! User Operation Undo v
|
||||
//! Execute Created(CONTRIBUTING.md) ────────> Trash(CONTRIBUTING.md)
|
||||
//! Record Trashed(TrashedEntry(1))
|
||||
//! History
|
||||
//! 0 Created(src/main.rs)
|
||||
//! 1 Renamed(README.md, readme.md) ─┐
|
||||
//! 2 +++cursor+++ │(before the cursor)
|
||||
//! 2 Trashed(TrashedEntry(1)) │
|
||||
//! │
|
||||
//! User Operation Undo v
|
||||
//! Execute Renamed(README.md, readme.md) ───> Rename(readme.md, README.md)
|
||||
//! Record Renamed(readme.md, README.md)
|
||||
//! History
|
||||
//! 0 Created(src/main.rs)
|
||||
//! 1 +++cursor+++
|
||||
//! 1 Renamed(readme.md, README.md) ─┐ (at the cursor)
|
||||
//! 2 Trashed(TrashedEntry(1)) │
|
||||
//! │
|
||||
//! ┌──────────────────────────────────┴─────────────────────────────────────────┐
|
||||
//! Redoing will take the result at the cursor position, convert that into the
|
||||
//! operation that can revert that result, execute that operation and replace
|
||||
//! the result in the history with the new result, obtained from running the
|
||||
//! inverse operation, advancing the cursor position.
|
||||
//! └──────────────────────────────────┬─────────────────────────────────────────┘
|
||||
//! │
|
||||
//! │
|
||||
//! User Operation Redo v
|
||||
//! Execute Renamed(readme.md, README.md) ───> Rename(README.md, readme.md)
|
||||
//! Record Renamed(README.md, readme.md)
|
||||
//! History
|
||||
//! 0 Created(src/main.rs)
|
||||
//! 1 Renamed(README.md, readme.md)
|
||||
//! 2 +++cursor+++
|
||||
//! 2 Trashed(TrashedEntry(1))────┐ (at the cursor)
|
||||
//! │
|
||||
//! User Operation Redo v
|
||||
//! Execute Trashed(TrashedEntry(1)) ────────> Restore(TrashedEntry(1))
|
||||
//! Record Restored(ProjectPath)
|
||||
//! History
|
||||
//! 0 Created(src/main.rs)
|
||||
//! 1 Renamed(README.md, readme.md)
|
||||
//! 2 Restored(ProjectPath)
|
||||
//! 2 +++cursor+++
|
||||
|
||||
//!
|
||||
//! create A; A
|
||||
//! rename A -> B; B
|
||||
//! undo (rename B -> A) (takes 10s for some reason) B (still b cause it's hanging for 10s)
|
||||
//! remove B _
|
||||
//! create B B
|
||||
//! put important content in B B
|
||||
//! undo manger renames (does not hang) A
|
||||
//! remove A _
|
||||
//! user sad
|
||||
|
||||
//!
|
||||
//! create A; A
|
||||
//! rename A -> B; B
|
||||
//! undo (rename B -> A) (takes 10s for some reason) B (still b cause it's hanging for 10s)
|
||||
//! create C B
|
||||
//! -- src/c.rs
|
||||
//! --
|
||||
|
||||
//!
|
||||
//! create docs/files/ directory docs/files/
|
||||
//! create docs/files/a.txt docs/files/
|
||||
//! undo (rename B -> A) (takes 10s for some reason) B (still b cause it's hanging for 10s)
|
||||
//! create C B
|
||||
//! -- src/c.rs
|
||||
//! --
|
||||
|
||||
//! List of "tainted files" that the user may not operate on
|
||||
|
||||
use crate::ProjectPanel;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use fs::TrashedEntry;
|
||||
use futures::channel::mpsc;
|
||||
use gpui::{AppContext, AsyncApp, SharedString, Task, WeakEntity};
|
||||
use project::{ProjectPath, WorktreeId};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::{collections::VecDeque, sync::Arc};
|
||||
use ui::App;
|
||||
use workspace::{
|
||||
Workspace,
|
||||
notifications::{NotificationId, simple_message_notification::MessageNotification},
|
||||
};
|
||||
use worktree::CreatedEntry;
|
||||
|
||||
enum Operation {
|
||||
Trash(ProjectPath),
|
||||
Rename(ProjectPath, ProjectPath),
|
||||
Restore(WorktreeId, TrashedEntry),
|
||||
Batch(Vec<Operation>),
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
async fn execute(self, undo_manager: &Inner, cx: &mut AsyncApp) -> Result<Change> {
|
||||
Ok(match self {
|
||||
Operation::Trash(project_path) => {
|
||||
let trash_entry = undo_manager.trash(&project_path, cx).await?;
|
||||
Change::Trashed(project_path.worktree_id, trash_entry)
|
||||
}
|
||||
Operation::Rename(from, to) => {
|
||||
undo_manager.rename(&from, &to, cx).await?;
|
||||
Change::Renamed(from, to)
|
||||
}
|
||||
Operation::Restore(worktree_id, trashed_entry) => {
|
||||
let project_path = undo_manager.restore(worktree_id, trashed_entry, cx).await?;
|
||||
Change::Restored(project_path)
|
||||
}
|
||||
Operation::Batch(operations) => {
|
||||
let mut res = Vec::new();
|
||||
for op in operations {
|
||||
res.push(Box::pin(op.execute(undo_manager, cx)).await?);
|
||||
}
|
||||
Change::Batched(res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Change {
|
||||
Created(ProjectPath),
|
||||
Trashed(WorktreeId, TrashedEntry),
|
||||
Renamed(ProjectPath, ProjectPath),
|
||||
Restored(ProjectPath),
|
||||
Batched(Vec<Change>),
|
||||
}
|
||||
|
||||
impl Change {
|
||||
fn to_inverse(self) -> Operation {
|
||||
match self {
|
||||
Change::Created(project_path) => Operation::Trash(project_path),
|
||||
Change::Trashed(worktree_id, trashed_entry) => {
|
||||
Operation::Restore(worktree_id, trashed_entry)
|
||||
}
|
||||
Change::Renamed(from, to) => Operation::Rename(to, from),
|
||||
Change::Restored(project_path) => Operation::Trash(project_path),
|
||||
// When inverting a batch of operations, we reverse the order of
|
||||
// operations to handle dependencies between them. For example, if a
|
||||
// batch contains the following order of operations:
|
||||
//
|
||||
// 1. Create `src/`
|
||||
// 2. Create `src/main.rs`
|
||||
//
|
||||
// If we first tried to revert the directory creation, it would fail
|
||||
// because there's still files inside the directory.
|
||||
Change::Batched(changes) => {
|
||||
Operation::Batch(changes.into_iter().rev().map(Change::to_inverse).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Imagine pressing undo 10000+ times?!
|
||||
const MAX_UNDO_OPERATIONS: usize = 10_000;
|
||||
|
||||
struct Inner {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
panel: WeakEntity<ProjectPanel>,
|
||||
history: VecDeque<Change>,
|
||||
cursor: usize,
|
||||
/// Maximum number of operations to keep on the undo history.
|
||||
limit: usize,
|
||||
can_undo: Arc<AtomicBool>,
|
||||
can_redo: Arc<AtomicBool>,
|
||||
rx: mpsc::Receiver<UndoMessage>,
|
||||
}
|
||||
|
||||
/// pls arc this
|
||||
#[derive(Clone)]
|
||||
pub struct UndoManager {
|
||||
tx: mpsc::Sender<UndoMessage>,
|
||||
can_undo: Arc<AtomicBool>,
|
||||
can_redo: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl UndoManager {
|
||||
pub fn new(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
panel: WeakEntity<ProjectPanel>,
|
||||
cx: &App,
|
||||
) -> Self {
|
||||
let (tx, rx) = mpsc::channel(1024);
|
||||
let inner = Inner::new(workspace, panel, rx);
|
||||
|
||||
let this = Self {
|
||||
tx,
|
||||
can_undo: Arc::clone(&inner.can_undo),
|
||||
can_redo: Arc::clone(&inner.can_redo),
|
||||
};
|
||||
|
||||
cx.spawn(async move |cx| inner.manage_undo_and_redo(cx.clone()).await)
|
||||
.detach();
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> Result<()> {
|
||||
self.tx
|
||||
.try_send(UndoMessage::Undo)
|
||||
.context("Undo and redo task can not keep up")
|
||||
}
|
||||
pub fn redo(&mut self) -> Result<()> {
|
||||
self.tx
|
||||
.try_send(UndoMessage::Redo)
|
||||
.context("Undo and redo task can not keep up")
|
||||
}
|
||||
pub fn record(&mut self, changes: impl IntoIterator<Item = Change>) -> Result<()> {
|
||||
self.tx
|
||||
.try_send(UndoMessage::Changed(changes.into_iter().collect()))
|
||||
.context("Undo and redo task can not keep up")
|
||||
}
|
||||
/// just for the UI, an undo may still fail if there are concurrent file
|
||||
/// operations happening.
|
||||
pub fn can_undo(&self) -> bool {
|
||||
self.can_undo.load(Ordering::Relaxed)
|
||||
}
|
||||
/// just for the UI, an undo may still fail if there are concurrent file
|
||||
/// operations happening.
|
||||
pub fn can_redo(&self) -> bool {
|
||||
self.can_redo.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum UndoMessage {
|
||||
Changed(Vec<Change>),
|
||||
Undo,
|
||||
Redo,
|
||||
}
|
||||
|
||||
impl UndoMessage {
|
||||
fn error_title(&self) -> &'static str {
|
||||
match self {
|
||||
UndoMessage::Changed(_) => {
|
||||
"this is a bug in the manage_undo_and_redo task please report"
|
||||
}
|
||||
UndoMessage::Undo => "Undo failed",
|
||||
UndoMessage::Redo => "Redo failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
async fn manage_undo_and_redo(mut self, mut cx: AsyncApp) {
|
||||
loop {
|
||||
let Ok(new) = self.rx.recv().await else {
|
||||
// project panel got closed
|
||||
return;
|
||||
};
|
||||
|
||||
let error_title = new.error_title();
|
||||
let res = match new {
|
||||
UndoMessage::Changed(changes) => {
|
||||
self.record(changes);
|
||||
Ok(())
|
||||
}
|
||||
UndoMessage::Undo => {
|
||||
let res = self.undo(&mut cx).await;
|
||||
let _ = self.panel.update(&mut cx, |_, cx| cx.notify());
|
||||
res
|
||||
}
|
||||
UndoMessage::Redo => {
|
||||
let res = self.redo(&mut cx).await;
|
||||
let _ = self.panel.update(&mut cx, |_, cx| cx.notify());
|
||||
res
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = res {
|
||||
Self::show_error(error_title, self.workspace.clone(), e.to_string(), &mut cx);
|
||||
}
|
||||
|
||||
self.can_undo.store(self.can_undo(), Ordering::Relaxed);
|
||||
self.can_redo.store(self.can_redo(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
pub fn new(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
panel: WeakEntity<ProjectPanel>,
|
||||
rx: mpsc::Receiver<UndoMessage>,
|
||||
) -> Self {
|
||||
Self::new_with_limit(workspace, panel, MAX_UNDO_OPERATIONS, rx)
|
||||
}
|
||||
|
||||
pub fn new_with_limit(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
panel: WeakEntity<ProjectPanel>,
|
||||
limit: usize,
|
||||
rx: mpsc::Receiver<UndoMessage>,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace,
|
||||
panel,
|
||||
history: VecDeque::new(),
|
||||
cursor: 0usize,
|
||||
limit,
|
||||
can_undo: Arc::new(AtomicBool::new(false)),
|
||||
can_redo: Arc::new(AtomicBool::new(false)),
|
||||
rx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
self.cursor > 0
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> bool {
|
||||
self.cursor < self.history.len()
|
||||
}
|
||||
|
||||
pub async fn undo(&mut self, cx: &mut AsyncApp) -> Result<()> {
|
||||
if !self.can_undo() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Undo failure:
|
||||
//
|
||||
// History
|
||||
// 0 Created(src/main.rs)
|
||||
// 1 Renamed(README.md, readme.md) ─┐
|
||||
// 2 +++cursor+++ │(before the cursor)
|
||||
// 2 Trashed(TrashedEntry(1)) │
|
||||
// │
|
||||
// User Operation Undo v
|
||||
// Failed execute Renamed(README.md, readme.md) ───> Rename(readme.md, README.md)
|
||||
// Record nothing
|
||||
// History
|
||||
// 0 Created(src/main.rs)
|
||||
// 1 +++cursor+++
|
||||
// 1 Trashed(TrashedEntry(1)) -----
|
||||
// |(at the cursor)
|
||||
// User Operation Redo v
|
||||
// Execute Trashed(TrashedEntry(1)) ────────> Restore(TrashedEntry(1))
|
||||
// Record Restored(ProjectPath)
|
||||
// History
|
||||
// 0 Created(src/main.rs)
|
||||
// 1 Restored(ProjectPath)
|
||||
// 1 +++cursor+++
|
||||
|
||||
// We always want to move the cursor back regardless of whether undoing
|
||||
// succeeds or fails, otherwise the cursor could end up pointing to a
|
||||
// position outside of the history, as we remove the change before the
|
||||
// cursor, in case undo fails.
|
||||
let before_cursor = self.cursor - 1; // see docs above
|
||||
self.cursor -= 1; // take a step back into the past
|
||||
|
||||
// If undoing fails, the user would be in a stuck state from which
|
||||
// manual intervention would likely be needed in order to undo. As such,
|
||||
// we remove the change from the `history` even before attempting to
|
||||
// execute its inversion.
|
||||
let undo_change = self
|
||||
.history
|
||||
.remove(before_cursor)
|
||||
.expect("we can undo")
|
||||
.to_inverse()
|
||||
.execute(self, cx)
|
||||
.await?;
|
||||
self.history.insert(before_cursor, undo_change);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn redo(&mut self, cx: &mut AsyncApp) -> Result<()> {
|
||||
if !self.can_redo() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If redoing fails, the user would be in a stuck state from which
|
||||
// manual intervention would likely be needed in order to redo. As such,
|
||||
// we remove the change from the `history` even before attempting to
|
||||
// execute its inversion.
|
||||
let redo_change = self
|
||||
.history
|
||||
.remove(self.cursor)
|
||||
.expect("we can redo")
|
||||
.to_inverse()
|
||||
.execute(self, cx)
|
||||
.await?;
|
||||
self.history.insert(self.cursor, redo_change);
|
||||
self.cursor += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Passed in changes will always be performed as a single step
|
||||
pub fn record(&mut self, mut changes: Vec<Change>) {
|
||||
let change = match changes.len() {
|
||||
0 => return,
|
||||
1 => changes.remove(0),
|
||||
_ => Change::Batched(changes),
|
||||
};
|
||||
|
||||
// When recording a new change, discard any changes that could still be
|
||||
// redone.
|
||||
if self.cursor < self.history.len() {
|
||||
self.history.drain(self.cursor..);
|
||||
}
|
||||
|
||||
// Ensure that the number of recorded changes does not exceed the
|
||||
// maximum amount of tracked changes.
|
||||
if self.history.len() >= self.limit {
|
||||
self.history.pop_front();
|
||||
} else {
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
self.history.push_back(change);
|
||||
}
|
||||
|
||||
async fn rename(
|
||||
&self,
|
||||
from: &ProjectPath,
|
||||
to: &ProjectPath,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<CreatedEntry> {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return Err(anyhow!("Failed to obtain workspace."));
|
||||
};
|
||||
|
||||
let res: Result<Task<Result<CreatedEntry>>> = workspace.update(cx, |workspace, cx| {
|
||||
workspace.project().update(cx, |project, cx| {
|
||||
let entry_id = project
|
||||
.entry_for_path(from, cx)
|
||||
.map(|entry| entry.id)
|
||||
.ok_or_else(|| anyhow!("No entry for path."))?;
|
||||
|
||||
Ok(project.rename_entry(entry_id, to.clone(), cx))
|
||||
})
|
||||
});
|
||||
|
||||
res?.await
|
||||
}
|
||||
|
||||
async fn trash(&self, project_path: &ProjectPath, cx: &mut AsyncApp) -> Result<TrashedEntry> {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return Err(anyhow!("Failed to obtain workspace."));
|
||||
};
|
||||
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.project().update(cx, |project, cx| {
|
||||
let entry_id = project
|
||||
.entry_for_path(&project_path, cx)
|
||||
.map(|entry| entry.id)
|
||||
.ok_or_else(|| anyhow!("No entry for path."))?;
|
||||
|
||||
project
|
||||
.delete_entry(entry_id, true, cx)
|
||||
.ok_or_else(|| anyhow!("Worktree entry should exist"))
|
||||
})
|
||||
})?
|
||||
.await
|
||||
.and_then(|entry| {
|
||||
entry.ok_or_else(|| anyhow!("When trashing we should always get a trashentry"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn restore(
|
||||
&self,
|
||||
worktree_id: WorktreeId,
|
||||
trashed_entry: TrashedEntry,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<ProjectPath> {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return Err(anyhow!("Failed to obtain workspace."));
|
||||
};
|
||||
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.project().update(cx, |project, cx| {
|
||||
project.restore_entry(worktree_id, trashed_entry, cx)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Displays a notification with the provided `title` and `error`.
|
||||
fn show_error(
|
||||
title: impl Into<SharedString>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
error: String,
|
||||
cx: &mut AsyncApp,
|
||||
) {
|
||||
workspace
|
||||
.update(cx, move |workspace, cx| {
|
||||
let notification_id =
|
||||
NotificationId::Named(SharedString::new_static("project_panel_undo"));
|
||||
|
||||
workspace.show_notification(notification_id, cx, move |cx| {
|
||||
cx.new(|cx| MessageNotification::new(error, cx).with_title(title))
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
42
crates/project_panel/src/utils.rs
Normal file
42
crates/project_panel/src/utils.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
pub(crate) struct ReversibleIterable<It> {
|
||||
pub(crate) it: It,
|
||||
pub(crate) reverse: bool,
|
||||
}
|
||||
|
||||
impl<T> ReversibleIterable<T> {
|
||||
pub(crate) fn new(it: T, reverse: bool) -> Self {
|
||||
Self { it, reverse }
|
||||
}
|
||||
}
|
||||
|
||||
impl<It, Item> ReversibleIterable<It>
|
||||
where
|
||||
It: Iterator<Item = Item>,
|
||||
{
|
||||
pub(crate) fn find_single_ended<F>(mut self, pred: F) -> Option<Item>
|
||||
where
|
||||
F: FnMut(&Item) -> bool,
|
||||
{
|
||||
if self.reverse {
|
||||
self.it.filter(pred).last()
|
||||
} else {
|
||||
self.it.find(pred)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<It, Item> ReversibleIterable<It>
|
||||
where
|
||||
It: DoubleEndedIterator<Item = Item>,
|
||||
{
|
||||
pub(crate) fn find<F>(mut self, mut pred: F) -> Option<Item>
|
||||
where
|
||||
F: FnMut(&Item) -> bool,
|
||||
{
|
||||
if self.reverse {
|
||||
self.it.rfind(|x| pred(x))
|
||||
} else {
|
||||
self.it.find(|x| pred(x))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user