feat: Inline Git Blame

fix: use relative path when finding file

style: cargo fmt

_

chore: better error message

refactor: rename to `blame_line`

fix: use line of primary cursor for git blame

feat: basic implementation of blocking Blame handler

feat: implement basic virtual text (end of line blame)

feat: figure out how to draw stuff at the end of lines

feat: implement end of line virtual text for the current line

feat: implement inline git blame

chore: clean up

chore: remove unused import

_

chore: set `blame` to `false` by default

docs: document `[editor.vcs.blame]`

chore: add progress

perf: use background task for worker

_

chore: remove unnecessary panic!s

chore: remove commented code

refactor: remove some layers of abstraction

refactor: remove nesting

feat: [editor.vcs] -> [editor.version-control]

fix: account for inserted and deleted lines

_

refactor: extract into a `blame` module

feat: allow using custom commit format

feat: allow more customizability for inline blame

test: add tests for custom inline commit parsser

refactor: rename `blame` -> `blame_line`

_

_

test: create helper macros for tests

test: make test syntax more expressive. Allow specifying line numbers
that just got added

test: with interspersed lines

feat: add `line_blame` static command

_

test: add an extra test case

test: add ability to have `delete`d lines

test: fix on windows (?)

test: `delete` test case

test: add extra step to test case

test: add documentation for macro

refactor: use `hashmap!` macro

refactor: collapse match arm

fix: remove panic

perf: update inline git blame every 150 milliseconds instead of on each
command

test: add attributes on blocks

style: move function earlier in the file

perf: cache blame results in a hashma

chore: remove log statements

chore: clean up.

ALSO: removes checking for inline blame every N seconds.

_

perf: use mspc instead of busy-wait

docs: add information why we don't optimize the repo

_

test: add back the commented out tests

chore: comment out cfg(not(windows))

test: add extra history to blame test

docs: remove incorrect static command

_

test: disable test on windows

feat: send inline blame event update when reloading or saving the
document

feat: rename `version-control` -> `inline-blame`

feat: update theme key used for inline-blame

chore: remove unused #![allow]

chore:

style: remove accidental formatting

docs: remove incorrect key

perf: Use a single `ThreadSafeRepository` instead of re-constructing it
each time

feat: add `inline_blame` static command bound to `space + B`

style: revert formatting in keymap.md

chore: do not compute blame for document when changing config option

This isn't needed anymore because the inline-blame will be computed
regardless if `inline_blame.enable` is set or not

style: remove newline

refactor: use `fold` instead of loop

chore: clean up

feat: log error forl line blame when it happens

feat: improve message when we don't have the blame

We know that we don't have it because we're still calculating it.

feat: do not render inline blame for empty lines

_

feat: do not show blame output when we are on a hunk that was added

refactor: remove additional wrapper methods

fix

_

feat: more readable time for git blame

chr

feat:

feat: improved error handling

fix: path separator on Windows

test: disable on windows

refactor: move pretty date function formatter into `helix-stdx`

perf: do not use a syscall on each render

chore: add TODO comment to update gix version

chore: use `gix::path` conversion from Path -> BString

_

_

chore: do not update file blame on document save

This is not needed because when we write the file, we don't make a new
commit so the blame will not change.

refactor: use statics to get time elapsed instead of editor state

refactor: do not use custom event, use handler instead

fix: do not spawn a new handler

docs: correct examples for `editor.inline-blame.format`

docs: correct static command name

refactor: add comments, and improve variable names

I didn't really understand this function when I made it. Was just
copy-pasted from end of line diagnostics

I wanted to know what this is actually doing, so I investigated and
while doing this also added comments and improved names of variables
so others can understand too

fix: time in future is accounted for

perf: inline some functions that are called in only 1 place, during a
render loop

perf: add option to disable requesting inline blame in the background

fix: request blame again when document is reloaded

chore: inline blame is disabled with request on demand

feat: when requesting line blame with "blame on demand", show blame in
status

perf: use less allocations

perf: less allocations in `format_relative_time`

_

_

_

_

docs: correct name of command

_

feat: improve error message

_

feat: rename enum variants for inline blame behaviour

docs: improve description of behaviour field
This commit is contained in:
Nik Revenco 2025-03-18 01:11:57 +00:00
parent 0ee5850016
commit 29f442887a
24 changed files with 1196 additions and 29 deletions

52
Cargo.lock generated
View file

