Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 5a9fc33

Browse files
committedMar 9, 2022
Show git diff signs in gutter
1 parent 3f603b2 commit 5a9fc33

File tree

10 files changed

+429
-4
lines changed

10 files changed

+429
-4
lines changed
 

‎Cargo.lock

+112
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ members = [
77
"helix-syntax",
88
"helix-lsp",
99
"helix-dap",
10+
"helix-vcs",
1011
"xtask",
1112
]
1213

‎helix-vcs/Cargo.toml

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "helix-vcs"
3+
version = "0.6.0"
4+
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
5+
edition = "2021"
6+
license = "MPL-2.0"
7+
categories = ["editor"]
8+
repository = "https://github.com/helix-editor/helix"
9+
homepage = "https://helix-editor.com"
10+
11+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
12+
13+
[dependencies]
14+
git2 = { version = "0.13", default-features = false }
15+
similar = "2.1"
16+
17+
[dev-dependencies]
18+
tempfile = "3.3"

‎helix-vcs/src/git.rs

+189
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
use std::{
2+
collections::HashMap,
3+
ops::Range,
4+
path::{Path, PathBuf},
5+
};
6+
7+
use git2::{Oid, Repository};
8+
use similar::DiffTag;
9+
10+
use crate::{LineDiff, LineDiffs, RepoRoot};
11+
12+
pub struct Git {
13+
repo: Repository,
14+
/// Absolute path to root of the repo
15+
root: RepoRoot,
16+
head: Oid,
17+
18+
/// A cache mapping absolute file paths to file contents
19+
/// in the HEAD commit.
20+
head_cache: HashMap<PathBuf, String>,
21+
}
22+
23+
impl std::fmt::Debug for Git {
24+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25+
f.debug_struct("Git").field("root", &self.root).finish()
26+
}
27+
}
28+
29+
impl Git {
30+
pub fn head_commit_id(repo: &Repository) -> Option<Oid> {
31+
repo.head()
32+
.and_then(|gitref| gitref.peel_to_commit())
33+
.map(|commit| commit.id())
34+
.ok()
35+
}
36+
37+
pub fn discover_from_path(file: &Path) -> Option<Self> {
38+
let repo = Repository::discover(file).ok()?;
39+
let root = repo.workdir()?.to_path_buf();
40+
let head_oid = Self::head_commit_id(&repo)?;
41+
Some(Self {
42+
repo,
43+
root,
44+
head: head_oid,
45+
head_cache: HashMap::new(),
46+
})
47+
}
48+
49+
pub fn root(&self) -> &Path {
50+
&self.root
51+
}
52+
53+
fn relative_to_root<'p>(&self, path: &'p Path) -> Option<&'p Path> {
54+
path.strip_prefix(&self.root).ok()
55+
}
56+
57+
pub fn read_file_from_head(&mut self, file: &Path) -> Option<&str> {
58+
let current_head = Self::head_commit_id(&self.repo)?;
59+
// TODO: Check cache validity on events like WindowChange
60+
// instead of on every keypress ? Will require hooks.
61+
if current_head != self.head {
62+
self.head_cache.clear();
63+
self.head = current_head;
64+
}
65+
66+
if !self.head_cache.contains_key(file) {
67+
let relative = self.relative_to_root(file)?;
68+
let revision = &format!("HEAD:{}", relative.display());
69+
let object = self.repo.revparse_single(revision).ok()?;
70+
let blob = object.peel_to_blob().ok()?;
71+
let contents = std::str::from_utf8(blob.content()).ok()?;
72+
self.head_cache
73+
.insert(file.to_path_buf(), contents.to_string());
74+
}
75+
76+
self.head_cache.get(file).map(|s| s.as_str())
77+
}
78+
79+
pub fn line_diff_with_head(&mut self, file: &Path, contents: &str) -> LineDiffs {
80+
let base = match self.read_file_from_head(file) {
81+
Some(b) => b,
82+
None => return LineDiffs::new(),
83+
};
84+
let mut config = similar::TextDiff::configure();
85+
config.timeout(std::time::Duration::from_millis(250));
86+
87+
let mut line_diffs: LineDiffs = HashMap::new();
88+
89+
let mut mark_lines = |range: Range<usize>, change: LineDiff| {
90+
for line in range {
91+
line_diffs.insert(line, change);
92+
}
93+
};
94+
95+
let diff = config.diff_lines(base, contents);
96+
for op in diff.ops() {
97+
let (tag, _, line_range) = op.as_tag_tuple();
98+
let start = line_range.start;
99+
match tag {
100+
DiffTag::Insert => mark_lines(line_range, LineDiff::Added),
101+
DiffTag::Replace => mark_lines(line_range, LineDiff::Modified),
102+
DiffTag::Delete => mark_lines(start..start + 1, LineDiff::Deleted),
103+
DiffTag::Equal => (),
104+
}
105+
}
106+
107+
line_diffs
108+
}
109+
}
110+
111+
#[cfg(test)]
112+
mod test {
113+
use std::{
114+
fs::{self, File},
115+
process::Command,
116+
};
117+
118+
use tempfile::TempDir;
119+
120+
use super::*;
121+
122+
fn empty_git_repo() -> TempDir {
123+
let tmp = tempfile::tempdir().expect("Could not create temp dir for git testing");
124+
exec_git_cmd("init", tmp.path());
125+
tmp
126+
}
127+
128+
fn exec_git_cmd(args: &str, git_dir: &Path) {
129+
Command::new("git")
130+
.arg("-C")
131+
.arg(git_dir) // execute the git command in this directory
132+
.args(args.split_whitespace())
133+
.status()
134+
.expect(&format!("`git {args}` failed"))
135+
.success()
136+
.then(|| ())
137+
.expect(&format!("`git {args}` failed"));
138+
}
139+
140+
#[test]
141+
fn test_cannot_discover_bare_git_repo() {
142+
let temp_git = empty_git_repo();
143+
let file = temp_git.path().join("file.txt");
144+
File::create(&file).expect("Could not create file");
145+
146+
assert!(Git::discover_from_path(&file).is_none());
147+
}
148+
149+
#[test]
150+
fn test_discover_git_repo() {
151+
let temp_git = empty_git_repo();
152+
let file = temp_git.path().join("file.txt");
153+
File::create(&file).expect("Could not create file");
154+
exec_git_cmd("add file.txt", temp_git.path());
155+
exec_git_cmd("commit -m message", temp_git.path());
156+
157+
let root = Git::discover_from_path(&file).map(|g| g.root().to_owned());
158+
assert_eq!(Some(temp_git.path().to_owned()), root);
159+
}
160+
161+
#[test]
162+
fn test_read_file_from_head() {
163+
let tmp_repo = empty_git_repo();
164+
let git_dir = tmp_repo.path();
165+
let file = git_dir.join("file.txt");
166+
167+
let contents = r#"
168+
a file with unnecessary
169+
indent and text.
170+
"#;
171+
fs::write(&file, contents).expect("Could not write to file");
172+
exec_git_cmd("add file.txt", git_dir);
173+
exec_git_cmd("commit -m message", git_dir);
174+
175+
let mut git = Git::discover_from_path(&file).unwrap();
176+
assert_eq!(
177+
Some(contents),
178+
git.read_file_from_head(&file),
179+
"Wrong blob contents from HEAD on clean index"
180+
);
181+
182+
fs::write(&file, "new text").expect("Could not write to file");
183+
assert_eq!(
184+
Some(contents),
185+
git.read_file_from_head(&file),
186+
"Wrong blob contents from HEAD when index is dirty"
187+
);
188+
}
189+
}

