Git tweaks (#28791)

Release Notes:

- git: Add a `git_panel.sort_by_path` setting to mix untracked/tracked
files in the diff list.
- git: Remove the "•" placeholder for "Tracked". The commit button says
"Commit Tracked" still by default, and this was misinterpreted to mean
"partially staged". Hovering over the button will show you which files
are tracked (in addition to the yellow square-with-a-dot-in-it).
- Increase the default value of `expand_excerpt_lines` from 3 to 5. This
makes it faster to see more context in the git diff view.

---------

Co-authored-by: Birk Skyum <birk.skyum@pm.me>
Co-authored-by: Peter Tripp <peter@zed.dev>
This commit is contained in:
Conrad Irwin 2025-04-28 23:42:23 -06:00 committed by GitHub
parent 3fd37799b4
commit 756fcd0733
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 229 additions and 204 deletions

View file

@ -226,7 +226,7 @@
// Hide the values of in variables from visual display in private files // Hide the values of in variables from visual display in private files
"redact_private_values": false, "redact_private_values": false,
// The default number of lines to expand excerpts in the multibuffer by. // The default number of lines to expand excerpts in the multibuffer by.
"expand_excerpt_lines": 3, "expand_excerpt_lines": 5,
// Globs to match against file paths to determine if a file is private. // Globs to match against file paths to determine if a file is private.
"private_files": ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"], "private_files": ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"],
// Whether to use additional LSP queries to format (and amend) the code after // Whether to use additional LSP queries to format (and amend) the code after
@ -601,6 +601,13 @@
// //
// Default: main // Default: main
"fallback_branch_name": "main", "fallback_branch_name": "main",
// Whether to sort entries in the panel by path
// or by status (the default).
//
// Default: false
"sort_by_path": false,
"scrollbar": { "scrollbar": {
// When to show the scrollbar in the git panel. // When to show the scrollbar in the git panel.
// //

View file

@ -354,6 +354,7 @@ pub struct GitPanel {
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>, context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
modal_open: bool, modal_open: bool,
show_placeholders: bool,
_settings_subscription: Subscription, _settings_subscription: Subscription,
} }
@ -407,6 +408,16 @@ impl GitPanel {
}) })
.detach(); .detach();
let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
cx.observe_global::<SettingsStore>(move |this, cx| {
let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
if is_sort_by_path != was_sort_by_path {
this.update_visible_entries(cx);
}
was_sort_by_path = is_sort_by_path
})
.detach();
// just to let us render a placeholder editor. // just to let us render a placeholder editor.
// Once the active git repo is set, this buffer will be replaced. // Once the active git repo is set, this buffer will be replaced.
let temporary_buffer = cx.new(|cx| Buffer::local("", cx)); let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
@ -506,6 +517,7 @@ impl GitPanel {
tracked_staged_count: 0, tracked_staged_count: 0,
update_visible_entries_task: Task::ready(()), update_visible_entries_task: Task::ready(()),
width: None, width: None,
show_placeholders: false,
context_menu: None, context_menu: None,
workspace, workspace,
modal_open: false, modal_open: false,
@ -598,7 +610,14 @@ impl GitPanel {
cx.notify(); cx.notify();
} }
pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> { pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option<usize> {
if GitPanelSettings::get_global(cx).sort_by_path {
return self
.entries
.binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
.ok();
}
if self.conflicted_count > 0 { if self.conflicted_count > 0 {
let conflicted_start = 1; let conflicted_start = 1;
if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count] if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count]
@ -650,7 +669,7 @@ impl GitPanel {
let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else { let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
return; return;
}; };
let Some(ix) = self.entry_by_path(&repo_path) else { let Some(ix) = self.entry_by_path(&repo_path, cx) else {
return; return;
}; };
self.selected_entry = Some(ix); self.selected_entry = Some(ix);
@ -2294,6 +2313,8 @@ impl GitPanel {
self.tracked_staged_count = 0; self.tracked_staged_count = 0;
self.entry_count = 0; self.entry_count = 0;
let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
let mut changed_entries = Vec::new(); let mut changed_entries = Vec::new();
let mut new_entries = Vec::new(); let mut new_entries = Vec::new();
let mut conflict_entries = Vec::new(); let mut conflict_entries = Vec::new();
@ -2353,7 +2374,9 @@ impl GitPanel {
None => max_width_item = Some((entry.repo_path.clone(), width_estimate)), None => max_width_item = Some((entry.repo_path.clone(), width_estimate)),
} }
if is_conflict { if sort_by_path {
changed_entries.push(entry);
} else if is_conflict {
conflict_entries.push(entry); conflict_entries.push(entry);
} else if is_new { } else if is_new {
new_entries.push(entry); new_entries.push(entry);
@ -2408,9 +2431,11 @@ impl GitPanel {
} }
if changed_entries.len() > 0 { if changed_entries.len() > 0 {
self.entries.push(GitListEntry::Header(GitHeaderEntry { if !sort_by_path {
header: Section::Tracked, self.entries.push(GitListEntry::Header(GitHeaderEntry {
})); header: Section::Tracked,
}));
}
self.entries.extend( self.entries.extend(
changed_entries changed_entries
.into_iter() .into_iter()
@ -2464,6 +2489,7 @@ impl GitPanel {
} }
fn update_counts(&mut self, repo: &Repository) { fn update_counts(&mut self, repo: &Repository) {
self.show_placeholders = false;
self.conflicted_count = 0; self.conflicted_count = 0;
self.conflicted_staged_count = 0; self.conflicted_staged_count = 0;
self.new_count = 0; self.new_count = 0;
@ -2525,10 +2551,6 @@ impl GitPanel {
|| self.conflicted_count > self.conflicted_staged_count || self.conflicted_count > self.conflicted_staged_count
} }
fn has_conflicts(&self) -> bool {
self.conflicted_count > 0
}
fn has_tracked_changes(&self) -> bool { fn has_tracked_changes(&self) -> bool {
self.tracked_count > 0 self.tracked_count > 0
} }
@ -2935,14 +2957,11 @@ impl GitPanel {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<impl IntoElement> { ) -> Option<impl IntoElement> {
let active_repository = self.active_repository.clone()?; let active_repository = self.active_repository.clone()?;
let (can_commit, tooltip) = self.configure_commit_button(cx);
let panel_editor_style = panel_editor_style(true, window, cx); let panel_editor_style = panel_editor_style(true, window, cx);
let enable_coauthors = self.render_co_authors(cx); let enable_coauthors = self.render_co_authors(cx);
let title = self.commit_button_title();
let editor_focus_handle = self.commit_editor.focus_handle(cx); let editor_focus_handle = self.commit_editor.focus_handle(cx);
let commit_tooltip_focus_handle = editor_focus_handle.clone();
let expand_tooltip_focus_handle = editor_focus_handle.clone(); let expand_tooltip_focus_handle = editor_focus_handle.clone();
let branch = active_repository.read(cx).branch.clone(); let branch = active_repository.read(cx).branch.clone();
@ -3010,183 +3029,7 @@ impl GitPanel {
h_flex() h_flex()
.gap_0p5() .gap_0p5()
.children(enable_coauthors) .children(enable_coauthors)
.when(self.amend_pending, { .child(self.render_commit_button(has_previous_commit, cx)),
|this| {
this.h_flex()
.gap_1()
.child(
panel_filled_button("Cancel")
.tooltip({
let handle =
commit_tooltip_focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Cancel amend",
&git::Cancel,
&handle,
window,
cx,
)
}
})
.on_click(move |_, window, cx| {
window.dispatch_action(
Box::new(git::Cancel),
cx,
);
}),
)
.child(
panel_filled_button(title)
.tooltip({
let handle =
commit_tooltip_focus_handle.clone();
move |window, cx| {
if can_commit {
Tooltip::for_action_in(
tooltip, &Amend, &handle,
window, cx,
)
} else {
Tooltip::simple(tooltip, cx)
}
}
})
.disabled(!can_commit || self.modal_open)
.on_click({
let git_panel = git_panel.downgrade();
move |_, window, cx| {
telemetry::event!(
"Git Amended",
source = "Git Panel"
);
git_panel
.update(cx, |git_panel, cx| {
git_panel
.set_amend_pending(
false, cx,
);
git_panel.commit_changes(
CommitOptions {
amend: true,
},
window,
cx,
);
})
.ok();
}
}),
)
}
})
.when(!self.amend_pending, |this| {
this.when(has_previous_commit, |this| {
this.child(SplitButton::new(
ui::ButtonLike::new_rounded_left(ElementId::Name(
format!("split-button-left-{}", title).into(),
))
.layer(ui::ElevationIndex::ModalSurface)
.size(ui::ButtonSize::Compact)
.child(
div()
.child(
Label::new(title)
.size(LabelSize::Small),
)
.mr_0p5(),
)
.on_click({
let git_panel = git_panel.downgrade();
move |_, window, cx| {
telemetry::event!(
"Git Committed",
source = "Git Panel"
);
git_panel
.update(cx, |git_panel, cx| {
git_panel.commit_changes(
CommitOptions { amend: false },
window,
cx,
);
})
.ok();
}
})
.disabled(!can_commit || self.modal_open)
.tooltip({
let handle =
commit_tooltip_focus_handle.clone();
move |window, cx| {
if can_commit {
Tooltip::with_meta_in(
tooltip,
Some(&git::Commit),
"git commit",
&handle.clone(),
window,
cx,
)
} else {
Tooltip::simple(tooltip, cx)
}
}
}),
self.render_git_commit_menu(
ElementId::Name(
format!("split-button-right-{}", title)
.into(),
),
Some(commit_tooltip_focus_handle.clone()),
)
.into_any_element(),
))
})
.when(
!has_previous_commit,
|this| {
this.child(
panel_filled_button(title)
.tooltip(move |window, cx| {
if can_commit {
Tooltip::with_meta_in(
tooltip,
Some(&git::Commit),
"git commit",
&commit_tooltip_focus_handle,
window,
cx,
)
} else {
Tooltip::simple(tooltip, cx)
}
})
.disabled(!can_commit || self.modal_open)
.on_click({
let git_panel = git_panel.downgrade();
move |_, window, cx| {
telemetry::event!(
"Git Committed",
source = "Git Panel"
);
git_panel
.update(cx, |git_panel, cx| {
git_panel.commit_changes(
CommitOptions {
amend: false,
},
window,
cx,
);
})
.ok();
}
}),
)
},
)
}),
), ),
) )
.child( .child(
@ -3235,6 +3078,168 @@ impl GitPanel {
Some(footer) Some(footer)
} }
fn render_commit_button(
&self,
has_previous_commit: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
let (can_commit, tooltip) = self.configure_commit_button(cx);
let title = self.commit_button_title();
let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
div()
.id("commit-wrapper")
.on_hover(cx.listener(move |this, hovered, _, cx| {
this.show_placeholders =
*hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
cx.notify()
}))
.when(self.amend_pending, {
|this| {
this.h_flex()
.gap_1()
.child(
panel_filled_button("Cancel")
.tooltip({
let handle = commit_tooltip_focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Cancel amend",
&git::Cancel,
&handle,
window,
cx,
)
}
})
.on_click(move |_, window, cx| {
window.dispatch_action(Box::new(git::Cancel), cx);
}),
)
.child(
panel_filled_button(title)
.tooltip({
let handle = commit_tooltip_focus_handle.clone();
move |window, cx| {
if can_commit {
Tooltip::for_action_in(
tooltip, &Amend, &handle, window, cx,
)
} else {
Tooltip::simple(tooltip, cx)
}
}
})
.disabled(!can_commit || self.modal_open)
.on_click({
let git_panel = cx.weak_entity();
move |_, window, cx| {
telemetry::event!("Git Amended", source = "Git Panel");
git_panel
.update(cx, |git_panel, cx| {
git_panel.set_amend_pending(false, cx);
git_panel.commit_changes(
CommitOptions { amend: true },
window,
cx,
);
})
.ok();
}
}),
)
}
})
.when(!self.amend_pending, |this| {
this.when(has_previous_commit, |this| {
this.child(SplitButton::new(
ui::ButtonLike::new_rounded_left(ElementId::Name(
format!("split-button-left-{}", title).into(),
))
.layer(ui::ElevationIndex::ModalSurface)
.size(ui::ButtonSize::Compact)
.child(
div()
.child(Label::new(title).size(LabelSize::Small))
.mr_0p5(),
)
.on_click({
let git_panel = cx.weak_entity();
move |_, window, cx| {
telemetry::event!("Git Committed", source = "Git Panel");
git_panel
.update(cx, |git_panel, cx| {
git_panel.commit_changes(
CommitOptions { amend: false },
window,
cx,
);
})
.ok();
}
})
.disabled(!can_commit || self.modal_open)
.tooltip({
let handle = commit_tooltip_focus_handle.clone();
move |window, cx| {
if can_commit {
Tooltip::with_meta_in(
tooltip,
Some(&git::Commit),
"git commit",
&handle.clone(),
window,
cx,
)
} else {
Tooltip::simple(tooltip, cx)
}
}
}),
self.render_git_commit_menu(
ElementId::Name(format!("split-button-right-{}", title).into()),
Some(commit_tooltip_focus_handle.clone()),
)
.into_any_element(),
))
})
.when(!has_previous_commit, |this| {
this.child(
panel_filled_button(title)
.tooltip(move |window, cx| {
if can_commit {
Tooltip::with_meta_in(
tooltip,
Some(&git::Commit),
"git commit",
&commit_tooltip_focus_handle,
window,
cx,
)
} else {
Tooltip::simple(tooltip, cx)
}
})
.disabled(!can_commit || self.modal_open)
.on_click({
let git_panel = cx.weak_entity();
move |_, window, cx| {
telemetry::event!("Git Committed", source = "Git Panel");
git_panel
.update(cx, |git_panel, cx| {
git_panel.commit_changes(
CommitOptions { amend: false },
window,
cx,
);
})
.ok();
}
}),
)
})
})
}
fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
div() div()
.py_2() .py_2()
@ -3482,7 +3487,7 @@ impl GitPanel {
let repo = self.active_repository.as_ref()?.read(cx); let repo = self.active_repository.as_ref()?.read(cx);
let project_path = (file.worktree_id(cx), file.path()).into(); let project_path = (file.worktree_id(cx), file.path()).into();
let repo_path = repo.project_path_to_repo_path(&project_path, cx)?; let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
let ix = self.entry_by_path(&repo_path)?; let ix = self.entry_by_path(&repo_path, cx)?;
let entry = self.entries.get(ix)?; let entry = self.entries.get(ix)?;
let entry_staging = self.entry_staging(entry.status_entry()?); let entry_staging = self.entry_staging(entry.status_entry()?);
@ -3852,8 +3857,7 @@ impl GitPanel {
let entry_staging = self.entry_staging(entry); let entry_staging = self.entry_staging(entry);
let mut is_staged: ToggleState = self.entry_staging(entry).as_bool().into(); let mut is_staged: ToggleState = self.entry_staging(entry).as_bool().into();
if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
is_staged = ToggleState::Selected; is_staged = ToggleState::Selected;
} }
@ -3953,11 +3957,6 @@ impl GitPanel {
Checkbox::new(checkbox_id, is_staged) Checkbox::new(checkbox_id, is_staged)
.disabled(!has_write_access) .disabled(!has_write_access)
.fill() .fill()
.placeholder(
!self.has_staged_changes()
&& !self.has_conflicts()
&& !entry.status.is_created(),
)
.elevation(ElevationIndex::Surface) .elevation(ElevationIndex::Surface)
.on_click({ .on_click({
let entry = entry.clone(); let entry = entry.clone();

View file

@ -64,6 +64,12 @@ pub struct GitPanelSettingsContent {
/// ///
/// Default: main /// Default: main
pub fallback_branch_name: Option<String>, pub fallback_branch_name: Option<String>,
/// Whether to sort entries in the panel by path
/// or by status (the default).
///
/// Default: false
pub sort_by_path: Option<bool>,
} }
#[derive(Deserialize, Debug, Clone, PartialEq)] #[derive(Deserialize, Debug, Clone, PartialEq)]
@ -74,6 +80,7 @@ pub struct GitPanelSettings {
pub status_style: StatusStyle, pub status_style: StatusStyle,
pub scrollbar: ScrollbarSettings, pub scrollbar: ScrollbarSettings,
pub fallback_branch_name: String, pub fallback_branch_name: String,
pub sort_by_path: bool,
} }
impl Settings for GitPanelSettings { impl Settings for GitPanelSettings {

View file

@ -1,6 +1,7 @@
use crate::{ use crate::{
conflict_view::ConflictAddon, conflict_view::ConflictAddon,
git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
git_panel_settings::GitPanelSettings,
remote_button::{render_publish_button, render_push_button}, remote_button::{render_publish_button, render_push_button},
}; };
use anyhow::Result; use anyhow::Result;
@ -27,10 +28,9 @@ use project::{
Project, ProjectPath, Project, ProjectPath,
git_store::{GitStore, GitStoreEvent, RepositoryEvent}, git_store::{GitStore, GitStoreEvent, RepositoryEvent},
}; };
use std::{ use settings::{Settings, SettingsStore};
any::{Any, TypeId}, use std::any::{Any, TypeId};
ops::Range, use std::ops::Range;
};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider}; use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider};
use util::ResultExt as _; use util::ResultExt as _;
@ -165,6 +165,16 @@ impl ProjectDiff {
}, },
); );
let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
cx.observe_global::<SettingsStore>(move |this, cx| {
let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
if is_sort_by_path != was_sort_by_path {
*this.update_needed.borrow_mut() = ();
}
was_sort_by_path = is_sort_by_path
})
.detach();
let (mut send, recv) = postage::watch::channel::<()>(); let (mut send, recv) = postage::watch::channel::<()>();
let worker = window.spawn(cx, { let worker = window.spawn(cx, {
let this = cx.weak_entity(); let this = cx.weak_entity();
@ -349,7 +359,9 @@ impl ProjectDiff {
else { else {
continue; continue;
}; };
let namespace = if repo.has_conflict(&entry.repo_path) { let namespace = if GitPanelSettings::get_global(cx).sort_by_path {
TRACKED_NAMESPACE
} else if repo.has_conflict(&entry.repo_path) {
CONFLICT_NAMESPACE CONFLICT_NAMESPACE
} else if entry.status.is_created() { } else if entry.status.is_created() {
NEW_NAMESPACE NEW_NAMESPACE