@ -211,6 +211,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
@ -509,6 +518,7 @@ checksum = "736f14636705f3a56ea52b553e67282519418d9a35bb1e90b3a9637a00296b68"
dependencies = [
"gix-actor",
"gix-attributes",
"gix-blame",
"gix-command",
"gix-commitgraph",
"gix-config",
@ -591,6 +601,21 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "gix-blame"
version = "0.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc795e239a2347eb50ed18b8c529382dd8b62439c57277f79af3d8f8928a986"
dependencies = [
"gix-diff",
"gix-hash",
"gix-object",
"gix-trace",
"gix-traverse",
"gix-worktree",
"thiserror 2.0.12",
]
[[package]]
name = "gix-chunk"
version = "0.4.11"
@ -739,12 +764,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bfdd4838a8d42bd482c9f0cb526411d003ee94cc7c7b08afe5007329c71d554"
dependencies = [
"crc32fast",
"crossbeam-channel",
"flate2",
"gix-hash",
"gix-trace",
"gix-utils",
"libc",
"once_cell",
"parking_lot",
"prodash",
"sha1_smol",
"thiserror 2.0.12",
@ -1526,6 +1553,7 @@ dependencies = [
"gix",
"helix-core",
"helix-event",
"helix-stdx",
"imara-diff",
"log",
"parking_lot",
@ -1823,15 +1851,15 @@ dependencies = [
[[package]]
name = "jiff-tzdb"
version = "0.1.1"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91335e575850c5c4c673b9bd467b0e025f164ca59d0564f69d0c2ee0ffad4653"
checksum = "962e1dfe9b2d75a84536cf5bf5eaaa4319aa7906c7160134a22883ac316d5f31"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.1"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9835f0060a626fe59f160437bc725491a6af23133ea906500027d1bd2f8f4329"
checksum = "a63c62e404e7b92979d2792352d885a7f8f83fd1d0d31eea582d77b2ceca697e"
dependencies = [
"jiff-tzdb",
]
@ -2087,15 +2115,15 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "portable-atomic"
version = "1.7.0"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265"
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
[[package]]
name = "proc-macro2"
version = "1.0.86"
version = "1.0.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
dependencies = [
"unicode-ident",
]
@ -2132,9 +2160,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.37"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
@ -2463,9 +2491,9 @@ checksum = "e9557cb6521e8d009c51a8666f09356f4b817ba9ba0981a305bd86aee47bd35c"
[[package]]
name = "syn"
version = "2.0.87"
version = "2.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d"
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
dependencies = [
"proc-macro2",
"quote",

View file

@ -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)
@ -161,6 +162,41 @@ 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
| Key | Description | Default |
| ------- | ------------------------------------------ | ------- |
| `behaviour` | Choose the behaviour of inline blame | `"disabled"` |
| `format` | The format in which to show the inline blame | `"{author}, {time-ago} • {message} • {commit}"` |
The `behaviour` can be one of the following:
- `"visible"`: Inline blame is turned on. Virtual text will appear at the end of each non-empty line, showing information about the latest commit for that line.
- `"background"`: Inline blame is turned off, but the blame is still requested in the background when opening and reloading files. This will have zero impact on performance, but will use slightly more resources in the background. This allows blame for line (`space + B`) to be retrieved instantaneously with zero delay.
- `"disabled"`: Inline blame is turned off, with no requests happening in the background. When you run `space + B` for the first time in a file, it will load the blame for this file. You may have to wait a little bit for the blame to become available, depending on the size of your repository. After it becomes available, for this file `space + B` will retrieve the blame for any line in this file with zero delay. If the file is reloaded, the process will repeat as the blame is potentially out of date and needs to be refreshed.
`inline-blame-format` allows customization of the blame message, and can be set to any string. Variables can be used like so: `{variable}`. These are the available variables:
- `author`: The author of the commit
- `date`: When the commit was made
- `time-ago`: How long ago the commit was made
- `message`: The message of the commit, excluding the body
- `body`: The body of the commit
- `commit`: The short hex SHA1 hash of the commit
- `email`: The email of the author of the commit
Any of the variables can potentially be empty.
In this case, the content before the variable will not be included in the string.
If the variable is at the beginning of the string, the content after the variable will not be included.
Some examples, using the default value `format` value:
- If `author` is empty: `"{time-ago} • {message} • {commit}"`
- If `time-ago` is empty: `"{author} • {message} • {commit}"`
- If `message` is empty: `"{author}, {time-ago} • {commit}"`
- If `commit` is empty: `"{author}, {time-ago} • {message}"`
- If `time-ago` and `message` is empty: `"{author} • {commit}"`
- If `author` and `message` is empty: `"{time-ago} • {commit}"`
### `[editor.cursor-shape]` Section
Defines the shape of cursor in each mode.

View file

@ -298,3 +298,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 `` |

View file

@ -309,6 +309,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.

View file

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

View file

@ -3,5 +3,6 @@ pub mod faccess;
pub mod path;
pub mod range;
pub mod rope;
pub mod time;
pub use range::Range;

81
helix-stdx/src/time.rs Normal file
View file

@ -0,0 +1,81 @@
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"
};
let mut relative_time = String::with_capacity(value.len() + 1 + unit.len() + 1 + label.len());
relative_time.push_str(&value);
relative_time.push(' ');
relative_time.push_str(unit);
relative_time.push(' ');
relative_time.push_str(label);
relative_time
}

View file

@ -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},
@ -596,6 +597,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",
);
}
@ -3470,6 +3472,54 @@ fn insert_at_line_start(cx: &mut Context) {
insert_with_indent(cx, IndentFallbackPos::LineStart);
}
pub(crate) fn blame_line_impl(editor: &mut Editor, doc: DocumentId, cursor_line: u32) {
let inline_blame_config = &editor.config().inline_blame;
let Some(doc) = editor.document(doc) 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.behaviour
== helix_view::editor::InlineBlameBehaviour::Disabled) =>
{
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 = doc_mut!(editor);
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) {

View file

@ -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};
@ -1326,16 +1327,33 @@ fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh
}
let scrolloff = cx.editor.config().scrolloff;
let inline_blame_behaviour = cx.editor.config().inline_blame.behaviour;
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(inline_blame_behaviour) {
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(())
}
@ -1362,6 +1380,8 @@ fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) ->
})
.collect();
let blame_behaviour = cx.editor.config().inline_blame.behaviour;
for (doc_id, view_ids) in docs_view_ids {
let doc = doc_mut!(cx.editor, &doc_id);
@ -1389,6 +1409,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_behaviour) {
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(())

View file

@ -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;
@ -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);
@ -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
}

View file

