@@ -12,13 +12,11 @@ use editor::scroll::ScrollbarAutoHide;
use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar};
use git::repository::RepoPath;
use git::status::FileStatus;
-use git::{
- CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll, COMMIT_MESSAGE,
-};
+use git::{CommitAllChanges, CommitChanges, ToggleStaged, COMMIT_MESSAGE};
use gpui::*;
use language::{Buffer, BufferId};
use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
-use project::git::{GitRepo, RepositoryHandle};
+use project::git::{GitEvent, GitRepo, RepositoryHandle};
use project::{CreateOptions, Fs, Project, ProjectPath};
use rpc::proto;
use serde::{Deserialize, Serialize};
@@ -43,7 +41,6 @@ actions!(
Close,
ToggleFocus,
OpenMenu,
- OpenSelected,
FocusEditor,
FocusChanges,
FillCoAuthors,
@@ -76,17 +73,17 @@ struct SerializedGitPanel {
width: Option<Pixels>,
}
-#[derive(Debug, PartialEq, Eq, Clone)]
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum Section {
Changed,
- New,
+ Created,
}
impl Section {
pub fn contains(&self, status: FileStatus) -> bool {
match self {
Section::Changed => !status.is_created(),
- Section::New => status.is_created(),
+ Section::Created => status.is_created(),
}
}
}
@@ -94,7 +91,6 @@ impl Section {
#[derive(Debug, PartialEq, Eq, Clone)]
struct GitHeaderEntry {
header: Section,
- all_staged: ToggleState,
}
impl GitHeaderEntry {
@@ -104,7 +100,7 @@ impl GitHeaderEntry {
pub fn title(&self) -> &'static str {
match self.header {
Section::Changed => "Changed",
- Section::New => "New",
+ Section::Created => "New",
}
}
}
@@ -126,11 +122,18 @@ impl GitListEntry {
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct GitStatusEntry {
- depth: usize,
- display_name: String,
- repo_path: RepoPath,
- status: FileStatus,
- is_staged: Option<bool>,
+ pub(crate) depth: usize,
+ pub(crate) display_name: String,
+ pub(crate) repo_path: RepoPath,
+ pub(crate) status: FileStatus,
+ pub(crate) is_staged: Option<bool>,
+}
+
+pub struct PendingOperation {
+ finished: bool,
+ will_become_staged: bool,
+ repo_paths: HashSet<RepoPath>,
+ op_id: usize,
}
pub struct GitPanel {
@@ -152,9 +155,11 @@ pub struct GitPanel {
entries: Vec<GitListEntry>,
entries_by_path: collections::HashMap<RepoPath, usize>,
width: Option<Pixels>,
- pending: HashMap<RepoPath, bool>,
+ pending: Vec<PendingOperation>,
commit_task: Task<Result<()>>,
commit_pending: bool,
+ can_commit: bool,
+ can_commit_all: bool,
}
fn commit_message_buffer(
@@ -287,9 +292,12 @@ impl GitPanel {
&git_state,
window,
move |this, git_state, event, window, cx| match event {
- project::git::Event::RepositoriesUpdated => {
+ GitEvent::FileSystemUpdated => {
+ this.schedule_update(false, window, cx);
+ }
+ GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
this.active_repository = git_state.read(cx).active_repository();
- this.schedule_update(window, cx);
+ this.schedule_update(true, window, cx);
}
},
)
@@ -303,7 +311,7 @@ impl GitPanel {
pending_serialization: Task::ready(None),
entries: Vec::new(),
entries_by_path: HashMap::default(),
- pending: HashMap::default(),
+ pending: Vec::new(),
current_modifiers: window.modifiers(),
width: Some(px(360.)),
scrollbar_state: ScrollbarState::new(scroll_handle.clone())
@@ -321,8 +329,10 @@ impl GitPanel {
commit_editor,
project,
workspace,
+ can_commit: false,
+ can_commit_all: false,
};
- git_panel.schedule_update(window, cx);
+ git_panel.schedule_update(false, window, cx);
git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
git_panel
});
@@ -617,7 +627,7 @@ impl GitPanel {
}
}
GitListEntry::Header(section) => {
- let goal_staged_state = !section.all_staged.selected();
+ let goal_staged_state = !self.header_state(section.header).selected();
let entries = self
.entries
.iter()
@@ -629,12 +639,17 @@ impl GitPanel {
.map(|status_entry| status_entry.repo_path)
.collect::<Vec<_>>();
- (!section.all_staged.selected(), entries)
+ (goal_staged_state, entries)
}
};
- for repo_path in repo_paths.iter() {
- self.pending.insert(repo_path.clone(), stage);
- }
+
+ let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
+ self.pending.push(PendingOperation {
+ op_id,
+ will_become_staged: stage,
+ repo_paths: repo_paths.iter().cloned().collect(),
+ finished: false,
+ });
cx.spawn({
let repo_paths = repo_paths.clone();
@@ -647,9 +662,9 @@ impl GitPanel {
};
this.update(&mut cx, |this, cx| {
- for repo_path in repo_paths {
- if this.pending.get(&repo_path) == Some(&stage) {
- this.pending.remove(&repo_path);
+ for pending in this.pending.iter_mut() {
+ if pending.op_id == op_id {
+ pending.finished = true
}
}
result
@@ -696,67 +711,6 @@ impl GitPanel {
cx.emit(Event::OpenedEntry { path });
}
- fn stage_all(&mut self, _: &git::StageAll, _window: &mut Window, cx: &mut Context<Self>) {
- let Some(active_repository) = self.active_repository.as_ref().cloned() else {
- return;
- };
- let mut pending_paths = Vec::new();
- for entry in self.entries.iter() {
- if let Some(status_entry) = entry.status_entry() {
- self.pending.insert(status_entry.repo_path.clone(), true);
- pending_paths.push(status_entry.repo_path.clone());
- }
- }
-
- cx.spawn(|this, mut cx| async move {
- if let Err(e) = active_repository.stage_all().await {
- this.update(&mut cx, |this, cx| {
- this.show_err_toast(e, cx);
- })
- .ok();
- };
- this.update(&mut cx, |this, _cx| {
- for repo_path in pending_paths {
- this.pending.remove(&repo_path);
- }
- })
- })
- .detach();
- }
-
- fn unstage_all(&mut self, _: &git::UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
- let Some(active_repository) = self.active_repository.as_ref().cloned() else {
- return;
- };
- let mut pending_paths = Vec::new();
- for entry in self.entries.iter() {
- if let Some(status_entry) = entry.status_entry() {
- self.pending.insert(status_entry.repo_path.clone(), false);
- pending_paths.push(status_entry.repo_path.clone());
- }
- }
-
- cx.spawn(|this, mut cx| async move {
- if let Err(e) = active_repository.unstage_all().await {
- this.update(&mut cx, |this, cx| {
- this.show_err_toast(e, cx);
- })
- .ok();
- };
- this.update(&mut cx, |this, _cx| {
- for repo_path in pending_paths {
- this.pending.remove(&repo_path);
- }
- })
- })
- .detach();
- }
-
- fn discard_all(&mut self, _: &git::RevertAll, _window: &mut Window, _cx: &mut Context<Self>) {
- // TODO: Implement discard all
- println!("Discard all triggered");
- }
-
/// Commit all staged changes
fn commit_changes(
&mut self,
@@ -768,7 +722,7 @@ impl GitPanel {
let Some(active_repository) = self.active_repository.clone() else {
return;
};
- if !active_repository.can_commit(false) {
+ if !self.can_commit {
return;
}
if self.commit_editor.read(cx).is_empty(cx) {
@@ -811,7 +765,7 @@ impl GitPanel {
let Some(active_repository) = self.active_repository.clone() else {
return;
};
- if !active_repository.can_commit(true) {
+ if !self.can_commit_all {
return;
}
if self.commit_editor.read(cx).is_empty(cx) {
@@ -926,7 +880,12 @@ impl GitPanel {
});
}
- fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ fn schedule_update(
+ &mut self,
+ clear_pending: bool,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
let project = self.project.clone();
let handle = cx.entity().downgrade();
self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
@@ -957,6 +916,9 @@ impl GitPanel {
git_panel
.update_in(&mut cx, |git_panel, window, cx| {
git_panel.update_visible_entries(cx);
+ if clear_pending {
+ git_panel.clear_pending();
+ }
git_panel.commit_editor =
cx.new(|cx| commit_message_editor(commit_message_buffer, window, cx));
})
@@ -965,6 +927,10 @@ impl GitPanel {
});
}
+ fn clear_pending(&mut self) {
+ self.pending.retain(|v| !v.finished)
+ }
+
fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
self.entries.clear();
self.entries_by_path.clear();
@@ -980,12 +946,11 @@ impl GitPanel {
// First pass - collect all paths
let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
- // Second pass - create entries with proper depth calculation
- let mut new_any_staged = false;
- let mut new_all_staged = true;
- let mut changed_any_staged = false;
- let mut changed_all_staged = true;
+ let mut has_changed_checked_boxes = false;
+ let mut has_changed = false;
+ let mut has_added_checked_boxes = false;
+ // Second pass - create entries with proper depth calculation
for entry in repo.status() {
let (depth, difference) =
Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
@@ -993,15 +958,6 @@ impl GitPanel {
let is_new = entry.status.is_created();
let is_staged = entry.status.is_staged();
- let new_is_staged = is_staged.unwrap_or(false);
- if is_new {
- new_any_staged |= new_is_staged;
- new_all_staged &= new_is_staged;
- } else {
- changed_any_staged |= new_is_staged;
- changed_all_staged &= new_is_staged;
- }
-
let display_name = if difference > 1 {
// Show partial path for deeply nested files
entry
@@ -1030,8 +986,15 @@ impl GitPanel {
};
if is_new {
+ if entry.is_staged != Some(false) {
+ has_added_checked_boxes = true
+ }
new_entries.push(entry);
} else {
+ has_changed = true;
+ if entry.is_staged != Some(false) {
+ has_changed_checked_boxes = true
+ }
changed_entries.push(entry);
}
}
@@ -1041,11 +1004,8 @@ impl GitPanel {
new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
if changed_entries.len() > 0 {
- let toggle_state =
- ToggleState::from_any_and_all(changed_any_staged, changed_all_staged);
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Changed,
- all_staged: toggle_state,
}));
self.entries.extend(
changed_entries
@@ -1054,10 +1014,8 @@ impl GitPanel {
);
}
if new_entries.len() > 0 {
- let toggle_state = ToggleState::from_any_and_all(new_any_staged, new_all_staged);
self.entries.push(GitListEntry::Header(GitHeaderEntry {
- header: Section::New,
- all_staged: toggle_state,
+ header: Section::Created,
}));
self.entries
.extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
@@ -1068,12 +1026,45 @@ impl GitPanel {
self.entries_by_path.insert(status_entry.repo_path, ix);
}
}
+ self.can_commit = has_changed_checked_boxes || has_added_checked_boxes;
+ self.can_commit_all = has_changed || has_added_checked_boxes;
self.select_first_entry_if_none(cx);
cx.notify();
}
+ fn header_state(&self, header_type: Section) -> ToggleState {
+ let mut count = 0;
+ let mut staged_count = 0;
+ 'outer: for entry in &self.entries {
+ let Some(entry) = entry.status_entry() else {
+ continue;
+ };
+ if entry.status.is_created() != (header_type == Section::Created) {
+ continue;
+ }
+ count += 1;
+ for pending in self.pending.iter().rev() {
+ if pending.repo_paths.contains(&entry.repo_path) {
+ if pending.will_become_staged {
+ staged_count += 1;
+ }
+ continue 'outer;
+ }
+ }
+ staged_count += entry.status.is_staged().unwrap_or(false) as usize;
+ }
+
+ if staged_count == 0 {
+ ToggleState::Unselected
+ } else if count == staged_count {
+ ToggleState::Selected
+ } else {
+ ToggleState::Indeterminate
+ }
+ }
+
fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
let Some(workspace) = self.workspace.upgrade() else {
return;
@@ -1089,7 +1080,6 @@ impl GitPanel {
}
}
-// GitPanel –– Render
impl GitPanel {
pub fn panel_button(
&self,
@@ -1199,21 +1189,13 @@ impl GitPanel {
pub fn render_commit_editor(
&self,
name_and_email: Option<(SharedString, SharedString)>,
- can_commit: bool,
cx: &Context<Self>,
) -> impl IntoElement {
let editor = self.commit_editor.clone();
- let can_commit = can_commit && !editor.read(cx).is_empty(cx);
+ let can_commit = !self.commit_pending && self.can_commit && !editor.read(cx).is_empty(cx);
+ let can_commit_all =
+ !self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx);
let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
- let (can_commit, can_commit_all) =
- self.active_repository
- .as_ref()
- .map_or((false, false), |active_repository| {
- (
- can_commit && active_repository.can_commit(false),
- can_commit && active_repository.can_commit(true),
- )
- });
let focus_handle_1 = self.focus_handle(cx).clone();
let focus_handle_2 = self.focus_handle(cx).clone();
@@ -1466,7 +1448,7 @@ impl GitPanel {
has_write_access: bool,
cx: &Context<Self>,
) -> AnyElement {
- let checkbox = Checkbox::new(header.title(), header.all_staged)
+ let checkbox = Checkbox::new(header.title(), self.header_state(header.header))
.disabled(!has_write_access)
.fill()
.elevation(ElevationIndex::Surface);
@@ -1510,7 +1492,14 @@ impl GitPanel {
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
- let pending = self.pending.get(&entry.repo_path).copied();
+ let pending = self.pending.iter().rev().find_map(|pending| {
+ if pending.repo_paths.contains(&entry.repo_path) {
+ Some(pending.will_become_staged)
+ } else {
+ None
+ }
+ });
+
let repo_path = entry.repo_path.clone();
let selected = self.selected_entry == Some(ix);
let status_style = GitPanelSettings::get_global(cx).status_style;
@@ -1559,13 +1548,19 @@ impl GitPanel {
window,
cx,
);
+ cx.stop_propagation();
})
});
let start_slot = h_flex()
+ .id(("start-slot", ix))
.gap(DynamicSpacing::Base04.rems(cx))
.child(checkbox)
- .child(git_status_icon(status, cx));
+ .child(git_status_icon(status, cx))
+ .on_mouse_down(MouseButton::Left, |_, _, cx| {
+ // prevent the list item active state triggering when toggling checkbox
+ cx.stop_propagation();
+ });
let id = ElementId::Name(format!("entry_{}", display_name).into());
@@ -1581,27 +1576,14 @@ impl GitPanel {
.toggle_state(selected)
.disabled(!has_write_access)
.on_click({
- let repo_path = entry.repo_path.clone();
+ let entry = entry.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;
};
- 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);
+ ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
})
})
})
@@ -1691,17 +1673,6 @@ impl Render for GitPanel {
this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
this.toggle_staged_for_selected(&ToggleStaged, window, cx)
}))
- .on_action(
- cx.listener(|this, &StageAll, window, cx| {
- this.stage_all(&StageAll, window, cx)
- }),
- )
- .on_action(cx.listener(|this, &UnstageAll, window, cx| {
- this.unstage_all(&UnstageAll, window, cx)
- }))
- .on_action(cx.listener(|this, &RevertAll, window, cx| {
- this.discard_all(&RevertAll, window, cx)
- }))
.when(can_commit, |git_panel| {
git_panel
.on_action({
@@ -1764,7 +1735,7 @@ impl Render for GitPanel {
self.render_empty_state(cx).into_any_element()
})
.child(self.render_divider(cx))
- .child(self.render_commit_editor(name_and_email, can_commit, cx))
+ .child(self.render_commit_editor(name_and_email, cx))
}
}
@@ -1,8 +1,4 @@
-use std::{
- any::{Any, TypeId},
- path::Path,
- sync::Arc,
-};
+use std::any::{Any, TypeId};
use anyhow::Result;
use collections::HashSet;
@@ -14,7 +10,7 @@ use gpui::{
FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
};
use language::{Anchor, Buffer, Capability, OffsetRangeExt};
-use multi_buffer::MultiBuffer;
+use multi_buffer::{MultiBuffer, PathKey};
use project::{buffer_store::BufferChangeSet, git::GitState, Project, ProjectPath};
use theme::ActiveTheme;
use ui::prelude::*;
@@ -25,7 +21,7 @@ use workspace::{
ItemNavHistory, ToolbarItemLocation, Workspace,
};
-use crate::git_panel::GitPanel;
+use crate::git_panel::{GitPanel, GitStatusEntry};
actions!(git, [Diff]);
@@ -37,18 +33,21 @@ pub(crate) struct ProjectDiff {
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
update_needed: postage::watch::Sender<()>,
- pending_scroll: Option<Arc<Path>>,
+ pending_scroll: Option<PathKey>,
_task: Task<Result<()>>,
_subscription: Subscription,
}
struct DiffBuffer {
- abs_path: Arc<Path>,
+ path_key: PathKey,
buffer: Entity<Buffer>,
change_set: Entity<BufferChangeSet>,
}
+const CHANGED_NAMESPACE: &'static str = "0";
+const ADDED_NAMESPACE: &'static str = "1";
+
impl ProjectDiff {
pub(crate) fn register(
_: &mut Workspace,
@@ -72,7 +71,7 @@ impl ProjectDiff {
pub fn deploy_at(
workspace: &mut Workspace,
- path: Option<Arc<Path>>,
+ entry: Option<GitStatusEntry>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
@@ -92,9 +91,9 @@ impl ProjectDiff {
);
project_diff
};
- if let Some(path) = path {
+ if let Some(entry) = entry {
project_diff.update(cx, |project_diff, cx| {
- project_diff.scroll_to(path, window, cx);
+ project_diff.scroll_to(entry, window, cx);
})
}
}
@@ -126,10 +125,8 @@ impl ProjectDiff {
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() = ();
- }
+ move |this, _git_state, _event, _window, _cx| {
+ *this.update_needed.borrow_mut() = ();
},
);
@@ -155,15 +152,39 @@ impl ProjectDiff {
}
}
- 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) {
+ pub fn scroll_to(
+ &mut self,
+ entry: GitStatusEntry,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(git_repo) = self.git_state.read(cx).active_repository() else {
+ return;
+ };
+
+ let Some(path) = git_repo
+ .repo_path_to_project_path(&entry.repo_path)
+ .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx))
+ else {
+ return;
+ };
+ let path_key = if entry.status.is_created() {
+ PathKey::namespaced(ADDED_NAMESPACE, &path)
+ } else {
+ PathKey::namespaced(CHANGED_NAMESPACE, &path)
+ };
+ self.scroll_to_path(path_key, window, cx)
+ }
+
+ fn scroll_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
+ if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, 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);
+ self.pending_scroll = Some(path_key);
}
}
@@ -223,9 +244,14 @@ impl ProjectDiff {
let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
continue;
};
- let abs_path = Arc::from(abs_path);
+ // Craft some artificial paths so that created entries will appear last.
+ let path_key = if entry.status.is_created() {
+ PathKey::namespaced(ADDED_NAMESPACE, &abs_path)
+ } else {
+ PathKey::namespaced(CHANGED_NAMESPACE, &abs_path)
+ };
- previous_paths.remove(&abs_path);
+ previous_paths.remove(&path_key);
let load_buffer = self
.project
.update(cx, |project, cx| project.open_buffer(project_path, cx));
@@ -235,11 +261,11 @@ impl ProjectDiff {
let buffer = load_buffer.await?;
let changes = project
.update(&mut cx, |project, cx| {
- project.open_unstaged_changes(buffer.clone(), cx)
+ project.open_uncommitted_changes(buffer.clone(), cx)
})?
.await?;
Ok(DiffBuffer {
- abs_path,
+ path_key,
buffer,
change_set: changes,
})
@@ -259,7 +285,7 @@ impl ProjectDiff {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let abs_path = diff_buffer.abs_path;
+ let path_key = diff_buffer.path_key;
let buffer = diff_buffer.buffer;
let change_set = diff_buffer.change_set;
@@ -272,15 +298,15 @@ impl ProjectDiff {
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
- abs_path.clone(),
+ path_key.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);
+ if self.pending_scroll.as_ref() == Some(&path_key) {
+ self.scroll_to_path(path_key, window, cx);
}
}