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
"redact_private_values": false,
// 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.
"private_files": ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"],
// Whether to use additional LSP queries to format (and amend) the code after
@ -601,6 +601,13 @@
//
// Default: 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": {
// When to show the scrollbar in the git panel.
//

View file

@ -354,6 +354,7 @@ pub struct GitPanel {
workspace: WeakEntity<Workspace>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
modal_open: bool,
show_placeholders: bool,
_settings_subscription: Subscription,
}
@ -407,6 +408,16 @@ impl GitPanel {
})
.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.
// Once the active git repo is set, this buffer will be replaced.
let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
@ -506,6 +517,7 @@ impl GitPanel {
tracked_staged_count: 0,
update_visible_entries_task: Task::ready(()),
width: None,
show_placeholders: false,
context_menu: None,
workspace,
modal_open: false,
@ -598,7 +610,14 @@ impl GitPanel {
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 {
let conflicted_start = 1;
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 {
return;
};
let Some(ix) = self.entry_by_path(&repo_path) else {
let Some(ix) = self.entry_by_path(&repo_path, cx) else {
return;
};
self.selected_entry = Some(ix);
@ -2294,6 +2313,8 @@ impl GitPanel {
self.tracked_staged_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 new_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)),
}
if is_conflict {
if sort_by_path {
changed_entries.push(entry);
} else if is_conflict {
conflict_entries.push(entry);
} else if is_new {
new_entries.push(entry);
@ -2408,9 +2431,11 @@ impl GitPanel {
}
if changed_entries.len() > 0 {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Tracked,
}));
if !sort_by_path {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Tracked,
}));
}
self.entries.extend(
changed_entries
.into_iter()
@ -2464,6 +2489,7 @@ impl GitPanel {
}
fn update_counts(&mut self, repo: &Repository) {
self.show_placeholders = false;
self.conflicted_count = 0;
self.conflicted_staged_count = 0;
self.new_count = 0;
@ -2525,10 +2551,6 @@ impl GitPanel {
|| self.conflicted_count > self.conflicted_staged_count
}
fn has_conflicts(&self) -> bool {
self.conflicted_count > 0
}
fn has_tracked_changes(&self) -> bool {
self.tracked_count > 0
}
@ -2935,14 +2957,11 @@ impl GitPanel {
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
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 enable_coauthors = self.render_co_authors(cx);
let title = self.commit_button_title();
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 branch = active_repository.read(cx).branch.clone();
@ -3010,183 +3029,7 @@ impl GitPanel {
h_flex()
.gap_0p5()
.children(enable_coauthors)
.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 = 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(self.render_commit_button(has_previous_commit, cx)),
),
)
.child(
@ -3235,6 +3078,168 @@ impl GitPanel {
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 {
div()
.py_2()
@ -3482,7 +3487,7 @@ impl GitPanel {
let repo = self.active_repository.as_ref()?.read(cx);
let project_path = (file.worktree_id(cx), file.path()).into();
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_staging = self.entry_staging(entry.status_entry()?);
@ -3852,8 +3857,7 @@ impl GitPanel {
let entry_staging = self.entry_staging(entry);
let mut is_staged: ToggleState = self.entry_staging(entry).as_bool().into();
if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
is_staged = ToggleState::Selected;
}
@ -3953,11 +3957,6 @@ impl GitPanel {
Checkbox::new(checkbox_id, is_staged)
.disabled(!has_write_access)
.fill()
.placeholder(
!self.has_staged_changes()
&& !self.has_conflicts()
&& !entry.status.is_created(),
)
.elevation(ElevationIndex::Surface)
.on_click({
let entry = entry.clone();

View file

@ -64,6 +64,12 @@ pub struct GitPanelSettingsContent {
///
/// Default: main
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)]
@ -74,6 +80,7 @@ pub struct GitPanelSettings {
pub status_style: StatusStyle,
pub scrollbar: ScrollbarSettings,
pub fallback_branch_name: String,
pub sort_by_path: bool,
}
impl Settings for GitPanelSettings {

View file

@ -1,6 +1,7 @@
use crate::{
conflict_view::ConflictAddon,
git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
git_panel_settings::GitPanelSettings,
remote_button::{render_publish_button, render_push_button},
};
use anyhow::Result;
@ -27,10 +28,9 @@ use project::{
Project, ProjectPath,
git_store::{GitStore, GitStoreEvent, RepositoryEvent},
};
use std::{
any::{Any, TypeId},
ops::Range,
};
use settings::{Settings, SettingsStore};
use std::any::{Any, TypeId};
use std::ops::Range;
use theme::ActiveTheme;
use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider};
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 worker = window.spawn(cx, {
let this = cx.weak_entity();
@ -349,7 +359,9 @@ impl ProjectDiff {
else {
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
} else if entry.status.is_created() {
NEW_NAMESPACE