Skip to content

Commit 0c34cf6

Browse files
committed
Merge upstream PR helix-editor#13133: feat: Inline Git Blame
2 parents 5cb5b0a + 48a8705 commit 0c34cf6

29 files changed

Lines changed: 1310 additions & 17 deletions

File tree

Cargo.lock

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

book/src/editor.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- [`[editor.clipboard-provider]` Section](#editorclipboard-provider-section)
55
- [`[editor.statusline]` Section](#editorstatusline-section)
66
- [`[editor.lsp]` Section](#editorlsp-section)
7+
- [`[editor.inline-blame]` Section](#editorinlineblame-section)
78
- [`[editor.cursor-shape]` Section](#editorcursor-shape-section)
89
- [`[editor.file-picker]` Section](#editorfile-picker-section)
910
- [`[editor.file-explorer]` Section](#editorfile-explorer-section)
@@ -176,6 +177,39 @@ The following statusline elements can be configured:
176177

177178
[^2]: You may also have to activate them in the language server config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them!
178179

180+
### `[editor.inline-blame]` Section
181+
182+
Inline blame is virtual text that appears at the end of a line, displaying information about the most recent commit that affected this line.
183+
184+
| Key | Description | Default |
185+
| ------- | ------------------------------------------ | ------- |
186+
| `show` | When to show inline blame | `"never"` |
187+
| `auto-fetch` | Automatically fetch blame information in the background | `false` |
188+
| `format` | Inline blame message format | `"{author}, {time-ago} • {title} • {commit}"` |
189+
190+
`show` can be one of the following:
191+
- `"all-lines"`: Inline blame is on every line.
192+
- `"cursor-line"`: Inline blame is only on the line of the primary cursor.
193+
- `"hidden"`: Inline blame is hidden.
194+
195+
With `auto-fetch` set to `false`, blame for the current file is fetched only when explicitly requested, such as when using `space + B` to display the blame for the line of the cursor. There may be a little delay when loading the blame.
196+
197+
When `auto-fetch` is set to `true`, blame for the file is fetched in the background; this will have no effect on performance, but will use a little bit extra resources in the background. Directly requesting the blame with `space + B` will be instant. Inline blame will show as soon as the blame is available when loading new files.
198+
199+
When opening new files, even with `show` set to `"all-lines"` or `"cursor-line"`, the inline blame won't show. It needs to be fetched first in order to become available, which can be triggered manually with `space + B`.
200+
201+
#### `format`
202+
203+
Change the `format` string to customize the blame message displayed. Variables are text placeholders wrapped in curly braces: `{variable}`. The following variables are available:
204+
205+
- `author`: The author of the commit
206+
- `date`: When the commit was made
207+
- `time-ago`: How long ago the commit was made
208+
- `title`: The title of the commit
209+
- `body`: The body of the commit
210+
- `commit`: The short hex SHA1 hash of the commit
211+
- `email`: The email of the author of the commit
212+
179213
### `[editor.cursor-shape]` Section
180214

181215
Defines the shape of cursor in each mode.

book/src/generated/static-cmd.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,5 +316,6 @@
316316
| `extend_to_word` | Extend to a two-character label | select: `` gw `` |
317317
| `goto_next_tabstop` | Goto next snippet placeholder | |
318318
| `goto_prev_tabstop` | Goto next snippet placeholder | |
319+
| `blame_line` | Show blame for the current line | normal: `` <space>B ``, select: `` <space>B `` |
319320
| `rotate_selections_first` | Make the first selection your primary one | |
320321
| `rotate_selections_last` | Make the last selection your primary one | |

book/src/keymap.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ This layer is a kludge of mappings, mostly pickers.
314314
| `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` |
315315
| `/` | Global search in workspace folder | `global_search` |
316316
| `?` | Open command palette | `command_palette` |
317+
| `B` | Show blame for the current line | `blame_line` |
317318

318319
> 💡 Global search displays results in a fuzzy picker, use `Space + '` to bring it back up after opening a file.
319320

book/src/themes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ These scopes are used for theming the editor interface:
340340
| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (language servers are not required to set a kind) |
341341
| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) |
342342
| `ui.virtual.jump-label` | Style for virtual jump labels |
343+
| `ui.virtual.inline-blame` | Inline blame indicator (see the [`editor.inline-blame` config][editor-section]) |
343344
| `ui.menu` | Code and command completion menus |
344345
| `ui.menu.selected` | Selected autocomplete item |
345346
| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar |

helix-stdx/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ pub mod faccess;
66
pub mod path;
77
pub mod range;
88
pub mod rope;
9+
pub mod time;
910

1011
pub use range::Range;

helix-stdx/src/time.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use std::time::{Instant, SystemTime};
2+
3+
use once_cell::sync::Lazy;
4+
5+
const SECOND: i64 = 1;
6+
const MINUTE: i64 = 60 * SECOND;
7+
const HOUR: i64 = 60 * MINUTE;
8+
const DAY: i64 = 24 * HOUR;
9+
const MONTH: i64 = 30 * DAY;
10+
const YEAR: i64 = 365 * DAY;
11+
12+
/// Like `std::time::SystemTime::now()` but does not cause a syscall on every invocation.
13+
///
14+
/// There is just one syscall at the start of the program, subsequent invocations are
15+
/// much cheaper and use the monotonic clock instead of trigerring a syscall.
16+
#[inline]
17+
fn now() -> SystemTime {
18+
static START_INSTANT: Lazy<Instant> = Lazy::new(Instant::now);
19+
static START_SYSTEM_TIME: Lazy<SystemTime> = Lazy::new(SystemTime::now);
20+
21+
*START_SYSTEM_TIME + START_INSTANT.elapsed()
22+
}
23+
24+
/// Formats a timestamp into a human-readable relative time string.
25+
///
26+
/// # Arguments
27+
///
28+
/// * `timestamp` - A point in history. Seconds since UNIX epoch (UTC)
29+
/// * `timezone_offset` - Timezone offset in seconds
30+
///
31+
/// # Returns
32+
///
33+
/// A String representing the relative time (e.g., "4 years ago", "11 months from now")
34+
#[inline]
35+
pub fn format_relative_time(timestamp: i64, timezone_offset: i32) -> String {
36+
let timestamp = timestamp + timezone_offset as i64;
37+
let now = now()
38+
.duration_since(std::time::UNIX_EPOCH)
39+
.unwrap_or_default()
40+
.as_secs() as i64
41+
+ timezone_offset as i64;
42+
43+
let time_passed = now - timestamp;
44+
45+
let time_difference = time_passed.abs();
46+
47+
let (value, unit) = if time_difference >= YEAR {
48+
let years = time_difference / YEAR;
49+
(years, if years == 1 { "year" } else { "years" })
50+
} else if time_difference >= MONTH {
51+
let months = time_difference / MONTH;
52+
(months, if months == 1 { "month" } else { "months" })
53+
} else if time_difference >= DAY {
54+
let days = time_difference / DAY;
55+
(days, if days == 1 { "day" } else { "days" })
56+
} else if time_difference >= HOUR {
57+
let hours = time_difference / HOUR;
58+
(hours, if hours == 1 { "hour" } else { "hours" })
59+
} else if time_difference >= MINUTE {
60+
let minutes = time_difference / MINUTE;
61+
(minutes, if minutes == 1 { "minute" } else { "minutes" })
62+
} else {
63+
let seconds = time_difference / SECOND;
64+
(seconds, if seconds == 1 { "second" } else { "seconds" })
65+
};
66+
let value = value.to_string();
67+
68+
let label = if time_passed.is_positive() {
69+
"ago"
70+
} else {
71+
"from now"
72+
};
73+
74+
format!("{value} {unit} {label}")
75+
}

helix-term/src/application.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use helix_view::{
1111
align_view,
1212
document::{DocumentOpenError, DocumentSavedEventResult},
1313
editor::{ConfigEvent, EditorEvent},
14+
events::EditorConfigDidChange,
1415
graphics::Rect,
1516
theme,
1617
tree::Layout,
@@ -389,6 +390,10 @@ impl Application {
389390
// the Application can apply it.
390391
ConfigEvent::Update(editor_config) => {
391392
let mut app_config = (*self.config.load().clone()).clone();
393+
helix_event::dispatch(EditorConfigDidChange {
394+
old_config: &app_config.editor,
395+
editor: &mut self.editor,
396+
});
392397
app_config.editor = *editor_config;
393398
if let Err(err) = self.terminal.reconfigure((&app_config.editor).into()) {
394399
self.editor.set_error(err.to_string());

helix-term/src/commands.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use helix_stdx::{
1111
rope::{self, RopeSliceExt},
1212
};
1313
use helix_vcs::{FileChange, Hunk};
14+
use helix_view::document::LineBlameError;
1415
pub use lsp::*;
1516
pub use syntax::*;
1617
use tui::{
@@ -619,6 +620,7 @@ impl MappableCommand {
619620
extend_to_word, "Extend to a two-character label",
620621
goto_next_tabstop, "Goto next snippet placeholder",
621622
goto_prev_tabstop, "Goto next snippet placeholder",
623+
blame_line, "Show blame for the current line",
622624
rotate_selections_first, "Make the first selection your primary one",
623625
rotate_selections_last, "Make the last selection your primary one",
624626
);
@@ -3533,6 +3535,55 @@ fn insert_at_line_start(cx: &mut Context) {
35333535
insert_with_indent(cx, IndentFallbackPos::LineStart);
35343536
}
35353537

3538+
pub(crate) fn blame_line_impl(editor: &mut Editor, doc_id: DocumentId, cursor_line: u32) {
3539+
let inline_blame_config = &editor.config().inline_blame;
3540+
let Some(doc) = editor.document(doc_id) else {
3541+
return;
3542+
};
3543+
let line_blame = match doc.line_blame(cursor_line, &inline_blame_config.format) {
3544+
result
3545+
if (result.is_ok() && doc.is_blame_potentially_out_of_date)
3546+
|| matches!(result, Err(LineBlameError::NotReadyYet) if !inline_blame_config.auto_fetch) =>
3547+
{
3548+
if let Some(path) = doc.path() {
3549+
let tx = editor.handlers.blame.clone();
3550+
helix_event::send_blocking(
3551+
&tx,
3552+
helix_view::handlers::BlameEvent {
3553+
path: path.to_path_buf(),
3554+
doc_id: doc.id(),
3555+
line: Some(cursor_line),
3556+
},
3557+
);
3558+
editor.set_status(format!("Requested blame for {}...", path.display()));
3559+
let doc = editor
3560+
.document_mut(doc_id)
3561+
.expect("exists since we return from the function earlier if it does not");
3562+
doc.is_blame_potentially_out_of_date = false;
3563+
} else {
3564+
editor.set_error("Could not get path of document");
3565+
};
3566+
return;
3567+
}
3568+
Ok(line_blame) => line_blame,
3569+
Err(err @ (LineBlameError::NotCommittedYet | LineBlameError::NotReadyYet)) => {
3570+
editor.set_status(err.to_string());
3571+
return;
3572+
}
3573+
Err(err @ LineBlameError::NoFileBlame(_, _)) => {
3574+
editor.set_error(err.to_string());
3575+
return;
3576+
}
3577+
};
3578+
3579+
editor.set_status(line_blame);
3580+
}
3581+
3582+
fn blame_line(cx: &mut Context) {
3583+
let (view, doc) = current_ref!(cx.editor);
3584+
blame_line_impl(cx.editor, doc.id(), doc.cursor_line(view.id) as u32);
3585+
}
3586+
35363587
// `A` inserts at the end of each line with a selection.
35373588
// If the line is empty, automatically indent.
35383589
fn insert_at_line_end(cx: &mut Context) {

helix-term/src/commands/typed.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use helix_stdx::path::home_dir;
1414
use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME};
1515
use helix_view::editor::{CloseError, ConfigEvent};
1616
use helix_view::expansion;
17+
use helix_view::handlers::BlameEvent;
1718
use serde_json::Value;
1819
use ui::completers::{self, Completer};
1920

@@ -1434,16 +1435,33 @@ fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh
14341435
}
14351436

14361437
let scrolloff = cx.editor.config().scrolloff;
1438+
let auto_fetch = cx.editor.config().inline_blame.auto_fetch;
14371439
let (view, doc) = current!(cx.editor);
14381440
doc.reload(view, &cx.editor.diff_providers).map(|_| {
14391441
view.ensure_cursor_in_view(doc, scrolloff);
14401442
})?;
1443+
let doc_id = doc.id();
14411444
if let Some(path) = doc.path() {
14421445
cx.editor
14431446
.language_servers
14441447
.file_event_handler
14451448
.file_changed(path.clone());
14461449
}
1450+
1451+
if doc.should_request_full_file_blame(auto_fetch) {
1452+
if let Some(path) = doc.path() {
1453+
helix_event::send_blocking(
1454+
&cx.editor.handlers.blame,
1455+
BlameEvent {
1456+
path: path.to_path_buf(),
1457+
doc_id,
1458+
line: None,
1459+
},
1460+
);
1461+
}
1462+
}
1463+
doc.is_blame_potentially_out_of_date = true;
1464+
14471465
Ok(())
14481466
}
14491467

@@ -1470,6 +1488,8 @@ fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) ->
14701488
})
14711489
.collect();
14721490

1491+
let blame_compute = cx.editor.config().inline_blame.auto_fetch;
1492+
14731493
for (doc_id, view_ids) in docs_view_ids {
14741494
let doc = doc_mut!(cx.editor, &doc_id);
14751495

@@ -1497,6 +1517,20 @@ fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) ->
14971517
view.ensure_cursor_in_view(doc, scrolloff);
14981518
}
14991519
}
1520+
1521+
if doc.should_request_full_file_blame(blame_compute) {
1522+
if let Some(path) = doc.path() {
1523+
helix_event::send_blocking(
1524+
&cx.editor.handlers.blame,
1525+
BlameEvent {
1526+
path: path.to_path_buf(),
1527+
doc_id,
1528+
line: None,
1529+
},
1530+
);
1531+
}
1532+
}
1533+
doc.is_blame_potentially_out_of_date = true;
15001534
}
15011535

15021536
Ok(())

0 commit comments

Comments
 (0)