git: Branch diff (#40188)

Release Notes:

- git: Adds the ability to view the diff of the current branch since
main

---------

Co-authored-by: Cole Miller <cole@zed.dev>
This commit is contained in:
Conrad Irwin
2025-10-23 22:38:40 -06:00
committed by GitHub
co-authored by Cole Miller
parent f11a3dcc97
commit f45a9b351d
18 changed files with 1362 additions and 777 deletions
Generated
-1
View File
@@ -7071,7 +7071,6 @@ dependencies = [
"notifications",
"panel",
"picker",
"postage",
"pretty_assertions",
"project",
"schemars 1.0.4",
+4 -16
View File
@@ -1162,34 +1162,22 @@ impl BufferDiff {
self.hunks_intersecting_range(start..end, buffer, cx)
}
pub fn set_base_text_buffer(
&mut self,
base_buffer: Entity<language::Buffer>,
buffer: text::BufferSnapshot,
cx: &mut Context<Self>,
) -> oneshot::Receiver<()> {
let base_buffer = base_buffer.read(cx);
let language_registry = base_buffer.language_registry();
let base_buffer = base_buffer.snapshot();
self.set_base_text(base_buffer, language_registry, buffer, cx)
}
/// Used in cases where the change set isn't derived from git.
pub fn set_base_text(
&mut self,
base_buffer: language::BufferSnapshot,
base_text: Option<Arc<String>>,
language: Option<Arc<Language>>,
language_registry: Option<Arc<LanguageRegistry>>,
buffer: text::BufferSnapshot,
cx: &mut Context<Self>,
) -> oneshot::Receiver<()> {
let (tx, rx) = oneshot::channel();
let this = cx.weak_entity();
let base_text = Arc::new(base_buffer.text());
let snapshot = BufferDiffSnapshot::new_with_base_text(
buffer.clone(),
Some(base_text),
base_buffer.language().cloned(),
base_text,
language,
language_registry,
cx,
);
+3
View File
@@ -347,6 +347,7 @@ impl Server {
.add_request_handler(forward_read_only_project_request::<proto::GetColorPresentation>)
.add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
.add_request_handler(forward_read_only_project_request::<proto::GitGetBranches>)
.add_request_handler(forward_read_only_project_request::<proto::GetDefaultBranch>)
.add_request_handler(forward_read_only_project_request::<proto::OpenUnstagedDiff>)
.add_request_handler(forward_read_only_project_request::<proto::OpenUncommittedDiff>)
.add_request_handler(forward_read_only_project_request::<proto::LspExtExpandMacro>)
@@ -461,6 +462,8 @@ impl Server {
.add_message_handler(broadcast_project_message_from_host::<proto::BreakpointsForFile>)
.add_request_handler(forward_mutating_project_request::<proto::OpenCommitMessageBuffer>)
.add_request_handler(forward_mutating_project_request::<proto::GitDiff>)
.add_request_handler(forward_mutating_project_request::<proto::GetTreeDiff>)
.add_request_handler(forward_mutating_project_request::<proto::GetBlobContent>)
.add_request_handler(forward_mutating_project_request::<proto::GitCreateBranch>)
.add_request_handler(forward_mutating_project_request::<proto::GitChangeBranch>)
.add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)
+19 -63
View File
@@ -32,7 +32,6 @@ mod lsp_ext;
mod mouse_context_menu;
pub mod movement;
mod persistence;
mod proposed_changes_editor;
mod rust_analyzer_ext;
pub mod scroll;
mod selections_collection;
@@ -68,14 +67,12 @@ pub use multi_buffer::{
Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, PathKey,
RowInfo, ToOffset, ToPoint,
};
pub use proposed_changes_editor::{
ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
};
pub use text::Bias;
use ::git::{
Restore,
blame::{BlameEntry, ParsedCommitMessage},
status::FileStatus,
};
use aho_corasick::AhoCorasick;
use anyhow::{Context as _, Result, anyhow};
@@ -847,6 +844,10 @@ pub trait Addon: 'static {
None
}
fn override_status_for_buffer_id(&self, _: BufferId, _: &App) -> Option<FileStatus> {
None
}
fn to_any(&self) -> &dyn std::any::Any;
fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
@@ -10641,6 +10642,20 @@ impl Editor {
}
}
pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
if let Some(status) = self
.addons
.iter()
.find_map(|(_, addon)| addon.override_status_for_buffer_id(buffer_id, cx))
{
return Some(status);
}
self.project
.as_ref()?
.read(cx)
.status_for_buffer_id(buffer_id, cx)
}
pub fn open_active_item_in_terminal(
&mut self,
_: &OpenInTerminal,
@@ -21011,65 +21026,6 @@ impl Editor {
self.searchable
}
fn open_proposed_changes_editor(
&mut self,
_: &OpenProposedChangesEditor,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace() else {
cx.propagate();
return;
};
let selections = self.selections.all::<usize>(&self.display_snapshot(cx));
let multi_buffer = self.buffer.read(cx);
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
let mut new_selections_by_buffer = HashMap::default();
for selection in selections {
for (buffer, range, _) in
multi_buffer_snapshot.range_to_buffer_ranges(selection.start..selection.end)
{
let mut range = range.to_point(buffer);
range.start.column = 0;
range.end.column = buffer.line_len(range.end.row);
new_selections_by_buffer
.entry(multi_buffer.buffer(buffer.remote_id()).unwrap())
.or_insert(Vec::new())
.push(range)
}
}
let proposed_changes_buffers = new_selections_by_buffer
.into_iter()
.map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges })
.collect::<Vec<_>>();
let proposed_changes_editor = cx.new(|cx| {
ProposedChangesEditor::new(
"Proposed changes",
proposed_changes_buffers,
self.project.clone(),
window,
cx,
)
});
window.defer(cx, move |window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(proposed_changes_editor),
true,
true,
None,
window,
cx,
);
});
});
});
}
pub fn open_excerpts_in_split(
&mut self,
_: &OpenExcerptsSplit,
+1 -8
View File
@@ -458,7 +458,6 @@ impl EditorElement {
register_action(editor, window, Editor::toggle_code_actions);
register_action(editor, window, Editor::open_excerpts);
register_action(editor, window, Editor::open_excerpts_in_split);
register_action(editor, window, Editor::open_proposed_changes_editor);
register_action(editor, window, Editor::toggle_soft_wrap);
register_action(editor, window, Editor::toggle_tab_bar);
register_action(editor, window, Editor::toggle_line_numbers);
@@ -3828,13 +3827,7 @@ impl EditorElement {
let multi_buffer = editor.buffer.read(cx);
let file_status = multi_buffer
.all_diff_hunks_expanded()
.then(|| {
editor
.project
.as_ref()?
.read(cx)
.status_for_buffer_id(for_excerpt.buffer_id, cx)
})
.then(|| editor.status_for_buffer_id(for_excerpt.buffer_id, cx))
.flatten();
let indicator = multi_buffer
.buffer(for_excerpt.buffer_id)
@@ -1,523 +0,0 @@
use crate::{ApplyAllDiffHunks, Editor, EditorEvent, SelectionEffects, SemanticsProvider};
use buffer_diff::BufferDiff;
use collections::{HashMap, HashSet};
use futures::{channel::mpsc, future::join_all};
use gpui::{App, Entity, EventEmitter, Focusable, Render, Subscription, Task};
use language::{Buffer, BufferEvent, BufferRow, Capability};
use multi_buffer::{ExcerptRange, MultiBuffer};
use project::{InvalidationStrategy, Project, lsp_store::CacheInlayHints};
use smol::stream::StreamExt;
use std::{any::TypeId, ops::Range, rc::Rc, time::Duration};
use text::{BufferId, ToOffset};
use ui::{ButtonLike, KeyBinding, prelude::*};
use workspace::{
Item, ItemHandle as _, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
item::SaveOptions, searchable::SearchableItemHandle,
};
pub struct ProposedChangesEditor {
editor: Entity<Editor>,
multibuffer: Entity<MultiBuffer>,
title: SharedString,
buffer_entries: Vec<BufferEntry>,
_recalculate_diffs_task: Task<Option<()>>,
recalculate_diffs_tx: mpsc::UnboundedSender<RecalculateDiff>,
}
pub struct ProposedChangeLocation<T> {
pub buffer: Entity<Buffer>,
pub ranges: Vec<Range<T>>,
}
struct BufferEntry {
base: Entity<Buffer>,
branch: Entity<Buffer>,
_subscription: Subscription,
}
pub struct ProposedChangesEditorToolbar {
current_editor: Option<Entity<ProposedChangesEditor>>,
}
struct RecalculateDiff {
buffer: Entity<Buffer>,
debounce: bool,
}
/// A provider of code semantics for branch buffers.
///
/// Requests in edited regions will return nothing, but requests in unchanged
/// regions will be translated into the base buffer's coordinates.
struct BranchBufferSemanticsProvider(Rc<dyn SemanticsProvider>);
impl ProposedChangesEditor {
pub fn new<T: Clone + ToOffset>(
title: impl Into<SharedString>,
locations: Vec<ProposedChangeLocation<T>>,
project: Option<Entity<Project>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded();
let mut this = Self {
editor: cx.new(|cx| {
let mut editor = Editor::for_multibuffer(multibuffer.clone(), project, window, cx);
editor.set_expand_all_diff_hunks(cx);
editor.set_completion_provider(None);
editor.clear_code_action_providers();
editor.set_semantics_provider(
editor
.semantics_provider()
.map(|provider| Rc::new(BranchBufferSemanticsProvider(provider)) as _),
);
editor
}),
multibuffer,
title: title.into(),
buffer_entries: Vec::new(),
recalculate_diffs_tx,
_recalculate_diffs_task: cx.spawn_in(window, async move |this, cx| {
let mut buffers_to_diff = HashSet::default();
while let Some(mut recalculate_diff) = recalculate_diffs_rx.next().await {
buffers_to_diff.insert(recalculate_diff.buffer);
while recalculate_diff.debounce {
cx.background_executor()
.timer(Duration::from_millis(50))
.await;
let mut had_further_changes = false;
while let Ok(next_recalculate_diff) = recalculate_diffs_rx.try_next() {
let next_recalculate_diff = next_recalculate_diff?;
recalculate_diff.debounce &= next_recalculate_diff.debounce;
buffers_to_diff.insert(next_recalculate_diff.buffer);
had_further_changes = true;
}
if !had_further_changes {
break;
}
}
let recalculate_diff_futures = this
.update(cx, |this, cx| {
buffers_to_diff
.drain()
.filter_map(|buffer| {
let buffer = buffer.read(cx);
let base_buffer = buffer.base_buffer()?;
let buffer = buffer.text_snapshot();
let diff =
this.multibuffer.read(cx).diff_for(buffer.remote_id())?;
Some(diff.update(cx, |diff, cx| {
diff.set_base_text_buffer(base_buffer.clone(), buffer, cx)
}))
})
.collect::<Vec<_>>()
})
.ok()?;
join_all(recalculate_diff_futures).await;
}
None
}),
};
this.reset_locations(locations, window, cx);
this
}
pub fn branch_buffer_for_base(&self, base_buffer: &Entity<Buffer>) -> Option<Entity<Buffer>> {
self.buffer_entries.iter().find_map(|entry| {
if &entry.base == base_buffer {
Some(entry.branch.clone())
} else {
None
}
})
}
pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
self.title = title;
cx.notify();
}
pub fn reset_locations<T: Clone + ToOffset>(
&mut self,
locations: Vec<ProposedChangeLocation<T>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Undo all branch changes
for entry in &self.buffer_entries {
let base_version = entry.base.read(cx).version();
entry.branch.update(cx, |buffer, cx| {
let undo_counts = buffer
.operations()
.iter()
.filter_map(|(timestamp, _)| {
if !base_version.observed(*timestamp) {
Some((*timestamp, u32::MAX))
} else {
None
}
})
.collect();
buffer.undo_operations(undo_counts, cx);
});
}
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.clear(cx);
});
let mut buffer_entries = Vec::new();
let mut new_diffs = Vec::new();
for location in locations {
let branch_buffer;
if let Some(ix) = self
.buffer_entries
.iter()
.position(|entry| entry.base == location.buffer)
{
let entry = self.buffer_entries.remove(ix);
branch_buffer = entry.branch.clone();
buffer_entries.push(entry);
} else {
branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx));
new_diffs.push(cx.new(|cx| {
let mut diff = BufferDiff::new(&branch_buffer.read(cx).snapshot(), cx);
let _ = diff.set_base_text_buffer(
location.buffer.clone(),
branch_buffer.read(cx).text_snapshot(),
cx,
);
diff
}));
buffer_entries.push(BufferEntry {
branch: branch_buffer.clone(),
base: location.buffer.clone(),
_subscription: cx.subscribe(&branch_buffer, Self::on_buffer_event),
});
}
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.push_excerpts(
branch_buffer,
location
.ranges
.into_iter()
.map(|range| ExcerptRange::new(range)),
cx,
);
});
}
self.buffer_entries = buffer_entries;
self.editor.update(cx, |editor, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.refresh()
});
editor.buffer.update(cx, |buffer, cx| {
for diff in new_diffs {
buffer.add_diff(diff, cx)
}
})
});
}
pub fn recalculate_all_buffer_diffs(&self) {
for (ix, entry) in self.buffer_entries.iter().enumerate().rev() {
self.recalculate_diffs_tx
.unbounded_send(RecalculateDiff {
buffer: entry.branch.clone(),
debounce: ix > 0,
})
.ok();
}
}
fn on_buffer_event(
&mut self,
buffer: Entity<Buffer>,
event: &BufferEvent,
_cx: &mut Context<Self>,
) {
if let BufferEvent::Operation { .. } = event {
self.recalculate_diffs_tx
.unbounded_send(RecalculateDiff {
buffer,
debounce: true,
})
.ok();
}
}
}
impl Render for ProposedChangesEditor {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.key_context("ProposedChangesEditor")
.child(self.editor.clone())
}
}
impl Focusable for ProposedChangesEditor {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
self.editor.focus_handle(cx)
}
}
impl EventEmitter<EditorEvent> for ProposedChangesEditor {}
impl Item for ProposedChangesEditor {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::Diff))
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
self.title.clone()
}
fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
) -> Option<gpui::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 added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
Item::added_to_workspace(editor, workspace, window, cx)
});
}
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 std::any::Any>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor
.update(cx, |editor, cx| Item::navigate(editor, data, window, cx))
}
fn set_nav_history(
&mut self,
nav_history: workspace::ItemNavHistory,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
Item::set_nav_history(editor, nav_history, window, cx)
});
}
fn can_save(&self, cx: &App) -> bool {
self.editor.read(cx).can_save(cx)
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<()>> {
self.editor.update(cx, |editor, cx| {
Item::save(editor, options, project, window, cx)
})
}
}
impl ProposedChangesEditorToolbar {
pub fn new() -> Self {
Self {
current_editor: None,
}
}
fn get_toolbar_item_location(&self) -> ToolbarItemLocation {
if self.current_editor.is_some() {
ToolbarItemLocation::PrimaryRight
} else {
ToolbarItemLocation::Hidden
}
}
}
impl Render for ProposedChangesEditorToolbar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let button_like = ButtonLike::new("apply-changes").child(Label::new("Apply All"));
match &self.current_editor {
Some(editor) => {
let focus_handle = editor.focus_handle(cx);
let keybinding = KeyBinding::for_action_in(&ApplyAllDiffHunks, &focus_handle, cx);
button_like.child(keybinding).on_click({
move |_event, window, cx| {
focus_handle.dispatch_action(&ApplyAllDiffHunks, window, cx)
}
})
}
None => button_like.disabled(true),
}
}
}
impl EventEmitter<ToolbarItemEvent> for ProposedChangesEditorToolbar {}
impl ToolbarItemView for ProposedChangesEditorToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn workspace::ItemHandle>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> workspace::ToolbarItemLocation {
self.current_editor =
active_pane_item.and_then(|item| item.downcast::<ProposedChangesEditor>());
self.get_toolbar_item_location()
}
}
impl BranchBufferSemanticsProvider {
fn to_base(
&self,
buffer: &Entity<Buffer>,
positions: &[text::Anchor],
cx: &App,
) -> Option<Entity<Buffer>> {
let base_buffer = buffer.read(cx).base_buffer()?;
let version = base_buffer.read(cx).version();
if positions
.iter()
.any(|position| !version.observed(position.timestamp))
{
return None;
}
Some(base_buffer)
}
}
impl SemanticsProvider for BranchBufferSemanticsProvider {
fn hover(
&self,
buffer: &Entity<Buffer>,
position: text::Anchor,
cx: &mut App,
) -> Option<Task<Option<Vec<project::Hover>>>> {
let buffer = self.to_base(buffer, &[position], cx)?;
self.0.hover(&buffer, position, cx)
}
fn applicable_inlay_chunks(
&self,
buffer: &Entity<Buffer>,
ranges: &[Range<text::Anchor>],
cx: &mut App,
) -> Vec<Range<BufferRow>> {
self.0.applicable_inlay_chunks(buffer, ranges, cx)
}
fn invalidate_inlay_hints(&self, for_buffers: &HashSet<BufferId>, cx: &mut App) {
self.0.invalidate_inlay_hints(for_buffers, cx);
}
fn inlay_hints(
&self,
invalidate: InvalidationStrategy,
buffer: Entity<Buffer>,
ranges: Vec<Range<text::Anchor>>,
known_chunks: Option<(clock::Global, HashSet<Range<BufferRow>>)>,
cx: &mut App,
) -> Option<HashMap<Range<BufferRow>, Task<anyhow::Result<CacheInlayHints>>>> {
let positions = ranges
.iter()
.flat_map(|range| [range.start, range.end])
.collect::<Vec<_>>();
let buffer = self.to_base(&buffer, &positions, cx)?;
self.0
.inlay_hints(invalidate, buffer, ranges, known_chunks, cx)
}
fn inline_values(
&self,
_: Entity<Buffer>,
_: Range<text::Anchor>,
_: &mut App,
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
None
}
fn supports_inlay_hints(&self, buffer: &Entity<Buffer>, cx: &mut App) -> bool {
if let Some(buffer) = self.to_base(buffer, &[], cx) {
self.0.supports_inlay_hints(&buffer, cx)
} else {
false
}
}
fn document_highlights(
&self,
buffer: &Entity<Buffer>,
position: text::Anchor,
cx: &mut App,
) -> Option<Task<anyhow::Result<Vec<project::DocumentHighlight>>>> {
let buffer = self.to_base(buffer, &[position], cx)?;
self.0.document_highlights(&buffer, position, cx)
}
fn definitions(
&self,
buffer: &Entity<Buffer>,
position: text::Anchor,
kind: crate::GotoDefinitionKind,
cx: &mut App,
) -> Option<Task<anyhow::Result<Option<Vec<project::LocationLink>>>>> {
let buffer = self.to_base(buffer, &[position], cx)?;
self.0.definitions(&buffer, position, kind, cx)
}
fn range_for_rename(
&self,
_: &Entity<Buffer>,
_: text::Anchor,
_: &mut App,
) -> Option<Task<anyhow::Result<Option<Range<text::Anchor>>>>> {
None
}
fn perform_rename(
&self,
_: &Entity<Buffer>,
_: text::Anchor,
_: String,
_: &mut App,
) -> Option<Task<anyhow::Result<project::ProjectTransaction>>> {
None
}
}
+46 -3
View File
@@ -9,7 +9,10 @@ use git::{
AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode,
},
status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
status::{
DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus,
UnmergedStatus,
},
};
use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task, TaskLabel};
use ignore::gitignore::GitignoreBuilder;
@@ -41,6 +44,9 @@ pub struct FakeGitRepositoryState {
pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
pub head_contents: HashMap<RepoPath, String>,
pub index_contents: HashMap<RepoPath, String>,
// everything in commit contents is in oids
pub merge_base_contents: HashMap<RepoPath, Oid>,
pub oids: HashMap<Oid, String>,
pub blames: HashMap<RepoPath, Blame>,
pub current_branch_name: Option<String>,
pub branches: HashSet<String>,
@@ -60,6 +66,8 @@ impl FakeGitRepositoryState {
branches: Default::default(),
simulated_index_write_error_message: Default::default(),
refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
merge_base_contents: Default::default(),
oids: Default::default(),
}
}
}
@@ -110,6 +118,13 @@ impl GitRepository for FakeGitRepository {
.boxed()
}
fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result<String>> {
self.with_state_async(false, move |state| {
state.oids.get(&oid).cloned().context("oid does not exist")
})
.boxed()
}
fn load_commit(
&self,
_commit: String,
@@ -140,6 +155,34 @@ impl GitRepository for FakeGitRepository {
None
}
fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
let mut entries = HashMap::default();
self.with_state_async(false, |state| {
for (path, content) in &state.head_contents {
let status = if let Some((oid, original)) = state
.merge_base_contents
.get(path)
.map(|oid| (oid, &state.oids[oid]))
{
if original == content {
continue;
}
TreeDiffStatus::Modified { old: *oid }
} else {
TreeDiffStatus::Added
};
entries.insert(path.clone(), status);
}
for (path, oid) in &state.merge_base_contents {
if !entries.contains_key(path) {
entries.insert(path.clone(), TreeDiffStatus::Deleted { old: *oid });
}
}
Ok(TreeDiff { entries })
})
.boxed()
}
fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
self.with_state_async(false, |state| {
Ok(revs
@@ -523,7 +566,7 @@ impl GitRepository for FakeGitRepository {
let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
async move {
executor.simulate_random_delay().await;
let oid = Oid::random(&mut executor.rng());
let oid = git::Oid::random(&mut executor.rng());
let entry = fs.entry(&repository_dir_path)?;
checkpoints.lock().insert(oid, entry);
Ok(GitRepositoryCheckpoint { commit_sha: oid })
@@ -579,7 +622,7 @@ impl GitRepository for FakeGitRepository {
}
fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
unimplemented!()
async { Ok(Some("main".into())) }.boxed()
}
}
+20
View File
@@ -1752,6 +1752,26 @@ impl FakeFs {
.unwrap();
}
pub fn set_merge_base_content_for_repo(
&self,
dot_git: &Path,
contents_by_path: &[(&str, String)],
) {
self.with_git_state(dot_git, true, |state| {
use git::Oid;
state.merge_base_contents.clear();
let oids = (1..)
.map(|n| n.to_string())
.map(|n| Oid::from_bytes(n.repeat(20).as_bytes()).unwrap());
for ((path, content), oid) in contents_by_path.iter().zip(oids) {
state.merge_base_contents.insert(repo_path(path), oid);
state.oids.insert(oid, content.clone());
}
})
.unwrap();
}
pub fn set_blame_for_repo(&self, dot_git: &Path, blames: Vec<(RepoPath, git::blame::Blame)>) {
self.with_git_state(dot_git, true, |state| {
state.blames.clear();
+74 -7
View File
@@ -1,6 +1,6 @@
use crate::commit::parse_git_diff_name_status;
use crate::stash::GitStash;
use crate::status::{GitStatus, StatusCode};
use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
use crate::{Oid, SHORT_SHA_LENGTH};
use anyhow::{Context as _, Result, anyhow, bail};
use collections::HashMap;
@@ -350,6 +350,7 @@ pub trait GitRepository: Send + Sync {
///
/// Also returns `None` for symlinks.
fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
fn set_index_text(
&self,
@@ -379,6 +380,7 @@ pub trait GitRepository: Send + Sync {
fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
@@ -908,6 +910,17 @@ impl GitRepository for RealGitRepository {
.boxed()
}
fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
let repo = self.repository.clone();
self.executor
.spawn(async move {
let repo = repo.lock();
let content = repo.find_blob(oid.0)?.content().to_owned();
Ok(String::from_utf8(content)?)
})
.boxed()
}
fn set_index_text(
&self,
path: RepoPath,
@@ -1060,6 +1073,50 @@ impl GitRepository for RealGitRepository {
})
}
fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
let git_binary_path = self.any_git_binary_path.clone();
let working_directory = match self.working_directory() {
Ok(working_directory) => working_directory,
Err(e) => return Task::ready(Err(e)).boxed(),
};
let mut args = vec![
OsString::from("--no-optional-locks"),
OsString::from("diff-tree"),
OsString::from("-r"),
OsString::from("-z"),
OsString::from("--no-renames"),
];
match request {
DiffTreeType::MergeBase { base, head } => {
args.push("--merge-base".into());
args.push(OsString::from(base.as_str()));
args.push(OsString::from(head.as_str()));
}
DiffTreeType::Since { base, head } => {
args.push(OsString::from(base.as_str()));
args.push(OsString::from(head.as_str()));
}
}
self.executor
.spawn(async move {
let output = new_smol_command(&git_binary_path)
.current_dir(working_directory)
.args(args)
.output()
.await?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.parse()
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git status failed: {stderr}");
}
})
.boxed()
}
fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
let git_binary_path = self.any_git_binary_path.clone();
let working_directory = self.working_directory();
@@ -1827,13 +1884,23 @@ impl GitRepository for RealGitRepository {
return Ok(output);
}
let output = git
.run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
.await?;
if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
return Ok(output
.strip_prefix("refs/remotes/origin/")
.map(|s| SharedString::from(s.to_owned())));
}
Ok(output
.strip_prefix("refs/remotes/origin/")
.map(|s| SharedString::from(s.to_owned())))
if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
if git.run(&["rev-parse", &default_branch]).await.is_ok() {
return Ok(Some(default_branch.into()));
}
}
if git.run(&["rev-parse", "master"]).await.is_ok() {
return Ok(Some("master".into()));
}
Ok(None)
})
.boxed()
}
+134 -3
View File
@@ -1,5 +1,7 @@
use crate::repository::RepoPath;
use anyhow::Result;
use crate::{Oid, repository::RepoPath};
use anyhow::{Result, anyhow};
use collections::HashMap;
use gpui::SharedString;
use serde::{Deserialize, Serialize};
use std::{str::FromStr, sync::Arc};
use util::{ResultExt, rel_path::RelPath};
@@ -190,7 +192,11 @@ impl FileStatus {
}
pub fn is_deleted(self) -> bool {
matches!(self, FileStatus::Tracked(tracked) if matches!((tracked.index_status, tracked.worktree_status), (StatusCode::Deleted, _) | (_, StatusCode::Deleted)))
let FileStatus::Tracked(tracked) = self else {
return false;
};
tracked.index_status == StatusCode::Deleted && tracked.worktree_status != StatusCode::Added
|| tracked.worktree_status == StatusCode::Deleted
}
pub fn is_untracked(self) -> bool {
@@ -486,3 +492,128 @@ impl Default for GitStatus {
}
}
}
pub enum DiffTreeType {
MergeBase {
base: SharedString,
head: SharedString,
},
Since {
base: SharedString,
head: SharedString,
},
}
impl DiffTreeType {
pub fn base(&self) -> &SharedString {
match self {
DiffTreeType::MergeBase { base, .. } => base,
DiffTreeType::Since { base, .. } => base,
}
}
pub fn head(&self) -> &SharedString {
match self {
DiffTreeType::MergeBase { head, .. } => head,
DiffTreeType::Since { head, .. } => head,
}
}
}
#[derive(Debug, PartialEq)]
pub struct TreeDiff {
pub entries: HashMap<RepoPath, TreeDiffStatus>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TreeDiffStatus {
Added,
Modified { old: Oid },
Deleted { old: Oid },
}
impl FromStr for TreeDiff {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
let mut fields = s.split('\0');
let mut parsed = HashMap::default();
while let Some((status, path)) = fields.next().zip(fields.next()) {
let path = RepoPath(RelPath::unix(path)?.into());
let mut fields = status.split(" ").skip(2);
let old_sha = fields
.next()
.ok_or_else(|| anyhow!("expected to find old_sha"))?
.to_owned()
.parse()?;
let _new_sha = fields
.next()
.ok_or_else(|| anyhow!("expected to find new_sha"))?;
let status = fields
.next()
.and_then(|s| {
if s.len() == 1 {
s.as_bytes().first()
} else {
None
}
})
.ok_or_else(|| anyhow!("expected to find status"))?;
let result = match StatusCode::from_byte(*status)? {
StatusCode::Modified => TreeDiffStatus::Modified { old: old_sha },
StatusCode::Added => TreeDiffStatus::Added,
StatusCode::Deleted => TreeDiffStatus::Deleted { old: old_sha },
_status => continue,
};
parsed.insert(path, result);
}
Ok(Self { entries: parsed })
}
}
#[cfg(test)]
mod tests {
use crate::{
repository::RepoPath,
status::{TreeDiff, TreeDiffStatus},
};
#[test]
fn test_tree_diff_parsing() {
let input = ":000000 100644 0000000000000000000000000000000000000000 0062c311b8727c3a2e3cd7a41bc9904feacf8f98 A\x00.zed/settings.json\x00".to_owned() +
":100644 000000 bb3e9ed2e97a8c02545bae243264d342c069afb3 0000000000000000000000000000000000000000 D\x00README.md\x00" +
":100644 100644 42f097005a1f21eb2260fad02ec8c991282beee8 a437d85f63bb8c62bd78f83f40c506631fabf005 M\x00parallel.go\x00";
let output: TreeDiff = input.parse().unwrap();
assert_eq!(
output,
TreeDiff {
entries: [
(
RepoPath::new(".zed/settings.json").unwrap(),
TreeDiffStatus::Added,
),
(
RepoPath::new("README.md").unwrap(),
TreeDiffStatus::Deleted {
old: "bb3e9ed2e97a8c02545bae243264d342c069afb3".parse().unwrap()
}
),
(
RepoPath::new("parallel.go").unwrap(),
TreeDiffStatus::Modified {
old: "42f097005a1f21eb2260fad02ec8c991282beee8".parse().unwrap(),
}
),
]
.into_iter()
.collect()
}
)
}
}
-1
View File
@@ -44,7 +44,6 @@ multi_buffer.workspace = true
notifications.workspace = true
panel.workspace = true
picker.workspace = true
postage.workspace = true
project.workspace = true
schemars.workspace = true
serde.workspace = true
+430 -147
View File
@@ -4,16 +4,15 @@ use crate::{
git_panel_settings::GitPanelSettings,
remote_button::{render_publish_button, render_push_button},
};
use anyhow::Result;
use anyhow::{Context as _, Result, anyhow};
use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
use collections::{HashMap, HashSet};
use editor::{
Editor, EditorEvent, SelectionEffects,
Addon, Editor, EditorEvent, SelectionEffects,
actions::{GoToHunk, GoToPreviousHunk},
multibuffer_context_lines,
scroll::Autoscroll,
};
use futures::StreamExt;
use git::{
Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
repository::{Branch, RepoPath, Upstream, UpstreamTracking, UpstreamTrackingStatus},
@@ -27,18 +26,23 @@ use language::{Anchor, Buffer, Capability, OffsetRangeExt};
use multi_buffer::{MultiBuffer, PathKey};
use project::{
Project, ProjectPath,
git_store::{GitStore, GitStoreEvent, Repository, RepositoryEvent},
git_store::{
Repository,
branch_diff::{self, BranchDiffEvent, DiffBase},
},
};
use settings::{Settings, SettingsStore};
use std::any::{Any, TypeId};
use std::ops::Range;
use std::sync::Arc;
use theme::ActiveTheme;
use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider};
use util::ResultExt as _;
use util::{ResultExt as _, rel_path::RelPath};
use workspace::{
CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
ToolbarItemView, Workspace,
item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
notifications::NotifyTaskExt,
searchable::SearchableItemHandle,
};
@@ -48,30 +52,24 @@ actions!(
/// Shows the diff between the working directory and the index.
Diff,
/// Adds files to the git staging area.
Add
Add,
/// Shows the diff between the working directory and your default
/// branch (typically main or master).
BranchDiff
]
);
pub struct ProjectDiff {
project: Entity<Project>,
multibuffer: Entity<MultiBuffer>,
branch_diff: Entity<branch_diff::BranchDiff>,
editor: Entity<Editor>,
git_store: Entity<GitStore>,
buffer_diff_subscriptions: HashMap<RepoPath, (Entity<BufferDiff>, Subscription)>,
buffer_diff_subscriptions: HashMap<Arc<RelPath>, (Entity<BufferDiff>, Subscription)>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
update_needed: postage::watch::Sender<()>,
pending_scroll: Option<PathKey>,
_task: Task<Result<()>>,
_git_store_subscription: Subscription,
}
#[derive(Debug)]
struct DiffBuffer {
path_key: PathKey,
buffer: Entity<Buffer>,
diff: Entity<BufferDiff>,
file_status: FileStatus,
_subscription: Subscription,
}
const CONFLICT_SORT_PREFIX: u64 = 1;
@@ -81,6 +79,7 @@ const NEW_SORT_PREFIX: u64 = 3;
impl ProjectDiff {
pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
workspace.register_action(Self::deploy);
workspace.register_action(Self::deploy_branch_diff);
workspace.register_action(|workspace, _: &Add, window, cx| {
Self::deploy(workspace, &Diff, window, cx);
});
@@ -96,6 +95,40 @@ impl ProjectDiff {
Self::deploy_at(workspace, None, window, cx)
}
fn deploy_branch_diff(
workspace: &mut Workspace,
_: &BranchDiff,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
telemetry::event!("Git Branch Diff Opened");
let project = workspace.project().clone();
let existing = workspace
.items_of_type::<Self>(cx)
.find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }));
if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
return;
}
let workspace = cx.entity();
window
.spawn(cx, async move |cx| {
let this = cx
.update(|window, cx| {
Self::new_with_default_branch(project, workspace.clone(), window, cx)
})?
.await?;
workspace
.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
})
.ok();
anyhow::Ok(())
})
.detach_and_notify_err(window, cx);
}
pub fn deploy_at(
workspace: &mut Workspace,
entry: Option<GitStatusEntry>,
@@ -110,7 +143,10 @@ impl ProjectDiff {
"Action"
}
);
let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
let existing = workspace
.items_of_type::<Self>(cx)
.find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
let project_diff = if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
existing
} else {
@@ -139,11 +175,54 @@ impl ProjectDiff {
})
}
fn new_with_default_branch(
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
return Task::ready(Err(anyhow!("No active repository")));
};
let main_branch = repo.update(cx, |repo, _| repo.default_branch());
window.spawn(cx, async move |cx| {
let main_branch = main_branch
.await??
.context("Could not determine default branch")?;
let branch_diff = cx.new_window_entity(|window, cx| {
branch_diff::BranchDiff::new(
DiffBase::Merge {
base_ref: main_branch,
},
project.clone(),
window,
cx,
)
})?;
cx.new_window_entity(|window, cx| {
Self::new_impl(branch_diff, project, workspace, window, cx)
})
})
}
fn new(
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let branch_diff =
cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx));
Self::new_impl(branch_diff, project, workspace, window, cx)
}
fn new_impl(
branch_diff: Entity<branch_diff::BranchDiff>,
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
@@ -153,9 +232,25 @@ impl ProjectDiff {
Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
diff_display_editor.disable_diagnostics(cx);
diff_display_editor.set_expand_all_diff_hunks(cx);
diff_display_editor.register_addon(GitPanelAddon {
workspace: workspace.downgrade(),
});
match branch_diff.read(cx).diff_base() {
DiffBase::Head => {
diff_display_editor.register_addon(GitPanelAddon {
workspace: workspace.downgrade(),
});
}
DiffBase::Merge { .. } => {
diff_display_editor.register_addon(BranchDiffAddon {
branch_diff: branch_diff.clone(),
});
diff_display_editor.start_temporary_diff_override();
diff_display_editor.set_render_diff_hunk_controls(
Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
cx,
);
//
}
}
diff_display_editor
});
window.defer(cx, {
@@ -172,71 +267,71 @@ impl ProjectDiff {
cx.subscribe_in(&editor, window, Self::handle_editor_event)
.detach();
let git_store = project.read(cx).git_store().clone();
let git_store_subscription = cx.subscribe_in(
&git_store,
let branch_diff_subscription = cx.subscribe_in(
&branch_diff,
window,
move |this, _git_store, event, _window, _cx| match event {
GitStoreEvent::ActiveRepositoryChanged(_)
| GitStoreEvent::RepositoryUpdated(
_,
RepositoryEvent::StatusesChanged { full_scan: _ },
true,
)
| GitStoreEvent::ConflictsUpdated => {
*this.update_needed.borrow_mut() = ();
move |this, _git_store, event, window, cx| match event {
BranchDiffEvent::FileListChanged => {
this._task = window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::refresh(this, cx).await
})
}
_ => {}
},
);
let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
let mut was_collapse_untracked_diff =
GitPanelSettings::get_global(cx).collapse_untracked_diff;
cx.observe_global::<SettingsStore>(move |this, cx| {
cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
let is_collapse_untracked_diff =
GitPanelSettings::get_global(cx).collapse_untracked_diff;
if is_sort_by_path != was_sort_by_path
|| is_collapse_untracked_diff != was_collapse_untracked_diff
{
*this.update_needed.borrow_mut() = ();
this._task = {
window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::refresh(this, cx).await
})
}
}
was_sort_by_path = is_sort_by_path;
was_collapse_untracked_diff = is_collapse_untracked_diff;
})
.detach();
let (mut send, recv) = postage::watch::channel::<()>();
let worker = window.spawn(cx, {
let task = window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::handle_status_updates(this, recv, cx).await
async |cx| Self::refresh(this, cx).await
});
// Kick off a refresh immediately
*send.borrow_mut() = ();
Self {
project,
git_store: git_store.clone(),
workspace: workspace.downgrade(),
branch_diff,
focus_handle,
editor,
multibuffer,
buffer_diff_subscriptions: Default::default(),
pending_scroll: None,
update_needed: send,
_task: worker,
_git_store_subscription: git_store_subscription,
_task: task,
_subscription: branch_diff_subscription,
}
}
pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
self.branch_diff.read(cx).diff_base()
}
pub fn move_to_entry(
&mut self,
entry: GitStatusEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(git_repo) = self.git_store.read(cx).active_repository() else {
let Some(git_repo) = self.branch_diff.read(cx).repo() else {
return;
};
let repo = git_repo.read(cx);
@@ -366,77 +461,28 @@ impl ProjectDiff {
}
}
fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
let Some(repo) = self.git_store.read(cx).active_repository() else {
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.clear(cx);
});
self.buffer_diff_subscriptions.clear();
return vec![];
};
let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
let mut result = vec![];
repo.update(cx, |repo, cx| {
for entry in repo.cached_status() {
if !entry.status.has_changes() {
continue;
}
let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path, cx)
else {
continue;
};
let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx);
let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.0.clone());
previous_paths.remove(&path_key);
let load_buffer = self
.project
.update(cx, |project, cx| project.open_buffer(project_path, cx));
let project = self.project.clone();
result.push(cx.spawn(async move |_, cx| {
let buffer = load_buffer.await?;
let changes = project
.update(cx, |project, cx| {
project.open_uncommitted_diff(buffer.clone(), cx)
})?
.await?;
Ok(DiffBuffer {
path_key,
buffer,
diff: changes,
file_status: entry.status,
})
}));
}
});
self.multibuffer.update(cx, |multibuffer, cx| {
for path in previous_paths {
self.buffer_diff_subscriptions
.remove(&path.path.clone().into());
multibuffer.remove_excerpts_for_path(path, cx);
}
});
result
}
fn register_buffer(
&mut self,
diff_buffer: DiffBuffer,
path_key: PathKey,
file_status: FileStatus,
buffer: Entity<Buffer>,
diff: Entity<BufferDiff>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let path_key = diff_buffer.path_key.clone();
let buffer = diff_buffer.buffer.clone();
let diff = diff_buffer.diff.clone();
let subscription = cx.subscribe(&diff, move |this, _, _, _| {
*this.update_needed.borrow_mut() = ();
if self.branch_diff.read(cx).diff_base().is_merge_base() {
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.add_diff(diff.clone(), cx);
});
}
let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| {
this._task = window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::refresh(this, cx).await
})
});
self.buffer_diff_subscriptions
.insert(path_key.path.clone().into(), (diff.clone(), subscription));
.insert(path_key.path.clone(), (diff.clone(), subscription));
let conflict_addon = self
.editor
@@ -480,8 +526,8 @@ impl ProjectDiff {
});
}
if is_excerpt_newly_added
&& (diff_buffer.file_status.is_deleted()
|| (diff_buffer.file_status.is_untracked()
&& (file_status.is_deleted()
|| (file_status.is_untracked()
&& GitPanelSettings::get_global(cx).collapse_untracked_diff))
{
editor.fold_buffer(snapshot.text.remote_id(), cx)
@@ -506,26 +552,51 @@ impl ProjectDiff {
}
}
pub async fn handle_status_updates(
this: WeakEntity<Self>,
mut recv: postage::watch::Receiver<()>,
cx: &mut AsyncWindowContext,
) -> Result<()> {
while (recv.next().await).is_some() {
let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?;
for buffer_to_load in buffers_to_load {
if let Some(buffer) = buffer_to_load.await.log_err() {
cx.update(|window, cx| {
this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
.ok();
})?;
pub async fn refresh(this: WeakEntity<Self>, cx: &mut AsyncWindowContext) -> Result<()> {
let mut path_keys = Vec::new();
let buffers_to_load = this.update(cx, |this, cx| {
let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
let load_buffers = branch_diff.load_buffers(cx);
(branch_diff.repo().cloned(), load_buffers)
});
let mut previous_paths = this.multibuffer.read(cx).paths().collect::<HashSet<_>>();
if let Some(repo) = repo {
let repo = repo.read(cx);
path_keys = Vec::with_capacity(buffers_to_load.len());
for entry in buffers_to_load.iter() {
let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx);
let path_key =
PathKey::with_sort_prefix(sort_prefix, entry.repo_path.0.clone());
previous_paths.remove(&path_key);
path_keys.push(path_key)
}
}
this.update(cx, |this, cx| {
this.pending_scroll.take();
cx.notify();
})?;
this.multibuffer.update(cx, |multibuffer, cx| {
for path in previous_paths {
this.buffer_diff_subscriptions.remove(&path.path);
multibuffer.remove_excerpts_for_path(path, cx);
}
});
buffers_to_load
})?;
for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys.into_iter()) {
if let Some((buffer, diff)) = entry.load.await.log_err() {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.register_buffer(path_key, entry.file_status, buffer, diff, window, cx)
})
.ok();
})?;
}
}
this.update(cx, |this, cx| {
this.pending_scroll.take();
cx.notify();
})?;
Ok(())
}
@@ -594,8 +665,8 @@ impl Item for ProjectDiff {
Some("Project Diff".into())
}
fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
Label::new("Uncommitted Changes")
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
Label::new(self.tab_content_text(0, cx))
.color(if params.selected {
Color::Default
} else {
@@ -604,8 +675,11 @@ impl Item for ProjectDiff {
.into_any_element()
}
fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
"Uncommitted Changes".into()
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
match self.branch_diff.read(cx).diff_base() {
DiffBase::Head => "Uncommitted Changes".into(),
DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
}
}
fn telemetry_event_text(&self) -> Option<&'static str> {
@@ -802,30 +876,47 @@ impl SerializableItem for ProjectDiff {
}
fn deserialize(
_project: Entity<Project>,
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
_workspace_id: workspace::WorkspaceId,
_item_id: workspace::ItemId,
workspace_id: workspace::WorkspaceId,
item_id: workspace::ItemId,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
window.spawn(cx, async move |cx| {
workspace.update_in(cx, |workspace, window, cx| {
let workspace_handle = cx.entity();
cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
})
let diff_base = persistence::PROJECT_DIFF_DB.get_diff_base(item_id, workspace_id)?;
let diff = cx.update(|window, cx| {
let branch_diff = cx
.new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx));
let workspace = workspace.upgrade().context("workspace gone")?;
anyhow::Ok(
cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)),
)
})??;
Ok(diff)
})
}
fn serialize(
&mut self,
_workspace: &mut Workspace,
_item_id: workspace::ItemId,
workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
cx: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
None
let workspace_id = workspace.database_id()?;
let diff_base = self.diff_base(cx).clone();
Some(cx.background_spawn({
async move {
persistence::PROJECT_DIFF_DB
.save_diff_base(item_id, workspace_id, diff_base.clone())
.await
}
}))
}
fn should_serialize(&self, _: &Self::Event) -> bool {
@@ -833,6 +924,80 @@ impl SerializableItem for ProjectDiff {
}
}
mod persistence {
use anyhow::Context as _;
use db::{
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use project::git_store::branch_diff::DiffBase;
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
pub struct ProjectDiffDb(ThreadSafeConnection);
impl Domain for ProjectDiffDb {
const NAME: &str = stringify!(ProjectDiffDb);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE project_diffs(
workspace_id INTEGER,
item_id INTEGER UNIQUE,
diff_base TEXT,
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
)];
}
db::static_connection!(PROJECT_DIFF_DB, ProjectDiffDb, [WorkspaceDb]);
impl ProjectDiffDb {
pub async fn save_diff_base(
&self,
item_id: ItemId,
workspace_id: WorkspaceId,
diff_base: DiffBase,
) -> anyhow::Result<()> {
self.write(move |connection| {
let sql_stmt = sql!(
INSERT OR REPLACE INTO project_diffs(item_id, workspace_id, diff_base) VALUES (?, ?, ?)
);
let diff_base_str = serde_json::to_string(&diff_base)?;
let mut query = connection.exec_bound::<(ItemId, WorkspaceId, String)>(sql_stmt)?;
query((item_id, workspace_id, diff_base_str)).context(format!(
"exec_bound failed to execute or parse for: {}",
sql_stmt
))
})
.await
}
pub fn get_diff_base(
&self,
item_id: ItemId,
workspace_id: WorkspaceId,
) -> anyhow::Result<DiffBase> {
let sql_stmt =
sql!(SELECT diff_base FROM project_diffs WHERE item_id = ?AND workspace_id = ?);
let diff_base_str = self.select_row_bound::<(ItemId, WorkspaceId), String>(sql_stmt)?(
(item_id, workspace_id),
)
.context(::std::format!(
"Error in get_diff_base, select_row_bound failed to execute or parse for: {}",
sql_stmt
))?;
let Some(diff_base_str) = diff_base_str else {
return Ok(DiffBase::Head);
};
serde_json::from_str(&diff_base_str).context("deserializing diff base")
}
}
}
pub struct ProjectDiffToolbar {
project_diff: Option<WeakEntity<ProjectDiff>>,
workspace: WeakEntity<Workspace>,
@@ -897,6 +1062,7 @@ impl ToolbarItemView for ProjectDiffToolbar {
) -> ToolbarItemLocation {
self.project_diff = active_pane_item
.and_then(|item| item.act_as::<ProjectDiff>(cx))
.filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head)
.map(|entity| entity.downgrade());
if self.project_diff.is_some() {
ToolbarItemLocation::PrimaryRight
@@ -1366,18 +1532,42 @@ fn merge_anchor_ranges<'a>(
})
}
struct BranchDiffAddon {
branch_diff: Entity<branch_diff::BranchDiff>,
}
impl Addon for BranchDiffAddon {
fn to_any(&self) -> &dyn std::any::Any {
self
}
fn override_status_for_buffer_id(
&self,
buffer_id: language::BufferId,
cx: &App,
) -> Option<FileStatus> {
self.branch_diff
.read(cx)
.status_for_buffer_id(buffer_id, cx)
}
}
#[cfg(test)]
mod tests {
use collections::HashMap;
use db::indoc;
use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
use git::status::{UnmergedStatus, UnmergedStatusCode};
use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode};
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use std::path::Path;
use unindent::Unindent as _;
use util::{path, rel_path::rel_path};
use util::{
path,
rel_path::{RelPath, rel_path},
};
use super::*;
@@ -2015,6 +2205,99 @@ mod tests {
);
}
#[gpui::test]
async fn test_branch_diff(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"a.txt": "C",
"b.txt": "new",
"c.txt": "in-merge-base-and-work-tree",
"d.txt": "created-in-head",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let diff = cx
.update(|window, cx| {
ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
})
.await
.unwrap();
cx.run_until_parked();
fs.set_head_for_repo(
Path::new(path!("/project/.git")),
&[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
"sha",
);
// fs.set_index_for_repo(dot_git, index_state);
fs.set_merge_base_content_for_repo(
Path::new(path!("/project/.git")),
&[
("a.txt", "A".into()),
("c.txt", "in-merge-base-and-work-tree".into()),
],
);
cx.run_until_parked();
let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
assert_state_with_diff(
&editor,
cx,
&"
- A
+ ˇC
+ new
+ created-in-head"
.unindent(),
);
let statuses: HashMap<Arc<RelPath>, Option<FileStatus>> =
editor.update(cx, |editor, cx| {
editor
.buffer()
.read(cx)
.all_buffers()
.iter()
.map(|buffer| {
(
buffer.read(cx).file().unwrap().path().clone(),
editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
)
})
.collect()
});
assert_eq!(
statuses,
HashMap::from_iter([
(
rel_path("a.txt").into_arc(),
Some(FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Modified,
worktree_status: git::status::StatusCode::Modified
}))
),
(rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
(
rel_path("d.txt").into_arc(),
Some(FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Added,
worktree_status: git::status::StatusCode::Added
}))
)
])
);
}
#[gpui::test]
async fn test_update_on_uncommit(cx: &mut TestAppContext) {
init_test(cx);
+196 -1
View File
@@ -1,3 +1,4 @@
pub mod branch_diff;
mod conflict_set;
pub mod git_traversal;
@@ -30,7 +31,8 @@ use git::{
},
stash::{GitStash, StashEntry},
status::{
FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode,
DiffTreeType, FileStatus, GitSummary, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus,
UnmergedStatus, UnmergedStatusCode,
},
};
use gpui::{
@@ -55,6 +57,7 @@ use std::{
mem,
ops::Range,
path::{Path, PathBuf},
str::FromStr,
sync::{
Arc,
atomic::{self, AtomicU64},
@@ -432,6 +435,8 @@ impl GitStore {
client.add_entity_request_handler(Self::handle_askpass);
client.add_entity_request_handler(Self::handle_check_for_pushed_commits);
client.add_entity_request_handler(Self::handle_git_diff);
client.add_entity_request_handler(Self::handle_tree_diff);
client.add_entity_request_handler(Self::handle_get_blob_content);
client.add_entity_request_handler(Self::handle_open_unstaged_diff);
client.add_entity_request_handler(Self::handle_open_uncommitted_diff);
client.add_entity_message_handler(Self::handle_update_diff_bases);
@@ -619,6 +624,52 @@ impl GitStore {
cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) })
}
pub fn open_diff_since(
&mut self,
oid: Option<git::Oid>,
buffer: Entity<Buffer>,
repo: Entity<Repository>,
languages: Arc<LanguageRegistry>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<BufferDiff>>> {
cx.spawn(async move |this, cx| {
let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
let content = match oid {
None => None,
Some(oid) => Some(
repo.update(cx, |repo, cx| repo.load_blob_content(oid, cx))?
.await?,
),
};
let buffer_diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, cx))?;
buffer_diff
.update(cx, |buffer_diff, cx| {
buffer_diff.set_base_text(
content.map(Arc::new),
buffer_snapshot.language().cloned(),
Some(languages.clone()),
buffer_snapshot.text,
cx,
)
})?
.await?;
let unstaged_diff = this
.update(cx, |this, cx| this.open_unstaged_diff(buffer.clone(), cx))?
.await?;
buffer_diff.update(cx, |buffer_diff, _| {
buffer_diff.set_secondary_diff(unstaged_diff);
})?;
this.update(cx, |_, cx| {
cx.subscribe(&buffer_diff, Self::on_buffer_diff_event)
.detach();
})?;
Ok(buffer_diff)
})
}
pub fn open_uncommitted_diff(
&mut self,
buffer: Entity<Buffer>,
@@ -2168,6 +2219,75 @@ impl GitStore {
Ok(proto::GitDiffResponse { diff })
}
async fn handle_tree_diff(
this: Entity<Self>,
request: TypedEnvelope<proto::GetTreeDiff>,
mut cx: AsyncApp,
) -> Result<proto::GetTreeDiffResponse> {
let repository_id = RepositoryId(request.payload.repository_id);
let diff_type = if request.payload.is_merge {
DiffTreeType::MergeBase {
base: request.payload.base.into(),
head: request.payload.head.into(),
}
} else {
DiffTreeType::Since {
base: request.payload.base.into(),
head: request.payload.head.into(),
}
};
let diff = this
.update(&mut cx, |this, cx| {
let repository = this.repositories().get(&repository_id)?;
Some(repository.update(cx, |repo, cx| repo.diff_tree(diff_type, cx)))
})?
.context("missing repository")?
.await??;
Ok(proto::GetTreeDiffResponse {
entries: diff
.entries
.into_iter()
.map(|(path, status)| proto::TreeDiffStatus {
path: path.0.to_proto(),
status: match status {
TreeDiffStatus::Added {} => proto::tree_diff_status::Status::Added.into(),
TreeDiffStatus::Modified { .. } => {
proto::tree_diff_status::Status::Modified.into()
}
TreeDiffStatus::Deleted { .. } => {
proto::tree_diff_status::Status::Deleted.into()
}
},
oid: match status {
TreeDiffStatus::Deleted { old } | TreeDiffStatus::Modified { old } => {
Some(old.to_string())
}
TreeDiffStatus::Added => None,
},
})
.collect(),
})
}
async fn handle_get_blob_content(
this: Entity<Self>,
request: TypedEnvelope<proto::GetBlobContent>,
mut cx: AsyncApp,
) -> Result<proto::GetBlobContentResponse> {
let oid = git::Oid::from_str(&request.payload.oid)?;
let repository_id = RepositoryId(request.payload.repository_id);
let content = this
.update(&mut cx, |this, cx| {
let repository = this.repositories().get(&repository_id)?;
Some(repository.update(cx, |repo, cx| repo.load_blob_content(oid, cx)))
})?
.context("missing repository")?
.await?;
Ok(proto::GetBlobContentResponse { content })
}
async fn handle_open_unstaged_diff(
this: Entity<Self>,
request: TypedEnvelope<proto::OpenUnstagedDiff>,
@@ -4303,6 +4423,62 @@ impl Repository {
})
}
pub fn diff_tree(
&mut self,
diff_type: DiffTreeType,
_cx: &App,
) -> oneshot::Receiver<Result<TreeDiff>> {
let repository_id = self.snapshot.id;
self.send_job(None, move |repo, _cx| async move {
match repo {
RepositoryState::Local { backend, .. } => backend.diff_tree(diff_type).await,
RepositoryState::Remote { client, project_id } => {
let response = client
.request(proto::GetTreeDiff {
project_id: project_id.0,
repository_id: repository_id.0,
is_merge: matches!(diff_type, DiffTreeType::MergeBase { .. }),
base: diff_type.base().to_string(),
head: diff_type.head().to_string(),
})
.await?;
let entries = response
.entries
.into_iter()
.filter_map(|entry| {
let status = match entry.status() {
proto::tree_diff_status::Status::Added => TreeDiffStatus::Added,
proto::tree_diff_status::Status::Modified => {
TreeDiffStatus::Modified {
old: git::Oid::from_str(
&entry.oid.context("missing oid").log_err()?,
)
.log_err()?,
}
}
proto::tree_diff_status::Status::Deleted => {
TreeDiffStatus::Deleted {
old: git::Oid::from_str(
&entry.oid.context("missing oid").log_err()?,
)
.log_err()?,
}
}
};
Some((
RepoPath(RelPath::from_proto(&entry.path).log_err()?),
status,
))
})
.collect();
Ok(TreeDiff { entries })
}
}
})
}
pub fn diff(&mut self, diff_type: DiffType, _cx: &App) -> oneshot::Receiver<Result<String>> {
let id = self.id;
self.send_job(None, move |repo, _cx| async move {
@@ -4775,6 +4951,25 @@ impl Repository {
cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
}
fn load_blob_content(&mut self, oid: Oid, cx: &App) -> Task<Result<String>> {
let repository_id = self.snapshot.id;
let rx = self.send_job(None, move |state, _| async move {
match state {
RepositoryState::Local { backend, .. } => backend.load_blob_content(oid).await,
RepositoryState::Remote { client, project_id } => {
let response = client
.request(proto::GetBlobContent {
project_id: project_id.to_proto(),
repository_id: repository_id.0,
oid: oid.to_string(),
})
.await?;
Ok(response.content)
}
}
});
cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
}
fn paths_changed(
&mut self,
+386
View File
@@ -0,0 +1,386 @@
use anyhow::Result;
use buffer_diff::BufferDiff;
use collections::HashSet;
use futures::StreamExt;
use git::{
repository::RepoPath,
status::{DiffTreeType, FileStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus},
};
use gpui::{
App, AsyncWindowContext, Context, Entity, EventEmitter, SharedString, Subscription, Task,
WeakEntity, Window,
};
use language::Buffer;
use text::BufferId;
use util::ResultExt;
use crate::{
Project,
git_store::{GitStoreEvent, Repository, RepositoryEvent},
};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DiffBase {
Head,
Merge { base_ref: SharedString },
}
impl DiffBase {
pub fn is_merge_base(&self) -> bool {
matches!(self, DiffBase::Merge { .. })
}
}
pub struct BranchDiff {
diff_base: DiffBase,
repo: Option<Entity<Repository>>,
project: Entity<Project>,
base_commit: Option<SharedString>,
head_commit: Option<SharedString>,
tree_diff: Option<TreeDiff>,
_subscription: Subscription,
update_needed: postage::watch::Sender<()>,
_task: Task<()>,
}
pub enum BranchDiffEvent {
FileListChanged,
}
impl EventEmitter<BranchDiffEvent> for BranchDiff {}
impl BranchDiff {
pub fn new(
source: DiffBase,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let git_store = project.read(cx).git_store().clone();
let git_store_subscription = cx.subscribe_in(
&git_store,
window,
move |this, _git_store, event, _window, cx| match event {
GitStoreEvent::ActiveRepositoryChanged(_)
| GitStoreEvent::RepositoryUpdated(
_,
RepositoryEvent::StatusesChanged { full_scan: _ },
true,
)
| GitStoreEvent::ConflictsUpdated => {
cx.emit(BranchDiffEvent::FileListChanged);
*this.update_needed.borrow_mut() = ();
}
_ => {}
},
);
let (send, recv) = postage::watch::channel::<()>();
let worker = window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::handle_status_updates(this, recv, cx).await
});
let repo = git_store.read(cx).active_repository();
Self {
diff_base: source,
repo,
project,
tree_diff: None,
base_commit: None,
head_commit: None,
_subscription: git_store_subscription,
_task: worker,
update_needed: send,
}
}
pub fn diff_base(&self) -> &DiffBase {
&self.diff_base
}
pub async fn handle_status_updates(
this: WeakEntity<Self>,
mut recv: postage::watch::Receiver<()>,
cx: &mut AsyncWindowContext,
) {
Self::reload_tree_diff(this.clone(), cx).await.log_err();
while recv.next().await.is_some() {
let Ok(needs_update) = this.update(cx, |this, cx| {
let mut needs_update = false;
let active_repo = this
.project
.read(cx)
.git_store()
.read(cx)
.active_repository();
if active_repo != this.repo {
needs_update = true;
this.repo = active_repo;
} else if let Some(repo) = this.repo.as_ref() {
repo.update(cx, |repo, _| {
if let Some(branch) = &repo.branch
&& let DiffBase::Merge { base_ref } = &this.diff_base
&& let Some(commit) = branch.most_recent_commit.as_ref()
&& &branch.ref_name == base_ref
&& this.base_commit.as_ref() != Some(&commit.sha)
{
this.base_commit = Some(commit.sha.clone());
needs_update = true;
}
if repo.head_commit.as_ref().map(|c| &c.sha) != this.head_commit.as_ref() {
this.head_commit = repo.head_commit.as_ref().map(|c| c.sha.clone());
needs_update = true;
}
})
}
needs_update
}) else {
return;
};
if needs_update {
Self::reload_tree_diff(this.clone(), cx).await.log_err();
}
}
}
pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
let (repo, path) = self
.project
.read(cx)
.git_store()
.read(cx)
.repository_and_path_for_buffer_id(buffer_id, cx)?;
if self.repo() == Some(&repo) {
return self.merge_statuses(
repo.read(cx)
.status_for_path(&path)
.map(|status| status.status),
self.tree_diff
.as_ref()
.and_then(|diff| diff.entries.get(&path)),
);
}
None
}
pub fn merge_statuses(
&self,
diff_from_head: Option<FileStatus>,
diff_from_merge_base: Option<&TreeDiffStatus>,
) -> Option<FileStatus> {
match (diff_from_head, diff_from_merge_base) {
(None, None) => None,
(Some(diff_from_head), None) => Some(diff_from_head),
(Some(diff_from_head @ FileStatus::Unmerged(_)), _) => Some(diff_from_head),
// file does not exist in HEAD
// but *does* exist in work-tree
// and *does* exist in merge-base
(
Some(FileStatus::Untracked)
| Some(FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Added,
worktree_status: _,
})),
Some(_),
) => Some(FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Modified,
worktree_status: StatusCode::Modified,
})),
// file exists in HEAD
// but *does not* exist in work-tree
(Some(diff_from_head), Some(diff_from_merge_base)) if diff_from_head.is_deleted() => {
match diff_from_merge_base {
TreeDiffStatus::Added => None, // unchanged, didn't exist in merge base or worktree
_ => Some(diff_from_head),
}
}
// file exists in HEAD
// and *does* exist in work-tree
(Some(FileStatus::Tracked(_)), Some(tree_status)) => {
Some(FileStatus::Tracked(TrackedStatus {
index_status: match tree_status {
TreeDiffStatus::Added { .. } => StatusCode::Added,
_ => StatusCode::Modified,
},
worktree_status: match tree_status {
TreeDiffStatus::Added => StatusCode::Added,
_ => StatusCode::Modified,
},
}))
}
(_, Some(diff_from_merge_base)) => {
Some(diff_status_to_file_status(diff_from_merge_base))
}
}
}
pub async fn reload_tree_diff(
this: WeakEntity<Self>,
cx: &mut AsyncWindowContext,
) -> Result<()> {
let task = this.update(cx, |this, cx| {
let DiffBase::Merge { base_ref } = this.diff_base.clone() else {
return None;
};
let Some(repo) = this.repo.as_ref() else {
this.tree_diff.take();
return None;
};
repo.update(cx, |repo, cx| {
Some(repo.diff_tree(
DiffTreeType::MergeBase {
base: base_ref,
head: "HEAD".into(),
},
cx,
))
})
})?;
let Some(task) = task else { return Ok(()) };
let diff = task.await??;
this.update(cx, |this, cx| {
this.tree_diff = Some(diff);
cx.emit(BranchDiffEvent::FileListChanged);
cx.notify();
})
}
pub fn repo(&self) -> Option<&Entity<Repository>> {
self.repo.as_ref()
}
pub fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<DiffBuffer> {
let mut output = Vec::default();
let Some(repo) = self.repo.clone() else {
return output;
};
self.project.update(cx, |_project, cx| {
let mut seen = HashSet::default();
for item in repo.read(cx).cached_status() {
seen.insert(item.repo_path.clone());
let branch_diff = self
.tree_diff
.as_ref()
.and_then(|t| t.entries.get(&item.repo_path))
.cloned();
let status = self
.merge_statuses(Some(item.status), branch_diff.as_ref())
.unwrap();
if !status.has_changes() {
continue;
}
let Some(project_path) =
repo.read(cx).repo_path_to_project_path(&item.repo_path, cx)
else {
continue;
};
let task = Self::load_buffer(branch_diff, project_path, repo.clone(), cx);
output.push(DiffBuffer {
repo_path: item.repo_path.clone(),
load: task,
file_status: item.status,
});
}
let Some(tree_diff) = self.tree_diff.as_ref() else {
return;
};
for (path, branch_diff) in tree_diff.entries.iter() {
if seen.contains(&path) {
continue;
}
let Some(project_path) = repo.read(cx).repo_path_to_project_path(&path, cx) else {
continue;
};
let task =
Self::load_buffer(Some(branch_diff.clone()), project_path, repo.clone(), cx);
let file_status = diff_status_to_file_status(branch_diff);
output.push(DiffBuffer {
repo_path: path.clone(),
load: task,
file_status,
});
}
});
output
}
fn load_buffer(
branch_diff: Option<git::status::TreeDiffStatus>,
project_path: crate::ProjectPath,
repo: Entity<Repository>,
cx: &Context<'_, Project>,
) -> Task<Result<(Entity<Buffer>, Entity<BufferDiff>)>> {
let task = cx.spawn(async move |project, cx| {
let buffer = project
.update(cx, |project, cx| project.open_buffer(project_path, cx))?
.await?;
let languages = project.update(cx, |project, _cx| project.languages().clone())?;
let changes = if let Some(entry) = branch_diff {
let oid = match entry {
git::status::TreeDiffStatus::Added { .. } => None,
git::status::TreeDiffStatus::Modified { old, .. }
| git::status::TreeDiffStatus::Deleted { old } => Some(old),
};
project
.update(cx, |project, cx| {
project.git_store().update(cx, |git_store, cx| {
git_store.open_diff_since(oid, buffer.clone(), repo, languages, cx)
})
})?
.await?
} else {
project
.update(cx, |project, cx| {
project.open_uncommitted_diff(buffer.clone(), cx)
})?
.await?
};
Ok((buffer, changes))
});
task
}
}
fn diff_status_to_file_status(branch_diff: &git::status::TreeDiffStatus) -> FileStatus {
let file_status = match branch_diff {
git::status::TreeDiffStatus::Added { .. } => FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Added,
worktree_status: StatusCode::Added,
}),
git::status::TreeDiffStatus::Modified { .. } => FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Modified,
worktree_status: StatusCode::Modified,
}),
git::status::TreeDiffStatus::Deleted { .. } => FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Deleted,
worktree_status: StatusCode::Deleted,
}),
};
file_status
}
#[derive(Debug)]
pub struct DiffBuffer {
pub repo_path: RepoPath,
pub file_status: FileStatus,
pub load: Task<Result<(Entity<Buffer>, Entity<BufferDiff>)>>,
}
+34
View File
@@ -472,3 +472,37 @@ message GetDefaultBranch {
message GetDefaultBranchResponse {
optional string branch = 1;
}
message GetTreeDiff {
uint64 project_id = 1;
uint64 repository_id = 2;
bool is_merge = 3;
string base = 4;
string head = 5;
}
message GetTreeDiffResponse {
repeated TreeDiffStatus entries = 1;
}
message TreeDiffStatus {
enum Status {
ADDED = 0;
MODIFIED = 1;
DELETED = 2;
}
Status status = 1;
string path = 2;
optional string oid = 3;
}
message GetBlobContent {
uint64 project_id = 1;
uint64 repository_id = 2;
string oid =3;
}
message GetBlobContentResponse {
string content = 1;
}
+7 -1
View File
@@ -421,7 +421,13 @@ message Envelope {
RemoteStarted remote_started = 381;
GetDirectoryEnvironment get_directory_environment = 382;
DirectoryEnvironment directory_environment = 383; // current max
DirectoryEnvironment directory_environment = 383;
GetTreeDiff get_tree_diff = 384;
GetTreeDiffResponse get_tree_diff_response = 385;
GetBlobContent get_blob_content = 386;
GetBlobContentResponse get_blob_content_response = 387; // current max
}
reserved 87 to 88;
+8
View File
@@ -316,6 +316,10 @@ messages!(
(PullWorkspaceDiagnostics, Background),
(GetDefaultBranch, Background),
(GetDefaultBranchResponse, Background),
(GetTreeDiff, Background),
(GetTreeDiffResponse, Background),
(GetBlobContent, Background),
(GetBlobContentResponse, Background),
(GitClone, Background),
(GitCloneResponse, Background),
(ToggleLspLogs, Background),
@@ -497,6 +501,8 @@ request_messages!(
(GetDocumentDiagnostics, GetDocumentDiagnosticsResponse),
(PullWorkspaceDiagnostics, Ack),
(GetDefaultBranch, GetDefaultBranchResponse),
(GetBlobContent, GetBlobContentResponse),
(GetTreeDiff, GetTreeDiffResponse),
(GitClone, GitCloneResponse),
(ToggleLspLogs, Ack),
(GetDirectoryEnvironment, DirectoryEnvironment),
@@ -659,6 +665,8 @@ entity_messages!(
GetDocumentDiagnostics,
PullWorkspaceDiagnostics,
GetDefaultBranch,
GetTreeDiff,
GetBlobContent,
GitClone,
GetAgentServerCommand,
ExternalAgentsUpdated,
-3
View File
@@ -18,7 +18,6 @@ use breadcrumbs::Breadcrumbs;
use client::zed_urls;
use collections::VecDeque;
use debugger_ui::debugger_panel::DebugPanel;
use editor::ProposedChangesEditorToolbar;
use editor::{Editor, MultiBuffer};
use extension_host::ExtensionStore;
use feature_flags::{FeatureFlagAppExt, PanicFeatureFlag};
@@ -1035,8 +1034,6 @@ fn initialize_pane(
)
});
toolbar.add_item(buffer_search_bar.clone(), window, cx);
let proposed_change_bar = cx.new(|_| ProposedChangesEditorToolbar::new());
toolbar.add_item(proposed_change_bar, window, cx);
let quick_action_bar =
cx.new(|cx| QuickActionBar::new(buffer_search_bar, workspace, cx));
toolbar.add_item(quick_action_bar, window, cx);