Project Diff 2 (#23891)

This adds a new version of the project diff editor to go alongside the
new git panel.

The basics seem to be working, but still todo:

* [ ] Fix untracked files
* [ ] Fix deleted files
* [ ] Show commit message editor at top
* [x] Handle empty state
* [x] Fix panic where locator sometimes seeks to wrong excerpt

Release Notes:

- N/A
This commit is contained in:
Conrad Irwin 2025-02-03 13:18:50 -07:00 committed by GitHub
parent 27a413a5e3
commit 45708d2680
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1023 additions and 125 deletions

4
Cargo.lock generated
View file

@ -4009,7 +4009,6 @@ dependencies = [
"db",
"emojis",
"env_logger 0.11.6",
"feature_flags",
"file_icons",
"fs",
"futures 0.3.31",
@ -5307,12 +5306,15 @@ dependencies = [
"collections",
"db",
"editor",
"feature_flags",
"futures 0.3.31",
"git",
"gpui",
"language",
"menu",
"multi_buffer",
"picker",
"postage",
"project",
"rpc",
"schemars",

View file

@ -201,9 +201,8 @@ impl UserStore {
cx.update(|cx| {
if let Some(info) = info {
let disable_staff = std::env::var("ZED_DISABLE_STAFF")
.map_or(false, |v| !v.is_empty() && v != "0");
let staff = info.staff && !disable_staff;
let staff =
info.staff && !*feature_flags::ZED_DISABLE_STAFF;
cx.update_flags(staff, info.flags);
client.telemetry.set_authenticated_user_info(
Some(info.metrics_id.clone()),

View file

@ -39,7 +39,6 @@ collections.workspace = true
convert_case.workspace = true
db.workspace = true
emojis.workspace = true
feature_flags.workspace = true
file_icons.workspace = true
futures.workspace = true
fuzzy.workspace = true

View file

@ -339,7 +339,6 @@ pub fn init(cx: &mut App) {
.detach();
}
});
git::project_diff::init(cx);
}
pub struct SearchWithinRange;
@ -4653,7 +4652,7 @@ impl Editor {
let mut read_ranges = Vec::new();
for highlight in highlights {
for (excerpt_id, excerpt_range) in
buffer.excerpts_for_buffer(&cursor_buffer, cx)
buffer.excerpts_for_buffer(cursor_buffer.read(cx).remote_id(), cx)
{
let start = highlight
.range
@ -11747,10 +11746,7 @@ impl Editor {
if self.buffer().read(cx).is_singleton() || self.is_buffer_folded(buffer_id, cx) {
return;
}
let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else {
return;
};
let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(&buffer, cx);
let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx);
self.display_map
.update(cx, |display_map, cx| display_map.fold_buffer(buffer_id, cx));
cx.emit(EditorEvent::BufferFoldToggled {
@ -11764,10 +11760,7 @@ impl Editor {
if self.buffer().read(cx).is_singleton() || !self.is_buffer_folded(buffer_id, cx) {
return;
}
let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else {
return;
};
let unfolded_excerpts = self.buffer().read(cx).excerpts_for_buffer(&buffer, cx);
let unfolded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx);
self.display_map.update(cx, |display_map, cx| {
display_map.unfold_buffer(buffer_id, cx);
});

View file

@ -1,2 +1 @@
pub mod blame;
pub mod project_diff;

View file

@ -743,12 +743,12 @@ fn determine_query_ranges(
excerpt_visible_range: Range<usize>,
cx: &mut Context<'_, MultiBuffer>,
) -> Option<QueryRanges> {
let buffer = excerpt_buffer.read(cx);
let full_excerpt_range = multi_buffer
.excerpts_for_buffer(excerpt_buffer, cx)
.excerpts_for_buffer(buffer.remote_id(), cx)
.into_iter()
.find(|(id, _)| id == &excerpt_id)
.map(|(_, range)| range.context)?;
let buffer = excerpt_buffer.read(cx);
let snapshot = buffer.snapshot();
let excerpt_visible_len = excerpt_visible_range.end - excerpt_visible_range.start;

View file

@ -1,6 +1,9 @@
use futures::channel::oneshot;
use futures::{select_biased, FutureExt};
use gpui::{App, Context, Global, Subscription, Task, Window};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::LazyLock;
use std::time::Duration;
use std::{future::Future, pin::Pin, task::Poll};
@ -10,12 +13,21 @@ struct FeatureFlags {
staff: bool,
}
pub static ZED_DISABLE_STAFF: LazyLock<bool> = LazyLock::new(|| {
std::env::var("ZED_DISABLE_STAFF").map_or(false, |value| !value.is_empty() && value != "0")
});
impl FeatureFlags {
fn has_flag<T: FeatureFlag>(&self) -> bool {
if self.staff && T::enabled_for_staff() {
return true;
}
#[cfg(debug_assertions)]
if T::enabled_in_development() {
return true;
}
self.flags.iter().any(|f| f.as_str() == T::NAME)
}
}
@ -35,6 +47,10 @@ pub trait FeatureFlag {
fn enabled_for_staff() -> bool {
true
}
fn enabled_in_development() -> bool {
Self::enabled_for_staff() && !*ZED_DISABLE_STAFF
}
}
pub struct Assistant2FeatureFlag;
@ -97,6 +113,12 @@ pub trait FeatureFlagViewExt<V: 'static> {
fn observe_flag<T: FeatureFlag, F>(&mut self, window: &Window, callback: F) -> Subscription
where
F: Fn(bool, &mut V, &mut Window, &mut Context<V>) + Send + Sync + 'static;
fn when_flag_enabled<T: FeatureFlag>(
&mut self,
window: &mut Window,
callback: impl Fn(&mut V, &mut Window, &mut Context<V>) + Send + Sync + 'static,
);
}
impl<V> FeatureFlagViewExt<V> for Context<'_, V>
@ -112,6 +134,35 @@ where
callback(feature_flags.has_flag::<T>(), v, window, cx);
})
}
fn when_flag_enabled<T: FeatureFlag>(
&mut self,
window: &mut Window,
callback: impl Fn(&mut V, &mut Window, &mut Context<V>) + Send + Sync + 'static,
) {
if self
.try_global::<FeatureFlags>()
.is_some_and(|f| f.has_flag::<T>())
|| cfg!(debug_assertions) && T::enabled_in_development()
{
self.defer_in(window, move |view, window, cx| {
callback(view, window, cx);
});
return;
}
let subscription = Rc::new(RefCell::new(None));
let inner = self.observe_global_in::<FeatureFlags>(window, {
let subscription = subscription.clone();
move |v, window, cx| {
let feature_flags = cx.global::<FeatureFlags>();
if feature_flags.has_flag::<T>() {
callback(v, window, cx);
subscription.take();
}
}
});
subscription.borrow_mut().replace(inner);
}
}
pub trait FeatureFlagAppExt {

View file

@ -133,6 +133,10 @@ impl FileStatus {
}
}
pub fn has_changes(&self) -> bool {
self.is_modified() || self.is_created() || self.is_deleted() || self.is_untracked()
}
pub fn is_modified(self) -> bool {
match self {
FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {

View file

@ -17,11 +17,14 @@ anyhow.workspace = true
collections.workspace = true
db.workspace = true
editor.workspace = true
feature_flags.workspace = true
futures.workspace = true
git.workspace = true
gpui.workspace = true
language.workspace = true
multi_buffer.workspace = true
menu.workspace = true
postage.workspace = true
project.workspace = true
rpc.workspace = true
schemars.workspace = true

View file

@ -1,5 +1,6 @@
use crate::git_panel_settings::StatusStyle;
use crate::repository_selector::RepositorySelectorPopoverMenu;
use crate::ProjectDiff;
use crate::{
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
};
@ -207,31 +208,6 @@ fn commit_message_editor(
}
impl GitPanel {
pub fn load(
workspace: WeakEntity<Workspace>,
cx: AsyncWindowContext,
) -> Task<Result<Entity<Self>>> {
cx.spawn(|mut cx| async move {
let commit_message_buffer = workspace.update(&mut cx, |workspace, cx| {
let project = workspace.project();
let active_repository = project.read(cx).active_repository(cx);
active_repository
.map(|active_repository| commit_message_buffer(project, &active_repository, cx))
})?;
let commit_message_buffer = match commit_message_buffer {
Some(commit_message_buffer) => Some(
commit_message_buffer
.await
.context("opening commit buffer")?,
),
None => None,
};
workspace.update_in(&mut cx, |workspace, window, cx| {
Self::new(workspace, window, commit_message_buffer, cx)
})
})
}
pub fn new(
workspace: &mut Workspace,
window: &mut Window,
@ -240,7 +216,7 @@ impl GitPanel {
) -> Entity<Self> {
let fs = workspace.app_state().fs.clone();
let project = workspace.project().clone();
let git_state = project.read(cx).git_state().cloned();
let git_state = project.read(cx).git_state().clone();
let active_repository = project.read(cx).active_repository(cx);
let (err_sender, mut err_receiver) = mpsc::channel(1);
let workspace = cx.entity().downgrade();
@ -261,19 +237,17 @@ impl GitPanel {
let scroll_handle = UniformListScrollHandle::new();
if let Some(git_state) = git_state {
cx.subscribe_in(
&git_state,
window,
move |this, git_state, event, window, cx| match event {
project::git::Event::RepositoriesUpdated => {
this.active_repository = git_state.read(cx).active_repository();
this.schedule_update(window, cx);
}
},
)
.detach();
}
cx.subscribe_in(
&git_state,
window,
move |this, git_state, event, window, cx| match event {
project::git::Event::RepositoriesUpdated => {
this.active_repository = git_state.read(cx).active_repository();
this.schedule_update(window, cx);
}
},
)
.detach();
let repository_selector =
cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
@ -344,8 +318,24 @@ impl GitPanel {
git_panel
}
pub fn set_focused_path(&mut self, path: ProjectPath, _: &mut Window, cx: &mut Context<Self>) {
let Some(git_repo) = self.active_repository.as_ref() else {
return;
};
let Some(repo_path) = git_repo.project_path_to_repo_path(&path) else {
return;
};
let Ok(ix) = self
.visible_entries
.binary_search_by_key(&&repo_path, |entry| &entry.repo_path)
else {
return;
};
self.selected_entry = Some(ix);
cx.notify();
}
fn serialize(&mut self, cx: &mut Context<Self>) {
// TODO: we can store stage status here
let width = self.width;
self.pending_serialization = cx.background_executor().spawn(
async move {
@ -623,7 +613,7 @@ impl GitPanel {
let Some(active_repository) = self.active_repository.as_ref() else {
return;
};
let Some(path) = active_repository.unrelativize(&entry.repo_path) else {
let Some(path) = active_repository.repo_path_to_project_path(&entry.repo_path) else {
return;
};
let path_exists = self.project.update(cx, |project, cx| {
@ -1021,8 +1011,8 @@ impl GitPanel {
.project
.read(cx)
.git_state()
.map(|state| state.read(cx).all_repositories())
.unwrap_or_default();
.read(cx)
.all_repositories();
let entry_count = self
.active_repository
.as_ref()
@ -1408,17 +1398,26 @@ impl GitPanel {
.toggle_state(selected)
.disabled(!has_write_access)
.on_click({
let handle = cx.entity().downgrade();
move |_, window, cx| {
let Some(this) = handle.upgrade() else {
let repo_path = entry_details.repo_path.clone();
cx.listener(move |this, _, window, cx| {
this.selected_entry = Some(ix);
window.dispatch_action(Box::new(OpenSelected), cx);
cx.notify();
let Some(workspace) = this.workspace.upgrade() else {
return;
};
this.update(cx, |this, cx| {
this.selected_entry = Some(ix);
window.dispatch_action(Box::new(OpenSelected), cx);
cx.notify();
});
}
let Some(git_repo) = this.active_repository.as_ref() else {
return;
};
let Some(path) = git_repo.repo_path_to_project_path(&repo_path).and_then(
|project_path| this.project.read(cx).absolute_path(&project_path, cx),
) else {
return;
};
workspace.update(cx, |workspace, cx| {
ProjectDiff::deploy_at(workspace, Some(path.into()), window, cx);
})
})
})
.child(
h_flex()

View file

@ -2,14 +2,17 @@ use ::settings::Settings;
use git::status::FileStatus;
use git_panel_settings::GitPanelSettings;
use gpui::App;
use project_diff::ProjectDiff;
use ui::{ActiveTheme, Color, Icon, IconName, IntoElement};
pub mod git_panel;
mod git_panel_settings;
pub mod project_diff;
pub mod repository_selector;
pub fn init(cx: &mut App) {
GitPanelSettings::register(cx);
cx.observe_new(ProjectDiff::register).detach();
}
// TODO: Add updated status colors to theme

View file

@ -0,0 +1,495 @@
use std::{
any::{Any, TypeId},
path::Path,
sync::Arc,
};
use anyhow::Result;
use collections::HashSet;
use editor::{scroll::Autoscroll, Editor, EditorEvent};
use feature_flags::FeatureFlagViewExt;
use futures::StreamExt;
use gpui::{
actions, AnyElement, AnyView, App, AppContext, AsyncWindowContext, Entity, EventEmitter,
FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
};
use language::{Anchor, Buffer, Capability, OffsetRangeExt};
use multi_buffer::MultiBuffer;
use project::{buffer_store::BufferChangeSet, git::GitState, Project, ProjectPath};
use theme::ActiveTheme;
use ui::prelude::*;
use util::ResultExt as _;
use workspace::{
item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
searchable::SearchableItemHandle,
ItemNavHistory, ToolbarItemLocation, Workspace,
};
use crate::git_panel::GitPanel;
actions!(git, [ShowUncommittedChanges]);
pub(crate) struct ProjectDiff {
multibuffer: Entity<MultiBuffer>,
editor: Entity<Editor>,
project: Entity<Project>,
git_state: Entity<GitState>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
update_needed: postage::watch::Sender<()>,
pending_scroll: Option<Arc<Path>>,
_task: Task<Result<()>>,
_subscription: Subscription,
}
struct DiffBuffer {
abs_path: Arc<Path>,
buffer: Entity<Buffer>,
change_set: Entity<BufferChangeSet>,
}
impl ProjectDiff {
pub(crate) fn register(
_: &mut Workspace,
window: Option<&mut Window>,
cx: &mut Context<Workspace>,
) {
let Some(window) = window else { return };
cx.when_flag_enabled::<feature_flags::GitUiFeatureFlag>(window, |workspace, _, _cx| {
workspace.register_action(Self::deploy);
});
}
fn deploy(
workspace: &mut Workspace,
_: &ShowUncommittedChanges,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
Self::deploy_at(workspace, None, window, cx)
}
pub fn deploy_at(
workspace: &mut Workspace,
path: Option<Arc<Path>>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
workspace.activate_item(&existing, true, true, window, cx);
existing
} else {
let workspace_handle = cx.entity().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.clone()),
None,
true,
window,
cx,
);
project_diff
};
if let Some(path) = path {
project_diff.update(cx, |project_diff, cx| {
project_diff.scroll_to(path, window, cx);
})
}
}
fn new(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let editor = cx.new(|cx| {
let mut diff_display_editor = Editor::for_multibuffer(
multibuffer.clone(),
Some(project.clone()),
true,
window,
cx,
);
diff_display_editor.set_expand_all_diff_hunks(cx);
diff_display_editor
});
cx.subscribe_in(&editor, window, Self::handle_editor_event)
.detach();
let git_state = project.read(cx).git_state().clone();
let git_state_subscription = cx.subscribe_in(
&git_state,
window,
move |this, _git_state, event, _window, _cx| match event {
project::git::Event::RepositoriesUpdated => {
*this.update_needed.borrow_mut() = ();
}
},
);
let (mut send, recv) = postage::watch::channel::<()>();
let worker = window.spawn(cx, {
let this = cx.weak_entity();
|cx| Self::handle_status_updates(this, recv, cx)
});
// Kick of a refresh immediately
*send.borrow_mut() = ();
Self {
project,
git_state: git_state.clone(),
workspace,
focus_handle,
editor,
multibuffer,
pending_scroll: None,
update_needed: send,
_task: worker,
_subscription: git_state_subscription,
}
}
pub fn scroll_to(&mut self, path: Arc<Path>, window: &mut Window, cx: &mut Context<Self>) {
if let Some(position) = self.multibuffer.read(cx).location_for_path(&path, cx) {
self.editor.update(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
s.select_ranges([position..position]);
})
})
} else {
self.pending_scroll = Some(path);
}
}
fn handle_editor_event(
&mut self,
editor: &Entity<Editor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
EditorEvent::ScrollPositionChanged { .. } => editor.update(cx, |editor, cx| {
let anchor = editor.scroll_manager.anchor().anchor;
let Some((_, buffer, _)) = self.multibuffer.read(cx).excerpt_containing(anchor, cx)
else {
return;
};
let Some(project_path) = buffer
.read(cx)
.file()
.map(|file| (file.worktree_id(cx), file.path().clone()))
else {
return;
};
self.workspace
.update(cx, |workspace, cx| {
if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
git_panel.update(cx, |git_panel, cx| {
git_panel.set_focused_path(project_path.into(), window, cx)
})
}
})
.ok();
}),
_ => {}
}
}
fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
let Some(repo) = self.git_state.read(cx).active_repository() else {
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.clear(cx);
});
return vec![];
};
let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
let mut result = vec![];
for entry in repo.status() {
if !entry.status.has_changes() {
continue;
}
let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else {
continue;
};
let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
continue;
};
let abs_path = Arc::from(abs_path);
previous_paths.remove(&abs_path);
let load_buffer = self
.project
.update(cx, |project, cx| project.open_buffer(project_path, cx));
let project = self.project.clone();
result.push(cx.spawn(|_, mut cx| async move {
let buffer = load_buffer.await?;
let changes = project
.update(&mut cx, |project, cx| {
project.open_unstaged_changes(buffer.clone(), cx)
})?
.await?;
Ok(DiffBuffer {
abs_path,
buffer,
change_set: changes,
})
}));
}
self.multibuffer.update(cx, |multibuffer, cx| {
for path in previous_paths {
multibuffer.remove_excerpts_for_path(path, cx);
}
});
result
}
fn register_buffer(
&mut self,
diff_buffer: DiffBuffer,
window: &mut Window,
cx: &mut Context<Self>,
) {
let abs_path = diff_buffer.abs_path;
let buffer = diff_buffer.buffer;
let change_set = diff_buffer.change_set;
let snapshot = buffer.read(cx).snapshot();
let diff_hunk_ranges = change_set
.read(cx)
.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot)
.map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
.collect::<Vec<_>>();
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
abs_path.clone(),
buffer,
diff_hunk_ranges,
editor::DEFAULT_MULTIBUFFER_CONTEXT,
cx,
);
});
if self.pending_scroll.as_ref() == Some(&abs_path) {
self.scroll_to(abs_path, window, cx);
}
}
pub async fn handle_status_updates(
this: WeakEntity<Self>,
mut recv: postage::watch::Receiver<()>,
mut cx: AsyncWindowContext,
) -> Result<()> {
while let Some(_) = recv.next().await {
let buffers_to_load = this.update(&mut 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();
})?;
}
}
this.update(&mut cx, |this, _| this.pending_scroll.take())?;
}
Ok(())
}
}
impl EventEmitter<EditorEvent> for ProjectDiff {}
impl Focusable for ProjectDiff {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Item for ProjectDiff {
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 {
Label::new("Uncommitted Changes")
.color(if params.selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("project diagnostics")
}
fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
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| ProjectDiff::new(self.project.clone(), self.workspace.clone(), window, cx)),
)
}
fn is_dirty(&self, cx: &App) -> bool {
self.multibuffer.read(cx).is_dirty(cx)
}
fn has_conflict(&self, cx: &App) -> bool {
self.multibuffer.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<Result<()>> {
self.editor.save(format, project, window, cx)
}
fn save_as(
&mut self,
_: Entity<Project>,
_: ProjectPath,
_window: &mut Window,
_: &mut Context<Self>,
) -> Task<Result<()>> {
unreachable!()
}
fn reload(
&mut self,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<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 ProjectDiff {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_empty = self.multibuffer.read(cx).is_empty();
if is_empty {
div()
.bg(cx.theme().colors().editor_background)
.flex()
.items_center()
.justify_center()
.size_full()
.child(Label::new("No uncommitted changes"))
} else {
div()
.bg(cx.theme().colors().editor_background)
.flex()
.items_center()
.justify_center()
.size_full()
.child(self.editor.clone())
}
}
}

View file

@ -20,10 +20,8 @@ pub struct RepositorySelector {
impl RepositorySelector {
pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let git_state = project.read(cx).git_state().cloned();
let all_repositories = git_state
.as_ref()
.map_or(vec![], |git_state| git_state.read(cx).all_repositories());
let git_state = project.read(cx).git_state().clone();
let all_repositories = git_state.read(cx).all_repositories();
let filtered_repositories = all_repositories.clone();
let delegate = RepositorySelectorDelegate {
project: project.downgrade(),
@ -38,11 +36,8 @@ impl RepositorySelector {
.max_height(Some(rems(20.).into()))
});
let _subscriptions = if let Some(git_state) = git_state {
vec![cx.subscribe_in(&git_state, window, Self::handle_project_git_event)]
} else {
Vec::new()
};
let _subscriptions =
vec![cx.subscribe_in(&git_state, window, Self::handle_project_git_event)];
RepositorySelector {
picker,

View file

@ -80,7 +80,7 @@ impl GoToLine {
let last_line = editor
.buffer()
.read(cx)
.excerpts_for_buffer(&active_buffer, cx)
.excerpts_for_buffer(snapshot.remote_id(), cx)
.into_iter()
.map(move |(_, range)| text::ToPoint::to_point(&range.context.end, &snapshot).row)
.max()

View file

@ -35,6 +35,7 @@ use std::{
iter::{self, FromIterator},
mem,
ops::{Range, RangeBounds, Sub},
path::Path,
str,
sync::Arc,
time::{Duration, Instant},
@ -65,6 +66,8 @@ pub struct MultiBuffer {
snapshot: RefCell<MultiBufferSnapshot>,
/// Contains the state of the buffers being edited
buffers: RefCell<HashMap<BufferId, BufferState>>,
// only used by consumers using `set_excerpts_for_buffer`
buffers_by_path: BTreeMap<Arc<Path>, Vec<ExcerptId>>,
diff_bases: HashMap<BufferId, ChangeSetState>,
all_diff_hunks_expanded: bool,
subscriptions: Topic,
@ -494,6 +497,7 @@ impl MultiBuffer {
singleton: false,
capability,
title: None,
buffers_by_path: Default::default(),
history: History {
next_transaction_id: clock::Lamport::default(),
undo_stack: Vec::new(),
@ -508,6 +512,7 @@ impl MultiBuffer {
Self {
snapshot: Default::default(),
buffers: Default::default(),
buffers_by_path: Default::default(),
diff_bases: HashMap::default(),
all_diff_hunks_expanded: false,
subscriptions: Default::default(),
@ -561,6 +566,7 @@ impl MultiBuffer {
Self {
snapshot: RefCell::new(self.snapshot.borrow().clone()),
buffers: RefCell::new(buffers),
buffers_by_path: Default::default(),
diff_bases,
all_diff_hunks_expanded: self.all_diff_hunks_expanded,
subscriptions: Default::default(),
@ -648,8 +654,8 @@ impl MultiBuffer {
self.read(cx).len()
}
pub fn is_empty(&self, cx: &App) -> bool {
self.len(cx) != 0
pub fn is_empty(&self) -> bool {
self.buffers.borrow().is_empty()
}
pub fn symbols_containing<T: ToOffset>(
@ -1388,6 +1394,138 @@ impl MultiBuffer {
anchor_ranges
}
pub fn location_for_path(&self, path: &Arc<Path>, cx: &App) -> Option<Anchor> {
let excerpt_id = self.buffers_by_path.get(path)?.first()?;
let snapshot = self.snapshot(cx);
let excerpt = snapshot.excerpt(*excerpt_id)?;
Some(Anchor::in_buffer(
*excerpt_id,
excerpt.buffer_id,
excerpt.range.context.start,
))
}
pub fn set_excerpts_for_path(
&mut self,
path: Arc<Path>,
buffer: Entity<Buffer>,
ranges: Vec<Range<Point>>,
context_line_count: u32,
cx: &mut Context<Self>,
) {
let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
let (mut insert_after, excerpt_ids) =
if let Some(existing) = self.buffers_by_path.get(&path) {
(*existing.last().unwrap(), existing.clone())
} else {
(
self.buffers_by_path
.range(..path.clone())
.next_back()
.map(|(_, value)| *value.last().unwrap())
.unwrap_or(ExcerptId::min()),
Vec::default(),
)
};
let (new, _) = build_excerpt_ranges(&buffer_snapshot, &ranges, context_line_count);
let mut new_iter = new.into_iter().peekable();
let mut existing_iter = excerpt_ids.into_iter().peekable();
let mut new_excerpt_ids = Vec::new();
let mut to_remove = Vec::new();
let mut to_insert = Vec::new();
let snapshot = self.snapshot(cx);
let mut excerpts_cursor = snapshot.excerpts.cursor::<Option<&Locator>>(&());
excerpts_cursor.next(&());
loop {
let (new, existing) = match (new_iter.peek(), existing_iter.peek()) {
(Some(new), Some(existing)) => (new, existing),
(None, None) => break,
(None, Some(_)) => {
to_remove.push(existing_iter.next().unwrap());
continue;
}
(Some(_), None) => {
to_insert.push(new_iter.next().unwrap());
continue;
}
};
let locator = snapshot.excerpt_locator_for_id(*existing);
excerpts_cursor.seek_forward(&Some(locator), Bias::Left, &());
let existing_excerpt = excerpts_cursor.item().unwrap();
if existing_excerpt.buffer_id != buffer_snapshot.remote_id() {
to_remove.push(existing_iter.next().unwrap());
to_insert.push(new_iter.next().unwrap());
continue;
}
let existing_start = existing_excerpt
.range
.context
.start
.to_point(&buffer_snapshot);
let existing_end = existing_excerpt
.range
.context
.end
.to_point(&buffer_snapshot);
if existing_end < new.context.start {
to_remove.push(existing_iter.next().unwrap());
continue;
} else if existing_start > new.context.end {
to_insert.push(new_iter.next().unwrap());
continue;
}
// maybe merge overlapping excerpts?
// it's hard to distinguish between a manually expanded excerpt, and one that
// got smaller because of a missing diff.
//
if existing_start == new.context.start && existing_end == new.context.end {
new_excerpt_ids.append(&mut self.insert_excerpts_after(
insert_after,
buffer.clone(),
mem::take(&mut to_insert),
cx,
));
insert_after = existing_iter.next().unwrap();
new_excerpt_ids.push(insert_after);
new_iter.next();
} else {
to_remove.push(existing_iter.next().unwrap());
to_insert.push(new_iter.next().unwrap());
}
}
new_excerpt_ids.append(&mut self.insert_excerpts_after(
insert_after,
buffer,
to_insert,
cx,
));
self.remove_excerpts(to_remove, cx);
if new_excerpt_ids.is_empty() {
self.buffers_by_path.remove(&path);
} else {
self.buffers_by_path.insert(path, new_excerpt_ids);
}
}
pub fn paths(&self) -> impl Iterator<Item = Arc<Path>> + '_ {
self.buffers_by_path.keys().cloned()
}
pub fn remove_excerpts_for_path(&mut self, path: Arc<Path>, cx: &mut Context<Self>) {
if let Some(to_remove) = self.buffers_by_path.remove(&path) {
self.remove_excerpts(to_remove, cx)
}
}
pub fn push_multiple_excerpts_with_context_lines(
&self,
buffers_with_ranges: Vec<(Entity<Buffer>, Vec<Range<text::Anchor>>)>,
@ -1654,7 +1792,7 @@ impl MultiBuffer {
pub fn excerpts_for_buffer(
&self,
buffer: &Entity<Buffer>,
buffer_id: BufferId,
cx: &App,
) -> Vec<(ExcerptId, ExcerptRange<text::Anchor>)> {
let mut excerpts = Vec::new();
@ -1662,7 +1800,7 @@ impl MultiBuffer {
let buffers = self.buffers.borrow();
let mut cursor = snapshot.excerpts.cursor::<Option<&Locator>>(&());
for locator in buffers
.get(&buffer.read(cx).remote_id())
.get(&buffer_id)
.map(|state| &state.excerpts)
.into_iter()
.flatten()
@ -1812,7 +1950,7 @@ impl MultiBuffer {
) -> Option<Anchor> {
let mut found = None;
let snapshot = buffer.read(cx).snapshot();
for (excerpt_id, range) in self.excerpts_for_buffer(buffer, cx) {
for (excerpt_id, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) {
let start = range.context.start.to_point(&snapshot);
let end = range.context.end.to_point(&snapshot);
if start <= point && point < end {
@ -4790,7 +4928,7 @@ impl MultiBufferSnapshot {
cursor.next_excerpt();
let mut visited_end = false;
iter::from_fn(move || {
iter::from_fn(move || loop {
if self.singleton {
return None;
}
@ -4800,7 +4938,8 @@ impl MultiBufferSnapshot {
let next_region_start = if let Some(region) = &next_region {
if !bounds.contains(&region.range.start.key) {
return None;
prev_region = next_region;
continue;
}
region.range.start.value.unwrap()
} else {
@ -4847,7 +4986,7 @@ impl MultiBufferSnapshot {
prev_region = next_region;
Some(ExcerptBoundary { row, prev, next })
return Some(ExcerptBoundary { row, prev, next });
})
}

View file

@ -6,7 +6,7 @@ use language::{Buffer, Rope};
use parking_lot::RwLock;
use rand::prelude::*;
use settings::SettingsStore;
use std::env;
use std::{env, path::PathBuf};
use util::test::sample_text;
#[ctor::ctor]
@ -315,7 +315,8 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) {
);
let snapshot = multibuffer.update(cx, |multibuffer, cx| {
let (buffer_2_excerpt_id, _) = multibuffer.excerpts_for_buffer(&buffer_2, cx)[0].clone();
let (buffer_2_excerpt_id, _) =
multibuffer.excerpts_for_buffer(buffer_2.read(cx).remote_id(), cx)[0].clone();
multibuffer.remove_excerpts([buffer_2_excerpt_id], cx);
multibuffer.snapshot(cx)
});
@ -1527,6 +1528,202 @@ fn test_repeatedly_expand_a_diff_hunk(cx: &mut TestAppContext) {
);
}
#[gpui::test]
fn test_set_excerpts_for_buffer(cx: &mut TestAppContext) {
let buf1 = cx.new(|cx| {
Buffer::local(
indoc! {
"zero
one
two
three
four
five
six
seven
",
},
cx,
)
});
let path1: Arc<Path> = Arc::from(PathBuf::from("path1"));
let buf2 = cx.new(|cx| {
Buffer::local(
indoc! {
"000
111
222
333
444
555
666
777
888
999
"
},
cx,
)
});
let path2: Arc<Path> = Arc::from(PathBuf::from("path2"));
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path1.clone(),
buf1.clone(),
vec![Point::row_range(0..1)],
2,
cx,
);
});
assert_excerpts_match(
&multibuffer,
cx,
indoc! {
"-----
zero
one
two
three
"
},
);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(path1.clone(), buf1.clone(), vec![], 2, cx);
});
assert_excerpts_match(&multibuffer, cx, "");
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path1.clone(),
buf1.clone(),
vec![Point::row_range(0..1), Point::row_range(7..8)],
2,
cx,
);
});
assert_excerpts_match(
&multibuffer,
cx,
indoc! {"-----
zero
one
two
three
-----
five
six
seven
"},
);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path1.clone(),
buf1.clone(),
vec![Point::row_range(0..1), Point::row_range(5..6)],
2,
cx,
);
});
assert_excerpts_match(
&multibuffer,
cx,
indoc! {"-----
zero
one
two
three
four
five
six
seven
"},
);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path2.clone(),
buf2.clone(),
vec![Point::row_range(2..3)],
2,
cx,
);
});
assert_excerpts_match(
&multibuffer,
cx,
indoc! {"-----
zero
one
two
three
four
five
six
seven
-----
000
111
222
333
444
555
"},
);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(path1.clone(), buf1.clone(), vec![], 2, cx);
});
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path1.clone(),
buf1.clone(),
vec![Point::row_range(3..4)],
2,
cx,
);
});
assert_excerpts_match(
&multibuffer,
cx,
indoc! {"-----
one
two
three
four
five
six
-----
000
111
222
333
444
555
"},
);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path1.clone(),
buf1.clone(),
vec![Point::row_range(3..4)],
2,
cx,
);
});
}
#[gpui::test]
fn test_diff_hunks_with_multiple_excerpts(cx: &mut TestAppContext) {
let base_text_1 = indoc!(
@ -2700,6 +2897,25 @@ fn format_diff(
.join("\n")
}
#[track_caller]
fn assert_excerpts_match(
multibuffer: &Entity<MultiBuffer>,
cx: &mut TestAppContext,
expected: &str,
) {
let mut output = String::new();
multibuffer.read_with(cx, |multibuffer, cx| {
for (_, buffer, range) in multibuffer.snapshot(cx).excerpts() {
output.push_str("-----\n");
output.extend(buffer.text_for_range(range.context));
if !output.ends_with('\n') {
output.push('\n');
}
}
});
assert_eq!(output, expected);
}
#[track_caller]
fn assert_new_snapshot(
multibuffer: &Entity<MultiBuffer>,

View file

@ -1017,7 +1017,7 @@ impl OutlinePanel {
.map(|buffer| {
active_multi_buffer
.read(cx)
.excerpts_for_buffer(&buffer, cx)
.excerpts_for_buffer(buffer.read(cx).remote_id(), cx)
})
.and_then(|excerpts| {
let (excerpt_id, excerpt_range) = excerpts.first()?;

View file

@ -12,7 +12,7 @@ use gpui::{App, Context, Entity, EventEmitter, SharedString, Subscription, WeakE
use rpc::{proto, AnyProtoClient};
use settings::WorktreeId;
use std::sync::Arc;
use util::maybe;
use util::{maybe, ResultExt};
use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry};
pub struct GitState {
@ -332,7 +332,7 @@ impl GitState {
impl RepositoryHandle {
pub fn display_name(&self, project: &Project, cx: &App) -> SharedString {
maybe!({
let path = self.unrelativize(&"".into())?;
let path = self.repo_path_to_project_path(&"".into())?;
Some(
project
.absolute_path(&path, cx)?
@ -367,11 +367,18 @@ impl RepositoryHandle {
self.repository_entry.status()
}
pub fn unrelativize(&self, path: &RepoPath) -> Option<ProjectPath> {
pub fn repo_path_to_project_path(&self, path: &RepoPath) -> Option<ProjectPath> {
let path = self.repository_entry.unrelativize(path)?;
Some((self.worktree_id, path).into())
}
pub fn project_path_to_repo_path(&self, path: &ProjectPath) -> Option<RepoPath> {
if path.worktree_id != self.worktree_id {
return None;
}
self.repository_entry.relativize(&path.path).log_err()
}
pub fn stage_entries(
&self,
entries: Vec<RepoPath>,

View file

@ -158,7 +158,7 @@ pub struct Project {
fs: Arc<dyn Fs>,
ssh_client: Option<Entity<SshRemoteClient>>,
client_state: ProjectClientState,
git_state: Option<Entity<GitState>>,
git_state: Entity<GitState>,
collaborators: HashMap<proto::PeerId, Collaborator>,
client_subscriptions: Vec<client::Subscription>,
worktree_store: Entity<WorktreeStore>,
@ -701,7 +701,7 @@ impl Project {
)
});
let git_state = Some(cx.new(|cx| GitState::new(&worktree_store, None, None, cx)));
let git_state = cx.new(|cx| GitState::new(&worktree_store, None, None, cx));
cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
@ -821,14 +821,14 @@ impl Project {
});
cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
let git_state = Some(cx.new(|cx| {
let git_state = cx.new(|cx| {
GitState::new(
&worktree_store,
Some(ssh_proto.clone()),
Some(ProjectId(SSH_PROJECT_ID)),
cx,
)
}));
});
cx.subscribe(&ssh, Self::on_ssh_event).detach();
cx.observe(&ssh, |_, _, cx| cx.notify()).detach();
@ -1026,15 +1026,14 @@ impl Project {
SettingsObserver::new_remote(worktree_store.clone(), task_store.clone(), cx)
})?;
let git_state = Some(cx.new(|cx| {
let git_state = cx.new(|cx| {
GitState::new(
&worktree_store,
Some(client.clone().into()),
Some(ProjectId(remote_id)),
cx,
)
}))
.transpose()?;
})?;
let this = cx.new(|cx| {
let replica_id = response.payload.replica_id as ReplicaId;
@ -4117,7 +4116,6 @@ impl Project {
this.update(cx, |project, cx| {
let repository_handle = project
.git_state()
.context("missing git state")?
.read(cx)
.all_repositories()
.into_iter()
@ -4332,19 +4330,16 @@ impl Project {
&self.buffer_store
}
pub fn git_state(&self) -> Option<&Entity<GitState>> {
self.git_state.as_ref()
pub fn git_state(&self) -> &Entity<GitState> {
&self.git_state
}
pub fn active_repository(&self, cx: &App) -> Option<RepositoryHandle> {
self.git_state()
.and_then(|git_state| git_state.read(cx).active_repository())
self.git_state.read(cx).active_repository()
}
pub fn all_repositories(&self, cx: &App) -> Vec<RepositoryHandle> {
self.git_state()
.map(|git_state| git_state.read(cx).all_repositories())
.unwrap_or_default()
self.git_state.read(cx).all_repositories()
}
}

View file

@ -1,6 +1,6 @@
use std::{
cmp::Ordering,
ops::{Add, AddAssign, Sub},
ops::{Add, AddAssign, Range, Sub},
};
/// A zero-indexed point in a text buffer consisting of a row and column.
@ -20,6 +20,16 @@ impl Point {
Point { row, column }
}
pub fn row_range(range: Range<u32>) -> Range<Self> {
Point {
row: range.start,
column: 0,
}..Point {
row: range.end,
column: 0,
}
}
pub fn zero() -> Self {
Point::new(0, 0)
}

View file

@ -19,7 +19,7 @@ use collections::VecDeque;
use command_palette_hooks::CommandPaletteFilter;
use editor::ProposedChangesEditorToolbar;
use editor::{scroll::Autoscroll, Editor, MultiBuffer};
use feature_flags::FeatureFlagAppExt;
use feature_flags::{FeatureFlagAppExt, FeatureFlagViewExt, GitUiFeatureFlag};
use futures::{channel::mpsc, select_biased, StreamExt};
use gpui::{
actions, point, px, Action, App, AppContext as _, AsyncApp, Context, DismissEvent, Element,
@ -364,8 +364,6 @@ fn initialize_panels(
) {
let assistant2_feature_flag =
cx.wait_for_flag_or_timeout::<feature_flags::Assistant2FeatureFlag>(Duration::from_secs(5));
let git_ui_feature_flag =
cx.wait_for_flag_or_timeout::<feature_flags::GitUiFeatureFlag>(Duration::from_secs(5));
let prompt_builder = prompt_builder.clone();
@ -405,19 +403,10 @@ fn initialize_panels(
workspace.add_panel(channels_panel, window, cx);
workspace.add_panel(chat_panel, window, cx);
workspace.add_panel(notification_panel, window, cx);
})?;
let git_ui_enabled = git_ui_feature_flag.await;
let git_panel = if git_ui_enabled {
Some(git_ui::git_panel::GitPanel::load(workspace_handle.clone(), cx.clone()).await?)
} else {
None
};
workspace_handle.update_in(&mut cx, |workspace, window, cx| {
if let Some(git_panel) = git_panel {
cx.when_flag_enabled::<GitUiFeatureFlag>(window, |workspace, window, cx| {
let git_panel = git_ui::git_panel::GitPanel::new(workspace, window, None, cx);
workspace.add_panel(git_panel, window, cx);
}
});
})?;
let is_assistant2_enabled = if cfg!(test) {