feat: show current-line git blame by default
This commit is contained in:
@@ -23,7 +23,8 @@
|
|||||||
- [x] copying is pretty slow; optimize performance to reduce latency.
|
- [x] copying is pretty slow; optimize performance to reduce latency.
|
||||||
- [x] enable margins in top/bottom bar elements; give the bottom bar's far-right position indicator a default leading margin of 1 column and no trailing margin.
|
- [x] enable margins in top/bottom bar elements; give the bottom bar's far-right position indicator a default leading margin of 1 column and no trailing margin.
|
||||||
- [x] better fuzzy search when searching revisions and bookmarks (or the git alternatives) in before and after picker for diff view.
|
- [x] better fuzzy search when searching revisions and bookmarks (or the git alternatives) in before and after picker for diff view.
|
||||||
- [ ] git blame (enabled by default).
|
- [x] git blame (enabled by default).
|
||||||
|
- [ ] when jumping to lsp definitions, that line should be center of the buffer (currently placed at the top of the buffer view)
|
||||||
- [ ] buffer picker (with search).
|
- [ ] buffer picker (with search).
|
||||||
- [ ] pull a pr description and display it in a buffer:
|
- [ ] pull a pr description and display it in a buffer:
|
||||||
- open a selector that allows you to pick a pr from remote
|
- open a selector that allows you to pick a pr from remote
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
|||||||
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||||
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
|
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
|
||||||
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
|
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
|
||||||
|
- `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched.
|
||||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||||
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
|
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
|
||||||
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
||||||
@@ -50,6 +51,8 @@ Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated orde
|
|||||||
Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning.
|
Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning.
|
||||||
Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity.
|
Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity.
|
||||||
|
|
||||||
|
Current-line blame is enabled by default. Session schedules one request per focused tab/pane/buffer/text-revision/path/row identity and rejects superseded results, including results returning after navigation away and back. The editor stores a sanitized borrowed label; rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame.
|
||||||
|
|
||||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
||||||
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
|
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Rejected configuration opens a centered floating **Configuration error** window,
|
|||||||
Vamp.configure do |config|
|
Vamp.configure do |config|
|
||||||
config.leader = "space"
|
config.leader = "space"
|
||||||
config.clipboard_mode = "system"
|
config.clipboard_mode = "system"
|
||||||
|
config.git_blame = true # false disables current-line attribution
|
||||||
config.file_explorer_side = "right"
|
config.file_explorer_side = "right"
|
||||||
config.file_explorer_view = "tree"
|
config.file_explorer_view = "tree"
|
||||||
config.file_picker_view = "tree"
|
config.file_picker_view = "tree"
|
||||||
@@ -44,6 +45,8 @@ Plugin paths resolve relative to the config file, not the shell's working direct
|
|||||||
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
||||||
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
||||||
|
|
||||||
|
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
|
||||||
|
|
||||||
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
|
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
|
||||||
|
|
||||||
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
|
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
|
||||||
@@ -169,6 +172,7 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
|
|||||||
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle` |
|
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle` |
|
||||||
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
||||||
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
||||||
|
| Normal, Insert, Visual, Command, Search | `git_blame.toggle` |
|
||||||
| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` |
|
| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` |
|
||||||
| Normal | `tab.new`, `tab.previous`, `tab.next`, `tab.close`, `tab.force_close` |
|
| Normal | `tab.new`, `tab.previous`, `tab.next`, `tab.close`, `tab.force_close` |
|
||||||
| Normal | `jump_back`, `jump_forward` |
|
| Normal | `jump_back`, `jump_forward` |
|
||||||
|
|||||||
@@ -206,6 +206,14 @@ Toggling preserves the source caret and anchors a visible caret to its previous
|
|||||||
|
|
||||||
Text wraps at word boundaries where possible, with grapheme-safe breaks for long words. Whitespace is preserved; tabs keep their source-column stops and wide/combining Unicode characters retain their geometry. Only the first display segment shows a line number. Syntax and semantic colors, diagnostics, search matches, selections, and cursors follow the wrapped text. Motions and counts still use source lines, so `j`/`k`, Home/End, and saved file contents are unchanged. Vertical wheel input scrolls continuation rows; switching wrapping off restores horizontal scrolling.
|
Text wraps at word boundaries where possible, with grapheme-safe breaks for long words. Whitespace is preserved; tabs keep their source-column stops and wide/combining Unicode characters retain their geometry. Only the first display segment shows a line number. Syntax and semantic colors, diagnostics, search matches, selections, and cursors follow the wrapped text. Motions and counts still use source lines, so `j`/`k`, Home/End, and saved file contents are unchanged. Vertical wheel input scrolls continuation rows; switching wrapping off restores horizontal scrolling.
|
||||||
|
|
||||||
|
## Current-line Git blame
|
||||||
|
|
||||||
|
Inline blame is enabled by default in ordinary live file buffers. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Git-backed jj repositories use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit attribution.
|
||||||
|
|
||||||
|
Loading happens in the background without network access or changing repository refs/checkouts. Missing, untracked, unborn, and non-repository files have no indicator; immutable historical buffers and comparison views do not receive live-file attribution. Moving, editing, switching views, or disabling clears stale results. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering.
|
||||||
|
|
||||||
|
Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history.
|
||||||
|
|
||||||
## Panes and splits
|
## Panes and splits
|
||||||
|
|
||||||
Splits can be nested in either direction. The new pane receives focus. Panes showing the same buffer share text, saved state, diagnostics, and undo/redo history, but retain independent carets, selections, modes, searches, and vertical/horizontal viewports. Buffer navigation changes only the focused pane. A file is still loaded once, even when displayed in several panes.
|
Splits can be nested in either direction. The new pane receives focus. Panes showing the same buffer share text, saved state, diagnostics, and undo/redo history, but retain independent carets, selections, modes, searches, and vertical/horizontal viewports. Buffer navigation changes only the focused pane. A file is still loaded once, even when displayed in several panes.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
Vamp.configure do |config|
|
Vamp.configure do |config|
|
||||||
config.leader = "space"
|
config.leader = "space"
|
||||||
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
|
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
|
||||||
|
config.git_blame = true # false disables current-line inline blame
|
||||||
config.file_explorer_side = "right"
|
config.file_explorer_side = "right"
|
||||||
config.file_explorer_view = "tree"
|
config.file_explorer_view = "tree"
|
||||||
config.file_picker_view = "tree"
|
config.file_picker_view = "tree"
|
||||||
|
|||||||
+740
@@ -0,0 +1,740 @@
|
|||||||
|
//! Read-only current-line attribution; repository commands never touch the editor thread.
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
io::{self, Read, Write},
|
||||||
|
path::{Component, Path, PathBuf},
|
||||||
|
process::{Command, Stdio},
|
||||||
|
sync::{
|
||||||
|
Arc, Condvar, Mutex,
|
||||||
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
|
},
|
||||||
|
thread::{self, JoinHandle},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIMIT: usize = 16 * 1024 * 1024;
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||||
|
type ResultLine = Result<Option<LineBlame>, String>;
|
||||||
|
type Request = (u64, u64, PathBuf, String, usize);
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) struct LineBlame {
|
||||||
|
pub(crate) commit: String,
|
||||||
|
pub(crate) author: String,
|
||||||
|
pub(crate) summary: String,
|
||||||
|
pub(crate) uncommitted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
request: Option<Request>,
|
||||||
|
result: Option<(u64, ResultLine)>,
|
||||||
|
}
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Shared {
|
||||||
|
state: Mutex<State>,
|
||||||
|
wake: Condvar,
|
||||||
|
generation: AtomicU64,
|
||||||
|
stopped: AtomicBool,
|
||||||
|
}
|
||||||
|
pub(crate) struct BlameWorker {
|
||||||
|
shared: Arc<Shared>,
|
||||||
|
thread: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
impl BlameWorker {
|
||||||
|
pub(crate) fn start() -> io::Result<Self> {
|
||||||
|
let shared = Arc::new(Shared::default());
|
||||||
|
let worker = Arc::clone(&shared);
|
||||||
|
let thread = thread::Builder::new()
|
||||||
|
.name("vamp-blame".into())
|
||||||
|
.spawn(move || {
|
||||||
|
loop {
|
||||||
|
let Ok(mut state) = worker.state.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
while state.request.is_none() && !worker.stopped.load(Ordering::Acquire) {
|
||||||
|
let Ok(next) = worker.wake.wait(state) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
if worker.stopped.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let (generation, id, path, contents, row) = state.request.take().unwrap();
|
||||||
|
drop(state);
|
||||||
|
let cancelled = || {
|
||||||
|
worker.stopped.load(Ordering::Acquire)
|
||||||
|
|| worker.generation.load(Ordering::Acquire) != generation
|
||||||
|
};
|
||||||
|
let result = read_line(&path, &contents, row, &cancelled);
|
||||||
|
let Ok(mut state) = worker.state.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !cancelled() {
|
||||||
|
state.result = Some((id, result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
shared,
|
||||||
|
thread: Some(thread),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn request(
|
||||||
|
&mut self,
|
||||||
|
id: u64,
|
||||||
|
path: PathBuf,
|
||||||
|
contents: String,
|
||||||
|
row: usize,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if contents.len() > LIMIT {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"Blame input exceeds 16 MiB",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut state = self
|
||||||
|
.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("Blame worker stopped"))?;
|
||||||
|
let generation = self
|
||||||
|
.shared
|
||||||
|
.generation
|
||||||
|
.fetch_add(1, Ordering::AcqRel)
|
||||||
|
.wrapping_add(1);
|
||||||
|
state.request = Some((generation, id, path, contents, row));
|
||||||
|
state.result = None;
|
||||||
|
self.shared.wake.notify_one();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn poll(&mut self) -> Option<(u64, ResultLine)> {
|
||||||
|
self.shared.state.lock().ok()?.result.take()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for BlameWorker {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let state = self.shared.state.lock();
|
||||||
|
self.shared.stopped.store(true, Ordering::Release);
|
||||||
|
self.shared.wake.notify_one();
|
||||||
|
drop(state);
|
||||||
|
if let Some(thread) = self.thread.take() {
|
||||||
|
let _ = thread.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Temporary(PathBuf);
|
||||||
|
impl Temporary {
|
||||||
|
fn new() -> Result<Self, String> {
|
||||||
|
for _ in 0..100 {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-blame-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
let mut builder = fs::DirBuilder::new();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
|
builder.mode(0o700);
|
||||||
|
}
|
||||||
|
match builder.create(&path) {
|
||||||
|
Ok(()) => return Ok(Self(path)),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(_) => return Err("Cannot create private blame directory".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("Cannot create private blame directory".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Temporary {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same process-group cancellation as repository comparisons, with a smaller output/time budget.
|
||||||
|
fn run(
|
||||||
|
mut command: Command,
|
||||||
|
input: &[u8],
|
||||||
|
cancelled: &dyn Fn() -> bool,
|
||||||
|
) -> Result<Option<Vec<u8>>, String> {
|
||||||
|
if cancelled() {
|
||||||
|
return Err("Blame cancelled".into());
|
||||||
|
}
|
||||||
|
for key in [
|
||||||
|
"GIT_DIR",
|
||||||
|
"GIT_COMMON_DIR",
|
||||||
|
"GIT_WORK_TREE",
|
||||||
|
"GIT_INDEX_FILE",
|
||||||
|
"GIT_OBJECT_DIRECTORY",
|
||||||
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||||
|
] {
|
||||||
|
command.env_remove(key);
|
||||||
|
}
|
||||||
|
command
|
||||||
|
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.env("GIT_NO_REPLACE_OBJECTS", "1")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
return Err("Blame requires Unix process-group cancellation".into());
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
command.process_group(0);
|
||||||
|
let mut child = command.spawn().map_err(|_| "Cannot start blame command")?;
|
||||||
|
let stdout = child.stdout.take().unwrap();
|
||||||
|
let mut stdin = child.stdin.take().unwrap();
|
||||||
|
thread::scope(|scope| {
|
||||||
|
let output = scope.spawn(move || {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
stdout
|
||||||
|
.take(LIMIT as u64 + 1)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|_| "Cannot read blame output")?;
|
||||||
|
if bytes.len() > LIMIT {
|
||||||
|
return Err("Blame output exceeds 16 MiB");
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
});
|
||||||
|
let writer = scope.spawn(move || stdin.write_all(input));
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut status = None;
|
||||||
|
let failure = loop {
|
||||||
|
if cancelled() {
|
||||||
|
break Some("Blame cancelled");
|
||||||
|
}
|
||||||
|
if started.elapsed() >= TIMEOUT {
|
||||||
|
break Some("Blame command timed out");
|
||||||
|
}
|
||||||
|
if status.is_none() {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(value) => status = value,
|
||||||
|
Err(_) => break Some("Cannot wait for blame command"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status.is_some() && output.is_finished() && writer.is_finished() {
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(10));
|
||||||
|
};
|
||||||
|
if failure.is_some() {
|
||||||
|
unsafe {
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn kill(pid: i32, signal: i32) -> i32;
|
||||||
|
}
|
||||||
|
kill(-(child.id() as i32), 9);
|
||||||
|
}
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
let bytes = output.join().map_err(|_| "Blame reader stopped")?;
|
||||||
|
let written = writer.join().map_err(|_| "Blame writer stopped")?;
|
||||||
|
if let Some(error) = failure {
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
let bytes = bytes.map_err(str::to_owned)?;
|
||||||
|
if !status.unwrap().success() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
written.map_err(|_| "Cannot supply blame contents")?;
|
||||||
|
Ok(Some(bytes))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn git(root: &Path) -> Command {
|
||||||
|
let mut command = Command::new("git");
|
||||||
|
command
|
||||||
|
.args([
|
||||||
|
"--no-pager",
|
||||||
|
"--literal-pathspecs",
|
||||||
|
"-c",
|
||||||
|
"core.hooksPath=/dev/null",
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
])
|
||||||
|
.arg("-C")
|
||||||
|
.arg(root);
|
||||||
|
command
|
||||||
|
}
|
||||||
|
fn oid(value: &str) -> bool {
|
||||||
|
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
|
||||||
|
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
Ok(PathBuf::from(
|
||||||
|
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn read_line(path: &Path, contents: &str, row: usize, cancelled: &dyn Fn() -> bool) -> ResultLine {
|
||||||
|
if contents.len() > LIMIT {
|
||||||
|
return Err("Blame input exceeds 16 MiB".into());
|
||||||
|
}
|
||||||
|
// The editor has a final empty row after a newline; Git has no corresponding source line.
|
||||||
|
if row >= contents.split_inclusive('\n').count() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let path = fs::canonicalize(path).map_err(|_| "Cannot resolve blame file")?;
|
||||||
|
let Some(root) = path.parent().and_then(|parent| {
|
||||||
|
parent
|
||||||
|
.ancestors()
|
||||||
|
.find(|root| root.join(".jj").exists() || root.join(".git").exists())
|
||||||
|
}) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let relative = path.strip_prefix(root).map_err(|_| "Invalid blame path")?;
|
||||||
|
if relative.components().any(|part| {
|
||||||
|
!matches!(part, Component::Normal(_))
|
||||||
|
|| part.as_os_str() == ".git"
|
||||||
|
|| part.as_os_str() == ".jj"
|
||||||
|
}) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mut temporary = None;
|
||||||
|
let mut command = git(root);
|
||||||
|
if root.join(".jj").exists() {
|
||||||
|
let mut jj = Command::new("jj");
|
||||||
|
jj.arg("--repository")
|
||||||
|
.arg(root)
|
||||||
|
.args([
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"--at-operation=@",
|
||||||
|
"--no-pager",
|
||||||
|
"--color=never",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-r",
|
||||||
|
"@-",
|
||||||
|
"-T",
|
||||||
|
"commit_id ++ \"\\n\"",
|
||||||
|
])
|
||||||
|
.current_dir(root);
|
||||||
|
let bytes = run(jj, &[], cancelled)?.ok_or("Cannot resolve Jujutsu blame parent")?;
|
||||||
|
let text = std::str::from_utf8(&bytes).map_err(|_| "Invalid Jujutsu blame metadata")?;
|
||||||
|
let mut parents = text.lines();
|
||||||
|
let Some(parent) = parents.next() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
// A merge has no single committed baseline; never pick an arbitrary parent.
|
||||||
|
if parents.next().is_some() || parent.bytes().all(|byte| byte == b'0') {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !oid(parent) {
|
||||||
|
return Err("Invalid Jujutsu blame metadata".into());
|
||||||
|
}
|
||||||
|
let mut repo = root.join(".jj/repo");
|
||||||
|
if repo.is_file() {
|
||||||
|
let pointer = fs::read(&repo).map_err(|_| "Cannot read Jujutsu repository pointer")?;
|
||||||
|
repo = root
|
||||||
|
.join(".jj")
|
||||||
|
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?);
|
||||||
|
}
|
||||||
|
let store = repo.join("store");
|
||||||
|
let target = fs::read(store.join("git_target"))
|
||||||
|
.map_err(|_| "Only Git-backed Jujutsu blame is supported")?;
|
||||||
|
let store = fs::canonicalize(
|
||||||
|
store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?),
|
||||||
|
)
|
||||||
|
.map_err(|_| "Cannot resolve Jujutsu Git store")?;
|
||||||
|
let private = Temporary::new()?;
|
||||||
|
fs::create_dir(private.0.join("refs"))
|
||||||
|
.map_err(|_| "Cannot prepare private blame repository")?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::symlink(store.join("objects"), private.0.join("objects"))
|
||||||
|
.map_err(|_| "Cannot link blame objects")?;
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
return Err("Jujutsu blame requires Unix object links".into());
|
||||||
|
fs::write(private.0.join("HEAD"), format!("{parent}\n"))
|
||||||
|
.map_err(|_| "Cannot prepare blame parent")?;
|
||||||
|
command
|
||||||
|
.arg("--git-dir")
|
||||||
|
.arg(&private.0)
|
||||||
|
.arg("--work-tree")
|
||||||
|
.arg(root);
|
||||||
|
temporary = Some(private);
|
||||||
|
}
|
||||||
|
let mut present = if let Some(private) = &temporary {
|
||||||
|
let mut command = git(root);
|
||||||
|
command.arg("--git-dir").arg(&private.0);
|
||||||
|
command
|
||||||
|
} else {
|
||||||
|
git(root)
|
||||||
|
};
|
||||||
|
let mut object = std::ffi::OsString::from("HEAD:");
|
||||||
|
object.push(relative);
|
||||||
|
present.args(["cat-file", "-e"]).arg(object);
|
||||||
|
if run(present, &[], cancelled)?.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let line = row.checked_add(1).ok_or("Invalid blame row")?;
|
||||||
|
command
|
||||||
|
.args(["blame", "--line-porcelain", "--no-textconv", "-L"])
|
||||||
|
.arg(format!("{line},{line}"))
|
||||||
|
.args(["--contents", "-", "--"])
|
||||||
|
.arg(relative);
|
||||||
|
let output = run(command, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
|
||||||
|
parse(&output, line).map(Some)
|
||||||
|
}
|
||||||
|
fn parse(bytes: &[u8], row: usize) -> Result<LineBlame, String> {
|
||||||
|
let malformed = || "Invalid Git blame metadata".to_owned();
|
||||||
|
let text = std::str::from_utf8(bytes).map_err(|_| malformed())?;
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let mut header = lines.next().ok_or_else(malformed)?.split(' ');
|
||||||
|
let commit = header.next().filter(|id| oid(id)).ok_or_else(malformed)?;
|
||||||
|
if !header
|
||||||
|
.next()
|
||||||
|
.and_then(|n| n.parse::<usize>().ok())
|
||||||
|
.is_some_and(|n| n > 0)
|
||||||
|
|| header.next().and_then(|n| n.parse::<usize>().ok()) != Some(row)
|
||||||
|
|| header.next() != Some("1")
|
||||||
|
|| header.next().is_some()
|
||||||
|
{
|
||||||
|
return Err(malformed());
|
||||||
|
}
|
||||||
|
let mut author = None;
|
||||||
|
let mut summary = None;
|
||||||
|
let mut source = false;
|
||||||
|
for line in lines.by_ref() {
|
||||||
|
if line.starts_with('\t') {
|
||||||
|
source = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(value) = line.strip_prefix("author ") {
|
||||||
|
if author.replace(value).is_some() {
|
||||||
|
return Err(malformed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = line.strip_prefix("summary ") {
|
||||||
|
if summary.replace(value).is_some() {
|
||||||
|
return Err(malformed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !source || lines.next().is_some() {
|
||||||
|
return Err(malformed());
|
||||||
|
}
|
||||||
|
let author = author.ok_or_else(malformed)?;
|
||||||
|
let summary = summary.ok_or_else(malformed)?;
|
||||||
|
let uncommitted = commit.bytes().all(|byte| byte == b'0');
|
||||||
|
Ok(LineBlame {
|
||||||
|
commit: if uncommitted {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
commit.into()
|
||||||
|
},
|
||||||
|
author: if uncommitted {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
author.into()
|
||||||
|
},
|
||||||
|
summary: if uncommitted {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
summary.into()
|
||||||
|
},
|
||||||
|
uncommitted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
if program == "jj" {
|
||||||
|
command.args([
|
||||||
|
"--config=user.name=\"Jj Author\"",
|
||||||
|
"--config=user.email=\"jj@example.test\"",
|
||||||
|
]);
|
||||||
|
command.args([
|
||||||
|
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let output = command
|
||||||
|
.args(args)
|
||||||
|
.current_dir(root)
|
||||||
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||||
|
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||||
|
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||||
|
.env("JJ_CONFIG", "/dev/null")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||||
|
}
|
||||||
|
fn fixture() -> (Temporary, PathBuf, String) {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||||
|
command(&temp.0, "git", &["config", "user.name", "Blame Author"]);
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"git",
|
||||||
|
&["config", "user.email", "blame@example.test"],
|
||||||
|
);
|
||||||
|
let path = temp.0.join("-literal [file].txt");
|
||||||
|
fs::write(&path, "first\nsecond\n").unwrap();
|
||||||
|
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"git",
|
||||||
|
&["commit", "--quiet", "-m", "Original summary"],
|
||||||
|
);
|
||||||
|
let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||||
|
(temp, path, commit)
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn committed_line_has_native_author_summary_and_commit() {
|
||||||
|
let (_temp, path, commit) = fixture();
|
||||||
|
let line = read_line(&path, "first\nsecond\n", 1, &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
line,
|
||||||
|
LineBlame {
|
||||||
|
commit,
|
||||||
|
author: "Blame Author".into(),
|
||||||
|
summary: "Original summary".into(),
|
||||||
|
uncommitted: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn unsaved_changed_line_is_uncommitted_without_metadata() {
|
||||||
|
let (_temp, path, _) = fixture();
|
||||||
|
let line = read_line(&path, "changed\nsecond\n", 0, &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
line,
|
||||||
|
LineBlame {
|
||||||
|
commit: String::new(),
|
||||||
|
author: String::new(),
|
||||||
|
summary: String::new(),
|
||||||
|
uncommitted: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn inserted_line_preserves_following_original_attribution() {
|
||||||
|
let (_temp, path, commit) = fixture();
|
||||||
|
let contents = "inserted\nfirst\nsecond\n";
|
||||||
|
assert!(
|
||||||
|
read_line(&path, contents, 0, &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.uncommitted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_line(&path, contents, 2, &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.commit,
|
||||||
|
commit
|
||||||
|
);
|
||||||
|
assert_eq!(read_line(&path, contents, 3, &|| false).unwrap(), None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
let path = temp.0.join("file");
|
||||||
|
assert_eq!(read_line(&path, "text", 0, &|| false).unwrap(), None);
|
||||||
|
fs::write(&path, "text").unwrap();
|
||||||
|
assert_eq!(read_line(&path, "text", 0, &|| false).unwrap(), None);
|
||||||
|
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||||
|
assert_eq!(read_line(&path, "text", 0, &|| false).unwrap(), None);
|
||||||
|
let (_committed, tracked, _) = fixture();
|
||||||
|
let untracked = tracked.parent().unwrap().join("untracked");
|
||||||
|
fs::write(&untracked, "text").unwrap();
|
||||||
|
assert_eq!(read_line(&untracked, "text", 0, &|| false).unwrap(), None);
|
||||||
|
let link = temp.0.join("link");
|
||||||
|
std::os::unix::fs::symlink(tracked, &link).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_line(&link, "first\nsecond\n", 0, &|| false).unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
|
||||||
|
let path = temp.0.join("file");
|
||||||
|
fs::write(&path, "original\n").unwrap();
|
||||||
|
assert_eq!(read_line(&path, "original\n", 0, &|| false).unwrap(), None);
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--config=user.name=\"Jj Author\"",
|
||||||
|
"--config=user.email=\"jj@example.test\"",
|
||||||
|
"describe",
|
||||||
|
"-m",
|
||||||
|
"Jj summary",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--config=user.name=\"Jj Author\"",
|
||||||
|
"--config=user.email=\"jj@example.test\"",
|
||||||
|
"new",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let parent = command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-r",
|
||||||
|
"@-",
|
||||||
|
"-T",
|
||||||
|
"commit_id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let operation = command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let line = read_line(&path, "original\n", 0, &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(line.commit, parent);
|
||||||
|
assert_eq!(line.author, "Jj Author");
|
||||||
|
assert_eq!(line.summary, "Jj summary");
|
||||||
|
fs::write(&path, "dirty on disk\n").unwrap();
|
||||||
|
assert!(
|
||||||
|
read_line(&path, "unsaved\n", 0, &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.uncommitted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
operation
|
||||||
|
);
|
||||||
|
assert!(!temp.0.join(".git").exists());
|
||||||
|
command(&temp.0, "jj", &["new", "root()"]);
|
||||||
|
fs::write(temp.0.join("other"), "sibling\n").unwrap();
|
||||||
|
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
||||||
|
command(&temp.0, "jj", &["new", &parent, "@"]);
|
||||||
|
assert_eq!(read_line(&path, "original\n", 0, &|| false).unwrap(), None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn malformed_metadata_errors_never_echo_metadata() {
|
||||||
|
for bytes in [b"secret metadata".as_slice(), b"\xffsecret metadata", b"0000000000000000000000000000000000000000 1 2 1\nauthor secret\nsummary secret\n\ttext\n"] {
|
||||||
|
assert_eq!(parse(bytes, 1).unwrap_err(), "Invalid Git blame metadata");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
let ready = temp.0.join("ready");
|
||||||
|
let cancellation = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop = Arc::clone(&cancellation);
|
||||||
|
let gate = ready.clone();
|
||||||
|
let worker = thread::spawn(move || {
|
||||||
|
let mut command = Command::new("sh");
|
||||||
|
command
|
||||||
|
.args([
|
||||||
|
"-c",
|
||||||
|
"sleep 600 & printf ready > \"$1\"; wait",
|
||||||
|
"vamp-blame-test",
|
||||||
|
])
|
||||||
|
.arg(gate);
|
||||||
|
run(command, &[], &|| stop.load(Ordering::Acquire))
|
||||||
|
});
|
||||||
|
while !ready.exists() && !worker.is_finished() {
|
||||||
|
thread::yield_now();
|
||||||
|
}
|
||||||
|
assert!(ready.exists());
|
||||||
|
cancellation.store(true, Ordering::Release);
|
||||||
|
assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn worker_keeps_only_latest_request_and_bounds_input() {
|
||||||
|
let (_temp, path, commit) = fixture();
|
||||||
|
let mut worker = BlameWorker::start().unwrap();
|
||||||
|
for id in 0..20 {
|
||||||
|
worker
|
||||||
|
.request(id, path.clone(), "first\nsecond\n".into(), 1)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
if let Some((id, result)) = worker.poll() {
|
||||||
|
assert_eq!(id, 19);
|
||||||
|
assert_eq!(result.unwrap().unwrap().commit, commit);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::yield_now();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
worker
|
||||||
|
.request(20, path, "x".repeat(LIMIT + 1), 0)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
io::ErrorKind::InvalidInput
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn subprocess_output_is_bounded() {
|
||||||
|
let mut command = Command::new("sh");
|
||||||
|
command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]);
|
||||||
|
assert_eq!(
|
||||||
|
run(command, &[], &|| false).unwrap_err(),
|
||||||
|
"Blame output exceeds 16 MiB"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -776,6 +776,18 @@ core_actions![
|
|||||||
],
|
],
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"core.git_blame.toggle",
|
||||||
|
Action::GitBlameToggle,
|
||||||
|
&[
|
||||||
|
Mode::Normal,
|
||||||
|
Mode::Insert,
|
||||||
|
Mode::Visual,
|
||||||
|
Mode::Command,
|
||||||
|
Mode::Search
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"core.diff.close",
|
"core.diff.close",
|
||||||
Action::DiffClose,
|
Action::DiffClose,
|
||||||
@@ -1654,6 +1666,16 @@ struct PendingLspNavigation {
|
|||||||
request: Option<u64>,
|
request: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct BlameSource {
|
||||||
|
buffer: usize,
|
||||||
|
revision: u64,
|
||||||
|
pub(crate) path: PathBuf,
|
||||||
|
pub(crate) row: usize,
|
||||||
|
pane: usize,
|
||||||
|
tab: usize,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Editor {
|
pub struct Editor {
|
||||||
active: Buffer,
|
active: Buffer,
|
||||||
parked: BTreeMap<usize, Buffer>,
|
parked: BTreeMap<usize, Buffer>,
|
||||||
@@ -1689,6 +1711,8 @@ pub struct Editor {
|
|||||||
labels: BTreeMap<String, String>,
|
labels: BTreeMap<String, String>,
|
||||||
battery_charge: Option<u8>,
|
battery_charge: Option<u8>,
|
||||||
battery_label: String,
|
battery_label: String,
|
||||||
|
git_blame: bool,
|
||||||
|
line_blame: Option<(BlameSource, String)>,
|
||||||
lsp_names: Vec<String>,
|
lsp_names: Vec<String>,
|
||||||
lsp_label: String,
|
lsp_label: String,
|
||||||
quit: bool,
|
quit: bool,
|
||||||
@@ -1787,6 +1811,8 @@ impl Editor {
|
|||||||
labels: BTreeMap::new(),
|
labels: BTreeMap::new(),
|
||||||
battery_charge: None,
|
battery_charge: None,
|
||||||
battery_label: String::new(),
|
battery_label: String::new(),
|
||||||
|
git_blame: true,
|
||||||
|
line_blame: None,
|
||||||
lsp_names: Vec::new(),
|
lsp_names: Vec::new(),
|
||||||
lsp_label: String::new(),
|
lsp_label: String::new(),
|
||||||
quit: false,
|
quit: false,
|
||||||
@@ -3209,6 +3235,7 @@ impl Editor {
|
|||||||
| Action::DiffViewToggle
|
| Action::DiffViewToggle
|
||||||
| Action::DiffOrientationToggle
|
| Action::DiffOrientationToggle
|
||||||
| Action::WordWrapToggle
|
| Action::WordWrapToggle
|
||||||
|
| Action::GitBlameToggle
|
||||||
| Action::Quit
|
| Action::Quit
|
||||||
| Action::ForceQuit
|
| Action::ForceQuit
|
||||||
| Action::Restart
|
| Action::Restart
|
||||||
@@ -4641,6 +4668,76 @@ impl Editor {
|
|||||||
pub fn battery_label(&self) -> &str {
|
pub fn battery_label(&self) -> &str {
|
||||||
&self.battery_label
|
&self.battery_label
|
||||||
}
|
}
|
||||||
|
pub fn git_blame(&self) -> bool {
|
||||||
|
self.git_blame
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_git_blame(&mut self, enabled: bool) {
|
||||||
|
self.git_blame = enabled;
|
||||||
|
self.line_blame = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn blame_source(&self) -> Option<BlameSource> {
|
||||||
|
if !self.git_blame
|
||||||
|
|| self.should_quit()
|
||||||
|
|| self.diff.is_some()
|
||||||
|
|| self.active.diff_snapshot.is_some()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(BlameSource {
|
||||||
|
buffer: self.buffer_id(),
|
||||||
|
revision: self.text_revision(),
|
||||||
|
path: self.path()?.to_owned(),
|
||||||
|
row: self.cursor().row,
|
||||||
|
pane: self.pane_id(),
|
||||||
|
tab: self.tab_id(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn blame_source_is_current(&self, source: &BlameSource) -> bool {
|
||||||
|
self.git_blame
|
||||||
|
&& !self.should_quit()
|
||||||
|
&& self.diff.is_none()
|
||||||
|
&& self.active.diff_snapshot.is_none()
|
||||||
|
&& source.buffer == self.buffer_id()
|
||||||
|
&& source.revision == self.text_revision()
|
||||||
|
&& Some(source.path.as_path()) == self.path()
|
||||||
|
&& source.row == self.cursor().row
|
||||||
|
&& source.pane == self.pane_id()
|
||||||
|
&& source.tab == self.tab_id()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_line_blame(&mut self) -> bool {
|
||||||
|
self.line_blame.take().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_line_blame(
|
||||||
|
&mut self,
|
||||||
|
source: BlameSource,
|
||||||
|
blame: crate::blame::LineBlame,
|
||||||
|
) -> bool {
|
||||||
|
if !self.blame_source_is_current(&source) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let label = if blame.uncommitted {
|
||||||
|
"Uncommitted".to_owned()
|
||||||
|
} else {
|
||||||
|
sanitize_label(format!(
|
||||||
|
"{} · {} ({:.8})",
|
||||||
|
blame.author, blame.summary, blame.commit
|
||||||
|
))
|
||||||
|
};
|
||||||
|
self.line_blame = Some((source, label));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn line_blame(&self) -> Option<&str> {
|
||||||
|
self.line_blame
|
||||||
|
.as_ref()
|
||||||
|
.filter(|(source, _)| self.blame_source_is_current(source))
|
||||||
|
.map(|(_, label)| label.as_str())
|
||||||
|
}
|
||||||
pub fn lsp_names(&self) -> &[String] {
|
pub fn lsp_names(&self) -> &[String] {
|
||||||
&self.lsp_names
|
&self.lsp_names
|
||||||
}
|
}
|
||||||
@@ -6022,6 +6119,7 @@ impl Editor {
|
|||||||
| Action::DiffViewToggle
|
| Action::DiffViewToggle
|
||||||
| Action::DiffOrientationToggle
|
| Action::DiffOrientationToggle
|
||||||
| Action::WordWrapToggle
|
| Action::WordWrapToggle
|
||||||
|
| Action::GitBlameToggle
|
||||||
| Action::LspDefinition
|
| Action::LspDefinition
|
||||||
| Action::LspHover
|
| Action::LspHover
|
||||||
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
|
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
|
||||||
@@ -6885,6 +6983,21 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if entry.action == Action::GitBlameToggle {
|
||||||
|
if !args.is_empty() {
|
||||||
|
self.set_message("The git_blame.toggle command takes no arguments");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.set_git_blame(!self.git_blame);
|
||||||
|
self.count = None;
|
||||||
|
self.clear_sequence();
|
||||||
|
self.palette = None;
|
||||||
|
if self.active.view.mode == Mode::Command {
|
||||||
|
self.active.view.command_line.clear();
|
||||||
|
self.active.view.mode = Mode::Normal;
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if entry.action == Action::WordWrapToggle {
|
if entry.action == Action::WordWrapToggle {
|
||||||
if !args.is_empty() {
|
if !args.is_empty() {
|
||||||
self.set_message("The word_wrap.toggle command takes no arguments");
|
self.set_message("The word_wrap.toggle command takes no arguments");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod battery;
|
mod battery;
|
||||||
|
mod blame;
|
||||||
pub mod chrome;
|
pub mod chrome;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
|
|||||||
+70
-3
@@ -38,6 +38,7 @@ pub struct Config {
|
|||||||
pub file_picker_view: String,
|
pub file_picker_view: String,
|
||||||
pub file_explorer_view: String,
|
pub file_explorer_view: String,
|
||||||
pub clipboard_mode: String,
|
pub clipboard_mode: String,
|
||||||
|
pub git_blame: bool,
|
||||||
pub top_bar: ChromeLine,
|
pub top_bar: ChromeLine,
|
||||||
pub bottom_bar: ChromeLine,
|
pub bottom_bar: ChromeLine,
|
||||||
pub popup: PopupConfig,
|
pub popup: PopupConfig,
|
||||||
@@ -80,6 +81,7 @@ impl Default for Config {
|
|||||||
file_picker_view: "tree".into(),
|
file_picker_view: "tree".into(),
|
||||||
file_explorer_view: "tree".into(),
|
file_explorer_view: "tree".into(),
|
||||||
clipboard_mode: "system".into(),
|
clipboard_mode: "system".into(),
|
||||||
|
git_blame: true,
|
||||||
top_bar: ChromeLine::top(),
|
top_bar: ChromeLine::top(),
|
||||||
bottom_bar: ChromeLine::bottom(),
|
bottom_bar: ChromeLine::bottom(),
|
||||||
popup: PopupConfig::default(),
|
popup: PopupConfig::default(),
|
||||||
@@ -109,6 +111,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
file_picker_view,
|
file_picker_view,
|
||||||
file_explorer_view,
|
file_explorer_view,
|
||||||
clipboard_mode,
|
clipboard_mode,
|
||||||
|
git_blame,
|
||||||
top_bar,
|
top_bar,
|
||||||
bottom_bar,
|
bottom_bar,
|
||||||
popup,
|
popup,
|
||||||
@@ -139,6 +142,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
config.file_picker_view = file_picker_view;
|
config.file_picker_view = file_picker_view;
|
||||||
config.file_explorer_view = file_explorer_view;
|
config.file_explorer_view = file_explorer_view;
|
||||||
config.clipboard_mode = clipboard_mode;
|
config.clipboard_mode = clipboard_mode;
|
||||||
|
config.git_blame = git_blame;
|
||||||
config.top_bar = top_bar;
|
config.top_bar = top_bar;
|
||||||
config.bottom_bar = bottom_bar;
|
config.bottom_bar = bottom_bar;
|
||||||
config.popup = popup;
|
config.popup = popup;
|
||||||
@@ -453,6 +457,7 @@ struct ConfigFrame {
|
|||||||
file_picker_view: String,
|
file_picker_view: String,
|
||||||
file_explorer_view: String,
|
file_explorer_view: String,
|
||||||
clipboard_mode: String,
|
clipboard_mode: String,
|
||||||
|
git_blame: bool,
|
||||||
#[serde(deserialize_with = "crate::chrome::top")]
|
#[serde(deserialize_with = "crate::chrome::top")]
|
||||||
top_bar: ChromeLine,
|
top_bar: ChromeLine,
|
||||||
#[serde(deserialize_with = "crate::chrome::bottom")]
|
#[serde(deserialize_with = "crate::chrome::bottom")]
|
||||||
@@ -854,6 +859,8 @@ fn read_frame(reader: &mut impl BufRead) -> Result<Vec<u8>, String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use crate::{editor::Input, session::EditorSession};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
@@ -1204,14 +1211,73 @@ end
|
|||||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||||
}
|
}
|
||||||
for frame in [
|
for frame in [
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
|
||||||
] {
|
] {
|
||||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let config = fixture.file("config.rb", "Vamp.configure { |_config| }");
|
||||||
|
assert!(load_config(&config).unwrap().git_blame);
|
||||||
|
let config = fixture.file("config.rb", r#"
|
||||||
|
Vamp.configure do |config|
|
||||||
|
config.git_blame = false
|
||||||
|
config.bind "G", config.core.git_blame.toggle
|
||||||
|
raise "mutable blame action" unless config.core.git_blame.frozen? && config.core.git_blame.toggle.frozen?
|
||||||
|
end
|
||||||
|
"#);
|
||||||
|
let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap();
|
||||||
|
assert!(!session.editor().git_blame());
|
||||||
|
session.handle(Input::Char('G'));
|
||||||
|
assert!(session.editor().git_blame());
|
||||||
|
session.execute("git_blame.toggle extra");
|
||||||
|
assert!(session.editor().git_blame());
|
||||||
|
session.execute("git_blame.toggle");
|
||||||
|
assert!(!session.editor().git_blame());
|
||||||
|
session.handle(Input::Ctrl('p'));
|
||||||
|
for ch in "git blame".chars() {
|
||||||
|
session.handle(Input::Char(ch));
|
||||||
|
}
|
||||||
|
assert_eq!(session.editor().palette().unwrap().entries.len(), 1);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
assert!(session.editor().git_blame());
|
||||||
|
for value in ["nil", "0", r#""false""#, "[]", "{}"] {
|
||||||
|
let config = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |config| config.git_blame = {value} }}"),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
load_config(&config)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("git_blame must be true or false")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut frame = serde_json::json!({
|
||||||
|
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
|
||||||
|
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
||||||
|
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||||||
|
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||||||
|
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
|
"diff_view_mode": "split", "diff_split_orientation": "vertical"
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
||||||
|
for value in [
|
||||||
|
serde_json::Value::Null,
|
||||||
|
serde_json::json!(0),
|
||||||
|
serde_json::json!("false"),
|
||||||
|
] {
|
||||||
|
frame["git_blame"] = value;
|
||||||
|
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||||||
|
}
|
||||||
|
frame.as_object_mut().unwrap().remove("git_blame");
|
||||||
|
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() {
|
fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -1842,6 +1908,7 @@ end"#,
|
|||||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||||
|
"git_blame": true,
|
||||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||||
"popup": {"command": {}, "search": {}},
|
"popup": {"command": {}, "search": {}},
|
||||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ module Vamp
|
|||||||
@file_picker_view = "tree"
|
@file_picker_view = "tree"
|
||||||
@file_explorer_view = "tree"
|
@file_explorer_view = "tree"
|
||||||
@clipboard_mode = "system"
|
@clipboard_mode = "system"
|
||||||
|
@git_blame = true
|
||||||
@bar = BarConfig.new
|
@bar = BarConfig.new
|
||||||
@popup = PopupConfig.new
|
@popup = PopupConfig.new
|
||||||
@labels = {}
|
@labels = {}
|
||||||
@@ -187,6 +188,14 @@ module Vamp
|
|||||||
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param enabled [Boolean]
|
||||||
|
# @return [Boolean]
|
||||||
|
def git_blame=(enabled)
|
||||||
|
raise TypeError, "git_blame must be true or false" unless [true, false].include?(enabled)
|
||||||
|
|
||||||
|
@git_blame = enabled
|
||||||
|
end
|
||||||
|
|
||||||
# @param values [Hash{String, Symbol => String}]
|
# @param values [Hash{String, Symbol => String}]
|
||||||
# @return [Hash]
|
# @return [Hash]
|
||||||
def labels=(values)
|
def labels=(values)
|
||||||
@@ -256,6 +265,7 @@ module Vamp
|
|||||||
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
||||||
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
||||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
||||||
|
"git_blame" => @git_blame,
|
||||||
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
|
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
|
||||||
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
||||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
||||||
|
|||||||
+116
-3
@@ -12,8 +12,9 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
blame::BlameWorker,
|
||||||
diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome},
|
diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome},
|
||||||
editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
|
editor::{BlameSource, Cursor, Editor, Effect, Input, Invocation, Mode},
|
||||||
lsp::Lsp,
|
lsp::Lsp,
|
||||||
plugin::{
|
plugin::{
|
||||||
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
||||||
@@ -173,6 +174,9 @@ pub struct EditorSession {
|
|||||||
pending_completion: bool,
|
pending_completion: bool,
|
||||||
hover_owner: Option<String>,
|
hover_owner: Option<String>,
|
||||||
diff_workers: BTreeMap<usize, DiffWorker>,
|
diff_workers: BTreeMap<usize, DiffWorker>,
|
||||||
|
blame_worker: Option<BlameWorker>,
|
||||||
|
blame_source: Option<BlameSource>,
|
||||||
|
blame_request_id: u64,
|
||||||
battery: Option<crate::battery::Battery>,
|
battery: Option<crate::battery::Battery>,
|
||||||
battery_enabled: bool,
|
battery_enabled: bool,
|
||||||
last_file_refresh: Instant,
|
last_file_refresh: Instant,
|
||||||
@@ -225,6 +229,7 @@ impl EditorSession {
|
|||||||
config.clipboard_mode
|
config.clipboard_mode
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
editor.set_git_blame(config.git_blame);
|
||||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||||
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
@@ -274,6 +279,9 @@ impl EditorSession {
|
|||||||
pending_completion: false,
|
pending_completion: false,
|
||||||
hover_owner: None,
|
hover_owner: None,
|
||||||
diff_workers: BTreeMap::new(),
|
diff_workers: BTreeMap::new(),
|
||||||
|
blame_worker: None,
|
||||||
|
blame_source: None,
|
||||||
|
blame_request_id: 0,
|
||||||
battery: None,
|
battery: None,
|
||||||
battery_enabled: false,
|
battery_enabled: false,
|
||||||
last_file_refresh: Instant::now(),
|
last_file_refresh: Instant::now(),
|
||||||
@@ -512,6 +520,7 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn synchronize_documents(&mut self) -> bool {
|
fn synchronize_documents(&mut self) -> bool {
|
||||||
|
let blame_changed = self.poll_blame();
|
||||||
// Leaving a request's source invalidates it permanently, even if history returns there.
|
// Leaving a request's source invalidates it permanently, even if history returns there.
|
||||||
if let Some(requests) = self.requests.get_mut("core")
|
if let Some(requests) = self.requests.get_mut("core")
|
||||||
&& requests.latest.is_some_and(|(_, source)| {
|
&& requests.latest.is_some_and(|(_, source)| {
|
||||||
@@ -536,7 +545,7 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
|
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
|
||||||
self.documents.clear();
|
self.documents.clear();
|
||||||
return self.refresh_lsp_names();
|
return self.refresh_lsp_names() | blame_changed;
|
||||||
}
|
}
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
if !self.editor.should_quit() {
|
if !self.editor.should_quit() {
|
||||||
@@ -612,7 +621,7 @@ impl EditorSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
let mut changed = false;
|
let mut changed = blame_changed;
|
||||||
for event in &events {
|
for event in &events {
|
||||||
if let Err(error) = self.lsp.notify_document(event) {
|
if let Err(error) = self.lsp.notify_document(event) {
|
||||||
self.editor.set_message(format!("LSP: {error}"));
|
self.editor.set_message(format!("LSP: {error}"));
|
||||||
@@ -1013,6 +1022,56 @@ impl EditorSession {
|
|||||||
Ok(changed)
|
Ok(changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn poll_blame(&mut self) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
if !self
|
||||||
|
.blame_source
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|source| self.editor.blame_source_is_current(source))
|
||||||
|
{
|
||||||
|
self.blame_source = self.editor.blame_source();
|
||||||
|
self.blame_request_id = self.blame_request_id.wrapping_add(1);
|
||||||
|
changed |= self.editor.clear_line_blame();
|
||||||
|
if let Some(source) = &self.blame_source {
|
||||||
|
if self.blame_worker.is_none() {
|
||||||
|
self.blame_worker = BlameWorker::start().ok();
|
||||||
|
}
|
||||||
|
if let Some(worker) = &mut self.blame_worker
|
||||||
|
&& worker
|
||||||
|
.request(
|
||||||
|
self.blame_request_id,
|
||||||
|
source.path.clone(),
|
||||||
|
self.editor.lines().join("\n"),
|
||||||
|
source.row,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
self.blame_worker = None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.blame_worker = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Some((id, result)) = self.blame_worker.as_mut().and_then(BlameWorker::poll) {
|
||||||
|
changed |= self.apply_blame_result(id, result);
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_blame_result(
|
||||||
|
&mut self,
|
||||||
|
id: u64,
|
||||||
|
result: Result<Option<crate::blame::LineBlame>, String>,
|
||||||
|
) -> bool {
|
||||||
|
if id == self.blame_request_id
|
||||||
|
&& let Some(source) = &self.blame_source
|
||||||
|
&& let Ok(Some(blame)) = result
|
||||||
|
{
|
||||||
|
return self.editor.set_line_blame(source.clone(), blame);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn poll_battery(&mut self) -> bool {
|
fn poll_battery(&mut self) -> bool {
|
||||||
let enabled = self.editor.label("battery").is_empty()
|
let enabled = self.editor.label("battery").is_empty()
|
||||||
&& [
|
&& [
|
||||||
@@ -1128,6 +1187,60 @@ mod tests {
|
|||||||
sync::atomic::{AtomicU64, Ordering},
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let path = fixture.file("source.txt", "first\nsecond");
|
||||||
|
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
|
||||||
|
assert!(session.editor.git_blame());
|
||||||
|
let first = session.blame_request_id;
|
||||||
|
session.poll_blame();
|
||||||
|
assert_eq!(session.blame_request_id, first);
|
||||||
|
let blame = || {
|
||||||
|
Ok(Some(crate::blame::LineBlame {
|
||||||
|
commit: "0123456789".into(),
|
||||||
|
author: "Author".into(),
|
||||||
|
summary: "Original".into(),
|
||||||
|
uncommitted: false,
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
assert!(session.apply_blame_result(first, blame()));
|
||||||
|
assert_eq!(
|
||||||
|
session.editor.line_blame(),
|
||||||
|
Some("Author · Original (01234567)")
|
||||||
|
);
|
||||||
|
session.handle(Input::Down);
|
||||||
|
assert!(session.editor.line_blame().is_none());
|
||||||
|
session.handle(Input::Up);
|
||||||
|
assert!(!session.apply_blame_result(first, blame()));
|
||||||
|
let source = session.editor.blame_source().unwrap();
|
||||||
|
session.handle(Input::Char('i'));
|
||||||
|
session.handle(Input::Char('x'));
|
||||||
|
assert!(
|
||||||
|
!session
|
||||||
|
.editor
|
||||||
|
.set_line_blame(source, blame().unwrap().unwrap())
|
||||||
|
);
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
session.execute("git_blame.toggle");
|
||||||
|
assert!(!session.editor.git_blame());
|
||||||
|
assert!(session.blame_worker.is_none());
|
||||||
|
assert!(session.blame_source.is_none());
|
||||||
|
assert!(session.editor.line_blame().is_none());
|
||||||
|
assert!(!session.apply_blame_result(session.blame_request_id, blame()));
|
||||||
|
session.poll_blame();
|
||||||
|
assert!(session.blame_worker.is_none());
|
||||||
|
let config = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
"Vamp.configure { |config| config.git_blame = false }",
|
||||||
|
);
|
||||||
|
let mut disabled = EditorSession::open(Some(path), vec![], Some(&config)).unwrap();
|
||||||
|
assert!(!disabled.editor.git_blame());
|
||||||
|
assert!(disabled.blame_worker.is_none());
|
||||||
|
disabled.poll_blame();
|
||||||
|
assert!(disabled.blame_worker.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
|
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
|
||||||
use crate::plugin::Utf16Range;
|
use crate::plugin::Utf16Range;
|
||||||
|
|||||||
+163
@@ -1834,6 +1834,36 @@ fn draw_document(
|
|||||||
document_caret = Some(position);
|
document_caret = Some(position);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if focused
|
||||||
|
&& document_caret.is_some()
|
||||||
|
&& let Some(label) = editor.line_blame()
|
||||||
|
&& let Some((screen_row, &(_, continuation, segment))) = document
|
||||||
|
.visible
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.rev()
|
||||||
|
.find(|(_, (row, _, _))| *row == cursor.row)
|
||||||
|
{
|
||||||
|
let line = &editor.lines()[cursor.row];
|
||||||
|
let end = display_column(line, usize::MAX);
|
||||||
|
let start = end.saturating_sub(segment.start).saturating_add(2);
|
||||||
|
let label_width = UnicodeWidthStr::width(label);
|
||||||
|
if end >= segment.start
|
||||||
|
&& end <= segment.end
|
||||||
|
&& (!wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len())
|
||||||
|
&& start.saturating_add(label_width) <= width
|
||||||
|
{
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(label).style(Style::default().fg(Color::DarkGray)),
|
||||||
|
Rect::new(
|
||||||
|
viewport.x + start as u16,
|
||||||
|
viewport.y + screen_row as u16,
|
||||||
|
label_width as u16,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if !focused {
|
if !focused {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -7394,6 +7424,139 @@ end
|
|||||||
assert!(titles.contains("2 [No Name]"), "{titles}");
|
assert!(titles.contains("2 [No Name]"), "{titles}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut editor = Editor::open(
|
||||||
|
Some(directory.file("source.txt", "界 e\u{301}\nsecond")),
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let source = editor.blame_source().unwrap();
|
||||||
|
assert!(editor.set_line_blame(
|
||||||
|
source,
|
||||||
|
crate::blame::LineBlame {
|
||||||
|
commit: "0123456789abcdef".into(),
|
||||||
|
author: "A\tuthor".into(),
|
||||||
|
summary: "Summary\n\u{1b}done".into(),
|
||||||
|
uncommitted: false,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap();
|
||||||
|
let mut views = BufferViewports::default();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
|
.unwrap();
|
||||||
|
let screen = screen_text(&terminal);
|
||||||
|
assert!(
|
||||||
|
screen.contains("界") && screen.contains("e\u{301}"),
|
||||||
|
"{screen}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
screen.contains("A�uthor · Summary��done (01234567)"),
|
||||||
|
"{screen}"
|
||||||
|
);
|
||||||
|
let caret = terminal.get_cursor_position().unwrap();
|
||||||
|
let cell = terminal.backend().buffer()[(caret.x, caret.y)].clone();
|
||||||
|
editor.handle(Input::Down);
|
||||||
|
assert!(editor.line_blame().is_none());
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!screen_text(&terminal).contains("Summary"));
|
||||||
|
editor.handle(Input::Up);
|
||||||
|
assert_eq!(
|
||||||
|
editor.line_blame(),
|
||||||
|
Some("A�uthor · Summary��done (01234567)")
|
||||||
|
);
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
|
.unwrap();
|
||||||
|
assert!(screen_text(&terminal).contains("A�uthor · Summary��done (01234567)"));
|
||||||
|
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
|
||||||
|
assert_eq!(terminal.backend().buffer()[(caret.x, caret.y)], cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_blame_uses_last_wrap_continuation_and_never_overflows_narrow_panes() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut editor =
|
||||||
|
Editor::open(Some(directory.file("source.txt", &"x".repeat(60))), vec![]).unwrap();
|
||||||
|
editor.execute("word_wrap.toggle");
|
||||||
|
let source = editor.blame_source().unwrap();
|
||||||
|
editor.set_line_blame(
|
||||||
|
source,
|
||||||
|
crate::blame::LineBlame {
|
||||||
|
commit: String::new(),
|
||||||
|
author: String::new(),
|
||||||
|
summary: String::new(),
|
||||||
|
uncommitted: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut views = BufferViewports::default();
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 8)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
|
.unwrap();
|
||||||
|
let caret = terminal.get_cursor_position().unwrap();
|
||||||
|
assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 50, 1)).contains("Uncommitted"));
|
||||||
|
assert!(pane_screen(&terminal, Rect::new(0, caret.y + 1, 50, 1)).contains("Uncommitted"));
|
||||||
|
let cell = terminal.backend().buffer()[(16, caret.y + 1)].clone();
|
||||||
|
assert_eq!(cell.symbol(), "U");
|
||||||
|
assert_eq!(cell.fg, Color::DarkGray);
|
||||||
|
let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 8)).unwrap();
|
||||||
|
narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap();
|
||||||
|
assert!(!screen_text(&narrow).contains("Uncommitted"));
|
||||||
|
assert!(screen_text(&narrow).contains("xxx"));
|
||||||
|
editor.execute("git_blame.toggle");
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!screen_text(&terminal).contains("Uncommitted"));
|
||||||
|
let mut historical = readonly_diff_editor(deletion_file());
|
||||||
|
assert!(historical.blame_source().is_none());
|
||||||
|
historical.execute("tab.new");
|
||||||
|
assert!(historical.diff().is_none());
|
||||||
|
assert!(historical.blame_source().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_blame_disappears_when_wheel_scroll_hides_the_primary_caret() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let text = (0..40)
|
||||||
|
.map(|row| format!("row {row}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let mut session =
|
||||||
|
EditorSession::open(Some(directory.file("source.txt", &text)), vec![], None).unwrap();
|
||||||
|
let source = session.editor().blame_source().unwrap();
|
||||||
|
session.editor_mut().set_line_blame(
|
||||||
|
source,
|
||||||
|
crate::blame::LineBlame {
|
||||||
|
commit: String::new(),
|
||||||
|
author: String::new(),
|
||||||
|
summary: String::new(),
|
||||||
|
uncommitted: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap();
|
||||||
|
let mut views = BufferViewports::default();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw_session(frame, &mut session))
|
||||||
|
.unwrap();
|
||||||
|
assert!(screen_text(&terminal).contains("Uncommitted"));
|
||||||
|
let area = views.document.geometry.unwrap().1;
|
||||||
|
assert!(views.scroll(
|
||||||
|
&mut session,
|
||||||
|
document_wheel(MouseEventKind::ScrollDown, area.x + 2, area.y + 2)
|
||||||
|
));
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw_session(frame, &mut session))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!screen_text(&terminal).contains("Uncommitted"));
|
||||||
|
assert_eq!(session.editor().cursor().row, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_native_battery_and_custom_override_redraw_through_clear() {
|
fn default_native_battery_and_custom_override_redraw_through_clear() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user