‎helix-vcs/src/lib.rs

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
mod git;
2+
3+
use std::{
4+
cell::RefCell,
5+
collections::HashMap,
6+
path::{Path, PathBuf},
7+
rc::Rc,
8+
};
9+
10+
pub use git::Git;
11+
12+
// TODO: Move to helix_core once we have a generic diff mode
13+
#[derive(Copy, Clone, Debug)]
14+
pub enum LineDiff {
15+
Added,
16+
Deleted,
17+
Modified,
18+
}
19+
20+
/// Maps line numbers to changes
21+
pub type LineDiffs = HashMap<usize, LineDiff>;
22+
23+
pub type RepoRoot = PathBuf;
24+
25+
#[derive(Debug, Default)]
26+
pub struct Registry {
27+
inner: HashMap<RepoRoot, Rc<RefCell<Git>>>,
28+
}
29+
30+
impl Registry {
31+
pub fn new() -> Self {
32+
Self::default()
33+
}
34+
35+
pub fn discover_from_path(&mut self, file: &Path) -> Option<Rc<RefCell<Git>>> {
36+
let cached_root = self.inner.keys().find(|root| file.starts_with(root));
37+
match cached_root {
38+
Some(root) => self.inner.get(root).cloned(),
39+
None => {
40+
let repo = Git::discover_from_path(file)?;
41+
let root = repo.root().to_path_buf();
42+
let repo = Rc::new(RefCell::new(repo));
43+
self.inner.insert(root, Rc::clone(&repo));
44+
Some(repo)
45+
}
46+
}
47+
}
48+
}

