Skip to content
Open
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
29f4428
feat: Inline Git Blame
nik-rev Mar 18, 2025
647615d
perf: optimize obtaining blame for the same line
nik-rev Mar 24, 2025
07c69c1
fix: update blame when editing config
nik-rev Mar 24, 2025
8f0721f
use format! instead of preallocating
nik-rev Mar 24, 2025
f54fdef
refactor: remove extra layer of sync
nik-rev Mar 25, 2025
7478d9e
refactor: extract as variable
nik-rev Mar 25, 2025
76a92af
feat: `all-lines` option for inline blame
nik-rev Mar 25, 2025
ac0e677
chore: appease clippy
nik-rev Mar 25, 2025
d34074a
perf: do not render inline blame on invisible lines
nik-rev Mar 25, 2025
b9f8226
refactor: remove `new_config` from EditorConfigDidChange event
nik-rev Mar 25, 2025
22f9571
feat: split `inline-blame.behaviour` into two options
nik-rev Mar 25, 2025
a8097f1
perf: use `Vec<T>` instead of `HashMap<usize, T>`
nik-rev Mar 25, 2025
00d168a
fix: funny boolean inversion
nik-rev Mar 25, 2025
ab56638
refactor: render inline blame in a separate Editor function
nik-rev Mar 25, 2025
082ba4d
refactor: `match` over `if`
nik-rev Mar 25, 2025
c101f37
style: fmt
nik-rev Mar 25, 2025
b3b1c88
refactor: pass the `Style` instead of `Theme`
nik-rev Mar 25, 2025
af3b670
refactor: move expression
nik-rev Mar 25, 2025
95344a9
perf: use string preallocations for string concatenation
nik-rev Mar 29, 2025
1a0dad3
perf: only render inline blame for visible lines when `all-lines` is set
nik-rev Mar 30, 2025
e8d7e76
fix: spelling error
nik-rev Apr 1, 2025
c74fec4
fix?: do not block on the main thread when acquiring diff handle
nik-rev Apr 1, 2025
616758e
refactor: rename macro
nik-rev Apr 4, 2025
5d83e93
Merge branch 'master' into gix-blame
nik-rev Apr 16, 2025
03f0883
chore: fix merge conflicts
nik-rev Apr 16, 2025
b31f1c7
Merge branch 'master' into gix-blame
nik-rev May 6, 2025
be5fbff
chore: resolve merge conflicts
nik-rev May 6, 2025
7effac9
fix: only render inline blame once per line at most
nik-rev May 6, 2025
deb5897
Merge branch 'master' into gix-blame
nik-rev May 19, 2025
b8bd060
chore: fix merge conflicts
nik-rev May 19, 2025
1ca7ee8
feat: rename config options
nik-rev May 19, 2025
0123bac
docs: remove confusing instructions
nik-rev May 19, 2025
f1a29ee
feat: rename the `message` variable to `title`
nik-rev May 19, 2025
a859cb2
docs: change sentence
nik-rev May 19, 2025
4eebdec
test: use renamed `commit_message`
nik-rev May 19, 2025
00117f8
fix: use correct variable name `title` instead of `message`
nik-rev May 19, 2025
01e9dc1
test: use correct variable name
nik-rev May 19, 2025
9d27551
docs: improve wording
nik-rev May 19, 2025
dd1f31d
docs: improve wording
nik-rev May 19, 2025
eb559cf
docs: remove hard to understand sentence
nik-rev May 19, 2025
92dc3ca
docs: improve wording
nik-rev May 19, 2025
e64b4fa
docs: improve wording
nik-rev May 19, 2025
08c6650
docs: Add line breaks to paragraph + improve wording
nik-rev May 19, 2025
d00ff25
Merge branch 'master' into gix-blame
nik-rev Jun 7, 2025
100ad75
chore: clarify comment
nik-rev Jun 7, 2025
41cb919
feat: if you are the author, use "You" instead of name
nik-rev Jun 7, 2025
a476d6d
Revert "feat: if you are the author, use "You" instead of name"
nik-rev Jun 7, 2025
14d4163
Merge branch 'master' into gix-blame
nik-rev Jun 17, 2025
5e21b7f
fix: Cargo.lock
nik-rev Jun 17, 2025
7a83e9e
feat: horizontal scroll of the current document into account when dra…
nik-rev Jul 27, 2025
1c0b99d
refactor: Do not use `unwrap_or_default` on simple integer
nik-rev Jul 27, 2025
e5f837f
Merge branch 'master' into gix-blame
nik-rev Aug 31, 2025
f8c5398
Merge branch 'master' into gix-blame
nik-rev Jan 27, 2026
b8b2430
Update for the latest `gix 0.78`
nik-rev Jan 27, 2026
f322b03
Remove unnecessary macro
nik-rev Jan 27, 2026
e755f63
Add documentation for a few fields
nik-rev Jan 27, 2026
cd008bb
Run `cargo fmt`
nik-rev Jan 27, 2026
ffabf44
Use same `Cargo.lock` as on the `master` branch
nik-rev Jan 27, 2026
5f5bc86
Run `cargo fmt`
nik-rev Jan 27, 2026
56c65bf
Remove stray formatting changes
nik-rev Jan 27, 2026
1e4d53f
Replace call to `with_capacity()` with `new()`
nik-rev Jan 27, 2026
48a8705
Remove stray formatting change
nik-rev Jan 27, 2026
575631f
Merge branch 'master' into gix-blame
NSPC911 May 8, 2026
a828b56
Merge pull request #1 from NSPC911-forks/gix-blame
nik-rev May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
675 changes: 363 additions & 312 deletions Cargo.lock

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions book/src/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- [`[editor.clipboard-provider]` Section](#editorclipboard-provider-section)
- [`[editor.statusline]` Section](#editorstatusline-section)
- [`[editor.lsp]` Section](#editorlsp-section)
- [`[editor.inline-blame]` Section](#editorinlineblame-section)
- [`[editor.cursor-shape]` Section](#editorcursor-shape-section)
- [`[editor.file-picker]` Section](#editorfile-picker-section)
- [`[editor.auto-pairs]` Section](#editorauto-pairs-section)
Expand Down Expand Up @@ -171,6 +172,39 @@ The following statusline elements can be configured:

[^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!

### `[editor.inline-blame]` Section

Inline blame is virtual text that appears at the end of a line, displaying information about the most recent commit that affected this line.

| Key | Description | Default |
| ------- | ------------------------------------------ | ------- |
| `show` | When to show inline blame | `"never"` |
| `auto-fetch` | Automatically fetch blame information in the background | `false` |
| `format` | Inline blame message format | `"{author}, {time-ago} • {title} • {commit}"` |

`show` can be one of the following:
- `"all-lines"`: Inline blame is on every line.
- `"cursor-line"`: Inline blame is only on the line of the primary cursor.
- `"hidden"`: Inline blame is hidden.
Comment thread
nik-rev marked this conversation as resolved.

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.

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.

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`.

#### `format`

Change the `format` string to customize the blame message displayed. Variables are text placeholders wrapped in curly braces: `{variable}`. The following variables are available:

- `author`: The author of the commit
- `date`: When the commit was made
- `time-ago`: How long ago the commit was made
- `title`: The title of the commit
- `body`: The body of the commit
- `commit`: The short hex SHA1 hash of the commit
- `email`: The email of the author of the commit

### `[editor.cursor-shape]` Section

Defines the shape of cursor in each mode.
Expand Down
1 change: 1 addition & 0 deletions book/src/generated/static-cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,5 +311,6 @@
| `extend_to_word` | Extend to a two-character label | select: `` gw `` |
| `goto_next_tabstop` | Goto next snippet placeholder | |
| `goto_prev_tabstop` | Goto next snippet placeholder | |
| `blame_line` | Show blame for the current line | normal: `` <space>B ``, select: `` <space>B `` |
| `rotate_selections_first` | Make the first selection your primary one | |
| `rotate_selections_last` | Make the last selection your primary one | |
1 change: 1 addition & 0 deletions book/src/keymap.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ This layer is a kludge of mappings, mostly pickers.
| `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` |
| `/` | Global search in workspace folder | `global_search` |
| `?` | Open command palette | `command_palette` |
| `B` | Show blame for the current line | `blame_line` |

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

Expand Down
1 change: 1 addition & 0 deletions book/src/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ These scopes are used for theming the editor interface:
| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (language servers are not required to set a kind) |
| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) |
| `ui.virtual.jump-label` | Style for virtual jump labels |
| `ui.virtual.inline-blame` | Inline blame indicator (see the [`editor.inline-blame` config][editor-section]) |
Comment thread
nik-rev marked this conversation as resolved.
| `ui.menu` | Code and command completion menus |
| `ui.menu.selected` | Selected autocomplete item |
| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar |
Expand Down
2 changes: 2 additions & 0 deletions helix-stdx/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ pub mod faccess;
pub mod path;
pub mod range;
pub mod rope;
pub mod str;
pub mod time;

pub use range::Range;
18 changes: 18 additions & 0 deletions helix-stdx/src/str.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// Concatenates strings together.
///
/// `str_concat!(a, " ", b, " ", c)` is:
/// - more performant than `format!("{a} {b} {c}")`
/// - more ergonomic than using `String::with_capacity` followed by a series of `String::push_str`
#[macro_export]
macro_rules! str_concat {
($($value:expr),*) => {{
// Rust does not allow using `+` as separator between value
// so we must add that at the end of everything. The `0` is necessary
// at the end so it does not end with "+ " (which would be invalid syntax)
let mut buf = String::with_capacity($($value.len() + )* 0);
$(
buf.push_str(&$value);
)*
buf
}}
}
75 changes: 75 additions & 0 deletions helix-stdx/src/time.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use std::time::{Instant, SystemTime};

use once_cell::sync::Lazy;

const SECOND: i64 = 1;
const MINUTE: i64 = 60 * SECOND;
const HOUR: i64 = 60 * MINUTE;
const DAY: i64 = 24 * HOUR;
const MONTH: i64 = 30 * DAY;
const YEAR: i64 = 365 * DAY;

/// Like `std::time::SystemTime::now()` but does not cause a syscall on every invocation.
///
/// There is just one syscall at the start of the program, subsequent invocations are
/// much cheaper and use the monotonic clock instead of trigerring a syscall.
#[inline]
fn now() -> SystemTime {
Comment thread
nik-rev marked this conversation as resolved.
static START_INSTANT: Lazy<Instant> = Lazy::new(Instant::now);
static START_SYSTEM_TIME: Lazy<SystemTime> = Lazy::new(SystemTime::now);

*START_SYSTEM_TIME + START_INSTANT.elapsed()
}

/// Formats a timestamp into a human-readable relative time string.
///
/// # Arguments
///
/// * `timestamp` - A point in history. Seconds since UNIX epoch (UTC)
/// * `timezone_offset` - Timezone offset in seconds
///
/// # Returns
///
/// A String representing the relative time (e.g., "4 years ago", "11 months from now")
#[inline]
pub fn format_relative_time(timestamp: i64, timezone_offset: i32) -> String {
Comment thread
nik-rev marked this conversation as resolved.
let timestamp = timestamp + timezone_offset as i64;
let now = now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
+ timezone_offset as i64;

let time_passed = now - timestamp;

let time_difference = time_passed.abs();

let (value, unit) = if time_difference >= YEAR {
let years = time_difference / YEAR;
(years, if years == 1 { "year" } else { "years" })
} else if time_difference >= MONTH {
let months = time_difference / MONTH;
(months, if months == 1 { "month" } else { "months" })
} else if time_difference >= DAY {
let days = time_difference / DAY;
(days, if days == 1 { "day" } else { "days" })
} else if time_difference >= HOUR {
let hours = time_difference / HOUR;
(hours, if hours == 1 { "hour" } else { "hours" })
} else if time_difference >= MINUTE {
let minutes = time_difference / MINUTE;
(minutes, if minutes == 1 { "minute" } else { "minutes" })
} else {
let seconds = time_difference / SECOND;
(seconds, if seconds == 1 { "second" } else { "seconds" })
};
let value = value.to_string();

let label = if time_passed.is_positive() {
"ago"
} else {
"from now"
};

crate::str_concat!(value, " ", unit, " ", label)
}
5 changes: 5 additions & 0 deletions helix-term/src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use helix_view::{
align_view,
document::{DocumentOpenError, DocumentSavedEventResult},
editor::{ConfigEvent, EditorEvent},
events::EditorConfigDidChange,
graphics::Rect,
theme,
tree::Layout,
Expand Down Expand Up @@ -366,6 +367,10 @@ impl Application {
// the Application can apply it.
ConfigEvent::Update(editor_config) => {
let mut app_config = (*self.config.load().clone()).clone();
helix_event::dispatch(EditorConfigDidChange {
old_config: &app_config.editor,
editor: &mut self.editor,
});
app_config.editor = *editor_config;
if let Err(err) = self.terminal.reconfigure(app_config.editor.clone().into()) {
self.editor.set_error(err.to_string());
Expand Down
51 changes: 51 additions & 0 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use helix_stdx::{
rope::{self, RopeSliceExt},
};
use helix_vcs::{FileChange, Hunk};
use helix_view::document::LineBlameError;
pub use lsp::*;
pub use syntax::*;
use tui::{
Expand Down Expand Up @@ -613,6 +614,7 @@ impl MappableCommand {
extend_to_word, "Extend to a two-character label",
goto_next_tabstop, "Goto next snippet placeholder",
goto_prev_tabstop, "Goto next snippet placeholder",
blame_line, "Show blame for the current line",
rotate_selections_first, "Make the first selection your primary one",
rotate_selections_last, "Make the last selection your primary one",
);
Expand Down Expand Up @@ -3507,6 +3509,55 @@ fn insert_at_line_start(cx: &mut Context) {
insert_with_indent(cx, IndentFallbackPos::LineStart);
}

pub(crate) fn blame_line_impl(editor: &mut Editor, doc_id: DocumentId, cursor_line: u32) {
let inline_blame_config = &editor.config().inline_blame;
let Some(doc) = editor.document(doc_id) else {
return;
};
let line_blame = match doc.line_blame(cursor_line, &inline_blame_config.format) {
result
if (result.is_ok() && doc.is_blame_potentially_out_of_date)
|| matches!(result, Err(LineBlameError::NotReadyYet) if !inline_blame_config.auto_fetch) =>
{
if let Some(path) = doc.path() {
let tx = editor.handlers.blame.clone();
helix_event::send_blocking(
&tx,
helix_view::handlers::BlameEvent {
path: path.to_path_buf(),
doc_id: doc.id(),
line: Some(cursor_line),
},
);
editor.set_status(format!("Requested blame for {}...", path.display()));
let doc = editor
.document_mut(doc_id)
.expect("exists since we return from the function earlier if it does not");
doc.is_blame_potentially_out_of_date = false;
} else {
editor.set_error("Could not get path of document");
};
return;
}
Ok(line_blame) => line_blame,
Err(err @ (LineBlameError::NotCommittedYet | LineBlameError::NotReadyYet)) => {
editor.set_status(err.to_string());
return;
}
Err(err @ LineBlameError::NoFileBlame(_, _)) => {
editor.set_error(err.to_string());
return;
}
Comment thread
nik-rev marked this conversation as resolved.
};

editor.set_status(line_blame);
}

fn blame_line(cx: &mut Context) {
let (view, doc) = current_ref!(cx.editor);
blame_line_impl(cx.editor, doc.id(), doc.cursor_line(view.id) as u32);
}

// `A` inserts at the end of each line with a selection.
// If the line is empty, automatically indent.
fn insert_at_line_end(cx: &mut Context) {
Expand Down
34 changes: 34 additions & 0 deletions helix-term/src/commands/typed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use helix_stdx::path::home_dir;
use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME};
use helix_view::editor::{CloseError, ConfigEvent};
use helix_view::expansion;
use helix_view::handlers::BlameEvent;
use serde_json::Value;
use ui::completers::{self, Completer};

Expand Down Expand Up @@ -1396,16 +1397,33 @@ fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh
}

let scrolloff = cx.editor.config().scrolloff;
let auto_fetch = cx.editor.config().inline_blame.auto_fetch;
let (view, doc) = current!(cx.editor);
doc.reload(view, &cx.editor.diff_providers).map(|_| {
view.ensure_cursor_in_view(doc, scrolloff);
})?;
let doc_id = doc.id();
if let Some(path) = doc.path() {
cx.editor
.language_servers
.file_event_handler
.file_changed(path.clone());
}

if doc.should_request_full_file_blame(auto_fetch) {
if let Some(path) = doc.path() {
helix_event::send_blocking(
&cx.editor.handlers.blame,
BlameEvent {
path: path.to_path_buf(),
doc_id,
line: None,
},
);
}
}
doc.is_blame_potentially_out_of_date = true;

Ok(())
}

Expand All @@ -1432,6 +1450,8 @@ fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) ->
})
.collect();

let blame_compute = cx.editor.config().inline_blame.auto_fetch;

for (doc_id, view_ids) in docs_view_ids {
let doc = doc_mut!(cx.editor, &doc_id);

Expand Down Expand Up @@ -1459,6 +1479,20 @@ fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) ->
view.ensure_cursor_in_view(doc, scrolloff);
}
}

if doc.should_request_full_file_blame(blame_compute) {
if let Some(path) = doc.path() {
helix_event::send_blocking(
&cx.editor.handlers.blame,
BlameEvent {
path: path.to_path_buf(),
doc_id,
line: None,
},
);
}
}
doc.is_blame_potentially_out_of_date = true;
}

Ok(())
Expand Down
4 changes: 3 additions & 1 deletion helix-term/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ use helix_event::{events, register_event};
use helix_view::document::Mode;
use helix_view::events::{
ConfigDidChange, DiagnosticsDidChange, DocumentDidChange, DocumentDidClose, DocumentDidOpen,
DocumentFocusLost, LanguageServerExited, LanguageServerInitialized, SelectionDidChange,
DocumentFocusLost,
EditorConfigDidChange, LanguageServerExited, LanguageServerInitialized, SelectionDidChange,
};

use crate::commands;
Expand All @@ -20,6 +21,7 @@ pub fn register() {
register_event::<PostCommand>();
register_event::<DocumentDidOpen>();
register_event::<DocumentDidChange>();
register_event::<EditorConfigDidChange>();
register_event::<DocumentDidClose>();
register_event::<DocumentFocusLost>();
register_event::<SelectionDidChange>();
Expand Down
5 changes: 5 additions & 0 deletions helix-term/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ use crate::handlers::signature_help::SignatureHelpHandler;

pub use helix_view::handlers::{word_index, Handlers};

use self::blame::BlameHandler;
use self::document_colors::DocumentColorsHandler;

mod auto_save;
pub mod blame;
pub mod completion;
mod diagnostics;
mod document_colors;
Expand All @@ -26,13 +28,15 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
let signature_hints = SignatureHelpHandler::new().spawn();
let auto_save = AutoSaveHandler::new().spawn();
let document_colors = DocumentColorsHandler::default().spawn();
let blame = BlameHandler::default().spawn();
let word_index = word_index::Handler::spawn();

let handlers = Handlers {
completions: helix_view::handlers::completion::CompletionHandler::new(event_tx),
signature_hints,
auto_save,
document_colors,
blame,
word_index,
};

Expand All @@ -43,5 +47,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
diagnostics::register_hooks(&handlers);
snippet::register_hooks(&handlers);
document_colors::register_hooks(&handlers);
blame::register_hooks(&handlers);
handlers
}
Loading
Loading