@ -0,0 +1,94 @@
use std::time::Duration;
use helix_event::register_hook;
use helix_vcs::FileBlame;
use helix_view::{
editor::InlineBlameBehaviour,
events::DocumentDidOpen,
handlers::{BlameEvent, Handlers},
DocumentId,
};
use tokio::{sync::oneshot, time::Instant};
use crate::job;
#[derive(Default)]
pub struct BlameHandler {
worker: Option<oneshot::Receiver<anyhow::Result<FileBlame>>>,
doc_id: DocumentId,
show_blame_for_line_in_statusline: Option<u32>,
}
impl helix_event::AsyncHook for BlameHandler {
type Event = BlameEvent;
fn handle_event(
&mut self,
event: Self::Event,
_timeout: Option<tokio::time::Instant>,
) -> Option<tokio::time::Instant> {
if let Some(worker) = &mut self.worker {
if worker.try_recv().is_ok() {
self.finish_debounce();
return None;
}
}
self.doc_id = event.doc_id;
self.show_blame_for_line_in_statusline = event.line;
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
let result = FileBlame::try_new(event.path);
let _ = tx.send(result);
});
self.worker = Some(rx);
Some(Instant::now() + Duration::from_millis(50))
}
fn finish_debounce(&mut self) {
let doc_id = self.doc_id;
let line_blame = self.show_blame_for_line_in_statusline;
if let Some(worker) = self.worker.take() {
tokio::spawn(async move {
let Ok(result) = worker.await else {
return;
};
job::dispatch(move |editor, _| {
let Some(doc) = editor.document_mut(doc_id) else {
return;
};
doc.file_blame = Some(result);
if editor.config().inline_blame.behaviour == InlineBlameBehaviour::Disabled {
if let Some(line) = line_blame {
crate::commands::blame_line_impl(editor, doc_id, line);
} else {
editor.set_status("Blame for this file is now available")
}
}
})
.await;
});
}
}
}
pub(super) fn register_hooks(handlers: &Handlers) {
let tx = handlers.blame.clone();
register_hook!(move |event: &mut DocumentDidOpen<'_>| {
if event.editor.config().inline_blame.behaviour != InlineBlameBehaviour::Disabled {
helix_event::send_blocking(
&tx,
BlameEvent {
path: event.path.to_path_buf(),
doc_id: event.doc,
line: None,
},
);
}
Ok(())
});
}

View file

