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

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:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

View 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"] }

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

File diff suppressed because it is too large Load Diff

View 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);

File diff suppressed because it is too large Load Diff

View 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(),
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
pub(crate) mod undo;

View 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(&[]);
}

View 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();
}
}

View 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))
}
}
}