git: Allow showing inline blame in the status bar (#59696)

# 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

<details>
  <summary>Click to view showcase</summary>

### Video

https://github.com/user-attachments/assets/6d9f490d-7bf3-473c-9562-8351546dfaf9

### Screenshots
<img width="894" height="749" alt="image"
src="https://github.com/user-attachments/assets/c6a49008-899d-4a0c-9098-1ffb9e28252b"
/>


</details>

Release Notes:

- Added a `git.inline_blame.location` setting to render current-line git
blame in the status bar instead of inline.
This commit is contained in:
Davit Koshkeli 2026-06-29 09:50:31 +04:00 committed by GitHub
parent cd7f1a0fb1
commit f2006b20ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 274 additions and 43 deletions

View file

@ -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,

View file

@ -195,6 +195,36 @@ impl Editor {
self.blame.as_ref()
}
pub fn active_git_blame_entry(&self, cx: &mut App) -> Option<BlameEntry> {
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::<Point>(&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)

View file

@ -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<SharedString>,
active_editor: Option<Entity<Editor>>,
_subscriptions: Vec<Subscription>,
}
impl GitBlameStatus {
fn update(&mut self, editor: Entity<Editor>, _window: &mut Window, cx: &mut Context<Self>) {
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<Self>) -> 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<Self>,
) {
if let Some(editor) = active_pane_item.and_then(|item| item.act_as::<Editor>(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<workspace::HideStatusItem> {
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<AnyElement> {
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()

View file

@ -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 {

View file

@ -524,6 +524,22 @@ impl From<settings::GitPathStyle> for GitPathStyle {
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum InlineBlameLocation {
#[default]
Inline,
StatusBar,
}
impl From<settings::InlineBlameLocation> 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(),

View file

@ -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<DelayMs>,
/// Where to render the blame information when enabled.
///
/// Default: inline
pub location: Option<InlineBlameLocation>,
/// The amount of padding between the end of the source line and the start
/// of the inline blame in units of columns.
///

View file

@ -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",

View file

@ -545,6 +545,7 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::<settings::GitGutterSetting>(render_dropdown)
.add_basic_renderer::<settings::GitHunkStyleSetting>(render_dropdown)
.add_basic_renderer::<settings::GitPathStyle>(render_dropdown)
.add_basic_renderer::<settings::InlineBlameLocation>(render_dropdown)
.add_basic_renderer::<settings::DiagnosticSeverityContent>(render_dropdown)
.add_basic_renderer::<settings::SeedQuerySetting>(render_dropdown)
.add_basic_renderer::<settings::DoubleClickInMultibuffer>(render_dropdown)

View file

@ -599,6 +599,7 @@ pub fn initialize_workspace(app_state: Arc<AppState>, 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<AppState>, 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);

View file

@ -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]
{

View file

@ -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