@@ -5,12 +5,11 @@ use crate::{
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
};
use anyhow::{Context as _, Result};
+use collections::HashMap;
use db::kvp::KEY_VALUE_STORE;
use editor::actions::MoveToEnd;
use editor::scroll::ScrollbarAutoHide;
use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar};
-use futures::channel::mpsc;
-use futures::{SinkExt, StreamExt as _};
use git::repository::RepoPath;
use git::status::FileStatus;
use git::{
@@ -24,13 +23,13 @@ use project::{CreateOptions, Fs, Project, ProjectPath};
use rpc::proto;
use serde::{Deserialize, Serialize};
use settings::Settings as _;
-use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize};
+use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize};
use theme::ThemeSettings;
use ui::{
- prelude::*, ButtonLike, Checkbox, Divider, DividerColor, ElevationIndex, ListItem,
- ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
+ prelude::*, ButtonLike, Checkbox, Divider, DividerColor, ElevationIndex, IndentGuideColors,
+ ListHeader, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
};
-use util::{ResultExt, TryFutureExt};
+use util::{maybe, ResultExt, TryFutureExt};
use workspace::notifications::{DetachAndPromptErr, NotificationId};
use workspace::Toast;
use workspace::{
@@ -78,7 +77,55 @@ struct SerializedGitPanel {
}
#[derive(Debug, PartialEq, Eq, Clone)]
-pub struct GitListEntry {
+enum Section {
+ Changed,
+ New,
+}
+
+impl Section {
+ pub fn contains(&self, status: FileStatus) -> bool {
+ match self {
+ Section::Changed => !status.is_created(),
+ Section::New => status.is_created(),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+struct GitHeaderEntry {
+ header: Section,
+ all_staged: ToggleState,
+}
+
+impl GitHeaderEntry {
+ pub fn contains(&self, status_entry: &GitStatusEntry) -> bool {
+ self.header.contains(status_entry.status)
+ }
+ pub fn title(&self) -> &'static str {
+ match self.header {
+ Section::Changed => "Changed",
+ Section::New => "New",
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+enum GitListEntry {
+ GitStatusEntry(GitStatusEntry),
+ Header(GitHeaderEntry),
+}
+
+impl GitListEntry {
+ fn status_entry(&self) -> Option<GitStatusEntry> {
+ match self {
+ GitListEntry::GitStatusEntry(entry) => Some(entry.clone()),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct GitStatusEntry {
depth: usize,
display_name: String,
repo_path: RepoPath,
@@ -102,11 +149,11 @@ pub struct GitPanel {
update_visible_entries_task: Task<()>,
repository_selector: Entity<RepositorySelector>,
commit_editor: Entity<Editor>,
- visible_entries: Vec<GitListEntry>,
- all_staged: Option<bool>,
+ entries: Vec<GitListEntry>,
+ entries_by_path: collections::HashMap<RepoPath, usize>,
width: Option<Pixels>,
- err_sender: mpsc::Sender<anyhow::Error>,
- commit_task: Task<()>,
+ pending: HashMap<RepoPath, bool>,
+ commit_task: Task<Result<()>>,
commit_pending: bool,
}
@@ -237,7 +284,6 @@ impl GitPanel {
let project = workspace.project().clone();
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();
let git_panel = cx.new(|cx| {
@@ -269,8 +315,9 @@ impl GitPanel {
let mut git_panel = Self {
focus_handle: cx.focus_handle(),
pending_serialization: Task::ready(None),
- visible_entries: Vec::new(),
- all_staged: None,
+ entries: Vec::new(),
+ entries_by_path: HashMap::default(),
+ pending: HashMap::default(),
current_modifiers: window.modifiers(),
width: Some(px(360.)),
scrollbar_state: ScrollbarState::new(scroll_handle.clone())
@@ -280,14 +327,13 @@ impl GitPanel {
show_scrollbar: false,
hide_scrollbar_task: None,
update_visible_entries_task: Task::ready(()),
- commit_task: Task::ready(()),
+ commit_task: Task::ready(Ok(())),
commit_pending: false,
active_repository,
scroll_handle,
fs,
commit_editor,
project,
- err_sender,
workspace,
};
git_panel.schedule_update(window, cx);
@@ -295,24 +341,6 @@ impl GitPanel {
git_panel
});
- let handle = git_panel.downgrade();
- cx.spawn(|_, mut cx| async move {
- while let Some(e) = err_receiver.next().await {
- let Some(this) = handle.upgrade() else {
- break;
- };
- if this
- .update(&mut cx, |this, cx| {
- this.show_err_toast("git operation error", e, cx);
- })
- .is_err()
- {
- break;
- }
- }
- })
- .detach();
-
cx.subscribe_in(
&git_panel,
window,
@@ -339,13 +367,11 @@ impl GitPanel {
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 {
+ let Some(ix) = self.entries_by_path.get(&repo_path) else {
return;
};
- self.selected_entry = Some(ix);
+
+ self.selected_entry = Some(*ix);
cx.notify();
}
@@ -489,14 +515,14 @@ impl GitPanel {
}
fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
- if self.visible_entries.first().is_some() {
+ if self.entries.first().is_some() {
self.selected_entry = Some(0);
self.scroll_to_selected_entry(cx);
}
}
fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
- let item_count = self.visible_entries.len();
+ let item_count = self.entries.len();
if item_count == 0 {
return;
}
@@ -517,7 +543,7 @@ impl GitPanel {
}
fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
- let item_count = self.visible_entries.len();
+ let item_count = self.entries.len();
if item_count == 0 {
return;
}
@@ -538,8 +564,8 @@ impl GitPanel {
}
fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
- if self.visible_entries.last().is_some() {
- self.selected_entry = Some(self.visible_entries.len() - 1);
+ if self.entries.last().is_some() {
+ self.selected_entry = Some(self.entries.len() - 1);
self.scroll_to_selected_entry(cx);
}
}
@@ -578,15 +604,11 @@ impl GitPanel {
}
fn get_selected_entry(&self) -> Option<&GitListEntry> {
- self.selected_entry
- .and_then(|i| self.visible_entries.get(i))
+ self.selected_entry.and_then(|i| self.entries.get(i))
}
fn open_selected(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
- if let Some(entry) = self
- .selected_entry
- .and_then(|i| self.visible_entries.get(i))
- {
+ if let Some(entry) = self.selected_entry.and_then(|i| self.entries.get(i)) {
self.open_entry(entry, cx);
}
}
@@ -600,16 +622,60 @@ impl GitPanel {
let Some(active_repository) = self.active_repository.as_ref() else {
return;
};
- let result = if entry.status.is_staged().unwrap_or(false) {
- active_repository
- .unstage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
- } else {
- active_repository.stage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
+ let (stage, repo_paths) = match entry {
+ GitListEntry::GitStatusEntry(status_entry) => {
+ if status_entry.status.is_staged().unwrap_or(false) {
+ (false, vec![status_entry.repo_path.clone()])
+ } else {
+ (true, vec![status_entry.repo_path.clone()])
+ }
+ }
+ GitListEntry::Header(section) => {
+ let goal_staged_state = !section.all_staged.selected();
+ let entries = self
+ .entries
+ .iter()
+ .filter_map(|entry| entry.status_entry())
+ .filter(|status_entry| {
+ section.contains(&status_entry)
+ && status_entry.is_staged != Some(goal_staged_state)
+ })
+ .map(|status_entry| status_entry.repo_path)
+ .collect::<Vec<_>>();
+
+ (!section.all_staged.selected(), entries)
+ }
};
- if let Err(e) = result {
- self.show_err_toast("toggle staged error", e, cx);
+ for repo_path in repo_paths.iter() {
+ self.pending.insert(repo_path.clone(), stage);
}
- cx.notify();
+
+ cx.spawn({
+ let repo_paths = repo_paths.clone();
+ let active_repository = active_repository.clone();
+ |this, mut cx| async move {
+ let result = if stage {
+ active_repository.stage_entries(repo_paths.clone()).await
+ } else {
+ active_repository.unstage_entries(repo_paths.clone()).await
+ };
+
+ 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);
+ }
+ }
+ result
+ .map_err(|e| {
+ this.show_err_toast(e, cx);
+ })
+ .ok();
+ cx.notify();
+ })
+ }
+ })
+ .detach();
}
fn toggle_staged_for_selected(
@@ -624,10 +690,14 @@ impl GitPanel {
}
fn open_entry(&self, entry: &GitListEntry, cx: &mut Context<Self>) {
+ let Some(status_entry) = entry.status_entry() else {
+ return;
+ };
let Some(active_repository) = self.active_repository.as_ref() else {
return;
};
- let Some(path) = active_repository.repo_path_to_project_path(&entry.repo_path) else {
+ let Some(path) = active_repository.repo_path_to_project_path(&status_entry.repo_path)
+ else {
return;
};
let path_exists = self.project.update(cx, |project, cx| {
@@ -641,30 +711,59 @@ impl GitPanel {
}
fn stage_all(&mut self, _: &git::StageAll, _window: &mut Window, cx: &mut Context<Self>) {
- let Some(active_repository) = self.active_repository.as_ref() else {
+ let Some(active_repository) = self.active_repository.as_ref().cloned() else {
return;
};
- for entry in &mut self.visible_entries {
- entry.is_staged = Some(true);
+ 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());
+ }
}
- self.all_staged = Some(true);
- if let Err(e) = active_repository.stage_all(self.err_sender.clone()) {
- self.show_err_toast("stage all error", e, cx);
- };
+ 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() else {
+ let Some(active_repository) = self.active_repository.as_ref().cloned() else {
return;
};
- for entry in &mut self.visible_entries {
- entry.is_staged = Some(false);
+ 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());
+ }
}
- self.all_staged = Some(false);
- if let Err(e) = active_repository.unstage_all(self.err_sender.clone()) {
- self.show_err_toast("unstage all error", e, cx);
- };
+
+ 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>) {
@@ -693,37 +792,30 @@ impl GitPanel {
let save_task = self.commit_editor.update(cx, |editor, cx| {
editor.save(false, self.project.clone(), window, cx)
});
- let mut err_sender = self.err_sender.clone();
let commit_editor = self.commit_editor.clone();
self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move {
- match save_task.await {
- Ok(()) => {
- if let Some(Ok(())) = cx
- .update(|_, cx| {
- active_repository.commit(name_and_email, err_sender.clone(), cx)
- })
- .ok()
- {
- cx.update(|window, cx| {
- commit_editor.update(cx, |editor, cx| editor.clear(window, cx));
- })
- .ok();
- }
- }
- Err(e) => {
- err_sender.send(e).await.ok();
- }
- }
- git_panel
- .update(&mut cx, |git_panel, _| {
- git_panel.commit_pending = false;
+ let result = maybe!(async {
+ save_task.await?;
+ active_repository.commit(name_and_email).await?;
+ cx.update(|window, cx| {
+ commit_editor.update(cx, |editor, cx| editor.clear(window, cx));
})
- .ok();
+ })
+ .await;
+
+ git_panel.update(&mut cx, |git_panel, cx| {
+ git_panel.commit_pending = false;
+ result
+ .map_err(|e| {
+ git_panel.show_err_toast(e, cx);
+ })
+ .ok();
+ })
});
}
/// Commit all changes, regardless of whether they are staged or not
- fn commit_all_changes(
+ fn commit_tracked_changes(
&mut self,
_: &git::CommitAllChanges,
name_and_email: Option<(SharedString, SharedString)>,
@@ -743,32 +835,43 @@ impl GitPanel {
let save_task = self.commit_editor.update(cx, |editor, cx| {
editor.save(false, self.project.clone(), window, cx)
});
- let mut err_sender = self.err_sender.clone();
+
let commit_editor = self.commit_editor.clone();
+ let tracked_files = self
+ .entries
+ .iter()
+ .filter_map(|entry| entry.status_entry())
+ .filter(|status_entry| {
+ Section::Changed.contains(status_entry.status)
+ && !status_entry.is_staged.unwrap_or(false)
+ })
+ .map(|status_entry| status_entry.repo_path)
+ .collect::<Vec<_>>();
+
self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move {
- match save_task.await {
- Ok(()) => {
- if let Some(Ok(())) = cx
- .update(|_, cx| {
- active_repository.commit_all(name_and_email, err_sender.clone(), cx)
- })
- .ok()
- {
- cx.update(|window, cx| {
- commit_editor.update(cx, |editor, cx| editor.clear(window, cx));
+ let result = maybe!(async {
+ save_task.await?;
+ active_repository.stage_entries(tracked_files).await?;
+ active_repository.commit(name_and_email).await
+ })
+ .await;
+ cx.update(|window, cx| match result {
+ Ok(_) => commit_editor.update(cx, |editor, cx| {
+ editor.clear(window, cx);
+ }),
+
+ Err(e) => {
+ git_panel
+ .update(cx, |git_panel, cx| {
+ git_panel.show_err_toast(e, cx);
})
.ok();
- }
}
- Err(e) => {
- err_sender.send(e).await.ok();
- }
- }
- git_panel
- .update(&mut cx, |git_panel, _| {
- git_panel.commit_pending = false;
- })
- .ok();
+ })?;
+
+ git_panel.update(&mut cx, |git_panel, _| {
+ git_panel.commit_pending = false;
+ })
});
}
@@ -837,39 +940,6 @@ impl GitPanel {
});
}
- fn for_each_visible_entry(
- &self,
- range: Range<usize>,
- cx: &mut Context<Self>,
- mut callback: impl FnMut(usize, GitListEntry, &mut Context<Self>),
- ) {
- let visible_entries = &self.visible_entries;
-
- for (ix, entry) in visible_entries
- .iter()
- .enumerate()
- .skip(range.start)
- .take(range.end - range.start)
- {
- let status = entry.status;
- let filename = entry
- .repo_path
- .file_name()
- .map(|name| name.to_string_lossy().into_owned())
- .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
-
- let details = GitListEntry {
- repo_path: entry.repo_path.clone(),
- status,
- depth: 0,
- display_name: filename,
- is_staged: entry.is_staged,
- };
-
- callback(ix, details, cx);
- }
- }
-
fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let project = self.project.clone();
let handle = cx.entity().downgrade();
@@ -910,7 +980,10 @@ impl GitPanel {
}
fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
- self.visible_entries.clear();
+ self.entries.clear();
+ self.entries_by_path.clear();
+ let mut changed_entries = Vec::new();
+ let mut new_entries = Vec::new();
let Some(repo) = self.active_repository.as_ref() else {
// Just clear entries if no repository is active.
@@ -922,19 +995,26 @@ impl GitPanel {
let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
// Second pass - create entries with proper depth calculation
- let mut all_staged = None;
- for (ix, entry) in repo.status().enumerate() {
+ let mut new_any_staged = false;
+ let mut new_all_staged = true;
+ let mut changed_any_staged = false;
+ let mut changed_all_staged = true;
+
+ for entry in repo.status() {
let (depth, difference) =
Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
+
+ let is_new = entry.status.is_created();
let is_staged = entry.status.is_staged();
- all_staged = if ix == 0 {
- 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 {
- match (all_staged, is_staged) {
- (None, _) | (_, None) => None,
- (Some(a), Some(b)) => (a == b).then_some(a),
- }
- };
+ 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
@@ -955,7 +1035,7 @@ impl GitPanel {
.unwrap_or_default()
};
- let entry = GitListEntry {
+ let entry = GitStatusEntry {
depth,
display_name,
repo_path: entry.repo_path.clone(),
@@ -963,24 +1043,56 @@ impl GitPanel {
is_staged,
};
- self.visible_entries.push(entry);
+ if is_new {
+ new_entries.push(entry);
+ } else {
+ changed_entries.push(entry);
+ }
}
- self.all_staged = all_staged;
// Sort entries by path to maintain consistent order
- self.visible_entries
- .sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
+ changed_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
+ 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
+ .into_iter()
+ .map(GitListEntry::GitStatusEntry),
+ );
+ }
+ 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,
+ }));
+ self.entries
+ .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
+ }
+
+ for (ix, entry) in self.entries.iter().enumerate() {
+ if let Some(status_entry) = entry.status_entry() {
+ self.entries_by_path.insert(status_entry.repo_path, ix);
+ }
+ }
self.select_first_entry_if_none(cx);
cx.notify();
}
- fn show_err_toast(&self, id: &'static str, e: anyhow::Error, cx: &mut Context<Self>) {
+ fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
- let notif_id = NotificationId::Named(id.into());
+ let notif_id = NotificationId::Named("git-operation-error".into());
let message = e.to_string();
workspace.update(cx, |workspace, cx| {
let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
@@ -1020,7 +1132,6 @@ impl GitPanel {
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
- let focus_handle = self.focus_handle(cx).clone();
let all_repositories = self
.project
.read(cx)
@@ -1058,43 +1169,6 @@ impl GitPanel {
self.render_repository_selector(cx).into_any_element()
}))
.child(div().flex_grow())
- .child(h_flex().gap_2().child(if self.all_staged.unwrap_or(false) {
- self.panel_button("unstage-all", "Unstage All")
- .tooltip({
- let focus_handle = focus_handle.clone();
- move |window, cx| {
- Tooltip::for_action_in(
- "Unstage all changes",
- &UnstageAll,
- &focus_handle,
- window,
- cx,
- )
- }
- })
- .on_click(cx.listener(move |this, _, window, cx| {
- this.unstage_all(&UnstageAll, window, cx)
- }))
- } else {
- self.panel_button("stage-all", "Stage All")
- .tooltip({
- let focus_handle = focus_handle.clone();
- move |window, cx| {
- Tooltip::for_action_in(
- "Stage all changes",
- &StageAll,
- &focus_handle,
- window,
- cx,
- )
- }
- })
- .on_click(
- cx.listener(move |this, _, window, cx| {
- this.stage_all(&StageAll, window, cx)
- }),
- )
- }))
}
pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -1104,7 +1178,7 @@ impl GitPanel {
.map(|repo| repo.display_name(self.project.read(cx), cx))
.unwrap_or_default();
- let entry_count = self.visible_entries.len();
+ let entry_count = self.entries.len();
RepositorySelectorPopoverMenu::new(
self.repository_selector.clone(),
@@ -1194,7 +1268,12 @@ impl GitPanel {
.on_click({
let name_and_email = name_and_email.clone();
cx.listener(move |this, _: &ClickEvent, window, cx| {
- this.commit_all_changes(&CommitAllChanges, name_and_email.clone(), window, cx)
+ this.commit_tracked_changes(
+ &CommitAllChanges,
+ name_and_email.clone(),
+ window,
+ cx,
+ )
})
});
@@ -1294,21 +1373,94 @@ impl GitPanel {
}
fn render_entries(&self, has_write_access: bool, cx: &mut Context<Self>) -> impl IntoElement {
- let entry_count = self.visible_entries.len();
+ let entry_count = self.entries.len();
v_flex()
.size_full()
.overflow_hidden()
.child(
uniform_list(cx.entity().clone(), "entries", entry_count, {
- move |git_panel, range, _window, cx| {
+ move |this, range, _window, cx| {
let mut items = Vec::with_capacity(range.end - range.start);
- git_panel.for_each_visible_entry(range, cx, |ix, details, cx| {
- items.push(git_panel.render_entry(ix, details, has_write_access, cx));
- });
+
+ for ix in range {
+ match &this.entries.get(ix) {
+ Some(GitListEntry::GitStatusEntry(entry)) => {
+ items.push(this.render_entry(ix, entry, has_write_access, cx));
+ }
+ Some(GitListEntry::Header(header)) => {
+ items.push(this.render_header(
+ ix,
+ header,
+ has_write_access,
+ cx,
+ ));
+ }
+ None => {}
+ }
+ }
+
items
}
})
+ .with_decoration(
+ ui::indent_guides(
+ cx.entity().clone(),
+ px(10.0),
+ IndentGuideColors::panel(cx),
+ |this, range, _windows, _cx| {
+ this.entries
+ .iter()
+ .skip(range.start)
+ .map(|entry| match entry {
+ GitListEntry::GitStatusEntry(_) => 1,
+ GitListEntry::Header(_) => 0,
+ })
+ .collect()
+ },
+ )
+ .with_render_fn(
+ cx.entity().clone(),
+ move |_, params, window, cx| {
+ let left_offset = Checkbox::container_size(cx)
+ .to_pixels(window.rem_size())
+ .half();
+ const PADDING_Y: f32 = 4.;
+ let indent_size = params.indent_size;
+ let item_height = params.item_height;
+
+ params
+ .indent_guides
+ .into_iter()
+ .enumerate()
+ .map(|(_, layout)| {
+ let offset = if layout.continues_offscreen {
+ px(0.)
+ } else {
+ px(PADDING_Y)
+ };
+ let bounds = Bounds::new(
+ point(
+ px(layout.offset.x as f32) * indent_size + left_offset,
+ px(layout.offset.y as f32) * item_height + offset,
+ ),
+ size(
+ px(1.),
+ px(layout.length as f32) * item_height
+ - px(offset.0 * 2.),
+ ),
+ );
+ ui::RenderedIndentGuide {
+ bounds,
+ layout,
+ is_active: false,
+ hitbox: None,
+ }
+ })
+ .collect()
+ },
+ ),
+ )
.size_full()
.with_sizing_behavior(ListSizingBehavior::Infer)
.with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
@@ -1321,17 +1473,62 @@ impl GitPanel {
Label::new(label.into()).color(color).single_line()
}
+ fn render_header(
+ &self,
+ ix: usize,
+ header: &GitHeaderEntry,
+ has_write_access: bool,
+ cx: &Context<Self>,
+ ) -> AnyElement {
+ let checkbox = Checkbox::new(header.title(), header.all_staged)
+ .disabled(!has_write_access)
+ .fill()
+ .elevation(ElevationIndex::Surface);
+ let selected = self.selected_entry == Some(ix);
+
+ div()
+ .w_full()
+ .px_0p5()
+ .child(
+ ListHeader::new(header.title())
+ .start_slot(checkbox)
+ .toggle_state(selected)
+ .on_toggle({
+ let header = header.clone();
+ cx.listener(move |this, _, window, cx| {
+ if !has_write_access {
+ return;
+ }
+ this.selected_entry = Some(ix);
+ this.toggle_staged_for_entry(
+ &GitListEntry::Header(header.clone()),
+ window,
+ cx,
+ )
+ })
+ }),
+ )
+ .into_any_element()
+ }
+
fn render_entry(
&self,
ix: usize,
- entry_details: GitListEntry,
+ entry: &GitStatusEntry,
has_write_access: bool,
cx: &Context<Self>,
- ) -> impl IntoElement {
- let repo_path = entry_details.repo_path.clone();
+ ) -> AnyElement {
+ let display_name = entry
+ .repo_path
+ .file_name()
+ .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 repo_path = entry.repo_path.clone();
let selected = self.selected_entry == Some(ix);
let status_style = GitPanelSettings::get_global(cx).status_style;
- let status = entry_details.status;
+ let status = entry.status;
let has_conflict = status.is_conflicted();
let is_modified = status.is_modified();
let is_deleted = status.is_deleted();
@@ -1357,101 +1554,93 @@ impl GitPanel {
Color::Muted
};
- let id: ElementId = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
+ let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
- let checkbox = Checkbox::new(
- id,
- entry_details
- .is_staged
- .map_or(ToggleState::Indeterminate, ToggleState::from),
- )
- .disabled(!has_write_access)
- .fill()
- .elevation(ElevationIndex::Surface)
- .on_click({
- let handle = cx.entity().downgrade();
- let repo_path = repo_path.clone();
- move |toggle, _window, cx| {
- let Some(this) = handle.upgrade() else {
- return;
- };
- this.update(cx, |this, cx| {
- this.visible_entries[ix].is_staged = match *toggle {
- ToggleState::Selected => Some(true),
- ToggleState::Unselected => Some(false),
- ToggleState::Indeterminate => None,
- };
- let repo_path = repo_path.clone();
- let Some(active_repository) = this.active_repository.as_ref() else {
- return;
- };
- let result = match toggle {
- ToggleState::Selected | ToggleState::Indeterminate => active_repository
- .stage_entries(vec![repo_path], this.err_sender.clone()),
- ToggleState::Unselected => active_repository
- .unstage_entries(vec![repo_path], this.err_sender.clone()),
- };
- if let Err(e) = result {
- this.show_err_toast("toggle staged error", e, cx);
- }
- });
- }
- });
+ let is_staged = pending
+ .or_else(|| entry.is_staged)
+ .map(ToggleState::from)
+ .unwrap_or(ToggleState::Indeterminate);
+
+ let checkbox = Checkbox::new(id, is_staged)
+ .disabled(!has_write_access)
+ .fill()
+ .elevation(ElevationIndex::Surface)
+ .on_click({
+ let entry = entry.clone();
+ cx.listener(move |this, _, window, cx| {
+ this.toggle_staged_for_entry(
+ &GitListEntry::GitStatusEntry(entry.clone()),
+ window,
+ cx,
+ );
+ })
+ });
let start_slot = h_flex()
.gap(DynamicSpacing::Base04.rems(cx))
.child(checkbox)
.child(git_status_icon(status, cx));
- let id = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
-
- div().w_full().px_0p5().child(
- ListItem::new(id)
- .spacing(ListItemSpacing::Sparse)
- .start_slot(start_slot)
- .toggle_state(selected)
- .disabled(!has_write_access)
- .on_click({
- 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;
- };
- 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);
+ let id = ElementId::Name(format!("entry_{}", display_name).into());
+
+ div()
+ .w_full()
+ .px_0p5()
+ .child(
+ ListItem::new(id)
+ .indent_level(1)
+ .indent_step_size(px(10.0))
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(start_slot)
+ .toggle_state(selected)
+ .disabled(!has_write_access)
+ .on_click({
+ let repo_path = entry.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;
+ };
+ 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()
- .when_some(repo_path.parent(), |this, parent| {
- let parent_str = parent.to_string_lossy();
- if !parent_str.is_empty() {
- this.child(
- self.entry_label(format!("{}/", parent_str), path_color)
- .when(status.is_deleted(), |this| this.strikethrough(true)),
- )
- } else {
- this
- }
- })
- .child(
- self.entry_label(entry_details.display_name.clone(), label_color)
- .when(status.is_deleted(), |this| this.strikethrough(true)),
- ),
- ),
- )
+ .child(
+ h_flex()
+ .when_some(repo_path.parent(), |this, parent| {
+ let parent_str = parent.to_string_lossy();
+ if !parent_str.is_empty() {
+ this.child(
+ self.entry_label(format!("{}/", parent_str), path_color)
+ .when(status.is_deleted(), |this| {
+ this.strikethrough(true)
+ }),
+ )
+ } else {
+ this
+ }
+ })
+ .child(
+ self.entry_label(display_name.clone(), label_color)
+ .when(status.is_deleted(), |this| this.strikethrough(true)),
+ ),
+ ),
+ )
+ .into_any_element()
}
}
@@ -2,8 +2,8 @@ use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent};
use crate::{Project, ProjectPath};
use anyhow::{anyhow, Context as _};
use client::ProjectId;
-use futures::channel::mpsc;
-use futures::{SinkExt as _, StreamExt as _};
+use futures::channel::{mpsc, oneshot};
+use futures::StreamExt as _;
use git::{
repository::{GitRepository, RepoPath},
status::{GitSummary, TrackedSummary},
@@ -20,7 +20,7 @@ pub struct GitState {
client: Option<AnyProtoClient>,
repositories: Vec<RepositoryHandle>,
active_index: Option<usize>,
- update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
+ update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender<anyhow::Result<()>>)>,
_subscription: Subscription,
}
@@ -30,7 +30,7 @@ pub struct RepositoryHandle {
pub worktree_id: WorktreeId,
pub repository_entry: RepositoryEntry,
pub git_repo: GitRepo,
- update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
+ update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender<anyhow::Result<()>>)>,
}
#[derive(Clone)]
@@ -61,11 +61,6 @@ impl PartialEq<RepositoryEntry> for RepositoryHandle {
}
enum Message {
- StageAndCommit {
- git_repo: GitRepo,
- paths: Vec<RepoPath>,
- name_and_email: Option<(SharedString, SharedString)>,
- },
Commit {
git_repo: GitRepo,
name_and_email: Option<(SharedString, SharedString)>,
@@ -87,151 +82,7 @@ impl GitState {
project_id: Option<ProjectId>,
cx: &mut Context<'_, Self>,
) -> Self {
- let (update_sender, mut update_receiver) =
- mpsc::unbounded::<(Message, mpsc::Sender<anyhow::Error>)>();
- cx.spawn(|_, cx| async move {
- while let Some((msg, mut err_sender)) = update_receiver.next().await {
- let result =
- cx.background_executor()
- .spawn(async move {
- match msg {
- Message::StageAndCommit {
- git_repo,
- name_and_email,
- paths,
- } => {
- match git_repo {
- GitRepo::Local(repo) => {
- repo.stage_paths(&paths)?;
- repo.commit(name_and_email.as_ref().map(
- |(name, email)| (name.as_ref(), email.as_ref()),
- ))?;
- }
- GitRepo::Remote {
- project_id,
- client,
- worktree_id,
- work_directory_id,
- } => {
- client
- .request(proto::Stage {
- project_id: project_id.0,
- worktree_id: worktree_id.to_proto(),
- work_directory_id: work_directory_id.to_proto(),
- paths: paths
- .into_iter()
- .map(|repo_path| repo_path.to_proto())
- .collect(),
- })
- .await
- .context("sending stage request")?;
- let (name, email) = name_and_email.unzip();
- client
- .request(proto::Commit {
- project_id: project_id.0,
- worktree_id: worktree_id.to_proto(),
- work_directory_id: work_directory_id.to_proto(),
- name: name.map(String::from),
- email: email.map(String::from),
- })
- .await
- .context("sending commit request")?;
- }
- }
-
- Ok(())
- }
- Message::Stage(repo, paths) => {
- match repo {
- GitRepo::Local(repo) => repo.stage_paths(&paths)?,
- GitRepo::Remote {
- project_id,
- client,
- worktree_id,
- work_directory_id,
- } => {
- client
- .request(proto::Stage {
- project_id: project_id.0,
- worktree_id: worktree_id.to_proto(),
- work_directory_id: work_directory_id.to_proto(),
- paths: paths
- .into_iter()
- .map(|repo_path| repo_path.to_proto())
- .collect(),
- })
- .await
- .context("sending stage request")?;
- }
- }
- Ok(())
- }
- Message::Unstage(repo, paths) => {
- match repo {
- GitRepo::Local(repo) => repo.unstage_paths(&paths)?,
- GitRepo::Remote {
- project_id,
- client,
- worktree_id,
- work_directory_id,
- } => {
- client
- .request(proto::Unstage {
- project_id: project_id.0,
- worktree_id: worktree_id.to_proto(),
- work_directory_id: work_directory_id.to_proto(),
- paths: paths
- .into_iter()
- .map(|repo_path| repo_path.to_proto())
- .collect(),
- })
- .await
- .context("sending unstage request")?;
- }
- }
- Ok(())
- }
- Message::Commit {
- git_repo,
- name_and_email,
- } => {
- match git_repo {
- GitRepo::Local(repo) => {
- repo.commit(name_and_email.as_ref().map(
- |(name, email)| (name.as_ref(), email.as_ref()),
- ))?
- }
- GitRepo::Remote {
- project_id,
- client,
- worktree_id,
- work_directory_id,
- } => {
- let (name, email) = name_and_email.unzip();
- client
- .request(proto::Commit {
- project_id: project_id.0,
- worktree_id: worktree_id.to_proto(),
- work_directory_id: work_directory_id.to_proto(),
- name: name.map(String::from),
- email: email.map(String::from),
- })
- .await
- .context("sending commit request")?;
- }
- }
- Ok(())
- }
- }
- })
- .await;
- if let Err(e) = result {
- err_sender.send(e).await.ok();
- }
- }
- })
- .detach();
-
+ let update_sender = Self::spawn_git_worker(cx);
let _subscription = cx.subscribe(worktree_store, Self::on_worktree_store_event);
GitState {
@@ -327,6 +178,110 @@ impl GitState {
pub fn all_repositories(&self) -> Vec<RepositoryHandle> {
self.repositories.clone()
}
+
+ fn spawn_git_worker(
+ cx: &mut Context<'_, GitState>,
+ ) -> mpsc::UnboundedSender<(Message, oneshot::Sender<anyhow::Result<()>>)> {
+ let (update_sender, mut update_receiver) =
+ mpsc::unbounded::<(Message, oneshot::Sender<anyhow::Result<()>>)>();
+ cx.spawn(|_, cx| async move {
+ while let Some((msg, respond)) = update_receiver.next().await {
+ let result = cx
+ .background_executor()
+ .spawn(Self::process_git_msg(msg))
+ .await;
+ respond.send(result).ok();
+ }
+ })
+ .detach();
+ update_sender
+ }
+
+ async fn process_git_msg(msg: Message) -> Result<(), anyhow::Error> {
+ match msg {
+ Message::Stage(repo, paths) => {
+ match repo {
+ GitRepo::Local(repo) => repo.stage_paths(&paths)?,
+ GitRepo::Remote {
+ project_id,
+ client,
+ worktree_id,
+ work_directory_id,
+ } => {
+ client
+ .request(proto::Stage {
+ project_id: project_id.0,
+ worktree_id: worktree_id.to_proto(),
+ work_directory_id: work_directory_id.to_proto(),
+ paths: paths
+ .into_iter()
+ .map(|repo_path| repo_path.to_proto())
+ .collect(),
+ })
+ .await
+ .context("sending stage request")?;
+ }
+ }
+ Ok(())
+ }
+ Message::Unstage(repo, paths) => {
+ match repo {
+ GitRepo::Local(repo) => repo.unstage_paths(&paths)?,
+ GitRepo::Remote {
+ project_id,
+ client,
+ worktree_id,
+ work_directory_id,
+ } => {
+ client
+ .request(proto::Unstage {
+ project_id: project_id.0,
+ worktree_id: worktree_id.to_proto(),
+ work_directory_id: work_directory_id.to_proto(),
+ paths: paths
+ .into_iter()
+ .map(|repo_path| repo_path.to_proto())
+ .collect(),
+ })
+ .await
+ .context("sending unstage request")?;
+ }
+ }
+ Ok(())
+ }
+ Message::Commit {
+ git_repo,
+ name_and_email,
+ } => {
+ match git_repo {
+ GitRepo::Local(repo) => repo.commit(
+ name_and_email
+ .as_ref()
+ .map(|(name, email)| (name.as_ref(), email.as_ref())),
+ )?,
+ GitRepo::Remote {
+ project_id,
+ client,
+ worktree_id,
+ work_directory_id,
+ } => {
+ let (name, email) = name_and_email.unzip();
+ client
+ .request(proto::Commit {
+ project_id: project_id.0,
+ worktree_id: worktree_id.to_proto(),
+ work_directory_id: work_directory_id.to_proto(),
+ name: name.map(String::from),
+ email: email.map(String::from),
+ })
+ .await
+ .context("sending commit request")?;
+ }
+ }
+ Ok(())
+ }
+ }
+ }
}
impl RepositoryHandle {
@@ -379,54 +334,47 @@ impl RepositoryHandle {
self.repository_entry.relativize(&path.path).log_err()
}
- pub fn stage_entries(
- &self,
- entries: Vec<RepoPath>,
- err_sender: mpsc::Sender<anyhow::Error>,
- ) -> anyhow::Result<()> {
+ pub async fn stage_entries(&self, entries: Vec<RepoPath>) -> anyhow::Result<()> {
if entries.is_empty() {
return Ok(());
}
+ let (result_tx, result_rx) = futures::channel::oneshot::channel();
self.update_sender
- .unbounded_send((Message::Stage(self.git_repo.clone(), entries), err_sender))
+ .unbounded_send((Message::Stage(self.git_repo.clone(), entries), result_tx))
.map_err(|_| anyhow!("Failed to submit stage operation"))?;
- Ok(())
+
+ result_rx.await?
}
- pub fn unstage_entries(
- &self,
- entries: Vec<RepoPath>,
- err_sender: mpsc::Sender<anyhow::Error>,
- ) -> anyhow::Result<()> {
+ pub async fn unstage_entries(&self, entries: Vec<RepoPath>) -> anyhow::Result<()> {
if entries.is_empty() {
return Ok(());
}
+ let (result_tx, result_rx) = futures::channel::oneshot::channel();
self.update_sender
- .unbounded_send((Message::Unstage(self.git_repo.clone(), entries), err_sender))
+ .unbounded_send((Message::Unstage(self.git_repo.clone(), entries), result_tx))
.map_err(|_| anyhow!("Failed to submit unstage operation"))?;
- Ok(())
+ result_rx.await?
}
- pub fn stage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
+ pub async fn stage_all(&self) -> anyhow::Result<()> {
let to_stage = self
.repository_entry
.status()
.filter(|entry| !entry.status.is_staged().unwrap_or(false))
.map(|entry| entry.repo_path.clone())
.collect();
- self.stage_entries(to_stage, err_sender)?;
- Ok(())
+ self.stage_entries(to_stage).await
}
- pub fn unstage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
+ pub async fn unstage_all(&self) -> anyhow::Result<()> {
let to_unstage = self
.repository_entry
.status()
.filter(|entry| entry.status.is_staged().unwrap_or(true))
.map(|entry| entry.repo_path.clone())
.collect();
- self.unstage_entries(to_unstage, err_sender)?;
- Ok(())
+ self.unstage_entries(to_unstage).await
}
/// Get a count of all entries in the active repository, including
@@ -447,64 +395,18 @@ impl RepositoryHandle {
return self.have_changes() && (commit_all || self.have_staged_changes());
}
- pub fn commit(
+ pub async fn commit(
&self,
name_and_email: Option<(SharedString, SharedString)>,
- mut err_sender: mpsc::Sender<anyhow::Error>,
- cx: &mut App,
) -> anyhow::Result<()> {
- let result = self.update_sender.unbounded_send((
+ let (result_tx, result_rx) = futures::channel::oneshot::channel();
+ self.update_sender.unbounded_send((
Message::Commit {
git_repo: self.git_repo.clone(),
name_and_email,
},
- err_sender.clone(),
- ));
- if result.is_err() {
- cx.spawn(|_| async move {
- err_sender
- .send(anyhow!("Failed to submit commit operation"))
- .await
- .ok();
- })
- .detach();
- anyhow::bail!("Failed to submit commit operation");
- } else {
- Ok(())
- }
- }
-
- pub fn commit_all(
- &self,
- name_and_email: Option<(SharedString, SharedString)>,
- mut err_sender: mpsc::Sender<anyhow::Error>,
- cx: &mut App,
- ) -> anyhow::Result<()> {
- let to_stage = self
- .repository_entry
- .status()
- .filter(|entry| !entry.status.is_staged().unwrap_or(false))
- .map(|entry| entry.repo_path.clone())
- .collect();
- let result = self.update_sender.unbounded_send((
- Message::StageAndCommit {
- git_repo: self.git_repo.clone(),
- paths: to_stage,
- name_and_email,
- },
- err_sender.clone(),
- ));
- if result.is_err() {
- cx.spawn(|_| async move {
- err_sender
- .send(anyhow!("Failed to submit commit all operation"))
- .await
- .ok();
- })
- .detach();
- anyhow::bail!("Failed to submit commit all operation");
- } else {
- Ok(())
- }
+ result_tx,
+ ))?;
+ result_rx.await?
}
}