@ -289,6 +289,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"C" => toggle_block_comments,
"A-c" => toggle_line_comments,
"?" => command_palette,
"B" => blame_line,
},
"z" => { "View"
"z" | "c" => align_view_center,

View file

@ -25,7 +25,7 @@ use helix_core::{
use helix_view::{
annotations::diagnostics::DiagnosticFilter,
document::{Mode, SCRATCH_BUFFER_NAME},
editor::{CompleteAction, CursorShapeConfig},
editor::{CompleteAction, CursorShapeConfig, InlineBlameBehaviour},
graphics::{Color, CursorKind, Modifier, Rect, Style},
input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
keyboard::{KeyCode, KeyModifiers},
@ -35,6 +35,8 @@ use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc};
use tui::{buffer::Buffer as Surface, text::Span};
use super::text_decorations::blame::InlineBlame;
pub struct EditorView {
pub keymaps: Keymaps,
on_next_key: Option<(OnKeyCallback, OnKeyCallbackKind)>,
@ -201,6 +203,24 @@ impl EditorView {
inline_diagnostic_config,
config.end_of_line_diagnostics,
));
if config.inline_blame.behaviour == InlineBlameBehaviour::Visible {
let cursor_line_idx = doc.cursor_line(view.id);
// do not render inline blame for empty lines to reduce visual noise
if doc.text().line(cursor_line_idx) != doc.line_ending.as_str() {
if let Ok(line_blame) =
doc.line_blame(cursor_line_idx as u32, &config.inline_blame.format)
{
decorations.add_decoration(InlineBlame::new(
theme,
cursor_line_idx,
line_blame,
));
};
}
}
render_document(
surface,
inner,

View file

@ -8,6 +8,7 @@ use crate::ui::document::{LinePos, TextRenderer};
pub use diagnostics::InlineDiagnostics;
pub mod blame;
mod diagnostics;
/// Decorations are the primary mechanism for extending the text rendering.

View file

@ -0,0 +1,67 @@
use helix_core::Position;
use helix_view::theme::Style;
use helix_view::Theme;
use crate::ui::document::{LinePos, TextRenderer};
use crate::ui::text_decorations::Decoration;
pub struct InlineBlame {
message: String,
cursor: usize,
style: Style,
}
impl InlineBlame {
pub fn new(theme: &Theme, cursor: usize, message: String) -> Self {
InlineBlame {
style: theme.get("ui.virtual.inline-blame"),
message,
cursor,
}
}
}
impl Decoration for InlineBlame {
fn render_virt_lines(
&mut self,
renderer: &mut TextRenderer,
pos: LinePos,
virt_off: Position,
) -> Position {
// do not draw inline blame for lines other than the cursor line
if self.cursor != pos.doc_line {
return Position::new(0, 0);
}
// where the line in the document ends
let end_of_line = virt_off.col as u16;
// length of line in the document
// draw the git blame 6 spaces after the end of the line
let start_drawing_at = end_of_line + 6;
let amount_of_characters_drawn = renderer
.column_in_bounds(start_drawing_at as usize, 1)
.then(|| {
// the column where we stop drawing the blame
let stopped_drawing_at = renderer
.set_string_truncated(
renderer.viewport.x + start_drawing_at,
pos.visual_line,
&self.message,
renderer.viewport.width.saturating_sub(start_drawing_at) as usize,
|_| self.style,
true,
false,
)
.0;
let line_length = end_of_line - renderer.offset.col as u16;
stopped_drawing_at - line_length
})
.unwrap_or_default();
Position::new(0, amount_of_characters_drawn as usize)
}
}

View file

@ -14,12 +14,13 @@ homepage.workspace = true
[dependencies]
helix-core = { path = "../helix-core" }
helix-event = { path = "../helix-event" }
helix-stdx = { path = "../helix-stdx" }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot", "macros"] }
parking_lot.workspace = true
arc-swap = { version = "1.7.1" }
gix = { version = "0.70.0", features = ["attributes", "status"], default-features = false, optional = true }
gix = { version = "0.70.0", features = ["attributes", "status", "blame", "parallel"], default-features = false, optional = true }
imara-diff = "0.1.8"
anyhow = "1"

View file

@ -1,11 +1,11 @@
use anyhow::{bail, Context, Result};
use arc_swap::ArcSwap;
use gix::bstr::ByteSlice as _;
use gix::filter::plumbing::driver::apply::Delay;
use std::io::Read;
use std::path::Path;
use std::sync::Arc;
use gix::bstr::ByteSlice;
use gix::diff::Rewrites;
use gix::dir::entry::Status;
use gix::objs::tree::EntryKind;
@ -22,6 +22,8 @@ use crate::FileChange;
#[cfg(test)]
mod test;
pub mod blame;
#[inline]
fn get_repo_dir(file: &Path) -> Result<&Path> {
file.parent().context("file has no parent directory")

632
helix-vcs/src/git/blame.rs Normal file
View file

@ -0,0 +1,632 @@
use anyhow::Context as _;
use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::DiffHandle;
use super::{get_repo_dir, open_repo};
/// Stores information about the blame for a file
#[derive(Debug)]
pub struct FileBlame {
/// A map from line numbers to commit IDs
blame: HashMap<u32, gix::ObjectId>,
/// The owning repository for this file's `ObjectId`s
repo: gix::ThreadSafeRepository,
}
impl FileBlame {
/// Get the blame information corresponing to a line in file and diff for that line
///
/// returns `None` if the line is part of an insertion, as the blame for that line would not
/// be meaningful
#[inline]
pub fn blame_for_line(&self, line: u32, diff_handle: Option<&DiffHandle>) -> Option<LineBlame> {
let (inserted_lines, removed_lines) = diff_handle.map_or(
// in theory there can be situations where we don't have the diff for a file
// but we have the blame. In this case, we can just act like there is no diff
Some((0, 0)),
|diff_handle| {
// Compute the amount of lines inserted and deleted before the `line`
// This information is needed to accurately transform the state of the
// file in the file system into what gix::blame knows about (gix::blame only
// knows about commit history, it does not know about uncommitted changes)
diff_handle
.load()
.hunks_intersecting_line_ranges(std::iter::once((0, line as usize)))
.try_fold(
(0, 0),
|(total_inserted_lines, total_deleted_lines), hunk| {
// check if the line intersects the hunk's `after` (which represents
// inserted lines)
(hunk.after.start > line || hunk.after.end <= line).then_some((
total_inserted_lines + (hunk.after.end - hunk.after.start),
total_deleted_lines + (hunk.before.end - hunk.before.start),
))
},
)
},
)?;
Some(self.blame_for_line_inserted_removed(line, inserted_lines, removed_lines))
}
// this is a separate function for use in Tests
#[inline]
fn blame_for_line_inserted_removed(
&self,
line: u32,
inserted_lines: u32,
removed_lines: u32,
) -> LineBlame {
// Because gix_blame doesn't care about stuff that is not commited, we have to "normalize" the
// line number to account for uncommited code.
//
// You'll notice that blame_line can be 0 when, for instance we have:
// - removed 0 lines
// - added 10 lines
// - cursor_line is 8
//
// So when our cursor is on the 10th added line or earlier, blame_line will be 0. This means
// the blame will be incorrect. But that's fine, because when the cursor_line is on some hunk,
// we can show to the user nothing at all. This is detected in the editor
let blame_line = line.saturating_sub(inserted_lines) + removed_lines;
let repo = self.repo.to_thread_local();
let commit = self
.blame
.get(&blame_line)
.and_then(|obj| repo.find_commit(*obj).ok());
let message = commit.as_ref().and_then(|c| c.message().ok());
let author = commit.as_ref().and_then(|c| c.author().ok());
LineBlame {
commit_hash: commit
.as_ref()
.and_then(|c| c.short_id().map(|id| id.to_string()).ok()),
author_name: author.map(|a| a.name.to_string()),
author_email: author.map(|a| a.email.to_string()),
commit_date: author.map(|a| a.time.format(gix::date::time::format::SHORT)),
commit_message: message.as_ref().map(|msg| msg.title.to_string()),
commit_body: message
.as_ref()
.and_then(|msg| msg.body.map(|body| body.to_string())),
time_ago: author
.map(|a| helix_stdx::time::format_relative_time(a.time.seconds, a.time.offset)),
}
}
/// Compute blame for this file
pub fn try_new(file: PathBuf) -> Result<Self> {
let thread_safe_repo =
open_repo(get_repo_dir(&file)?).context("Failed to open git repo")?;
let repo = thread_safe_repo.to_thread_local();
let head = repo.head()?.peel_to_commit_in_place()?.id;
// TODO: this iterator has a performane issue for large repos
// It was replaced in a new (yet unreleased) version of `gix`.
//
// Update to the new version once it releases.
//
// More info: https://github.com/helix-editor/helix/pull/13133#discussion_r2008611830
let traverse = gix::traverse::commit::topo::Builder::from_iters(
&repo.objects,
[head],
None::<Vec<gix::ObjectId>>,
)
.build()?;
let mut resource_cache = repo.diff_resource_cache_for_tree_diff()?;
let file_blame = gix::blame::file(
&repo.objects,
traverse.into_iter(),
&mut resource_cache,
// bstr always uses unix separators
&gix::path::to_unix_separators_on_windows(gix::path::try_into_bstr(
file.strip_prefix(
repo.path()
.parent()
.context("Could not get the parent path of the repo")?,
)?,
)?),
None,
)?
.entries;
Ok(Self {
blame: file_blame
.into_iter()
.flat_map(|blame| {
(blame.start_in_blamed_file..blame.start_in_blamed_file + blame.len.get())
.map(move |i| (i, blame.commit_id))
})
.collect(),
repo: thread_safe_repo,
})
}
}
#[derive(Clone, PartialEq, PartialOrd, Ord, Eq, Debug)]
pub struct LineBlame {
commit_hash: Option<String>,
author_name: Option<String>,
author_email: Option<String>,
commit_date: Option<String>,
commit_message: Option<String>,
commit_body: Option<String>,
time_ago: Option<String>,
}
impl LineBlame {
/// Longest variable is: `time-ago` (and `message`)
const LONGEST_VARIABLE_LENGTH: usize = 7;
/// # Returns
///
/// None => Invalid variable
/// Some(None) => Valid variable, but is empty
#[inline]
fn get_variable(&self, var: &str) -> Option<Option<&str>> {
Some(
// if adding new variables, update `Self::LONGEST_VARIABLE_LENGTH`
match var {
"commit" => &self.commit_hash,
"author" => &self.author_name,
"date" => &self.commit_date,
"message" => &self.commit_message,
"email" => &self.author_email,
"body" => &self.commit_body,
"time-ago" => &self.time_ago,
_ => return None,
}
.as_deref(),
)
}
/// Parse the user's blame format
#[inline]
pub fn parse_format(&self, format: &str) -> String {
let mut line_blame = String::new();
let mut content_before_variable = String::with_capacity(format.len());
let mut chars = format.char_indices().peekable();
// in all cases, when any of the variables is empty we exclude the content before the variable
// However, if the variable is the first and it is empty - then exclude the content after the variable
let mut exclude_content_after_variable = false;
while let Some((ch_idx, ch)) = chars.next() {
if ch == '{' {
let mut variable = String::with_capacity(Self::LONGEST_VARIABLE_LENGTH);
// eat all characters until the end
while let Some((_, ch)) = chars.next_if(|(_, ch)| *ch != '}') {
variable.push(ch);
}
// eat the '}' if it was found
let has_closing = chars.next().is_some();
#[derive(PartialEq, Eq, PartialOrd, Ord)]
enum Variable<'a> {
Valid(&'a str),
Invalid(&'a str),
Empty,
}
let variable_value = self.get_variable(&variable).map_or_else(
|| {
// Invalid variable. So just add whatever we parsed before.
// The length of the variable, including opening and optionally
// closing curly braces
let variable_len = 1 + variable.len() + has_closing as usize;
Variable::Invalid(&format[ch_idx..ch_idx + variable_len])
},
|s| s.map(Variable::Valid).unwrap_or(Variable::Empty),
);
match variable_value {
Variable::Invalid(value) | Variable::Valid(value) => {
if exclude_content_after_variable {
// don't push anything.
exclude_content_after_variable = false;
} else {
line_blame.push_str(&content_before_variable);
}
line_blame.push_str(value);
}
Variable::Empty => {
if line_blame.is_empty() {
// exclude content AFTER this variable (at next iteration of the loop,
// we'll exclude the content before a valid variable)
exclude_content_after_variable = true;
} else {
// exclude content BEFORE this variable
// also just don't add anything.
}
}
}
// we've consumed the content before the variable so just get rid of it and
// make space for new
content_before_variable.drain(..);
} else {
content_before_variable.push(ch);
}
}
line_blame
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::git::test::create_commit_with_message;
use crate::git::test::empty_git_repo;
use std::fs::File;
/// describes how a line was modified
#[derive(PartialEq, PartialOrd, Ord, Eq)]
enum LineDiff {
/// this line is added
Insert,
/// this line is deleted
Delete,
/// no changes for this line
None,
}
/// checks if the first argument is `no_commit` or not
macro_rules! no_commit_flag {
(no_commit, $commit_msg:literal) => {
false
};
(, $commit_msg:literal) => {
true
};
($any:tt, $commit_msg:literal) => {
compile_error!(concat!(
"expected `no_commit` or nothing for commit ",
$commit_msg
))
};
}
/// checks if the first argument is `insert` or `delete`
macro_rules! line_diff_flag {
(insert, $commit_msg:literal, $line:expr) => {
LineDiff::Insert
};
(delete, $commit_msg:literal, $line:expr) => {
LineDiff::Delete
};
(, $commit_msg:literal, $line:expr) => {
LineDiff::None
};
($any:tt, $commit_msg:literal, $line:expr) => {
compile_error!(concat!(
"expected `insert`, `delete` or nothing for commit ",
$commit_msg,
" line ",
$line
))
};
}
/// This macro exists because we can't pass a `match` statement into `concat!`
/// we would like to exclude any lines that are `delete`
macro_rules! line_diff_flag_str {
(insert, $commit_msg:literal, $line:expr) => {
concat!($line, newline_literal!())
};
(delete, $commit_msg:literal, $line:expr) => {
""
};
(, $commit_msg:literal, $line:expr) => {
concat!($line, newline_literal!())
};
($any:tt, $commit_msg:literal, $line:expr) => {
compile_error!(concat!(
"expected `insert`, `delete` or nothing for commit ",
$commit_msg,
" line ",
$line
))
};
}
#[cfg(windows)]
macro_rules! newline_literal {
() => {
"\r\n"
};
}
#[cfg(not(windows))]
macro_rules! newline_literal {
() => {
"\n"
};
}
/// Helper macro to create a history of the same file being modified.
macro_rules! assert_line_blame_progress {
(
$(
// a unique identifier for the commit, other commits must not use this
// If `no_commit` option is used, use the identifier of the previous commit
$commit_msg:literal
// must be `no_commit` if exists.
// If exists, this block won't be committed
$($no_commit:ident)? =>
$(
// contents of a line in the file
$line:literal
// what commit identifier we are expecting for this line
$($expected:literal)?
// must be `insert` or `delete` if exists
// if exists, must be used with `no_commit`
// - `insert`: this line is added
// - `delete`: this line is deleted
$($line_diff:ident)?
),+
);+
$(;)?
) => {{
use std::fs::OpenOptions;
use std::io::Write;
let repo = empty_git_repo();
let file = repo.path().join("file.txt");
File::create(&file).expect("could not create file");
$(
let file_content = concat!(
$(
line_diff_flag_str!($($line_diff)?, $commit_msg, $line),
)*
);
eprintln!("at commit {}:\n\n{file_content}", stringify!($commit_msg));
let mut f = OpenOptions::new()
.write(true)
.truncate(true)
.open(&file)
.unwrap();
f.write_all(file_content.as_bytes()).unwrap();
let should_commit = no_commit_flag!($($no_commit)?, $commit_msg);
if should_commit {
create_commit_with_message(repo.path(), true, stringify!($commit_msg));
}
let mut line_number = 0;
let mut added_lines = 0;
let mut removed_lines = 0;
$(
let line_diff_flag = line_diff_flag!($($line_diff)?, $commit_msg, $line);
#[allow(unused_assignments)]
match line_diff_flag {
LineDiff::Insert => added_lines += 1,
LineDiff::Delete => removed_lines += 1,
LineDiff::None => ()
}
// completely skip lines that are marked as `delete`
if line_diff_flag != LineDiff::Delete {
// if there is no $expected, then we don't care what blame_line returns
// because we won't show it to the user.
$(
let blame_result =
FileBlame::try_new(file.clone())
.unwrap()
.blame_for_line_inserted_removed(line_number, added_lines, removed_lines)
.commit_message;
assert_eq!(
blame_result,
Some(concat!(stringify!($expected), newline_literal!()).to_owned()),
"Blame mismatch\nat commit: {}\nat line: {}\nline contents: {}\nexpected commit: {}\nbut got commit: {}",
$commit_msg,
line_number,
file_content
.lines()
.nth(line_number.try_into().unwrap())
.unwrap(),
stringify!($expected),
blame_result
.as_ref()
.map(|blame| blame.trim_end())
.unwrap_or("<no commit>")
);
)?
#[allow(unused_assignments)]
{
line_number += 1;
}
}
)*
)*
}};
}
// For some reasons the CI is failing on windows with the message "Commits not found".
// The created temporary repository has no commits... But this is not an issue on unix.
// There is nothing platform-specific in this implementation. This is a problem only
// for tests on Windows.
// As such it should be fine to disable this test in Windows.
// As long as these tests pass on other platforms, on Windows it will work too.
#[cfg(not(windows))]
#[test]
pub fn blamed_lines() {
assert_line_blame_progress! {
// initialize
1 =>
"fn main() {" 1,
"" 1,
"}" 1;
// modifying a line works
2 =>
"fn main() {" 1,
" one" 2,
"}" 1;
// inserting a line works
3 =>
"fn main() {" 1,
" one" 2,
" two" 3,
"}" 1;
// deleting a line works
4 =>
"fn main() {" 1,
" two" 3,
"}" 1;
// when a line is inserted in-between the blame order is preserved
4 no_commit =>
"fn main() {" 1,
" hello world" insert,
" two" 3,
"}" 1;
// Having a bunch of random lines interspersed should not change which lines
// have blame for which commits
4 no_commit =>
" six" insert,
" three" insert,
"fn main() {" 1,
" five" insert,
" four" insert,
" two" 3,
" five" insert,
" four" insert,
"}" 1,
" five" insert,
" four" insert;
// committing all of those insertions should recognize that they are
// from the current commit, while still keeping the information about
// previous commits
5 =>
" six" 5,
" three" 5,
"fn main() {" 1,
" five" 5,
" four" 5,
" two" 3,
" five" 5,
" four" 5,
"}" 1,
" five" 5,
" four" 5;
// several lines deleted
5 no_commit =>
" six" 5,
" three" 5,
"fn main() {" delete,
" five" delete,
" four" delete,
" two" delete,
" five" delete,
" four" 5,
"}" 1,
" five" 5,
" four" 5;
// committing the deleted changes
6 =>
" six" 5,
" three" 5,
" four" 5,
"}" 1,
" five" 5,
" four" 5;
// mixing inserts with deletes
6 no_commit =>
" six" delete,
" 2" insert,
" three" delete,
" four" 5,
" 1" insert,
"}" 1,
"]" insert,
" five" delete,
" four" 5;
// committing inserts and deletes
7 =>
" 2" 7,
" four" 5,
" 1" 7,
"}" 1,
"]" 7,
" four" 5;
};
}
fn bob() -> LineBlame {
LineBlame {
commit_hash: Some("f14ab1cf".to_owned()),
author_name: Some("Bob TheBuilder".to_owned()),
author_email: Some("bob@bob.com".to_owned()),
commit_date: Some("2028-01-10".to_owned()),
commit_message: Some("feat!: extend house".to_owned()),
commit_body: Some("BREAKING CHANGE: Removed door".to_owned()),
time_ago: None,
}
}
#[test]
pub fn inline_blame_format_parser() {
let format = "{author}, {date} • {message} • {commit}";
assert_eq!(
bob().parse_format(format),
"Bob TheBuilder, 2028-01-10 • feat!: extend house • f14ab1cf".to_owned()
);
assert_eq!(
LineBlame {
author_name: None,
..bob()
}
.parse_format(format),
"2028-01-10 • feat!: extend house • f14ab1cf".to_owned()
);
assert_eq!(
LineBlame {
commit_date: None,
..bob()
}
.parse_format(format),
"Bob TheBuilder • feat!: extend house • f14ab1cf".to_owned()
);
assert_eq!(
LineBlame {
commit_message: None,
author_email: None,
..bob()
}
.parse_format(format),
"Bob TheBuilder, 2028-01-10 • f14ab1cf".to_owned()
);
assert_eq!(
LineBlame {
commit_hash: None,
..bob()
}
.parse_format(format),
"Bob TheBuilder, 2028-01-10 • feat!: extend house".to_owned()
);
assert_eq!(
LineBlame {
commit_date: None,
author_name: None,
..bob()
}
.parse_format(format),
"feat!: extend house • f14ab1cf".to_owned()
);
assert_eq!(
LineBlame {
author_name: None,
commit_message: None,
..bob()
}
.parse_format(format),
"2028-01-10 • f14ab1cf".to_owned()
);
}
}

View file

@ -4,11 +4,11 @@ use tempfile::TempDir;
use crate::git;
fn exec_git_cmd(args: &str, git_dir: &Path) {
pub fn exec_git_cmd(args: &[&str], git_dir: &Path) {
let res = Command::new("git")
.arg("-C")
.arg(git_dir) // execute the git command in this directory
.args(args.split_whitespace())
.args(args)
.env_remove("GIT_DIR")
.env_remove("GIT_ASKPASS")
.env_remove("SSH_ASKPASS")
@ -25,26 +25,30 @@ fn exec_git_cmd(args: &str, git_dir: &Path) {
.env("GIT_CONFIG_KEY_1", "init.defaultBranch")
.env("GIT_CONFIG_VALUE_1", "main")
.output()
.unwrap_or_else(|_| panic!("`git {args}` failed"));
.unwrap_or_else(|_| panic!("`git {args:?}` failed"));
if !res.status.success() {
println!("{}", String::from_utf8_lossy(&res.stdout));
eprintln!("{}", String::from_utf8_lossy(&res.stderr));
panic!("`git {args}` failed (see output above)")
panic!("`git {args:?}` failed (see output above)")
}
}
fn create_commit(repo: &Path, add_modified: bool) {
pub fn create_commit(repo: &Path, add_modified: bool) {
create_commit_with_message(repo, add_modified, "commit")
}
pub fn create_commit_with_message(repo: &Path, add_modified: bool, message: &str) {
if add_modified {
exec_git_cmd("add -A", repo);
exec_git_cmd(&["add", "-A"], repo);
}
exec_git_cmd("commit -m message", repo);
exec_git_cmd(&["commit", "-m", message], repo);
}
fn empty_git_repo() -> TempDir {
pub fn empty_git_repo() -> TempDir {
let tmp = tempfile::tempdir().expect("create temp dir for git testing");
exec_git_cmd("init", tmp.path());
exec_git_cmd("config user.email test@helix.org", tmp.path());
exec_git_cmd("config user.name helix-test", tmp.path());
exec_git_cmd(&["init"], tmp.path());
exec_git_cmd(&["config", "user.email", "test@helix.org"], tmp.path());
exec_git_cmd(&["config", "user.name", "helix-test"], tmp.path());
tmp
}

View file

@ -7,6 +7,7 @@ use std::{
#[cfg(feature = "git")]
mod git;
pub use git::blame::FileBlame;
mod diff;
@ -16,7 +17,7 @@ mod status;
pub use status::FileChange;
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct DiffProviderRegistry {
providers: Vec<DiffProvider>,
}
@ -84,7 +85,7 @@ impl Default for DiffProviderRegistry {
/// cloning [DiffProviderRegistry] as `Clone` cannot be used in trait objects.
///
/// `Copy` is simply to ensure the `clone()` call is the simplest it can be.
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum DiffProvider {
#[cfg(feature = "git")]
Git,

View file

@ -42,6 +42,7 @@ use helix_core::{
ChangeSet, Diagnostic, LineEnding, Range, Rope, RopeBuilder, Selection, Syntax, Transaction,
};
use crate::editor::InlineBlameBehaviour;
use crate::{
editor::Config,
events::{DocumentDidChange, SelectionDidChange},
@ -196,6 +197,10 @@ pub struct Document {
diff_handle: Option<DiffHandle>,
version_control_head: Option<Arc<ArcSwap<Box<str>>>>,
/// Contains blame information for each line in the file
/// We store the Result because when we access the blame directly we want to log the error
/// But if it is in the background we are just going to ignore the error
pub file_blame: Option<anyhow::Result<helix_vcs::FileBlame>>,
// when document was used for most-recent-used buffer picker
pub focused_at: std::time::Instant,
@ -207,6 +212,8 @@ pub struct Document {
// NOTE: ideally this would live on the handler for color swatches. This is blocked on a
// large refactor that would make `&mut Editor` available on the `DocumentDidChange` event.
pub color_swatch_controller: TaskController,
// when fetching blame on-demand, if this field is `true` we request the blame for this document again
pub is_blame_potentially_out_of_date: bool,
}
#[derive(Debug, Clone, Default)]
@ -283,6 +290,16 @@ pub struct DocumentInlayHintsId {
pub last_line: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum LineBlameError<'a> {
#[error("Not committed yet")]
NotCommittedYet,
#[error("Unable to get blame for line {0}: {1}")]
NoFileBlame(u32, &'a anyhow::Error),
#[error("The blame for this file is not ready yet. Try again in a few seconds")]
NotReadyYet,
}
use std::{fmt, mem};
impl fmt::Debug for Document {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@ -719,6 +736,19 @@ impl Document {
jump_labels: HashMap::new(),
color_swatches: None,
color_swatch_controller: TaskController::new(),
file_blame: None,
is_blame_potentially_out_of_date: false,
}
}
pub fn should_request_full_file_blame(
&mut self,
blame_behaviour: InlineBlameBehaviour,
) -> bool {
if blame_behaviour == InlineBlameBehaviour::Disabled {
self.is_blame_potentially_out_of_date
} else {
true
}
}
@ -1310,6 +1340,13 @@ impl Document {
Range::new(0, 1).grapheme_aligned(self.text().slice(..))
}
/// Get the line of cursor for the primary selection
pub fn cursor_line(&self, view_id: ViewId) -> usize {
let text = self.text();
let selection = self.selection(view_id);
text.char_to_line(selection.primary().cursor(text.slice(..)))
}
/// Reset the view's selection on this document to the
/// [origin](Document::origin) cursor.
pub fn reset_selection(&mut self, view_id: ViewId) {
@ -1541,6 +1578,19 @@ impl Document {
self.apply_inner(transaction, view_id, true)
}
/// Get the line blame for this view
pub fn line_blame(&self, cursor_line: u32, format: &str) -> Result<String, LineBlameError> {
Ok(self
.file_blame
.as_ref()
.ok_or(LineBlameError::NotReadyYet)?
.as_ref()
.map_err(|err| LineBlameError::NoFileBlame(cursor_line.saturating_add(1), err))?
.blame_for_line(cursor_line, self.diff_handle())
.ok_or(LineBlameError::NotCommittedYet)?
.parse_format(format))
}
/// Apply a [`Transaction`] to the [`Document`] to change its text
/// without notifying the language servers. This is useful for temporary transactions
/// that must not influence the server.

View file

@ -172,6 +172,44 @@ impl Default for GutterLineNumbersConfig {
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum InlineBlameBehaviour {
/// Do not show inline blame
///
/// Loads blame for the file in the background when the document is
/// opened and request it again when it is `:reload`ed.
///
/// This allows instantaneous access to line blame with `space + B` and when
/// `:toggle inline-blame.enable` but for the cost of consuming more
/// resources in the background
Background,
/// Do not show inline blame, and do not request it in the background
///
/// When manually requesting the inline blame, it may take several seconds to appear.
Disabled,
/// Show the inline blame
Visible,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
pub struct InlineBlameConfig {
/// Show inline blame for a line when cursor is on that line
pub behaviour: InlineBlameBehaviour,
/// How the inline blame should look like and the information it includes
pub format: String,
}
impl Default for InlineBlameConfig {
fn default() -> Self {
Self {
behaviour: InlineBlameBehaviour::Disabled,
format: "{author}, {time-ago} • {message} • {commit}".to_owned(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
pub struct FilePickerConfig {
@ -370,6 +408,8 @@ pub struct Config {
/// Whether to read settings from [EditorConfig](https://editorconfig.org) files. Defaults to
/// `true`.
pub editor_config: bool,
/// Inline blame allows showing the latest commit that affected the line the cursor is on as virtual text
pub inline_blame: InlineBlameConfig,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
@ -1016,6 +1056,7 @@ impl Default for Config {
inline_diagnostics: InlineDiagnosticsConfig::default(),
end_of_line_diagnostics: DiagnosticFilter::Disable,
clipboard_provider: ClipboardProvider::default(),
inline_blame: InlineBlameConfig::default(),
editor_config: true,
}
}
@ -1784,11 +1825,13 @@ impl Editor {
doc.set_version_control_head(self.diff_providers.get_current_head_name(&path));
let id = self.new_document(doc);
self.launch_language_servers(id);
helix_event::dispatch(DocumentDidOpen {
editor: self,
doc: id,
path: &path,
});
id

View file

@ -7,7 +7,8 @@ use crate::{Document, DocumentId, Editor, ViewId};
events! {
DocumentDidOpen<'a> {
editor: &'a mut Editor,
doc: DocumentId
doc: DocumentId,
path: &'a std::path::PathBuf
}
DocumentDidChange<'a> {
doc: &'a mut Document,

View file

@ -16,12 +16,24 @@ pub enum AutoSaveEvent {
LeftInsertMode,
}
#[derive(Debug)]
pub struct BlameEvent {
/// The path for which we request blame
pub path: std::path::PathBuf,
/// Document for which the blame is requested
pub doc_id: DocumentId,
/// If this field is set, when we obtain the blame for the file we will
/// show blame for this line in the status line
pub line: Option<u32>,
}
pub struct Handlers {
// only public because most of the actual implementation is in helix-term right now :/
pub completions: CompletionHandler,
pub signature_hints: Sender<lsp::SignatureHelpEvent>,
pub auto_save: Sender<AutoSaveEvent>,
pub document_colors: Sender<lsp::DocumentColorsEvent>,
pub blame: Sender<BlameEvent>,
}
impl Handlers {