There's still a bit more work to do on this, but this PR is compiling (with warnings) after eliminating the key types. When the tasks below are complete, this will be the new narrative for GPUI: - `Entity<T>` - This replaces `View<T>`/`Model<T>`. It represents a unit of state, and if `T` implements `Render`, then `Entity<T>` implements `Element`. - `&mut App` This replaces `AppContext` and represents the app. - `&mut Context<T>` This replaces `ModelContext` and derefs to `App`. It is provided by the framework when updating an entity. - `&mut Window` Broken out of `&mut WindowContext` which no longer exists. Every method that once took `&mut WindowContext` now takes `&mut Window, &mut App` and every method that took `&mut ViewContext<T>` now takes `&mut Window, &mut Context<T>` Not pictured here are the two other failed attempts. It's been quite a month! Tasks: - [x] Remove `View`, `ViewContext`, `WindowContext` and thread through `Window` - [x] [@cole-miller @mikayla-maki] Redraw window when entities change - [x] [@cole-miller @mikayla-maki] Get examples and Zed running - [x] [@cole-miller @mikayla-maki] Fix Zed rendering - [x] [@mikayla-maki] Fix todo! macros and comments - [x] Fix a bug where the editor would not be redrawn because of view caching - [x] remove publicness window.notify() and replace with `AppContext::notify` - [x] remove `observe_new_window_models`, replace with `observe_new_models` with an optional window - [x] Fix a bug where the project panel would not be redrawn because of the wrong refresh() call being used - [x] Fix the tests - [x] Fix warnings by eliminating `Window` params or using `_` - [x] Fix conflicts - [x] Simplify generic code where possible - [x] Rename types - [ ] Update docs ### issues post merge - [x] Issues switching between normal and insert mode - [x] Assistant re-rendering failure - [x] Vim test failures - [x] Mac build issue Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra <me@as-cii.com> Co-authored-by: Cole Miller <cole@zed.dev> Co-authored-by: Mikayla <mikayla@zed.dev> Co-authored-by: Joseph <joseph@zed.dev> Co-authored-by: max <max@zed.dev> Co-authored-by: Michael Sloan <michael@zed.dev> Co-authored-by: Mikayla Maki <mikaylamaki@Mikaylas-MacBook-Pro.local> Co-authored-by: Mikayla <mikayla.c.maki@gmail.com> Co-authored-by: joão <joao@zed.dev>
1297 lines
66 KiB
Rust
1297 lines
66 KiB
Rust
use std::{
|
|
any::{Any, TypeId},
|
|
cmp::Ordering,
|
|
collections::HashSet,
|
|
ops::Range,
|
|
time::Duration,
|
|
};
|
|
|
|
use anyhow::{anyhow, Context as _};
|
|
use collections::{BTreeMap, HashMap};
|
|
use feature_flags::FeatureFlagAppExt;
|
|
use git::diff::{BufferDiff, DiffHunk};
|
|
use gpui::{
|
|
actions, AnyElement, AnyView, App, Entity, EventEmitter, FocusHandle, Focusable,
|
|
InteractiveElement, Render, Subscription, Task, WeakEntity,
|
|
};
|
|
use language::{Buffer, BufferRow};
|
|
use multi_buffer::{ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer};
|
|
use project::{Project, ProjectEntryId, ProjectPath, WorktreeId};
|
|
use text::{OffsetRangeExt, ToPoint};
|
|
use theme::ActiveTheme;
|
|
use ui::prelude::*;
|
|
use util::{paths::compare_paths, ResultExt};
|
|
use workspace::{
|
|
item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
|
|
ItemNavHistory, ToolbarItemLocation, Workspace,
|
|
};
|
|
|
|
use crate::{Editor, EditorEvent, DEFAULT_MULTIBUFFER_CONTEXT};
|
|
|
|
actions!(project_diff, [Deploy]);
|
|
|
|
pub fn init(cx: &mut App) {
|
|
cx.observe_new(ProjectDiffEditor::register).detach();
|
|
}
|
|
|
|
const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
|
|
|
|
struct ProjectDiffEditor {
|
|
buffer_changes: BTreeMap<WorktreeId, HashMap<ProjectEntryId, Changes>>,
|
|
entry_order: HashMap<WorktreeId, Vec<(ProjectPath, ProjectEntryId)>>,
|
|
excerpts: Entity<MultiBuffer>,
|
|
editor: Entity<Editor>,
|
|
|
|
project: Entity<Project>,
|
|
workspace: WeakEntity<Workspace>,
|
|
focus_handle: FocusHandle,
|
|
worktree_rescans: HashMap<WorktreeId, Task<()>>,
|
|
_subscriptions: Vec<Subscription>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Changes {
|
|
buffer: Entity<Buffer>,
|
|
hunks: Vec<DiffHunk>,
|
|
}
|
|
|
|
impl ProjectDiffEditor {
|
|
fn register(
|
|
workspace: &mut Workspace,
|
|
_window: Option<&mut Window>,
|
|
_: &mut Context<Workspace>,
|
|
) {
|
|
workspace.register_action(Self::deploy);
|
|
}
|
|
|
|
fn deploy(
|
|
workspace: &mut Workspace,
|
|
_: &Deploy,
|
|
window: &mut Window,
|
|
cx: &mut Context<Workspace>,
|
|
) {
|
|
if !cx.is_staff() {
|
|
return;
|
|
}
|
|
|
|
if let Some(existing) = workspace.item_of_type::<Self>(cx) {
|
|
workspace.activate_item(&existing, true, true, window, cx);
|
|
} else {
|
|
let workspace_handle = cx.model().downgrade();
|
|
let project_diff =
|
|
cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
|
|
workspace.add_item_to_active_pane(Box::new(project_diff), None, true, window, cx);
|
|
}
|
|
}
|
|
|
|
fn new(
|
|
project: Entity<Project>,
|
|
workspace: WeakEntity<Workspace>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
// TODO diff change subscriptions. For that, needed:
|
|
// * `-20/+50` stats retrieval: some background process that reacts on file changes
|
|
let focus_handle = cx.focus_handle();
|
|
let changed_entries_subscription =
|
|
cx.subscribe_in(&project, window, |project_diff_editor, _, e, window, cx| {
|
|
let mut worktree_to_rescan = None;
|
|
match e {
|
|
project::Event::WorktreeAdded(id) => {
|
|
worktree_to_rescan = Some(*id);
|
|
// project_diff_editor
|
|
// .buffer_changes
|
|
// .insert(*id, HashMap::default());
|
|
}
|
|
project::Event::WorktreeRemoved(id) => {
|
|
project_diff_editor.buffer_changes.remove(id);
|
|
}
|
|
project::Event::WorktreeUpdatedEntries(id, _updated_entries) => {
|
|
// TODO cannot invalidate buffer entries without invalidating the corresponding excerpts and order entries.
|
|
worktree_to_rescan = Some(*id);
|
|
// let entry_changes =
|
|
// project_diff_editor.buffer_changes.entry(*id).or_default();
|
|
// for (_, entry_id, change) in updated_entries.iter() {
|
|
// let changes = entry_changes.entry(*entry_id);
|
|
// match change {
|
|
// project::PathChange::Removed => {
|
|
// if let hash_map::Entry::Occupied(entry) = changes {
|
|
// entry.remove();
|
|
// }
|
|
// }
|
|
// // TODO understand the invalidation case better: now, we do that but still rescan the entire worktree
|
|
// // What if we already have the buffer loaded inside the diff multi buffer and it was edited there? We should not do anything.
|
|
// _ => match changes {
|
|
// hash_map::Entry::Occupied(mut o) => o.get_mut().invalidate(),
|
|
// hash_map::Entry::Vacant(v) => {
|
|
// v.insert(None);
|
|
// }
|
|
// },
|
|
// }
|
|
// }
|
|
}
|
|
project::Event::WorktreeUpdatedGitRepositories(id) => {
|
|
worktree_to_rescan = Some(*id);
|
|
// project_diff_editor.buffer_changes.clear();
|
|
}
|
|
project::Event::DeletedEntry(id, _entry_id) => {
|
|
worktree_to_rescan = Some(*id);
|
|
// if let Some(entries) = project_diff_editor.buffer_changes.get_mut(id) {
|
|
// entries.remove(entry_id);
|
|
// }
|
|
}
|
|
project::Event::Closed => {
|
|
project_diff_editor.buffer_changes.clear();
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if let Some(worktree_to_rescan) = worktree_to_rescan {
|
|
project_diff_editor.schedule_worktree_rescan(worktree_to_rescan, window, cx);
|
|
}
|
|
});
|
|
|
|
let excerpts = cx.new(|cx| MultiBuffer::new(project.read(cx).capability()));
|
|
|
|
let editor = cx.new(|cx| {
|
|
let mut diff_display_editor =
|
|
Editor::for_multibuffer(excerpts.clone(), Some(project.clone()), true, window, cx);
|
|
diff_display_editor.set_expand_all_diff_hunks(cx);
|
|
diff_display_editor
|
|
});
|
|
|
|
let mut new_self = Self {
|
|
project,
|
|
workspace,
|
|
buffer_changes: BTreeMap::default(),
|
|
entry_order: HashMap::default(),
|
|
worktree_rescans: HashMap::default(),
|
|
focus_handle,
|
|
editor,
|
|
excerpts,
|
|
_subscriptions: vec![changed_entries_subscription],
|
|
};
|
|
new_self.schedule_rescan_all(window, cx);
|
|
new_self
|
|
}
|
|
|
|
fn schedule_rescan_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let mut current_worktrees = HashSet::<WorktreeId>::default();
|
|
for worktree in self.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
|
|
let worktree_id = worktree.read(cx).id();
|
|
current_worktrees.insert(worktree_id);
|
|
self.schedule_worktree_rescan(worktree_id, window, cx);
|
|
}
|
|
|
|
self.worktree_rescans
|
|
.retain(|worktree_id, _| current_worktrees.contains(worktree_id));
|
|
self.buffer_changes
|
|
.retain(|worktree_id, _| current_worktrees.contains(worktree_id));
|
|
self.entry_order
|
|
.retain(|worktree_id, _| current_worktrees.contains(worktree_id));
|
|
}
|
|
|
|
fn schedule_worktree_rescan(
|
|
&mut self,
|
|
id: WorktreeId,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let project = self.project.clone();
|
|
self.worktree_rescans.insert(
|
|
id,
|
|
cx.spawn_in(window, |project_diff_editor, mut cx| async move {
|
|
cx.background_executor().timer(UPDATE_DEBOUNCE).await;
|
|
let open_tasks = project
|
|
.update(&mut cx, |project, cx| {
|
|
let worktree = project.worktree_for_id(id, cx)?;
|
|
let snapshot = worktree.read(cx).snapshot();
|
|
let applicable_entries = snapshot
|
|
.repositories()
|
|
.iter()
|
|
.flat_map(|entry| {
|
|
entry
|
|
.status()
|
|
.map(|git_entry| entry.join(git_entry.repo_path))
|
|
})
|
|
.filter_map(|path| {
|
|
let id = snapshot.entry_for_path(&path)?.id;
|
|
Some((
|
|
id,
|
|
ProjectPath {
|
|
worktree_id: snapshot.id(),
|
|
path: path.into(),
|
|
},
|
|
))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
Some(
|
|
applicable_entries
|
|
.into_iter()
|
|
.map(|(entry_id, entry_path)| {
|
|
let open_task = project.open_path(entry_path.clone(), cx);
|
|
(entry_id, entry_path, open_task)
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
})
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_default();
|
|
|
|
let Some((buffers, mut new_entries, change_sets)) = cx
|
|
.spawn(|mut cx| async move {
|
|
let mut new_entries = Vec::new();
|
|
let mut buffers = HashMap::<
|
|
ProjectEntryId,
|
|
(text::BufferSnapshot, Entity<Buffer>, BufferDiff),
|
|
>::default();
|
|
let mut change_sets = Vec::new();
|
|
for (entry_id, entry_path, open_task) in open_tasks {
|
|
let Some(buffer) = open_task
|
|
.await
|
|
.and_then(|(_, opened_model)| {
|
|
opened_model
|
|
.downcast::<Buffer>()
|
|
.map_err(|_| anyhow!("Unexpected non-buffer"))
|
|
})
|
|
.with_context(|| {
|
|
format!("loading {:?} for git diff", entry_path.path)
|
|
})
|
|
.log_err()
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
let Some(change_set) = project
|
|
.update(&mut cx, |project, cx| {
|
|
project.open_unstaged_changes(buffer.clone(), cx)
|
|
})?
|
|
.await
|
|
.log_err()
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
cx.update(|_, cx| {
|
|
buffers.insert(
|
|
entry_id,
|
|
(
|
|
buffer.read(cx).text_snapshot(),
|
|
buffer,
|
|
change_set.read(cx).diff_to_buffer.clone(),
|
|
),
|
|
);
|
|
})?;
|
|
change_sets.push(change_set);
|
|
new_entries.push((entry_path, entry_id));
|
|
}
|
|
|
|
anyhow::Ok((buffers, new_entries, change_sets))
|
|
})
|
|
.await
|
|
.log_err()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let (new_changes, new_entry_order) = cx
|
|
.background_executor()
|
|
.spawn(async move {
|
|
let mut new_changes = HashMap::<ProjectEntryId, Changes>::default();
|
|
for (entry_id, (buffer_snapshot, buffer, buffer_diff)) in buffers {
|
|
new_changes.insert(
|
|
entry_id,
|
|
Changes {
|
|
buffer,
|
|
hunks: buffer_diff
|
|
.hunks_in_row_range(0..BufferRow::MAX, &buffer_snapshot)
|
|
.collect::<Vec<_>>(),
|
|
},
|
|
);
|
|
}
|
|
|
|
new_entries.sort_by(|(project_path_a, _), (project_path_b, _)| {
|
|
compare_paths(
|
|
(project_path_a.path.as_ref(), true),
|
|
(project_path_b.path.as_ref(), true),
|
|
)
|
|
});
|
|
(new_changes, new_entries)
|
|
})
|
|
.await;
|
|
|
|
project_diff_editor
|
|
.update_in(&mut cx, |project_diff_editor, _window, cx| {
|
|
project_diff_editor.update_excerpts(id, new_changes, new_entry_order, cx);
|
|
project_diff_editor.editor.update(cx, |editor, cx| {
|
|
editor.buffer.update(cx, |buffer, cx| {
|
|
for change_set in change_sets {
|
|
buffer.add_change_set(change_set, cx)
|
|
}
|
|
});
|
|
});
|
|
})
|
|
.ok();
|
|
}),
|
|
);
|
|
}
|
|
|
|
fn update_excerpts(
|
|
&mut self,
|
|
worktree_id: WorktreeId,
|
|
new_changes: HashMap<ProjectEntryId, Changes>,
|
|
new_entry_order: Vec<(ProjectPath, ProjectEntryId)>,
|
|
|
|
cx: &mut Context<ProjectDiffEditor>,
|
|
) {
|
|
if let Some(current_order) = self.entry_order.get(&worktree_id) {
|
|
let current_entries = self.buffer_changes.entry(worktree_id).or_default();
|
|
let mut new_order_entries = new_entry_order.iter().fuse().peekable();
|
|
let mut excerpts_to_remove = Vec::new();
|
|
let mut new_excerpt_hunks = BTreeMap::<
|
|
ExcerptId,
|
|
Vec<(ProjectPath, Entity<Buffer>, Vec<Range<text::Anchor>>)>,
|
|
>::new();
|
|
let mut excerpt_to_expand =
|
|
HashMap::<(u32, ExpandExcerptDirection), Vec<ExcerptId>>::default();
|
|
let mut latest_excerpt_id = ExcerptId::min();
|
|
|
|
for (current_path, current_entry_id) in current_order {
|
|
let current_changes = match current_entries.get(current_entry_id) {
|
|
Some(current_changes) => {
|
|
if current_changes.hunks.is_empty() {
|
|
continue;
|
|
}
|
|
current_changes
|
|
}
|
|
None => continue,
|
|
};
|
|
let buffer_excerpts = self
|
|
.excerpts
|
|
.read(cx)
|
|
.excerpts_for_buffer(¤t_changes.buffer, cx);
|
|
let last_current_excerpt_id =
|
|
buffer_excerpts.last().map(|(excerpt_id, _)| *excerpt_id);
|
|
let mut current_excerpts = buffer_excerpts.into_iter().fuse().peekable();
|
|
loop {
|
|
match new_order_entries.peek() {
|
|
Some((new_path, new_entry)) => {
|
|
match compare_paths(
|
|
(current_path.path.as_ref(), true),
|
|
(new_path.path.as_ref(), true),
|
|
) {
|
|
Ordering::Less => {
|
|
excerpts_to_remove
|
|
.extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id));
|
|
break;
|
|
}
|
|
Ordering::Greater => {
|
|
if let Some(new_changes) = new_changes.get(new_entry) {
|
|
if !new_changes.hunks.is_empty() {
|
|
let hunks = new_excerpt_hunks
|
|
.entry(latest_excerpt_id)
|
|
.or_default();
|
|
match hunks.binary_search_by(|(probe, ..)| {
|
|
compare_paths(
|
|
(new_path.path.as_ref(), true),
|
|
(probe.path.as_ref(), true),
|
|
)
|
|
}) {
|
|
Ok(i) => hunks[i].2.extend(
|
|
new_changes
|
|
.hunks
|
|
.iter()
|
|
.map(|hunk| hunk.buffer_range.clone()),
|
|
),
|
|
Err(i) => hunks.insert(
|
|
i,
|
|
(
|
|
new_path.clone(),
|
|
new_changes.buffer.clone(),
|
|
new_changes
|
|
.hunks
|
|
.iter()
|
|
.map(|hunk| hunk.buffer_range.clone())
|
|
.collect(),
|
|
),
|
|
),
|
|
}
|
|
}
|
|
};
|
|
let _ = new_order_entries.next();
|
|
}
|
|
Ordering::Equal => {
|
|
match new_changes.get(new_entry) {
|
|
Some(new_changes) => {
|
|
let buffer_snapshot =
|
|
new_changes.buffer.read(cx).snapshot();
|
|
let mut current_hunks =
|
|
current_changes.hunks.iter().fuse().peekable();
|
|
let mut new_hunks_unchanged =
|
|
Vec::with_capacity(new_changes.hunks.len());
|
|
let mut new_hunks_with_updates =
|
|
Vec::with_capacity(new_changes.hunks.len());
|
|
'new_changes: for new_hunk in &new_changes.hunks {
|
|
loop {
|
|
match current_hunks.peek() {
|
|
Some(current_hunk) => {
|
|
match (
|
|
current_hunk
|
|
.buffer_range
|
|
.start
|
|
.cmp(
|
|
&new_hunk
|
|
.buffer_range
|
|
.start,
|
|
&buffer_snapshot,
|
|
),
|
|
current_hunk.buffer_range.end.cmp(
|
|
&new_hunk.buffer_range.end,
|
|
&buffer_snapshot,
|
|
),
|
|
) {
|
|
(
|
|
Ordering::Equal,
|
|
Ordering::Equal,
|
|
) => {
|
|
new_hunks_unchanged
|
|
.push(new_hunk);
|
|
let _ = current_hunks.next();
|
|
continue 'new_changes;
|
|
}
|
|
(Ordering::Equal, _)
|
|
| (_, Ordering::Equal) => {
|
|
new_hunks_with_updates
|
|
.push(new_hunk);
|
|
continue 'new_changes;
|
|
}
|
|
(
|
|
Ordering::Less,
|
|
Ordering::Greater,
|
|
)
|
|
| (
|
|
Ordering::Greater,
|
|
Ordering::Less,
|
|
) => {
|
|
new_hunks_with_updates
|
|
.push(new_hunk);
|
|
continue 'new_changes;
|
|
}
|
|
(
|
|
Ordering::Less,
|
|
Ordering::Less,
|
|
) => {
|
|
if current_hunk
|
|
.buffer_range
|
|
.start
|
|
.cmp(
|
|
&new_hunk
|
|
.buffer_range
|
|
.end,
|
|
&buffer_snapshot,
|
|
)
|
|
.is_le()
|
|
{
|
|
new_hunks_with_updates
|
|
.push(new_hunk);
|
|
continue 'new_changes;
|
|
} else {
|
|
let _ =
|
|
current_hunks.next();
|
|
}
|
|
}
|
|
(
|
|
Ordering::Greater,
|
|
Ordering::Greater,
|
|
) => {
|
|
if current_hunk
|
|
.buffer_range
|
|
.end
|
|
.cmp(
|
|
&new_hunk
|
|
.buffer_range
|
|
.start,
|
|
&buffer_snapshot,
|
|
)
|
|
.is_ge()
|
|
{
|
|
new_hunks_with_updates
|
|
.push(new_hunk);
|
|
continue 'new_changes;
|
|
} else {
|
|
let _ =
|
|
current_hunks.next();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
new_hunks_with_updates.push(new_hunk);
|
|
continue 'new_changes;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut excerpts_with_new_changes =
|
|
HashSet::<ExcerptId>::default();
|
|
'new_hunks: for new_hunk in new_hunks_with_updates {
|
|
loop {
|
|
match current_excerpts.peek() {
|
|
Some((
|
|
current_excerpt_id,
|
|
current_excerpt_range,
|
|
)) => {
|
|
match (
|
|
current_excerpt_range
|
|
.context
|
|
.start
|
|
.cmp(
|
|
&new_hunk
|
|
.buffer_range
|
|
.start,
|
|
&buffer_snapshot,
|
|
),
|
|
current_excerpt_range
|
|
.context
|
|
.end
|
|
.cmp(
|
|
&new_hunk.buffer_range.end,
|
|
&buffer_snapshot,
|
|
),
|
|
) {
|
|
(
|
|
Ordering::Less
|
|
| Ordering::Equal,
|
|
Ordering::Greater
|
|
| Ordering::Equal,
|
|
) => {
|
|
excerpts_with_new_changes
|
|
.insert(
|
|
*current_excerpt_id,
|
|
);
|
|
continue 'new_hunks;
|
|
}
|
|
(
|
|
Ordering::Greater
|
|
| Ordering::Equal,
|
|
Ordering::Less
|
|
| Ordering::Equal,
|
|
) => {
|
|
let expand_up = current_excerpt_range
|
|
.context
|
|
.start
|
|
.to_point(&buffer_snapshot)
|
|
.row
|
|
.saturating_sub(
|
|
new_hunk
|
|
.buffer_range
|
|
.start
|
|
.to_point(&buffer_snapshot)
|
|
.row,
|
|
);
|
|
let expand_down = new_hunk
|
|
.buffer_range
|
|
.end
|
|
.to_point(&buffer_snapshot)
|
|
.row
|
|
.saturating_sub(
|
|
current_excerpt_range
|
|
.context
|
|
.end
|
|
.to_point(
|
|
&buffer_snapshot,
|
|
)
|
|
.row,
|
|
);
|
|
excerpt_to_expand.entry((expand_up.max(expand_down).max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::UpAndDown)).or_default().push(*current_excerpt_id);
|
|
excerpts_with_new_changes
|
|
.insert(
|
|
*current_excerpt_id,
|
|
);
|
|
continue 'new_hunks;
|
|
}
|
|
(
|
|
Ordering::Less,
|
|
Ordering::Less,
|
|
) => {
|
|
if current_excerpt_range
|
|
.context
|
|
.start
|
|
.cmp(
|
|
&new_hunk
|
|
.buffer_range
|
|
.end,
|
|
&buffer_snapshot,
|
|
)
|
|
.is_le()
|
|
{
|
|
let expand_up = current_excerpt_range
|
|
.context
|
|
.start
|
|
.to_point(&buffer_snapshot)
|
|
.row
|
|
.saturating_sub(
|
|
new_hunk.buffer_range
|
|
.start
|
|
.to_point(
|
|
&buffer_snapshot,
|
|
)
|
|
.row,
|
|
);
|
|
excerpt_to_expand.entry((expand_up.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Up)).or_default().push(*current_excerpt_id);
|
|
excerpts_with_new_changes
|
|
.insert(
|
|
*current_excerpt_id,
|
|
);
|
|
continue 'new_hunks;
|
|
} else {
|
|
if !new_changes
|
|
.hunks
|
|
.is_empty()
|
|
{
|
|
let hunks = new_excerpt_hunks
|
|
.entry(latest_excerpt_id)
|
|
.or_default();
|
|
match hunks.binary_search_by(|(probe, ..)| {
|
|
compare_paths(
|
|
(new_path.path.as_ref(), true),
|
|
(probe.path.as_ref(), true),
|
|
)
|
|
}) {
|
|
Ok(i) => hunks[i].2.extend(
|
|
new_changes
|
|
.hunks
|
|
.iter()
|
|
.map(|hunk| hunk.buffer_range.clone()),
|
|
),
|
|
Err(i) => hunks.insert(
|
|
i,
|
|
(
|
|
new_path.clone(),
|
|
new_changes.buffer.clone(),
|
|
new_changes
|
|
.hunks
|
|
.iter()
|
|
.map(|hunk| hunk.buffer_range.clone())
|
|
.collect(),
|
|
),
|
|
),
|
|
}
|
|
}
|
|
continue 'new_hunks;
|
|
}
|
|
}
|
|
/* TODO remove or leave?
|
|
[ ><<<<<<<<new_e
|
|
----[---->--]----<--
|
|
cur_s > cur_e <
|
|
> <
|
|
new_s>>>>>>>><
|
|
*/
|
|
(
|
|
Ordering::Greater,
|
|
Ordering::Greater,
|
|
) => {
|
|
if current_excerpt_range
|
|
.context
|
|
.end
|
|
.cmp(
|
|
&new_hunk
|
|
.buffer_range
|
|
.start,
|
|
&buffer_snapshot,
|
|
)
|
|
.is_ge()
|
|
{
|
|
let expand_down = new_hunk
|
|
.buffer_range
|
|
.end
|
|
.to_point(&buffer_snapshot)
|
|
.row
|
|
.saturating_sub(
|
|
current_excerpt_range
|
|
.context
|
|
.end
|
|
.to_point(
|
|
&buffer_snapshot,
|
|
)
|
|
.row,
|
|
);
|
|
excerpt_to_expand.entry((expand_down.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Down)).or_default().push(*current_excerpt_id);
|
|
excerpts_with_new_changes
|
|
.insert(
|
|
*current_excerpt_id,
|
|
);
|
|
continue 'new_hunks;
|
|
} else {
|
|
latest_excerpt_id =
|
|
*current_excerpt_id;
|
|
let _ =
|
|
current_excerpts.next();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
let hunks = new_excerpt_hunks
|
|
.entry(latest_excerpt_id)
|
|
.or_default();
|
|
match hunks.binary_search_by(
|
|
|(probe, ..)| {
|
|
compare_paths(
|
|
(
|
|
new_path.path.as_ref(),
|
|
true,
|
|
),
|
|
(probe.path.as_ref(), true),
|
|
)
|
|
},
|
|
) {
|
|
Ok(i) => hunks[i].2.extend(
|
|
new_changes.hunks.iter().map(
|
|
|hunk| {
|
|
hunk.buffer_range
|
|
.clone()
|
|
},
|
|
),
|
|
),
|
|
Err(i) => hunks.insert(
|
|
i,
|
|
(
|
|
new_path.clone(),
|
|
new_changes.buffer.clone(),
|
|
new_changes
|
|
.hunks
|
|
.iter()
|
|
.map(|hunk| {
|
|
hunk.buffer_range
|
|
.clone()
|
|
})
|
|
.collect(),
|
|
),
|
|
),
|
|
}
|
|
continue 'new_hunks;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (excerpt_id, excerpt_range) in current_excerpts {
|
|
if !excerpts_with_new_changes.contains(&excerpt_id)
|
|
&& !new_hunks_unchanged.iter().any(|hunk| {
|
|
excerpt_range
|
|
.context
|
|
.start
|
|
.cmp(
|
|
&hunk.buffer_range.end,
|
|
&buffer_snapshot,
|
|
)
|
|
.is_le()
|
|
&& excerpt_range
|
|
.context
|
|
.end
|
|
.cmp(
|
|
&hunk.buffer_range.start,
|
|
&buffer_snapshot,
|
|
)
|
|
.is_ge()
|
|
})
|
|
{
|
|
excerpts_to_remove.push(excerpt_id);
|
|
}
|
|
latest_excerpt_id = excerpt_id;
|
|
}
|
|
}
|
|
None => excerpts_to_remove.extend(
|
|
current_excerpts.map(|(excerpt_id, _)| excerpt_id),
|
|
),
|
|
}
|
|
let _ = new_order_entries.next();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
excerpts_to_remove
|
|
.extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
latest_excerpt_id = last_current_excerpt_id.unwrap_or(latest_excerpt_id);
|
|
}
|
|
|
|
for (path, project_entry_id) in new_order_entries {
|
|
if let Some(changes) = new_changes.get(project_entry_id) {
|
|
if !changes.hunks.is_empty() {
|
|
let hunks = new_excerpt_hunks.entry(latest_excerpt_id).or_default();
|
|
match hunks.binary_search_by(|(probe, ..)| {
|
|
compare_paths((path.path.as_ref(), true), (probe.path.as_ref(), true))
|
|
}) {
|
|
Ok(i) => hunks[i]
|
|
.2
|
|
.extend(changes.hunks.iter().map(|hunk| hunk.buffer_range.clone())),
|
|
Err(i) => hunks.insert(
|
|
i,
|
|
(
|
|
path.clone(),
|
|
changes.buffer.clone(),
|
|
changes
|
|
.hunks
|
|
.iter()
|
|
.map(|hunk| hunk.buffer_range.clone())
|
|
.collect(),
|
|
),
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.excerpts.update(cx, |multi_buffer, cx| {
|
|
for (mut after_excerpt_id, excerpts_to_add) in new_excerpt_hunks {
|
|
for (_, buffer, hunk_ranges) in excerpts_to_add {
|
|
let buffer_snapshot = buffer.read(cx).snapshot();
|
|
let max_point = buffer_snapshot.max_point();
|
|
let new_excerpts = multi_buffer.insert_excerpts_after(
|
|
after_excerpt_id,
|
|
buffer,
|
|
hunk_ranges.into_iter().map(|range| {
|
|
let mut extended_point_range = range.to_point(&buffer_snapshot);
|
|
extended_point_range.start.row = extended_point_range
|
|
.start
|
|
.row
|
|
.saturating_sub(DEFAULT_MULTIBUFFER_CONTEXT);
|
|
extended_point_range.end.row = (extended_point_range.end.row
|
|
+ DEFAULT_MULTIBUFFER_CONTEXT)
|
|
.min(max_point.row);
|
|
ExcerptRange {
|
|
context: extended_point_range,
|
|
primary: None,
|
|
}
|
|
}),
|
|
cx,
|
|
);
|
|
after_excerpt_id = new_excerpts.last().copied().unwrap_or(after_excerpt_id);
|
|
}
|
|
}
|
|
multi_buffer.remove_excerpts(excerpts_to_remove, cx);
|
|
for ((line_count, direction), excerpts) in excerpt_to_expand {
|
|
multi_buffer.expand_excerpts(excerpts, line_count, direction, cx);
|
|
}
|
|
});
|
|
} else {
|
|
self.excerpts.update(cx, |multi_buffer, cx| {
|
|
for new_changes in new_entry_order
|
|
.iter()
|
|
.filter_map(|(_, entry_id)| new_changes.get(entry_id))
|
|
{
|
|
multi_buffer.push_excerpts_with_context_lines(
|
|
new_changes.buffer.clone(),
|
|
new_changes
|
|
.hunks
|
|
.iter()
|
|
.map(|hunk| hunk.buffer_range.clone())
|
|
.collect(),
|
|
DEFAULT_MULTIBUFFER_CONTEXT,
|
|
cx,
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
let mut new_changes = new_changes;
|
|
let mut new_entry_order = new_entry_order;
|
|
std::mem::swap(
|
|
self.buffer_changes.entry(worktree_id).or_default(),
|
|
&mut new_changes,
|
|
);
|
|
std::mem::swap(
|
|
self.entry_order.entry(worktree_id).or_default(),
|
|
&mut new_entry_order,
|
|
);
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<EditorEvent> for ProjectDiffEditor {}
|
|
|
|
impl Focusable for ProjectDiffEditor {
|
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
|
self.focus_handle.clone()
|
|
}
|
|
}
|
|
|
|
impl Item for ProjectDiffEditor {
|
|
type Event = EditorEvent;
|
|
|
|
fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
|
|
Editor::to_item_events(event, f)
|
|
}
|
|
|
|
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.editor
|
|
.update(cx, |editor, cx| editor.deactivated(window, cx));
|
|
}
|
|
|
|
fn navigate(
|
|
&mut self,
|
|
data: Box<dyn Any>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> bool {
|
|
self.editor
|
|
.update(cx, |editor, cx| editor.navigate(data, window, cx))
|
|
}
|
|
|
|
fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
|
|
Some("Project Diff".into())
|
|
}
|
|
|
|
fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
|
|
if self.buffer_changes.is_empty() {
|
|
Label::new("No changes")
|
|
.color(if params.selected {
|
|
Color::Default
|
|
} else {
|
|
Color::Muted
|
|
})
|
|
.into_any_element()
|
|
} else {
|
|
h_flex()
|
|
.gap_1()
|
|
.when(true, |then| {
|
|
then.child(
|
|
h_flex()
|
|
.gap_1()
|
|
.child(Icon::new(IconName::XCircle).color(Color::Error))
|
|
.child(Label::new(self.buffer_changes.len().to_string()).color(
|
|
if params.selected {
|
|
Color::Default
|
|
} else {
|
|
Color::Muted
|
|
},
|
|
)),
|
|
)
|
|
})
|
|
.when(true, |then| {
|
|
then.child(
|
|
h_flex()
|
|
.gap_1()
|
|
.child(Icon::new(IconName::Indicator).color(Color::Warning))
|
|
.child(Label::new(self.buffer_changes.len().to_string()).color(
|
|
if params.selected {
|
|
Color::Default
|
|
} else {
|
|
Color::Muted
|
|
},
|
|
)),
|
|
)
|
|
})
|
|
.into_any_element()
|
|
}
|
|
}
|
|
|
|
fn telemetry_event_text(&self) -> Option<&'static str> {
|
|
Some("project diagnostics")
|
|
}
|
|
|
|
fn for_each_project_item(
|
|
&self,
|
|
cx: &App,
|
|
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
|
|
) {
|
|
self.editor.for_each_project_item(cx, f)
|
|
}
|
|
|
|
fn is_singleton(&self, _: &App) -> bool {
|
|
false
|
|
}
|
|
|
|
fn set_nav_history(
|
|
&mut self,
|
|
nav_history: ItemNavHistory,
|
|
_: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.editor.update(cx, |editor, _| {
|
|
editor.set_nav_history(Some(nav_history));
|
|
});
|
|
}
|
|
|
|
fn clone_on_split(
|
|
&self,
|
|
_workspace_id: Option<workspace::WorkspaceId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Entity<Self>>
|
|
where
|
|
Self: Sized,
|
|
{
|
|
Some(cx.new(|cx| {
|
|
ProjectDiffEditor::new(self.project.clone(), self.workspace.clone(), window, cx)
|
|
}))
|
|
}
|
|
|
|
fn is_dirty(&self, cx: &App) -> bool {
|
|
self.excerpts.read(cx).is_dirty(cx)
|
|
}
|
|
|
|
fn has_conflict(&self, cx: &App) -> bool {
|
|
self.excerpts.read(cx).has_conflict(cx)
|
|
}
|
|
|
|
fn can_save(&self, _: &App) -> bool {
|
|
true
|
|
}
|
|
|
|
fn save(
|
|
&mut self,
|
|
format: bool,
|
|
project: Entity<Project>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<anyhow::Result<()>> {
|
|
self.editor.save(format, project, window, cx)
|
|
}
|
|
|
|
fn save_as(
|
|
&mut self,
|
|
_: Entity<Project>,
|
|
_: ProjectPath,
|
|
_window: &mut Window,
|
|
_: &mut Context<Self>,
|
|
) -> Task<anyhow::Result<()>> {
|
|
unreachable!()
|
|
}
|
|
|
|
fn reload(
|
|
&mut self,
|
|
project: Entity<Project>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<anyhow::Result<()>> {
|
|
self.editor.reload(project, window, cx)
|
|
}
|
|
|
|
fn act_as_type<'a>(
|
|
&'a self,
|
|
type_id: TypeId,
|
|
self_handle: &'a Entity<Self>,
|
|
_: &'a App,
|
|
) -> Option<AnyView> {
|
|
if type_id == TypeId::of::<Self>() {
|
|
Some(self_handle.to_any())
|
|
} else if type_id == TypeId::of::<Editor>() {
|
|
Some(self.editor.to_any())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
|
|
ToolbarItemLocation::PrimaryLeft
|
|
}
|
|
|
|
fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
|
|
self.editor.breadcrumbs(theme, cx)
|
|
}
|
|
|
|
fn added_to_workspace(
|
|
&mut self,
|
|
workspace: &mut Workspace,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.added_to_workspace(workspace, window, cx)
|
|
});
|
|
}
|
|
}
|
|
|
|
impl Render for ProjectDiffEditor {
|
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let child = if self.buffer_changes.is_empty() {
|
|
div()
|
|
.bg(cx.theme().colors().editor_background)
|
|
.flex()
|
|
.items_center()
|
|
.justify_center()
|
|
.size_full()
|
|
.child(Label::new("No changes in the workspace"))
|
|
} else {
|
|
div().size_full().child(self.editor.clone())
|
|
};
|
|
|
|
div()
|
|
.track_focus(&self.focus_handle)
|
|
.size_full()
|
|
.child(child)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use git::status::{StatusCode, TrackedStatus};
|
|
use gpui::{SemanticVersion, TestAppContext, VisualTestContext};
|
|
use project::buffer_store::BufferChangeSet;
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use std::{
|
|
ops::Deref as _,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use crate::test::editor_test_context::assert_state_with_diff;
|
|
|
|
use super::*;
|
|
|
|
// TODO finish
|
|
// #[gpui::test]
|
|
// async fn randomized_tests(cx: &mut TestAppContext) {
|
|
// // Create a new project (how?? temp fs?),
|
|
// let fs = FakeFs::new(cx.executor());
|
|
// let project = Project::test(fs, [], cx).await;
|
|
|
|
// // create random files with random content
|
|
|
|
// // Commit it into git somehow (technically can do with "real" fs in a temp dir)
|
|
// //
|
|
// // Apply randomized changes to the project: select a random file, random change and apply to buffers
|
|
// }
|
|
|
|
#[gpui::test(iterations = 30)]
|
|
async fn simple_edit_test(cx: &mut TestAppContext) {
|
|
cx.executor().allow_parking();
|
|
init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor().clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
".git": {},
|
|
"file_a": "This is file_a",
|
|
"file_b": "This is file_b",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs.clone(), [Path::new("/root")], cx).await;
|
|
let workspace =
|
|
cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
|
let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx);
|
|
|
|
let file_a_editor = workspace
|
|
.update(cx, |workspace, window, cx| {
|
|
let file_a_editor =
|
|
workspace.open_abs_path(PathBuf::from("/root/file_a"), true, window, cx);
|
|
ProjectDiffEditor::deploy(workspace, &Deploy, window, cx);
|
|
file_a_editor
|
|
})
|
|
.unwrap()
|
|
.await
|
|
.expect("did not open an item at all")
|
|
.downcast::<Editor>()
|
|
.expect("did not open an editor for file_a");
|
|
let project_diff_editor = workspace
|
|
.update(cx, |workspace, _, cx| {
|
|
workspace
|
|
.active_pane()
|
|
.read(cx)
|
|
.items()
|
|
.find_map(|item| item.downcast::<ProjectDiffEditor>())
|
|
})
|
|
.unwrap()
|
|
.expect("did not find a ProjectDiffEditor");
|
|
project_diff_editor.update(cx, |project_diff_editor, cx| {
|
|
assert!(
|
|
project_diff_editor.editor.read(cx).text(cx).is_empty(),
|
|
"Should have no changes after opening the diff on no git changes"
|
|
);
|
|
});
|
|
|
|
let old_text = file_a_editor.update(cx, |editor, cx| editor.text(cx));
|
|
let change = "an edit after git add";
|
|
file_a_editor
|
|
.update_in(cx, |file_a_editor, window, cx| {
|
|
file_a_editor.insert(change, window, cx);
|
|
file_a_editor.save(false, project.clone(), window, cx)
|
|
})
|
|
.await
|
|
.expect("failed to save a file");
|
|
file_a_editor.update_in(cx, |file_a_editor, _window, cx| {
|
|
let change_set = cx.new(|cx| {
|
|
BufferChangeSet::new_with_base_text(
|
|
old_text.clone(),
|
|
&file_a_editor.buffer().read(cx).as_singleton().unwrap(),
|
|
cx,
|
|
)
|
|
});
|
|
file_a_editor.buffer.update(cx, |buffer, cx| {
|
|
buffer.add_change_set(change_set.clone(), cx)
|
|
});
|
|
project.update(cx, |project, cx| {
|
|
project.buffer_store().update(cx, |buffer_store, cx| {
|
|
buffer_store.set_change_set(
|
|
file_a_editor
|
|
.buffer()
|
|
.read(cx)
|
|
.as_singleton()
|
|
.unwrap()
|
|
.read(cx)
|
|
.remote_id(),
|
|
change_set,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
fs.set_status_for_repo_via_git_operation(
|
|
Path::new("/root/.git"),
|
|
&[(
|
|
Path::new("file_a"),
|
|
TrackedStatus {
|
|
worktree_status: StatusCode::Modified,
|
|
index_status: StatusCode::Unmodified,
|
|
}
|
|
.into(),
|
|
)],
|
|
);
|
|
cx.executor()
|
|
.advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
|
|
cx.run_until_parked();
|
|
let editor = project_diff_editor.update(cx, |diff_editor, _| diff_editor.editor.clone());
|
|
|
|
assert_state_with_diff(
|
|
&editor,
|
|
cx,
|
|
indoc::indoc! {
|
|
"
|
|
- This is file_a
|
|
+ an edit after git addThis is file_aˇ",
|
|
},
|
|
);
|
|
}
|
|
|
|
fn init_test(cx: &mut gpui::TestAppContext) {
|
|
if std::env::var("RUST_LOG").is_ok() {
|
|
env_logger::try_init().ok();
|
|
}
|
|
|
|
cx.update(|cx| {
|
|
assets::Assets.load_test_fonts(cx);
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
theme::init(theme::LoadThemes::JustBase, cx);
|
|
release_channel::init(SemanticVersion::default(), cx);
|
|
client::init_settings(cx);
|
|
language::init(cx);
|
|
Project::init_settings(cx);
|
|
workspace::init_settings(cx);
|
|
crate::init(cx);
|
|
cx.set_staff(true);
|
|
});
|
|
}
|
|
}
|