‎helix-view/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ anyhow = "1"
1919
helix-core = { version = "0.6", path = "../helix-core" }
2020
helix-lsp = { version = "0.6", path = "../helix-lsp"}
2121
helix-dap = { version = "0.6", path = "../helix-dap"}
22+
helix-vcs = { version = "0.6", path = "../helix-vcs"}
2223
crossterm = { version = "0.23", optional = true }
2324

2425
# Conversion traits

‎helix-view/src/document.rs

+27-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
use anyhow::{anyhow, bail, Context, Error};
22
use helix_core::auto_pairs::AutoPairs;
3+
use helix_vcs::LineDiffs;
34
use serde::de::{self, Deserialize, Deserializer};
45
use serde::Serialize;
5-
use std::cell::Cell;
6+
use std::cell::{Cell, RefCell};
67
use std::collections::HashMap;
78
use std::fmt::Display;
89
use std::future::Future;
910
use std::path::{Path, PathBuf};
11+
use std::rc::Rc;
1012
use std::str::FromStr;
1113
use std::sync::Arc;
1214

@@ -120,6 +122,8 @@ pub struct Document {
120122

121123
diagnostics: Vec<Diagnostic>,
122124
language_server: Option<Arc<helix_lsp::Client>>,
125+
version_control: Option<Rc<RefCell<helix_vcs::Git>>>,
126+
line_diffs: LineDiffs,
123127
}
124128

125129
use std::{fmt, mem};
@@ -360,6 +364,8 @@ impl Document {
360364
last_saved_revision: 0,
361365
modified_since_accessed: false,
362366
language_server: None,
367+
version_control: None,
368+
line_diffs: LineDiffs::new(),
363369
}
364370
}
365371

@@ -608,6 +614,20 @@ impl Document {
608614
self.language_server = language_server;
609615
}
610616

617+
pub fn set_version_control(&mut self, vcs: Option<Rc<RefCell<helix_vcs::Git>>>) {
618+
self.version_control = vcs;
619+
}
620+
621+
pub fn diff_with_vcs(&mut self) {
622+
let vcs = self
623+
.version_control
624+
.as_ref()
625+
.and_then(|v| v.try_borrow_mut().ok());
626+
if let Some((mut vcs, path)) = vcs.zip(self.path()) {
627+
self.line_diffs = vcs.line_diff_with_head(path, &self.text().to_string());
628+
}
629+
}
630+
611631
/// Select text within the [`Document`].
612632
pub fn set_selection(&mut self, view_id: ViewId, selection: Selection) {
613633
// TODO: use a transaction?
@@ -683,6 +703,8 @@ impl Document {
683703
tokio::spawn(notify);
684704
}
685705
}
706+
707+
self.diff_with_vcs();
686708
}
687709
success
688710
}
@@ -854,6 +876,10 @@ impl Document {
854876
server.is_initialized().then(|| server)
855877
}
856878

