Skip to content
Open
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
52 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
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
626 changes: 370 additions & 256 deletions Cargo.lock

Large diffs are not rendered by default.

38 changes: 38 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 @@ -165,6 +166,43 @@ 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` | Choose when to show inline blame | `"never"` |
| `auto-fetch` | Choose when inline blame should be computed | `false` |
| `format` | The format in which to show the inline blame | `"{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.

Inline blame will only show if the blame for the file has already been fetched.

The `auto-fetch` key determines under which circumstances the blame is fetched, and can be one of the following:
- `false`: Blame for the file is fetched only when explicitly requested, such as when using `space + B` to blame the line of the cursor. There may be a little delay when loading the blame.

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`.
- `true`: Blame for the file is fetched in the background.

This will have zero effect on performance of the Editor, but will use a little bit extra resources.

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.

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 @@ -303,3 +303,4 @@
| `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 `` |
1 change: 1 addition & 0 deletions book/src/keymap.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,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 @@ -314,6 +314,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]) |
| `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 @@ -3,5 +3,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 {
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 {
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 @@ -364,6 +365,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 @@ -10,6 +10,7 @@ use helix_stdx::{
rope::{self, RopeSliceExt},
};
use helix_vcs::{FileChange, Hunk};
use helix_view::document::LineBlameError;
pub use lsp::*;
use tui::{
text::{Span, Spans},
Expand Down Expand Up @@ -601,6 +602,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",
);
}

Expand Down Expand Up @@ -3491,6 +3493,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;
}
};

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 @@ -1328,16 +1329,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 @@ -1364,6 +1382,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 @@ -1391,6 +1411,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
3 changes: 2 additions & 1 deletion helix-term/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use helix_event::{events, register_event};
use helix_view::document::Mode;
use helix_view::events::{
DiagnosticsDidChange, DocumentDidChange, DocumentDidClose, DocumentDidOpen, DocumentFocusLost,
LanguageServerExited, LanguageServerInitialized, SelectionDidChange,
EditorConfigDidChange, LanguageServerExited, LanguageServerInitialized, SelectionDidChange,
};

use crate::commands;
Expand All @@ -20,6 +20,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::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,12 +28,14 @@ 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 handlers = Handlers {
completions: helix_view::handlers::completion::CompletionHandler::new(event_tx),
signature_hints,
auto_save,
document_colors,
blame,
};

helix_view::handlers::register_hooks(&handlers);
Expand All @@ -41,5 +45,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