From f2006b20aceff6ad660b6436bd48413388318e34 Mon Sep 17 00:00:00 2001 From: Davit Koshkeli <43637560+Koshkaj@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:50:31 +0400 Subject: [PATCH] git: Allow showing inline blame in the status bar (#59696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Add status bar location for inline git blame ## Objective - Closes #12133 by implementing the VS Code-like behavior for status bar git blame ## Solution Adds a VS Code–style option to render the current-line git blame in the status bar instead of inline at the cursor. Whats new - A new location setting: Under the inline_blame configuration, you can now choose between two options: - `inline` (Default) — Keeps things exactly as they are, showing the blame details at the end of your current line. - `status_bar` — Moves the blame info down to the bottom status bar. - The Status Bar Item: When you choose the status bar option, a clean new button appears at the bottom left of your window. It features a Git icon, the author's name, how long ago the change was made, and the commit summary - Behaves as it does before, but displayed at a different location - Clicking the status bar item will instantly open up the full Git commit details like at vscode - Settings UI: - The "Location" control is now a `DynamicItem` nested under "Enabled", so it only appears when inline blame is enabled. - Registers a dropdown renderer for `InlineBlameLocation`. - Updates `all-settings.md` and `visual-customization.md` for the new option. ## Testing - Manually tested both modes by toggling `git.inline_blame.location` between `inline` and `status_bar`: - `inline` keeps the existing behavior unchanged. - `status_bar` shows the blame for the focused line in the status bar, hides the inline blame, updates on cursor movement, clears when no editor is focused, and opens the blame commit on click. - Verified the settings UI shows the "Location" dropdown only when inline blame is enabled. - Tested with `show_commit_summary` toggled when location is `status_bar` (for inline location, nothing is changed) Areas that could use more testing: - Remote (collab) editors ? Reviewers can test by setting the following in `settings.json` and moving the cursor across blamed lines: ```json { "git": { "inline_blame": { "enabled": true, "location": "status_bar" } } } ``` ## Showcase
Click to view showcase ### Video https://github.com/user-attachments/assets/6d9f490d-7bf3-473c-9562-8351546dfaf9 ### Screenshots image
Release Notes: - Added a `git.inline_blame.location` setting to render current-line git blame in the status bar instead of inline. --- assets/settings/default.json | 6 +- crates/editor/src/git.rs | 34 ++++++- crates/git_ui/src/blame_ui.rs | 117 ++++++++++++++++++++++--- crates/git_ui/src/git_ui.rs | 1 + crates/project/src/project_settings.rs | 21 +++++ crates/settings_content/src/project.rs | 26 ++++++ crates/settings_ui/src/page_data.rs | 85 +++++++++++++----- crates/settings_ui/src/settings_ui.rs | 1 + crates/zed/src/zed.rs | 2 + docs/src/reference/all-settings.md | 21 ++++- docs/src/visual-customization.md | 3 +- 11 files changed, 274 insertions(+), 43 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 67aacf3f87c..8637d3d1b23 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1643,13 +1643,15 @@ /// /// Default: 0 "gutter_debounce": 0, - // Control whether the git blame information is shown inline, - // in the currently focused line. + // Control whether the git blame information is shown for the currently + // focused line, and where it is rendered. "inline_blame": { "enabled": true, // Sets a delay after which the inline blame information is shown. // Delay is restarted with every cursor movement. "delay_ms": 0, + // Where to render the blame information when it is enabled. + "location": "inline", // The amount of padding between the end of the source line and the start // of the inline blame in units of em widths. "padding": 7, diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index dbe4f073e50..7224fa15d4c 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -195,6 +195,36 @@ impl Editor { self.blame.as_ref() } + pub fn active_git_blame_entry(&self, cx: &mut App) -> Option { + if !self.show_git_blame_inline + || self.newest_selection_head_on_empty_line(cx) + || !self.has_blame_entries(cx) + { + return None; + } + + let blame = self.blame.as_ref()?; + let snapshot = self.display_snapshot(cx); + let cursor = self.selections.newest::(&snapshot).head(); + let (buffer, point) = snapshot.buffer_snapshot().point_to_buffer_point(cursor)?; + + blame + .update(cx, |blame, cx| { + blame + .blame_for_rows( + &[RowInfo { + buffer_id: Some(buffer.remote_id()), + buffer_row: Some(point.row), + ..Default::default() + }], + cx, + ) + .next() + }) + .flatten() + .map(|(_, entry)| entry) + } + pub fn show_git_blame_gutter(&self) -> bool { self.show_git_blame_gutter } @@ -1603,7 +1633,9 @@ impl Editor { } pub(super) fn render_git_blame_inline(&self, window: &Window, cx: &App) -> bool { - self.show_git_blame_inline + ProjectSettings::get_global(cx).git.inline_blame.location + == project::project_settings::InlineBlameLocation::Inline + && self.show_git_blame_inline && (self.focus_handle.is_focused(window) || self.inline_blame_popover.is_some()) && !self.newest_selection_head_on_empty_line(cx) && self.has_blame_entries(cx) diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index 080217e5c53..90d5948de51 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -9,7 +9,10 @@ use gpui::{ TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, }; use markdown::{Markdown, MarkdownElement}; -use project::{git_store::Repository, project_settings::ProjectSettings}; +use project::{ + git_store::Repository, + project_settings::{InlineBlameLocation, ProjectSettings}, +}; use settings::Settings as _; use theme_settings::ThemeSettings; use time::OffsetDateTime; @@ -20,6 +23,104 @@ const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20; pub struct GitBlameRenderer; +fn format_blame_text(blame_entry: &BlameEntry, cx: &App) -> String { + let relative_timestamp = blame_entry_relative_timestamp(blame_entry); + let author = blame_entry.author.as_deref().unwrap_or_default(); + let summary_enabled = ProjectSettings::get_global(cx) + .git + .inline_blame + .show_commit_summary; + + match blame_entry.summary.as_ref() { + Some(summary) if summary_enabled => { + format!("{author}, {relative_timestamp} - {summary}") + } + _ => format!("{author}, {relative_timestamp}"), + } +} + +#[derive(Default)] +pub struct GitBlameStatus { + text: Option, + active_editor: Option>, + _subscriptions: Vec, +} + +impl GitBlameStatus { + fn update(&mut self, editor: Entity, _window: &mut Window, cx: &mut Context) { + let inline_blame = ProjectSettings::get_global(cx).git.inline_blame; + let text = + if inline_blame.enabled && inline_blame.location == InlineBlameLocation::StatusBar { + editor + .update(cx, |editor, cx| editor.active_git_blame_entry(cx)) + .map(|blame_entry| SharedString::from(format_blame_text(&blame_entry, cx))) + } else { + None + }; + + if text != self.text { + self.text = text; + cx.notify(); + } + } +} + +impl Render for GitBlameStatus { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let inline_blame = ProjectSettings::get_global(cx).git.inline_blame; + if !inline_blame.enabled || inline_blame.location != InlineBlameLocation::StatusBar { + return div(); + } + + div().when_some(self.text.clone(), |el, text| { + el.child( + Button::new("git-blame-status", text.clone()) + .label_size(LabelSize::Small) + .start_icon( + Icon::new(IconName::FileGit) + .size(IconSize::Small) + .color(Color::Hint), + ) + .on_click(cx.listener(|this, _, window, cx| { + if let Some(editor) = this.active_editor.clone() { + let focus_handle = gpui::Focusable::focus_handle(editor.read(cx), cx); + focus_handle.dispatch_action( + &editor::actions::OpenGitBlameCommit, + window, + cx, + ); + } + })) + .tooltip(ui::Tooltip::text(text)), + ) + }) + } +} + +impl workspace::StatusItemView for GitBlameStatus { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn workspace::item::ItemHandle>, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(editor) = active_pane_item.and_then(|item| item.act_as::(cx)) { + self.active_editor = Some(editor.clone()); + self._subscriptions = vec![cx.observe_in(&editor, window, Self::update)]; + self.update(editor, window, cx); + } else { + self.text = None; + self.active_editor = None; + self._subscriptions.clear(); + cx.notify(); + } + } + + fn hide_setting(&self, _: &App) -> Option { + None + } +} + impl BlameRenderer for GitBlameRenderer { fn max_author_length(&self) -> usize { GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED @@ -144,19 +245,7 @@ impl BlameRenderer for GitBlameRenderer { blame_entry: BlameEntry, cx: &mut App, ) -> Option { - let relative_timestamp = blame_entry_relative_timestamp(&blame_entry); - let author = blame_entry.author.as_deref().unwrap_or_default(); - let summary_enabled = ProjectSettings::get_global(cx) - .git - .inline_blame - .show_commit_summary; - - let text = match blame_entry.summary.as_ref() { - Some(summary) if summary_enabled => { - format!("{}, {} - {}", author, relative_timestamp, summary) - } - _ => format!("{}, {}", author, relative_timestamp), - }; + let text = format_blame_text(&blame_entry, cx); Some( h_flex() diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index c221b8814e2..a5dc98c5049 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -62,6 +62,7 @@ pub mod worktree_names; pub mod worktree_picker; pub mod worktree_service; +pub use blame_ui::GitBlameStatus; pub use conflict_view::MergeConflictIndicator; pub fn get_provider_icon(name: &str) -> IconName { diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index f11263a8f70..52c2a9c2012 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -524,6 +524,22 @@ impl From for GitPathStyle { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum InlineBlameLocation { + #[default] + Inline, + StatusBar, +} + +impl From for InlineBlameLocation { + fn from(location: settings::InlineBlameLocation) -> Self { + match location { + settings::InlineBlameLocation::Inline => InlineBlameLocation::Inline, + settings::InlineBlameLocation::StatusBar => InlineBlameLocation::StatusBar, + } + } +} + #[derive(Clone, Copy, Debug)] pub struct InlineBlameSettings { /// Whether or not to show git blame data inline in @@ -536,6 +552,10 @@ pub struct InlineBlameSettings { /// /// Default: 0 pub delay_ms: settings::DelayMs, + /// Where to render the blame information when enabled. + /// + /// Default: inline + pub location: InlineBlameLocation, /// The amount of padding between the end of the source line and the start /// of the inline blame in units of columns. /// @@ -664,6 +684,7 @@ impl Settings for ProjectSettings { InlineBlameSettings { enabled: inline.enabled.unwrap(), delay_ms: inline.delay_ms.unwrap(), + location: inline.location.unwrap().into(), padding: inline.padding.unwrap(), min_column: inline.min_column.unwrap(), show_commit_summary: inline.show_commit_summary.unwrap(), diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 6e62f56ddf3..7f4d8d53912 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -616,6 +616,28 @@ pub enum GitGutterSetting { Hide, } +#[derive( + Clone, + Copy, + Debug, + PartialEq, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum InlineBlameLocation { + /// Show git blame inline at the current line. + #[default] + Inline, + /// Show git blame in the status bar at the bottom of the window. + StatusBar, +} + #[with_fallible_options] #[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)] #[serde(rename_all = "snake_case")] @@ -630,6 +652,10 @@ pub struct InlineBlameSettings { /// /// Default: 0 pub delay_ms: Option, + /// Where to render the blame information when enabled. + /// + /// Default: inline + pub location: Option, /// The amount of padding between the end of the source line and the start /// of the inline blame in units of columns. /// diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 5bdb50b22e8..f8de55b07bb 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7518,32 +7518,75 @@ fn version_control_page() -> SettingsPage { fn inline_git_blame_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("Inline Git Blame"), - SettingsPageItem::SettingItem(SettingItem { - title: "Enabled", - description: "Whether or not to show Git blame data inline in the currently focused line.", - field: Box::new(SettingField { - organization_override: None, - json_path: Some("git.inline_blame.enabled"), - pick: |settings_content| { - settings_content + SettingsPageItem::DynamicItem(DynamicItem { + discriminant: SettingItem { + title: "Enabled", + description: "Whether or not to show Git blame data for the currently focused line.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("git.inline_blame.enabled"), + pick: |settings_content| { + settings_content + .git + .as_ref()? + .inline_blame + .as_ref()? + .enabled + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .git + .get_or_insert_default() + .inline_blame + .get_or_insert_default() + .enabled = value; + }, + }), + metadata: None, + files: USER, + }, + pick_discriminant: |settings_content| { + Some( + *settings_content .git .as_ref()? .inline_blame .as_ref()? .enabled - .as_ref() - }, - write: |settings_content, value, _| { - settings_content - .git - .get_or_insert_default() - .inline_blame - .get_or_insert_default() - .enabled = value; - }, - }), - metadata: None, - files: USER, + .as_ref()? as usize, + ) + }, + fields: vec![ + vec![], + vec![SettingItem { + title: "Location", + description: "Where to render Git blame when it is enabled.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("git.inline_blame.location"), + pick: |settings_content| { + settings_content + .git + .as_ref()? + .inline_blame + .as_ref()? + .location + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .git + .get_or_insert_default() + .inline_blame + .get_or_insert_default() + .location = value; + }, + }), + metadata: None, + files: USER, + }], + ], }), SettingsPageItem::SettingItem(SettingItem { title: "Delay", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index fbc06a70166..4847d066bae 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -545,6 +545,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3709a6c4b42..d4f3d958304 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -599,6 +599,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace)); let line_ending_indicator = cx.new(|_| line_ending_selector::LineEndingIndicator::default()); + let git_blame_status = cx.new(|_| git_ui::GitBlameStatus::default()); let merge_conflict_indicator = cx.new(|cx| git_ui::MergeConflictIndicator::new(workspace, cx)); workspace.status_bar().update(cx, |status_bar, cx| { @@ -606,6 +607,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { status_bar.add_left_item(lsp_button, window, cx); status_bar.add_left_item(diagnostic_summary, window, cx); status_bar.add_left_item(active_file_name, window, cx); + status_bar.add_left_item(git_blame_status, window, cx); status_bar.add_left_item(merge_conflict_indicator, window, cx); status_bar.add_left_item(activity_indicator, window, cx); status_bar.add_right_item(edit_prediction_ui, window, cx); diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 1045c09996a..ebeacf2bb96 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -2325,7 +2325,8 @@ Example: { "git": { "inline_blame": { - "enabled": true + "enabled": true, + "location": "inline" } } } @@ -2357,7 +2358,19 @@ Example: } ``` -3. Show a commit summary next to the commit date and author: +3. Show git blame in the status bar at the bottom of the window: + +```json [settings] +{ + "git": { + "inline_blame": { + "location": "status_bar" + } + } +} +``` + +4. Show a commit summary next to the commit date and author: ```json [settings] { @@ -2369,7 +2382,7 @@ Example: } ``` -4. Use this as the minimum column at which to display inline blame information: +5. Use this as the minimum column at which to display inline blame information: ```json [settings] { @@ -2381,7 +2394,7 @@ Example: } ``` -5. Set the padding between the end of the line and the inline blame hint, in ems: +6. Set the padding between the end of the line and the inline blame hint, in ems: ```json [settings] { diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 91922498486..b58469d12e4 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -247,7 +247,8 @@ TBD: Centered layout related settings "git": { "inline_blame": { "enabled": true, // Show/hide inline blame - "delay_ms": 0, // Show after delay (ms) + "delay_ms": 0, // Show after delay (ms) + "location": "inline", // inline, status_bar "min_column": 0, // Minimum column to inline display blame "padding": 7, // Padding between code and inline blame (em) "show_commit_summary": false // Show/hide commit summary