879+
pub fn line_diffs(&self) -> &LineDiffs {
880+
&self.line_diffs
881+
}
882+
857883
#[inline]
858884
/// Tree-sitter AST tree
859885
pub fn syntax(&self) -> Option<&Syntax> {

‎helix-view/src/editor.rs

+5-3
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ pub struct Editor {
282282
pub macro_recording: Option<(char, Vec<KeyEvent>)>,
283283
pub theme: Theme,
284284
pub language_servers: helix_lsp::Registry,
285+
pub vcs_providers: helix_vcs::Registry,
285286

286287
pub debugger: Option<dap::Client>,
287288
pub debugger_events: SelectAll<UnboundedReceiverStream<dap::Payload>>,
@@ -328,7 +329,6 @@ impl Editor {
328329
syn_loader: Arc<syntax::Loader>,
329330
config: Config,
330331
) -> Self {
331-
let language_servers = helix_lsp::Registry::new();
332332
let auto_pairs = (&config.auto_pairs).into();
333333

334334
debug!("Editor config: {config:#?}");
@@ -344,7 +344,8 @@ impl Editor {
344344
selected_register: None,
345345
macro_recording: None,
346346
theme: theme_loader.default(),
347-
language_servers,
347+
language_servers: helix_lsp::Registry::new(),
348+
vcs_providers: helix_vcs::Registry::new(),
348349
debugger: None,
349350
debugger_events: SelectAll::new(),
350351
breakpoints: HashMap::new(),
@@ -590,7 +591,8 @@ impl Editor {
590591
let mut doc = Document::open(&path, None, Some(self.syn_loader.clone()))?;
591592

592593
let _ = Self::launch_language_server(&mut self.language_servers, &mut doc);
593-
594+
doc.set_version_control(self.vcs_providers.discover_from_path(&path));
595+
doc.diff_with_vcs();
594596
self.new_document(doc)
595597
};
596598

‎helix-view/src/gutter.rs

+27
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::fmt::Write;
22

3+
use helix_vcs::LineDiff;
4+
35
use crate::{
46
graphics::{Color, Modifier, Style},
57
Document, Editor, Theme, View,
@@ -39,6 +41,31 @@ pub fn diagnostic<'doc>(
3941
})
4042
}
4143

44+
pub fn git_diff<'doc>(
45+
_editor: &'doc Editor,
46+
doc: &'doc Document,
47+
_view: &View,
48+
theme: &Theme,
49+
_is_focused: bool,
50+
_width: usize,
51+
) -> GutterFn<'doc> {
52+
let added = theme.get("diff.plus");
53+
let deleted = theme.get("diff.minus");
54+
let modified = theme.get("diff.delta");
55+
56+
Box::new(move |line: usize, _selected: bool, out: &mut String| {
57+
let diff = doc.line_diffs().get(&(line))?;
58+
59+
let (icon, style) = match diff {
60+
LineDiff::Added => ("▍", added),
61+
LineDiff::Deleted => ("▔", deleted),
62+
LineDiff::Modified => ("▍", modified),
63+
};
64+
write!(out, "{}", icon).unwrap();
65+
Some(style)
66+
})
67+
}
68+
4269
pub fn line_number<'doc>(
4370
editor: &'doc Editor,
4471
doc: &'doc Document,

‎helix-view/src/view.rs

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ impl JumpList {
6565
}
6666

6767
const GUTTERS: &[(Gutter, usize)] = &[
68+
(gutter::git_diff, 1),
6869
(gutter::diagnostics_or_breakpoints, 1),
6970
(gutter::line_number, 5),
7071
];

0 commit comments

Comments
 (0)
Please sign in to comment.