Detailed changes
@@ -1031,6 +1031,7 @@ dependencies = [
"env_logger",
"envy",
"futures",
+ "git",
"gpui",
"hyper",
"language",
@@ -1061,6 +1062,7 @@ dependencies = [
"tracing",
"tracing-log",
"tracing-subscriber",
+ "unindent",
"util",
"workspace",
]
@@ -1697,6 +1699,7 @@ dependencies = [
"env_logger",
"futures",
"fuzzy",
+ "git",
"gpui",
"indoc",
"itertools",
@@ -2224,6 +2227,39 @@ dependencies = [
"stable_deref_trait",
]
+[[package]]
+name = "git"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "clock",
+ "collections",
+ "futures",
+ "git2",
+ "lazy_static",
+ "log",
+ "parking_lot 0.11.2",
+ "smol",
+ "sum_tree",
+ "text",
+ "unindent",
+ "util",
+]
+
+[[package]]
+name = "git2"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2994bee4a3a6a51eb90c218523be382fd7ea09b16380b9312e9dbe955ff7c7d1"
+dependencies = [
+ "bitflags",
+ "libc",
+ "libgit2-sys",
+ "log",
+ "url",
+]
+
[[package]]
name = "glob"
version = "0.3.0"
@@ -2840,6 +2876,7 @@ dependencies = [
"env_logger",
"futures",
"fuzzy",
+ "git",
"gpui",
"lazy_static",
"log",
@@ -2894,6 +2931,18 @@ version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836"
+[[package]]
+name = "libgit2-sys"
+version = "0.14.0+1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47a00859c70c8a4f7218e6d1cc32875c4b55f6799445b842b0d8ed5e4c3d959b"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "pkg-config",
+]
+
[[package]]
name = "libloading"
version = "0.7.3"
@@ -3970,6 +4019,7 @@ dependencies = [
"fsevent",
"futures",
"fuzzy",
+ "git",
"gpui",
"ignore",
"language",
@@ -6332,6 +6382,8 @@ version = "0.1.0"
dependencies = [
"anyhow",
"futures",
+ "git2",
+ "lazy_static",
"log",
"rand 0.8.5",
"serde_json",
@@ -74,6 +74,15 @@
"hard_tabs": false,
// How many columns a tab should occupy.
"tab_size": 4,
+ // Git gutter behavior configuration.
+ "git": {
+ // Control whether the git gutter is shown. May take 2 values:
+ // 1. Show the gutter
+ // "git_gutter": "tracked_files"
+ // 2. Hide the gutter
+ // "git_gutter": "hide"
+ "git_gutter": "tracked_files"
+ },
// Settings specific to the terminal
"terminal": {
// What shell to use when opening a terminal. May take 3 values:
@@ -1,5 +1,5 @@
[package]
-authors = ["Nathan Sobo <nathan@warp.dev>"]
+authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab"
edition = "2021"
name = "collab"
@@ -26,6 +26,7 @@ base64 = "0.13"
clap = { version = "3.1", features = ["derive"], optional = true }
envy = "0.4.2"
futures = "0.3"
+git = { path = "../git" }
hyper = "0.14"
lazy_static = "1.4"
lipsum = { version = "0.8", optional = true }
@@ -65,11 +66,13 @@ project = { path = "../project", features = ["test-support"] }
settings = { path = "../settings", features = ["test-support"] }
theme = { path = "../theme" }
workspace = { path = "../workspace", features = ["test-support"] }
+git = { path = "../git", features = ["test-support"] }
ctor = "0.1"
env_logger = "0.9"
util = { path = "../util" }
lazy_static = "1.4"
serde_json = { version = "1.0", features = ["preserve_order"] }
+unindent = "0.1"
[features]
seed-support = ["clap", "lipsum", "reqwest"]
@@ -51,6 +51,7 @@ use std::{
time::Duration,
};
use theme::ThemeRegistry;
+use unindent::Unindent as _;
use workspace::{Item, SplitDirection, ToggleFollow, Workspace};
#[ctor::ctor]
@@ -946,6 +947,136 @@ async fn test_propagate_saves_and_fs_changes(
.await;
}
+#[gpui::test(iterations = 10)]
+async fn test_git_diff_base_change(
+ executor: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ executor.forbid_parking();
+ let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+ server
+ .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+ .await;
+
+ client_a
+ .fs
+ .insert_tree(
+ "/dir",
+ json!({
+ ".git": {
+ },
+ "a.txt": "
+ one
+ two
+ three
+ ".unindent(),
+ }),
+ )
+ .await;
+
+ let diff_base = "
+ one
+ three
+ "
+ .unindent();
+
+ let new_diff_base = "
+ one
+ two
+ "
+ .unindent();
+
+ client_a
+ .fs
+ .as_fake()
+ .set_index_for_repo(
+ Path::new("/dir/.git"),
+ &[(Path::new("a.txt"), diff_base.clone())],
+ )
+ .await;
+
+ let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
+ let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+
+ // Create the buffer
+ let buffer_a = project_a
+ .update(cx_a, |p, cx| p.open_buffer((worktree_id, "/dir/a.txt"), cx))
+ .await
+ .unwrap();
+
+ // Wait for it to catch up to the new diff
+ executor.run_until_parked();
+
+ // Smoke test diffing
+ buffer_a.read_with(cx_a, |buffer, _| {
+ assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
+ git::diff::assert_hunks(
+ buffer.snapshot().git_diff_hunks_in_range(0..4),
+ &buffer,
+ &diff_base,
+ &[(1..2, "", "two\n")],
+ );
+ });
+
+ // Create remote buffer
+ let buffer_b = project_b
+ .update(cx_b, |p, cx| p.open_buffer((worktree_id, "/dir/a.txt"), cx))
+ .await
+ .unwrap();
+
+ // Wait remote buffer to catch up to the new diff
+ executor.run_until_parked();
+
+ // Smoke test diffing
+ buffer_b.read_with(cx_b, |buffer, _| {
+ assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
+ git::diff::assert_hunks(
+ buffer.snapshot().git_diff_hunks_in_range(0..4),
+ &buffer,
+ &diff_base,
+ &[(1..2, "", "two\n")],
+ );
+ });
+
+ client_a
+ .fs
+ .as_fake()
+ .set_index_for_repo(
+ Path::new("/dir/.git"),
+ &[(Path::new("a.txt"), new_diff_base.clone())],
+ )
+ .await;
+
+ // Wait for buffer_a to receive it
+ executor.run_until_parked();
+
+ // Smoke test new diffing
+ buffer_a.read_with(cx_a, |buffer, _| {
+ assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref()));
+
+ git::diff::assert_hunks(
+ buffer.snapshot().git_diff_hunks_in_range(0..4),
+ &buffer,
+ &diff_base,
+ &[(2..3, "", "three\n")],
+ );
+ });
+
+ // Smoke test B
+ buffer_b.read_with(cx_b, |buffer, _| {
+ assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref()));
+ git::diff::assert_hunks(
+ buffer.snapshot().git_diff_hunks_in_range(0..4),
+ &buffer,
+ &diff_base,
+ &[(2..3, "", "three\n")],
+ );
+ });
+}
+
#[gpui::test(iterations = 10)]
async fn test_fs_operations(
executor: Arc<Deterministic>,
@@ -206,6 +206,7 @@ impl Server {
.add_message_handler(Server::unfollow)
.add_message_handler(Server::update_followers)
.add_request_handler(Server::get_channel_messages)
+ .add_message_handler(Server::update_diff_base)
.add_request_handler(Server::get_private_user_info);
Arc::new(server)
@@ -1728,6 +1729,21 @@ impl Server {
Ok(())
}
+ async fn update_diff_base(
+ self: Arc<Server>,
+ request: TypedEnvelope<proto::UpdateDiffBase>,
+ ) -> Result<()> {
+ let receiver_ids = self.store().await.project_connection_ids(
+ ProjectId::from_proto(request.payload.project_id),
+ request.sender_id,
+ )?;
+ broadcast(request.sender_id, receiver_ids, |connection_id| {
+ self.peer
+ .forward_send(request.sender_id, connection_id, request.payload.clone())
+ });
+ Ok(())
+ }
+
async fn get_private_user_info(
self: Arc<Self>,
request: TypedEnvelope<proto::GetPrivateUserInfo>,
@@ -25,6 +25,7 @@ clock = { path = "../clock" }
collections = { path = "../collections" }
context_menu = { path = "../context_menu" }
fuzzy = { path = "../fuzzy" }
+git = { path = "../git" }
gpui = { path = "../gpui" }
language = { path = "../language" }
lsp = { path = "../lsp" }
@@ -274,6 +274,7 @@ impl FoldMap {
if buffer.edit_count() != new_buffer.edit_count()
|| buffer.parse_count() != new_buffer.parse_count()
|| buffer.diagnostics_update_count() != new_buffer.diagnostics_update_count()
+ || buffer.git_diff_update_count() != new_buffer.git_diff_update_count()
|| buffer.trailing_excerpt_update_count()
!= new_buffer.trailing_excerpt_update_count()
{
@@ -16,6 +16,7 @@ use crate::{
};
use clock::ReplicaId;
use collections::{BTreeMap, HashMap};
+use git::diff::{DiffHunk, DiffHunkStatus};
use gpui::{
color::Color,
elements::*,
@@ -36,7 +37,7 @@ use gpui::{
use json::json;
use language::{Bias, DiagnosticSeverity, OffsetUtf16, Selection};
use project::ProjectPath;
-use settings::Settings;
+use settings::{GitGutter, Settings};
use smallvec::SmallVec;
use std::{
cmp::{self, Ordering},
@@ -45,6 +46,7 @@ use std::{
ops::Range,
sync::Arc,
};
+use theme::DiffStyle;
struct SelectionLayout {
head: DisplayPoint,
@@ -524,30 +526,143 @@ impl EditorElement {
layout: &mut LayoutState,
cx: &mut PaintContext,
) {
- let scroll_top =
- layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height;
+ struct GutterLayout {
+ line_height: f32,
+ // scroll_position: Vector2F,
+ scroll_top: f32,
+ bounds: RectF,
+ }
+
+ struct DiffLayout<'a> {
+ buffer_row: u32,
+ last_diff: Option<&'a DiffHunk<u32>>,
+ }
+
+ fn diff_quad(
+ hunk: &DiffHunk<u32>,
+ gutter_layout: &GutterLayout,
+ diff_style: &DiffStyle,
+ ) -> Quad {
+ let color = match hunk.status() {
+ DiffHunkStatus::Added => diff_style.inserted,
+ DiffHunkStatus::Modified => diff_style.modified,
+
+ //TODO: This rendering is entirely a horrible hack
+ DiffHunkStatus::Removed => {
+ let row = hunk.buffer_range.start;
+
+ let offset = gutter_layout.line_height / 2.;
+ let start_y =
+ row as f32 * gutter_layout.line_height + offset - gutter_layout.scroll_top;
+ let end_y = start_y + gutter_layout.line_height;
+
+ let width = diff_style.removed_width_em * gutter_layout.line_height;
+ let highlight_origin = gutter_layout.bounds.origin() + vec2f(-width, start_y);
+ let highlight_size = vec2f(width * 2., end_y - start_y);
+ let highlight_bounds = RectF::new(highlight_origin, highlight_size);
+
+ return Quad {
+ bounds: highlight_bounds,
+ background: Some(diff_style.deleted),
+ border: Border::new(0., Color::transparent_black()),
+ corner_radius: 1. * gutter_layout.line_height,
+ };
+ }
+ };
+
+ let start_row = hunk.buffer_range.start;
+ let end_row = hunk.buffer_range.end;
+
+ let start_y = start_row as f32 * gutter_layout.line_height - gutter_layout.scroll_top;
+ let end_y = end_row as f32 * gutter_layout.line_height - gutter_layout.scroll_top;
+
+ let width = diff_style.width_em * gutter_layout.line_height;
+ let highlight_origin = gutter_layout.bounds.origin() + vec2f(-width, start_y);
+ let highlight_size = vec2f(width * 2., end_y - start_y);
+ let highlight_bounds = RectF::new(highlight_origin, highlight_size);
+
+ Quad {
+ bounds: highlight_bounds,
+ background: Some(color),
+ border: Border::new(0., Color::transparent_black()),
+ corner_radius: diff_style.corner_radius * gutter_layout.line_height,
+ }
+ }
+
+ let scroll_position = layout.position_map.snapshot.scroll_position();
+ let gutter_layout = {
+ let line_height = layout.position_map.line_height;
+ GutterLayout {
+ scroll_top: scroll_position.y() * line_height,
+ line_height,
+ bounds,
+ }
+ };
+
+ let mut diff_layout = DiffLayout {
+ buffer_row: scroll_position.y() as u32,
+ last_diff: None,
+ };
+
+ let diff_style = &cx.global::<Settings>().theme.editor.diff.clone();
+ let show_gutter = matches!(
+ &cx.global::<Settings>()
+ .git_overrides
+ .git_gutter
+ .unwrap_or_default(),
+ GitGutter::TrackedFiles
+ );
+
+ // line is `None` when there's a line wrap
for (ix, line) in layout.line_number_layouts.iter().enumerate() {
if let Some(line) = line {
let line_origin = bounds.origin()
+ vec2f(
bounds.width() - line.width() - layout.gutter_padding,
- ix as f32 * layout.position_map.line_height
- - (scroll_top % layout.position_map.line_height),
+ ix as f32 * gutter_layout.line_height
+ - (gutter_layout.scroll_top % gutter_layout.line_height),
);
- line.paint(
- line_origin,
- visible_bounds,
- layout.position_map.line_height,
- cx,
- );
+
+ line.paint(line_origin, visible_bounds, gutter_layout.line_height, cx);
+
+ if show_gutter {
+ //This line starts a buffer line, so let's do the diff calculation
+ let new_hunk = get_hunk(diff_layout.buffer_row, &layout.diff_hunks);
+
+ let (is_ending, is_starting) = match (diff_layout.last_diff, new_hunk) {
+ (Some(old_hunk), Some(new_hunk)) if new_hunk == old_hunk => {
+ (false, false)
+ }
+ (a, b) => (a.is_some(), b.is_some()),
+ };
+
+ if is_ending {
+ let last_hunk = diff_layout.last_diff.take().unwrap();
+ cx.scene
+ .push_quad(diff_quad(last_hunk, &gutter_layout, diff_style));
+ }
+
+ if is_starting {
+ let new_hunk = new_hunk.unwrap();
+ diff_layout.last_diff = Some(new_hunk);
+ };
+
+ diff_layout.buffer_row += 1;
+ }
}
}
+ // If we ran out with a diff hunk still being prepped, paint it now
+ if let Some(last_hunk) = diff_layout.last_diff {
+ cx.scene
+ .push_quad(diff_quad(last_hunk, &gutter_layout, diff_style))
+ }
+
if let Some((row, indicator)) = layout.code_actions_indicator.as_mut() {
let mut x = bounds.width() - layout.gutter_padding;
- let mut y = *row as f32 * layout.position_map.line_height - scroll_top;
+ let mut y = *row as f32 * gutter_layout.line_height - gutter_layout.scroll_top;
x += ((layout.gutter_padding + layout.gutter_margin) - indicator.size().x()) / 2.;
- y += (layout.position_map.line_height - indicator.size().y()) / 2.;
+ y += (gutter_layout.line_height - indicator.size().y()) / 2.;
indicator.paint(bounds.origin() + vec2f(x, y), visible_bounds, cx);
}
}
@@ -1252,6 +1367,27 @@ impl EditorElement {
}
}
+/// Get the hunk that contains buffer_line, starting from start_idx
+/// Returns none if there is none found, and
+fn get_hunk(buffer_line: u32, hunks: &[DiffHunk<u32>]) -> Option<&DiffHunk<u32>> {
+ for i in 0..hunks.len() {
+ // Safety: Index out of bounds is handled by the check above
+ let hunk = hunks.get(i).unwrap();
+ if hunk.buffer_range.contains(&(buffer_line as u32)) {
+ return Some(hunk);
+ } else if hunk.status() == DiffHunkStatus::Removed && buffer_line == hunk.buffer_range.start
+ {
+ return Some(hunk);
+ } else if hunk.buffer_range.start > buffer_line as u32 {
+ // If we've passed the buffer_line, just stop
+ return None;
+ }
+ }
+
+ // We reached the end of the array without finding a hunk, just return none.
+ return None;
+}
+
impl Element for EditorElement {
type LayoutState = LayoutState;
type PaintState = ();
@@ -1425,6 +1561,11 @@ impl Element for EditorElement {
let line_number_layouts =
self.layout_line_numbers(start_row..end_row, &active_rows, &snapshot, cx);
+ let diff_hunks = snapshot
+ .buffer_snapshot
+ .git_diff_hunks_in_range(start_row..end_row)
+ .collect();
+
let mut max_visible_line_width = 0.0;
let line_layouts = self.layout_lines(start_row..end_row, &snapshot, cx);
for line in &line_layouts {
@@ -1573,6 +1714,7 @@ impl Element for EditorElement {
highlighted_rows,
highlighted_ranges,
line_number_layouts,
+ diff_hunks,
blocks,
selections,
context_menu,
@@ -1710,6 +1852,7 @@ pub struct LayoutState {
highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
context_menu: Option<(DisplayPoint, ElementBox)>,
+ diff_hunks: Vec<DiffHunk<u32>>,
code_actions_indicator: Option<(u32, ElementBox)>,
hover_popovers: Option<(DisplayPoint, Vec<ElementBox>)>,
}
@@ -478,6 +478,17 @@ impl Item for Editor {
})
}
+ fn git_diff_recalc(
+ &mut self,
+ _project: ModelHandle<Project>,
+ cx: &mut ViewContext<Self>,
+ ) -> Task<Result<()>> {
+ self.buffer().update(cx, |multibuffer, cx| {
+ multibuffer.git_diff_recalc(cx);
+ });
+ Task::ready(Ok(()))
+ }
+
fn to_item_events(event: &Self::Event) -> Vec<workspace::ItemEvent> {
let mut result = Vec::new();
match event {
@@ -4,6 +4,7 @@ pub use anchor::{Anchor, AnchorRangeExt};
use anyhow::Result;
use clock::ReplicaId;
use collections::{BTreeMap, Bound, HashMap, HashSet};
+use git::diff::DiffHunk;
use gpui::{AppContext, Entity, ModelContext, ModelHandle, Task};
pub use language::Completion;
use language::{
@@ -90,6 +91,7 @@ struct BufferState {
last_selections_update_count: usize,
last_diagnostics_update_count: usize,
last_file_update_count: usize,
+ last_git_diff_update_count: usize,
excerpts: Vec<ExcerptId>,
_subscriptions: [gpui::Subscription; 2],
}
@@ -101,6 +103,7 @@ pub struct MultiBufferSnapshot {
parse_count: usize,
diagnostics_update_count: usize,
trailing_excerpt_update_count: usize,
+ git_diff_update_count: usize,
edit_count: usize,
is_dirty: bool,
has_conflict: bool,
@@ -202,6 +205,7 @@ impl MultiBuffer {
last_selections_update_count: buffer_state.last_selections_update_count,
last_diagnostics_update_count: buffer_state.last_diagnostics_update_count,
last_file_update_count: buffer_state.last_file_update_count,
+ last_git_diff_update_count: buffer_state.last_git_diff_update_count,
excerpts: buffer_state.excerpts.clone(),
_subscriptions: [
new_cx.observe(&buffer_state.buffer, |_, _, cx| cx.notify()),
@@ -308,6 +312,17 @@ impl MultiBuffer {
self.read(cx).symbols_containing(offset, theme)
}
+ pub fn git_diff_recalc(&mut self, cx: &mut ModelContext<Self>) {
+ let buffers = self.buffers.borrow();
+ for buffer_state in buffers.values() {
+ if buffer_state.buffer.read(cx).needs_git_diff_recalc() {
+ buffer_state
+ .buffer
+ .update(cx, |buffer, cx| buffer.git_diff_recalc(cx))
+ }
+ }
+ }
+
pub fn edit<I, S, T>(
&mut self,
edits: I,
@@ -827,6 +842,7 @@ impl MultiBuffer {
last_selections_update_count: buffer_snapshot.selections_update_count(),
last_diagnostics_update_count: buffer_snapshot.diagnostics_update_count(),
last_file_update_count: buffer_snapshot.file_update_count(),
+ last_git_diff_update_count: buffer_snapshot.git_diff_update_count(),
excerpts: Default::default(),
_subscriptions: [
cx.observe(&buffer, |_, _, cx| cx.notify()),
@@ -1249,6 +1265,7 @@ impl MultiBuffer {
let mut excerpts_to_edit = Vec::new();
let mut reparsed = false;
let mut diagnostics_updated = false;
+ let mut git_diff_updated = false;
let mut is_dirty = false;
let mut has_conflict = false;
let mut edited = false;
@@ -1260,6 +1277,7 @@ impl MultiBuffer {
let selections_update_count = buffer.selections_update_count();
let diagnostics_update_count = buffer.diagnostics_update_count();
let file_update_count = buffer.file_update_count();
+ let git_diff_update_count = buffer.git_diff_update_count();
let buffer_edited = version.changed_since(&buffer_state.last_version);
let buffer_reparsed = parse_count > buffer_state.last_parse_count;
@@ -1268,17 +1286,21 @@ impl MultiBuffer {
let buffer_diagnostics_updated =
diagnostics_update_count > buffer_state.last_diagnostics_update_count;
let buffer_file_updated = file_update_count > buffer_state.last_file_update_count;
+ let buffer_git_diff_updated =
+ git_diff_update_count > buffer_state.last_git_diff_update_count;
if buffer_edited
|| buffer_reparsed
|| buffer_selections_updated
|| buffer_diagnostics_updated
|| buffer_file_updated
+ || buffer_git_diff_updated
{
buffer_state.last_version = version;
buffer_state.last_parse_count = parse_count;
buffer_state.last_selections_update_count = selections_update_count;
buffer_state.last_diagnostics_update_count = diagnostics_update_count;
buffer_state.last_file_update_count = file_update_count;
+ buffer_state.last_git_diff_update_count = git_diff_update_count;
excerpts_to_edit.extend(
buffer_state
.excerpts
@@ -1290,6 +1312,7 @@ impl MultiBuffer {
edited |= buffer_edited;
reparsed |= buffer_reparsed;
diagnostics_updated |= buffer_diagnostics_updated;
+ git_diff_updated |= buffer_git_diff_updated;
is_dirty |= buffer.is_dirty();
has_conflict |= buffer.has_conflict();
}
@@ -1302,6 +1325,9 @@ impl MultiBuffer {
if diagnostics_updated {
snapshot.diagnostics_update_count += 1;
}
+ if git_diff_updated {
+ snapshot.git_diff_update_count += 1;
+ }
snapshot.is_dirty = is_dirty;
snapshot.has_conflict = has_conflict;
@@ -2479,6 +2505,10 @@ impl MultiBufferSnapshot {
self.diagnostics_update_count
}
+ pub fn git_diff_update_count(&self) -> usize {
+ self.git_diff_update_count
+ }
+
pub fn trailing_excerpt_update_count(&self) -> usize {
self.trailing_excerpt_update_count
}
@@ -2529,6 +2559,15 @@ impl MultiBufferSnapshot {
})
}
+ pub fn git_diff_hunks_in_range<'a>(
+ &'a self,
+ row_range: Range<u32>,
+ ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+ self.as_singleton()
+ .into_iter()
+ .flat_map(move |(_, _, buffer)| buffer.git_diff_hunks_in_range(row_range.clone()))
+ }
+
pub fn range_for_syntax_ancestor<T: ToOffset>(&self, range: Range<T>) -> Option<Range<usize>> {
let range = range.start.to_offset(self)..range.end.to_offset(self);
@@ -0,0 +1,28 @@
+[package]
+name = "git"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/git.rs"
+
+[dependencies]
+anyhow = "1.0.38"
+clock = { path = "../clock" }
+git2 = { version = "0.15", default-features = false }
+lazy_static = "1.4.0"
+sum_tree = { path = "../sum_tree" }
+text = { path = "../text" }
+collections = { path = "../collections" }
+util = { path = "../util" }
+log = { version = "0.4.16", features = ["kv_unstable_serde"] }
+smol = "1.2"
+parking_lot = "0.11.1"
+async-trait = "0.1"
+futures = "0.3"
+
+[dev-dependencies]
+unindent = "0.1.7"
+
+[features]
+test-support = []
@@ -0,0 +1,354 @@
+use std::ops::Range;
+
+use sum_tree::SumTree;
+use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point};
+
+pub use git2 as libgit;
+use libgit::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DiffHunkStatus {
+ Added,
+ Modified,
+ Removed,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DiffHunk<T> {
+ pub buffer_range: Range<T>,
+ pub head_byte_range: Range<usize>,
+}
+
+impl DiffHunk<u32> {
+ pub fn status(&self) -> DiffHunkStatus {
+ if self.head_byte_range.is_empty() {
+ DiffHunkStatus::Added
+ } else if self.buffer_range.is_empty() {
+ DiffHunkStatus::Removed
+ } else {
+ DiffHunkStatus::Modified
+ }
+ }
+}
+
+impl sum_tree::Item for DiffHunk<Anchor> {
+ type Summary = DiffHunkSummary;
+
+ fn summary(&self) -> Self::Summary {
+ DiffHunkSummary {
+ buffer_range: self.buffer_range.clone(),
+ }
+ }
+}
+
+#[derive(Debug, Default, Clone)]
+pub struct DiffHunkSummary {
+ buffer_range: Range<Anchor>,
+}
+
+impl sum_tree::Summary for DiffHunkSummary {
+ type Context = text::BufferSnapshot;
+
+ fn add_summary(&mut self, other: &Self, buffer: &Self::Context) {
+ self.buffer_range.start = self
+ .buffer_range
+ .start
+ .min(&other.buffer_range.start, buffer);
+ self.buffer_range.end = self.buffer_range.end.max(&other.buffer_range.end, buffer);
+ }
+}
+
+#[derive(Clone)]
+pub struct BufferDiff {
+ last_buffer_version: Option<clock::Global>,
+ tree: SumTree<DiffHunk<Anchor>>,
+}
+
+impl BufferDiff {
+ pub fn new() -> BufferDiff {
+ BufferDiff {
+ last_buffer_version: None,
+ tree: SumTree::new(),
+ }
+ }
+
+ pub fn hunks_in_range<'a>(
+ &'a self,
+ query_row_range: Range<u32>,
+ buffer: &'a BufferSnapshot,
+ ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+ let start = buffer.anchor_before(Point::new(query_row_range.start, 0));
+ let end = buffer.anchor_after(Point::new(query_row_range.end, 0));
+
+ let mut cursor = self.tree.filter::<_, DiffHunkSummary>(move |summary| {
+ let before_start = summary.buffer_range.end.cmp(&start, buffer).is_lt();
+ let after_end = summary.buffer_range.start.cmp(&end, buffer).is_gt();
+ !before_start && !after_end
+ });
+
+ std::iter::from_fn(move || {
+ cursor.next(buffer);
+ let hunk = cursor.item()?;
+
+ let range = hunk.buffer_range.to_point(buffer);
+ let end_row = if range.end.column > 0 {
+ range.end.row + 1
+ } else {
+ range.end.row
+ };
+
+ Some(DiffHunk {
+ buffer_range: range.start.row..end_row,
+ head_byte_range: hunk.head_byte_range.clone(),
+ })
+ })
+ }
+
+ pub fn needs_update(&self, buffer: &text::BufferSnapshot) -> bool {
+ match &self.last_buffer_version {
+ Some(last) => buffer.version().changed_since(last),
+ None => true,
+ }
+ }
+
+ pub async fn update(&mut self, diff_base: &str, buffer: &text::BufferSnapshot) {
+ let mut tree = SumTree::new();
+
+ let buffer_text = buffer.as_rope().to_string();
+ let patch = Self::diff(&diff_base, &buffer_text);
+
+ if let Some(patch) = patch {
+ let mut divergence = 0;
+ for hunk_index in 0..patch.num_hunks() {
+ let hunk = Self::process_patch_hunk(&patch, hunk_index, buffer, &mut divergence);
+ tree.push(hunk, buffer);
+ }
+ }
+
+ self.tree = tree;
+ self.last_buffer_version = Some(buffer.version().clone());
+ }
+
+ #[cfg(test)]
+ fn hunks<'a>(&'a self, text: &'a BufferSnapshot) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+ self.hunks_in_range(0..u32::MAX, text)
+ }
+
+ fn diff<'a>(head: &'a str, current: &'a str) -> Option<GitPatch<'a>> {
+ let mut options = GitOptions::default();
+ options.context_lines(0);
+
+ let patch = GitPatch::from_buffers(
+ head.as_bytes(),
+ None,
+ current.as_bytes(),
+ None,
+ Some(&mut options),
+ );
+
+ match patch {
+ Ok(patch) => Some(patch),
+
+ Err(err) => {
+ log::error!("`GitPatch::from_buffers` failed: {}", err);
+ None
+ }
+ }
+ }
+
+ fn process_patch_hunk<'a>(
+ patch: &GitPatch<'a>,
+ hunk_index: usize,
+ buffer: &text::BufferSnapshot,
+ buffer_row_divergence: &mut i64,
+ ) -> DiffHunk<Anchor> {
+ let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap();
+ assert!(line_item_count > 0);
+
+ let mut first_deletion_buffer_row: Option<u32> = None;
+ let mut buffer_row_range: Option<Range<u32>> = None;
+ let mut head_byte_range: Option<Range<usize>> = None;
+
+ for line_index in 0..line_item_count {
+ let line = patch.line_in_hunk(hunk_index, line_index).unwrap();
+ let kind = line.origin_value();
+ let content_offset = line.content_offset() as isize;
+ let content_len = line.content().len() as isize;
+
+ if kind == GitDiffLineType::Addition {
+ *buffer_row_divergence += 1;
+ let row = line.new_lineno().unwrap().saturating_sub(1);
+
+ match &mut buffer_row_range {
+ Some(buffer_row_range) => buffer_row_range.end = row + 1,
+ None => buffer_row_range = Some(row..row + 1),
+ }
+ }
+
+ if kind == GitDiffLineType::Deletion {
+ *buffer_row_divergence -= 1;
+ let end = content_offset + content_len;
+
+ match &mut head_byte_range {
+ Some(head_byte_range) => head_byte_range.end = end as usize,
+ None => head_byte_range = Some(content_offset as usize..end as usize),
+ }
+
+ if first_deletion_buffer_row.is_none() {
+ let old_row = line.old_lineno().unwrap().saturating_sub(1);
+ let row = old_row as i64 + *buffer_row_divergence;
+ first_deletion_buffer_row = Some(row as u32);
+ }
+ }
+ }
+
+ //unwrap_or deletion without addition
+ let buffer_row_range = buffer_row_range.unwrap_or_else(|| {
+ //we cannot have an addition-less hunk without deletion(s) or else there would be no hunk
+ let row = first_deletion_buffer_row.unwrap();
+ row..row
+ });
+
+ //unwrap_or addition without deletion
+ let head_byte_range = head_byte_range.unwrap_or(0..0);
+
+ let start = Point::new(buffer_row_range.start, 0);
+ let end = Point::new(buffer_row_range.end, 0);
+ let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end);
+ DiffHunk {
+ buffer_range,
+ head_byte_range,
+ }
+ }
+}
+
+/// Range (crossing new lines), old, new
+#[cfg(any(test, feature = "test-support"))]
+#[track_caller]
+pub fn assert_hunks<Iter>(
+ diff_hunks: Iter,
+ buffer: &BufferSnapshot,
+ diff_base: &str,
+ expected_hunks: &[(Range<u32>, &str, &str)],
+) where
+ Iter: Iterator<Item = DiffHunk<u32>>,
+{
+ let actual_hunks = diff_hunks
+ .map(|hunk| {
+ (
+ hunk.buffer_range.clone(),
+ &diff_base[hunk.head_byte_range],
+ buffer
+ .text_for_range(
+ Point::new(hunk.buffer_range.start, 0)
+ ..Point::new(hunk.buffer_range.end, 0),
+ )
+ .collect::<String>(),
+ )
+ })
+ .collect::<Vec<_>>();
+
+ let expected_hunks: Vec<_> = expected_hunks
+ .iter()
+ .map(|(r, s, h)| (r.clone(), *s, h.to_string()))
+ .collect();
+
+ assert_eq!(actual_hunks, expected_hunks);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use text::Buffer;
+ use unindent::Unindent as _;
+
+ #[test]
+ fn test_buffer_diff_simple() {
+ let diff_base = "
+ one
+ two
+ three
+ "
+ .unindent();
+
+ let buffer_text = "
+ one
+ HELLO
+ three
+ "
+ .unindent();
+
+ let mut buffer = Buffer::new(0, 0, buffer_text);
+ let mut diff = BufferDiff::new();
+ smol::block_on(diff.update(&diff_base, &buffer));
+ assert_hunks(
+ diff.hunks(&buffer),
+ &buffer,
+ &diff_base,
+ &[(1..2, "two\n", "HELLO\n")],
+ );
+
+ buffer.edit([(0..0, "point five\n")]);
+ smol::block_on(diff.update(&diff_base, &buffer));
+ assert_hunks(
+ diff.hunks(&buffer),
+ &buffer,
+ &diff_base,
+ &[(0..1, "", "point five\n"), (2..3, "two\n", "HELLO\n")],
+ );
+ }
+
+ #[test]
+ fn test_buffer_diff_range() {
+ let diff_base = "
+ one
+ two
+ three
+ four
+ five
+ six
+ seven
+ eight
+ nine
+ ten
+ "
+ .unindent();
+
+ let buffer_text = "
+ A
+ one
+ B
+ two
+ C
+ three
+ HELLO
+ four
+ five
+ SIXTEEN
+ seven
+ eight
+ WORLD
+ nine
+
+ ten
+
+ "
+ .unindent();
+
+ let buffer = Buffer::new(0, 0, buffer_text);
+ let mut diff = BufferDiff::new();
+ smol::block_on(diff.update(&diff_base, &buffer));
+ assert_eq!(diff.hunks(&buffer).count(), 8);
+
+ assert_hunks(
+ diff.hunks_in_range(7..12, &buffer),
+ &buffer,
+ &diff_base,
+ &[
+ (6..7, "", "HELLO\n"),
+ (9..10, "six\n", "SIXTEEN\n"),
+ (12..13, "", "WORLD\n"),
+ ],
+ );
+ }
+}
@@ -0,0 +1,12 @@
+use std::ffi::OsStr;
+
+pub use git2 as libgit;
+pub use lazy_static::lazy_static;
+
+pub mod diff;
+pub mod repository;
+
+lazy_static! {
+ pub static ref DOT_GIT: &'static OsStr = OsStr::new(".git");
+ pub static ref GITIGNORE: &'static OsStr = OsStr::new(".gitignore");
+}
@@ -0,0 +1,61 @@
+use anyhow::Result;
+use collections::HashMap;
+use parking_lot::Mutex;
+use std::{
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+
+pub use git2::Repository as LibGitRepository;
+
+#[async_trait::async_trait]
+pub trait GitRepository: Send {
+ fn load_index(&self, relative_file_path: &Path) -> Option<String>;
+}
+
+#[async_trait::async_trait]
+impl GitRepository for LibGitRepository {
+ fn load_index(&self, relative_file_path: &Path) -> Option<String> {
+ fn logic(repo: &LibGitRepository, relative_file_path: &Path) -> Result<Option<String>> {
+ const STAGE_NORMAL: i32 = 0;
+ let index = repo.index()?;
+ let oid = match index.get_path(relative_file_path, STAGE_NORMAL) {
+ Some(entry) => entry.id,
+ None => return Ok(None),
+ };
+
+ let content = repo.find_blob(oid)?.content().to_owned();
+ Ok(Some(String::from_utf8(content)?))
+ }
+
+ match logic(&self, relative_file_path) {
+ Ok(value) => return value,
+ Err(err) => log::error!("Error loading head text: {:?}", err),
+ }
+ None
+ }
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct FakeGitRepository {
+ state: Arc<Mutex<FakeGitRepositoryState>>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct FakeGitRepositoryState {
+ pub index_contents: HashMap<PathBuf, String>,
+}
+
+impl FakeGitRepository {
+ pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<Mutex<dyn GitRepository>> {
+ Arc::new(Mutex::new(FakeGitRepository { state }))
+ }
+}
+
+#[async_trait::async_trait]
+impl GitRepository for FakeGitRepository {
+ fn load_index(&self, path: &Path) -> Option<String> {
+ let state = self.state.lock();
+ state.index_contents.get(path).cloned()
+ }
+}
@@ -25,6 +25,7 @@ client = { path = "../client" }
clock = { path = "../clock" }
collections = { path = "../collections" }
fuzzy = { path = "../fuzzy" }
+git = { path = "../git" }
gpui = { path = "../gpui" }
lsp = { path = "../lsp" }
rpc = { path = "../rpc" }
@@ -45,8 +45,16 @@ pub use {tree_sitter_rust, tree_sitter_typescript};
pub use lsp::DiagnosticSeverity;
+struct GitDiffStatus {
+ diff: git::diff::BufferDiff,
+ update_in_progress: bool,
+ update_requested: bool,
+}
+
pub struct Buffer {
text: TextBuffer,
+ diff_base: Option<String>,
+ git_diff_status: GitDiffStatus,
file: Option<Arc<dyn File>>,
saved_version: clock::Global,
saved_version_fingerprint: String,
@@ -66,6 +74,7 @@ pub struct Buffer {
diagnostics_update_count: usize,
diagnostics_timestamp: clock::Lamport,
file_update_count: usize,
+ git_diff_update_count: usize,
completion_triggers: Vec<String>,
completion_triggers_timestamp: clock::Lamport,
deferred_ops: OperationQueue<Operation>,
@@ -73,11 +82,13 @@ pub struct Buffer {
pub struct BufferSnapshot {
text: text::BufferSnapshot,
+ pub git_diff: git::diff::BufferDiff,
pub(crate) syntax: SyntaxSnapshot,
file: Option<Arc<dyn File>>,
diagnostics: DiagnosticSet,
diagnostics_update_count: usize,
file_update_count: usize,
+ git_diff_update_count: usize,
remote_selections: TreeMap<ReplicaId, SelectionSet>,
selections_update_count: usize,
language: Option<Arc<Language>>,
@@ -328,17 +339,20 @@ impl Buffer {
Self::build(
TextBuffer::new(replica_id, cx.model_id() as u64, base_text.into()),
None,
+ None,
)
}
pub fn from_file<T: Into<String>>(
replica_id: ReplicaId,
base_text: T,
+ diff_base: Option<T>,
file: Arc<dyn File>,
cx: &mut ModelContext<Self>,
) -> Self {
Self::build(
TextBuffer::new(replica_id, cx.model_id() as u64, base_text.into()),
+ diff_base.map(|h| h.into().into_boxed_str().into()),
Some(file),
)
}
@@ -349,7 +363,11 @@ impl Buffer {
file: Option<Arc<dyn File>>,
) -> Result<Self> {
let buffer = TextBuffer::new(replica_id, message.id, message.base_text);
- let mut this = Self::build(buffer, file);
+ let mut this = Self::build(
+ buffer,
+ message.diff_base.map(|text| text.into_boxed_str().into()),
+ file,
+ );
this.text.set_line_ending(proto::deserialize_line_ending(
proto::LineEnding::from_i32(message.line_ending)
.ok_or_else(|| anyhow!("missing line_ending"))?,
@@ -362,6 +380,7 @@ impl Buffer {
id: self.remote_id(),
file: self.file.as_ref().map(|f| f.to_proto()),
base_text: self.base_text().to_string(),
+ diff_base: self.diff_base.as_ref().map(|h| h.to_string()),
line_ending: proto::serialize_line_ending(self.line_ending()) as i32,
}
}
@@ -404,7 +423,7 @@ impl Buffer {
self
}
- fn build(buffer: TextBuffer, file: Option<Arc<dyn File>>) -> Self {
+ fn build(buffer: TextBuffer, diff_base: Option<String>, file: Option<Arc<dyn File>>) -> Self {
let saved_mtime = if let Some(file) = file.as_ref() {
file.mtime()
} else {
@@ -418,6 +437,12 @@ impl Buffer {
transaction_depth: 0,
was_dirty_before_starting_transaction: None,
text: buffer,
+ diff_base,
+ git_diff_status: GitDiffStatus {
+ diff: git::diff::BufferDiff::new(),
+ update_in_progress: false,
+ update_requested: false,
+ },
file,
syntax_map: Mutex::new(SyntaxMap::new()),
parsing_in_background: false,
@@ -432,6 +457,7 @@ impl Buffer {
diagnostics_update_count: 0,
diagnostics_timestamp: Default::default(),
file_update_count: 0,
+ git_diff_update_count: 0,
completion_triggers: Default::default(),
completion_triggers_timestamp: Default::default(),
deferred_ops: OperationQueue::new(),
@@ -447,11 +473,13 @@ impl Buffer {
BufferSnapshot {
text,
syntax,
+ git_diff: self.git_diff_status.diff.clone(),
file: self.file.clone(),
remote_selections: self.remote_selections.clone(),
diagnostics: self.diagnostics.clone(),
diagnostics_update_count: self.diagnostics_update_count,
file_update_count: self.file_update_count,
+ git_diff_update_count: self.git_diff_update_count,
language: self.language.clone(),
parse_count: self.parse_count,
selections_update_count: self.selections_update_count,
@@ -584,6 +612,7 @@ impl Buffer {
cx,
);
}
+ self.git_diff_recalc(cx);
cx.emit(Event::Reloaded);
cx.notify();
}
@@ -633,6 +662,55 @@ impl Buffer {
task
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn diff_base(&self) -> Option<&str> {
+ self.diff_base.as_deref()
+ }
+
+ pub fn update_diff_base(&mut self, diff_base: Option<String>, cx: &mut ModelContext<Self>) {
+ self.diff_base = diff_base;
+ self.git_diff_recalc(cx);
+ }
+
+ pub fn needs_git_diff_recalc(&self) -> bool {
+ self.git_diff_status.diff.needs_update(self)
+ }
+
+ pub fn git_diff_recalc(&mut self, cx: &mut ModelContext<Self>) {
+ if self.git_diff_status.update_in_progress {
+ self.git_diff_status.update_requested = true;
+ return;
+ }
+
+ if let Some(diff_base) = &self.diff_base {
+ let snapshot = self.snapshot();
+ let diff_base = diff_base.clone();
+
+ let mut diff = self.git_diff_status.diff.clone();
+ let diff = cx.background().spawn(async move {
+ diff.update(&diff_base, &snapshot).await;
+ diff
+ });
+
+ cx.spawn_weak(|this, mut cx| async move {
+ let buffer_diff = diff.await;
+ if let Some(this) = this.upgrade(&cx) {
+ this.update(&mut cx, |this, cx| {
+ this.git_diff_status.diff = buffer_diff;
+ this.git_diff_update_count += 1;
+ cx.notify();
+
+ this.git_diff_status.update_in_progress = false;
+ if this.git_diff_status.update_requested {
+ this.git_diff_recalc(cx);
+ }
+ })
+ }
+ })
+ .detach()
+ }
+ }
+
pub fn close(&mut self, cx: &mut ModelContext<Self>) {
cx.emit(Event::Closed);
}
@@ -657,6 +735,10 @@ impl Buffer {
self.file_update_count
}
+ pub fn git_diff_update_count(&self) -> usize {
+ self.git_diff_update_count
+ }
+
#[cfg(any(test, feature = "test-support"))]
pub fn is_parsing(&self) -> bool {
self.parsing_in_background
@@ -2139,6 +2221,13 @@ impl BufferSnapshot {
})
}
+ pub fn git_diff_hunks_in_range<'a>(
+ &'a self,
+ query_row_range: Range<u32>,
+ ) -> impl 'a + Iterator<Item = git::diff::DiffHunk<u32>> {
+ self.git_diff.hunks_in_range(query_row_range, self)
+ }
+
pub fn diagnostics_in_range<'a, T, O>(
&'a self,
search_range: Range<T>,
@@ -2186,6 +2275,10 @@ impl BufferSnapshot {
pub fn file_update_count(&self) -> usize {
self.file_update_count
}
+
+ pub fn git_diff_update_count(&self) -> usize {
+ self.git_diff_update_count
+ }
}
pub fn indent_size_for_line(text: &text::BufferSnapshot, row: u32) -> IndentSize {
@@ -2212,6 +2305,7 @@ impl Clone for BufferSnapshot {
fn clone(&self) -> Self {
Self {
text: self.text.clone(),
+ git_diff: self.git_diff.clone(),
syntax: self.syntax.clone(),
file: self.file.clone(),
remote_selections: self.remote_selections.clone(),
@@ -2219,6 +2313,7 @@ impl Clone for BufferSnapshot {
selections_update_count: self.selections_update_count,
diagnostics_update_count: self.diagnostics_update_count,
file_update_count: self.file_update_count,
+ git_diff_update_count: self.git_diff_update_count,
language: self.language.clone(),
parse_count: self.parse_count,
}
@@ -24,6 +24,7 @@ collections = { path = "../collections" }
db = { path = "../db" }
fsevent = { path = "../fsevent" }
fuzzy = { path = "../fuzzy" }
+git = { path = "../git" }
gpui = { path = "../gpui" }
language = { path = "../language" }
lsp = { path = "../lsp" }
@@ -1,8 +1,11 @@
use anyhow::{anyhow, Result};
use fsevent::EventStream;
use futures::{future::BoxFuture, Stream, StreamExt};
+use git::repository::{GitRepository, LibGitRepository};
use language::LineEnding;
+use parking_lot::Mutex as SyncMutex;
use smol::io::{AsyncReadExt, AsyncWriteExt};
+use std::sync::Arc;
use std::{
io,
os::unix::fs::MetadataExt,
@@ -11,13 +14,16 @@ use std::{
time::{Duration, SystemTime},
};
use text::Rope;
+use util::ResultExt;
#[cfg(any(test, feature = "test-support"))]
use collections::{btree_map, BTreeMap};
#[cfg(any(test, feature = "test-support"))]
use futures::lock::Mutex;
#[cfg(any(test, feature = "test-support"))]
-use std::sync::{Arc, Weak};
+use git::repository::FakeGitRepositoryState;
+#[cfg(any(test, feature = "test-support"))]
+use std::sync::Weak;
#[async_trait::async_trait]
pub trait Fs: Send + Sync {
@@ -42,6 +48,7 @@ pub trait Fs: Send + Sync {
path: &Path,
latency: Duration,
) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>>;
+ fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<SyncMutex<dyn GitRepository>>>;
fn is_fake(&self) -> bool;
#[cfg(any(test, feature = "test-support"))]
fn as_fake(&self) -> &FakeFs;
@@ -235,6 +242,14 @@ impl Fs for RealFs {
})))
}
+ fn open_repo(&self, dotgit_path: &Path) -> Option<Arc<SyncMutex<dyn GitRepository>>> {
+ LibGitRepository::open(&dotgit_path)
+ .log_err()
+ .and_then::<Arc<SyncMutex<dyn GitRepository>>, _>(|libgit_repository| {
+ Some(Arc::new(SyncMutex::new(libgit_repository)))
+ })
+ }
+
fn is_fake(&self) -> bool {
false
}
@@ -270,6 +285,7 @@ enum FakeFsEntry {
inode: u64,
mtime: SystemTime,
entries: BTreeMap<String, Arc<Mutex<FakeFsEntry>>>,
+ git_repo_state: Option<Arc<SyncMutex<git::repository::FakeGitRepositoryState>>>,
},
Symlink {
target: PathBuf,
@@ -384,6 +400,7 @@ impl FakeFs {
inode: 0,
mtime: SystemTime::now(),
entries: Default::default(),
+ git_repo_state: None,
})),
next_inode: 1,
event_txs: Default::default(),
@@ -473,6 +490,29 @@ impl FakeFs {
.boxed()
}
+ pub async fn set_index_for_repo(&self, dot_git: &Path, head_state: &[(&Path, String)]) {
+ let content_path = dot_git.parent().unwrap();
+ let mut state = self.state.lock().await;
+ let entry = state.read_path(dot_git).await.unwrap();
+ let mut entry = entry.lock().await;
+
+ if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
+ let repo_state = git_repo_state.get_or_insert_with(Default::default);
+ let mut repo_state = repo_state.lock();
+
+ repo_state.index_contents.clear();
+ repo_state.index_contents.extend(
+ head_state
+ .iter()
+ .map(|(path, content)| (content_path.join(path), content.clone())),
+ );
+
+ state.emit_event([dot_git]);
+ } else {
+ panic!("not a directory");
+ }
+ }
+
pub async fn files(&self) -> Vec<PathBuf> {
let mut result = Vec::new();
let mut queue = collections::VecDeque::new();
@@ -562,6 +602,7 @@ impl Fs for FakeFs {
inode,
mtime: SystemTime::now(),
entries: Default::default(),
+ git_repo_state: None,
}))
});
Ok(())
@@ -846,6 +887,24 @@ impl Fs for FakeFs {
}))
}
+ fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<SyncMutex<dyn GitRepository>>> {
+ smol::block_on(async move {
+ let state = self.state.lock().await;
+ let entry = state.read_path(abs_dot_git).await.unwrap();
+ let mut entry = entry.lock().await;
+ if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
+ let state = git_repo_state
+ .get_or_insert_with(|| {
+ Arc::new(SyncMutex::new(FakeGitRepositoryState::default()))
+ })
+ .clone();
+ Some(git::repository::FakeGitRepository::open(state))
+ } else {
+ None
+ }
+ })
+ }
+
fn is_fake(&self) -> bool {
true
}
@@ -8,10 +8,14 @@ pub mod worktree;
mod project_tests;
use anyhow::{anyhow, Context, Result};
-use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
+use client::{
+ proto::{self},
+ Client, PeerId, TypedEnvelope, User, UserStore,
+};
use clock::ReplicaId;
use collections::{hash_map, BTreeMap, HashMap, HashSet};
use futures::{future::Shared, AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt};
+
use gpui::{
AnyModelHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
MutableAppContext, Task, UpgradeModelHandle, WeakModelHandle,
@@ -420,6 +424,7 @@ impl Project {
client.add_model_request_handler(Self::handle_open_buffer_by_id);
client.add_model_request_handler(Self::handle_open_buffer_by_path);
client.add_model_request_handler(Self::handle_save_buffer);
+ client.add_model_message_handler(Self::handle_update_diff_base);
}
pub fn local(
@@ -4533,8 +4538,11 @@ impl Project {
fn add_worktree(&mut self, worktree: &ModelHandle<Worktree>, cx: &mut ModelContext<Self>) {
cx.observe(worktree, |_, _, cx| cx.notify()).detach();
if worktree.read(cx).is_local() {
- cx.subscribe(worktree, |this, worktree, _, cx| {
- this.update_local_worktree_buffers(worktree, cx);
+ cx.subscribe(worktree, |this, worktree, event, cx| match event {
+ worktree::Event::UpdatedEntries => this.update_local_worktree_buffers(worktree, cx),
+ worktree::Event::UpdatedGitRepositories(updated_repos) => {
+ this.update_local_worktree_buffers_git_repos(worktree, updated_repos, cx)
+ }
})
.detach();
}
@@ -4642,6 +4650,58 @@ impl Project {
}
}
+ fn update_local_worktree_buffers_git_repos(
+ &mut self,
+ worktree: ModelHandle<Worktree>,
+ repos: &[GitRepositoryEntry],
+ cx: &mut ModelContext<Self>,
+ ) {
+ for (_, buffer) in &self.opened_buffers {
+ if let Some(buffer) = buffer.upgrade(cx) {
+ let file = match File::from_dyn(buffer.read(cx).file()) {
+ Some(file) => file,
+ None => continue,
+ };
+ if file.worktree != worktree {
+ continue;
+ }
+
+ let path = file.path().clone();
+
+ let repo = match repos.iter().find(|repo| repo.manages(&path)) {
+ Some(repo) => repo.clone(),
+ None => return,
+ };
+
+ let shared_remote_id = self.shared_remote_id();
+ let client = self.client.clone();
+
+ cx.spawn(|_, mut cx| async move {
+ let diff_base = cx
+ .background()
+ .spawn(async move { repo.repo.lock().load_index(&path) })
+ .await;
+
+ let buffer_id = buffer.update(&mut cx, |buffer, cx| {
+ buffer.update_diff_base(diff_base.clone(), cx);
+ buffer.remote_id()
+ });
+
+ if let Some(project_id) = shared_remote_id {
+ client
+ .send(proto::UpdateDiffBase {
+ project_id,
+ buffer_id: buffer_id as u64,
+ diff_base,
+ })
+ .log_err();
+ }
+ })
+ .detach();
+ }
+ }
+ }
+
pub fn set_active_path(&mut self, entry: Option<ProjectPath>, cx: &mut ModelContext<Self>) {
let new_active_entry = entry.and_then(|project_path| {
let worktree = self.worktree_for_id(project_path.worktree_id, cx)?;
@@ -5214,6 +5274,27 @@ impl Project {
})
}
+ async fn handle_update_diff_base(
+ this: ModelHandle<Self>,
+ envelope: TypedEnvelope<proto::UpdateDiffBase>,
+ _: Arc<Client>,
+ mut cx: AsyncAppContext,
+ ) -> Result<()> {
+ this.update(&mut cx, |this, cx| {
+ let buffer_id = envelope.payload.buffer_id;
+ let diff_base = envelope.payload.diff_base;
+ let buffer = this
+ .opened_buffers
+ .get_mut(&buffer_id)
+ .and_then(|b| b.upgrade(cx))
+ .ok_or_else(|| anyhow!("No such buffer {}", buffer_id))?;
+
+ buffer.update(cx, |buffer, cx| buffer.update_diff_base(diff_base, cx));
+
+ Ok(())
+ })
+ }
+
async fn handle_update_buffer_file(
this: ModelHandle<Self>,
envelope: TypedEnvelope<proto::UpdateBufferFile>,
@@ -5780,7 +5861,7 @@ impl Project {
cx: &mut ModelContext<Self>,
) -> Task<Result<ModelHandle<Buffer>>> {
let mut opened_buffer_rx = self.opened_buffer.1.clone();
- cx.spawn(|this, cx| async move {
+ cx.spawn(|this, mut cx| async move {
let buffer = loop {
let buffer = this.read_with(&cx, |this, cx| {
this.opened_buffers
@@ -5798,6 +5879,7 @@ impl Project {
.await
.ok_or_else(|| anyhow!("project dropped while waiting for buffer"))?;
};
+ buffer.update(&mut cx, |buffer, cx| buffer.git_diff_recalc(cx));
Ok(buffer)
})
}
@@ -1,10 +1,9 @@
-use crate::{copy_recursive, ProjectEntryId, RemoveOptions};
-
use super::{
fs::{self, Fs},
ignore::IgnoreStack,
DiagnosticSummary,
};
+use crate::{copy_recursive, ProjectEntryId, RemoveOptions};
use ::ignore::gitignore::{Gitignore, GitignoreBuilder};
use anyhow::{anyhow, Context, Result};
use client::{proto, Client};
@@ -18,6 +17,8 @@ use futures::{
Stream, StreamExt,
};
use fuzzy::CharBag;
+use git::repository::GitRepository;
+use git::{DOT_GIT, GITIGNORE};
use gpui::{
executor, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext,
Task,
@@ -26,12 +27,12 @@ use language::{
proto::{deserialize_version, serialize_line_ending, serialize_version},
Buffer, DiagnosticEntry, LineEnding, PointUtf16, Rope,
};
-use lazy_static::lazy_static;
use parking_lot::Mutex;
use postage::{
prelude::{Sink as _, Stream as _},
watch,
};
+
use smol::channel::{self, Sender};
use std::{
any::Any,
@@ -40,6 +41,7 @@ use std::{
ffi::{OsStr, OsString},
fmt,
future::Future,
+ mem,
ops::{Deref, DerefMut},
os::unix::prelude::{OsStrExt, OsStringExt},
path::{Path, PathBuf},
@@ -50,10 +52,6 @@ use std::{
use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet};
use util::{ResultExt, TryFutureExt};
-lazy_static! {
- static ref GITIGNORE: &'static OsStr = OsStr::new(".gitignore");
-}
-
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
pub struct WorktreeId(usize);
@@ -101,15 +99,51 @@ pub struct Snapshot {
}
#[derive(Clone)]
+pub struct GitRepositoryEntry {
+ pub(crate) repo: Arc<Mutex<dyn GitRepository>>,
+
+ pub(crate) scan_id: usize,
+ // Path to folder containing the .git file or directory
+ pub(crate) content_path: Arc<Path>,
+ // Path to the actual .git folder.
+ // Note: if .git is a file, this points to the folder indicated by the .git file
+ pub(crate) git_dir_path: Arc<Path>,
+}
+
+impl std::fmt::Debug for GitRepositoryEntry {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("GitRepositoryEntry")
+ .field("content_path", &self.content_path)
+ .field("git_dir_path", &self.git_dir_path)
+ .field("libgit_repository", &"LibGitRepository")
+ .finish()
+ }
+}
+
pub struct LocalSnapshot {
abs_path: Arc<Path>,
ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, usize)>,
+ git_repositories: Vec<GitRepositoryEntry>,
removed_entry_ids: HashMap<u64, ProjectEntryId>,
next_entry_id: Arc<AtomicUsize>,
snapshot: Snapshot,
extension_counts: HashMap<OsString, usize>,
}
+impl Clone for LocalSnapshot {
+ fn clone(&self) -> Self {
+ Self {
+ abs_path: self.abs_path.clone(),
+ ignores_by_parent_abs_path: self.ignores_by_parent_abs_path.clone(),
+ git_repositories: self.git_repositories.iter().cloned().collect(),
+ removed_entry_ids: self.removed_entry_ids.clone(),
+ next_entry_id: self.next_entry_id.clone(),
+ snapshot: self.snapshot.clone(),
+ extension_counts: self.extension_counts.clone(),
+ }
+ }
+}
+
impl Deref for LocalSnapshot {
type Target = Snapshot;
@@ -142,6 +176,7 @@ struct ShareState {
pub enum Event {
UpdatedEntries,
+ UpdatedGitRepositories(Vec<GitRepositoryEntry>),
}
impl Entity for Worktree {
@@ -372,6 +407,7 @@ impl LocalWorktree {
let mut snapshot = LocalSnapshot {
abs_path,
ignores_by_parent_abs_path: Default::default(),
+ git_repositories: Default::default(),
removed_entry_ids: Default::default(),
next_entry_id,
snapshot: Snapshot {
@@ -446,10 +482,14 @@ impl LocalWorktree {
) -> Task<Result<ModelHandle<Buffer>>> {
let path = Arc::from(path);
cx.spawn(move |this, mut cx| async move {
- let (file, contents) = this
+ let (file, contents, diff_base) = this
.update(&mut cx, |t, cx| t.as_local().unwrap().load(&path, cx))
.await?;
- Ok(cx.add_model(|cx| Buffer::from_file(0, contents, Arc::new(file), cx)))
+ Ok(cx.add_model(|cx| {
+ let mut buffer = Buffer::from_file(0, contents, diff_base, Arc::new(file), cx);
+ buffer.git_diff_recalc(cx);
+ buffer
+ }))
})
}
@@ -499,17 +539,37 @@ impl LocalWorktree {
fn poll_snapshot(&mut self, force: bool, cx: &mut ModelContext<Worktree>) {
self.poll_task.take();
+
match self.scan_state() {
ScanState::Idle => {
- self.snapshot = self.background_snapshot.lock().clone();
+ let new_snapshot = self.background_snapshot.lock().clone();
+ let updated_repos = Self::changed_repos(
+ &self.snapshot.git_repositories,
+ &new_snapshot.git_repositories,
+ );
+ self.snapshot = new_snapshot;
+
if let Some(share) = self.share.as_mut() {
*share.snapshots_tx.borrow_mut() = self.snapshot.clone();
}
+
cx.emit(Event::UpdatedEntries);
+
+ if !updated_repos.is_empty() {
+ cx.emit(Event::UpdatedGitRepositories(updated_repos));
+ }
}
+
ScanState::Initializing => {
let is_fake_fs = self.fs.is_fake();
- self.snapshot = self.background_snapshot.lock().clone();
+
+ let new_snapshot = self.background_snapshot.lock().clone();
+ let updated_repos = Self::changed_repos(
+ &self.snapshot.git_repositories,
+ &new_snapshot.git_repositories,
+ );
+ self.snapshot = new_snapshot;
+
self.poll_task = Some(cx.spawn_weak(|this, mut cx| async move {
if is_fake_fs {
#[cfg(any(test, feature = "test-support"))]
@@ -521,17 +581,52 @@ impl LocalWorktree {
this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
}
}));
+
cx.emit(Event::UpdatedEntries);
+
+ if !updated_repos.is_empty() {
+ cx.emit(Event::UpdatedGitRepositories(updated_repos));
+ }
}
+
_ => {
if force {
self.snapshot = self.background_snapshot.lock().clone();
}
}
}
+
cx.notify();
}
+ fn changed_repos(
+ old_repos: &[GitRepositoryEntry],
+ new_repos: &[GitRepositoryEntry],
+ ) -> Vec<GitRepositoryEntry> {
+ fn diff<'a>(
+ a: &'a [GitRepositoryEntry],
+ b: &'a [GitRepositoryEntry],
+ updated: &mut HashMap<&'a Path, GitRepositoryEntry>,
+ ) {
+ for a_repo in a {
+ let matched = b.iter().find(|b_repo| {
+ a_repo.git_dir_path == b_repo.git_dir_path && a_repo.scan_id == b_repo.scan_id
+ });
+
+ if matched.is_none() {
+ updated.insert(a_repo.git_dir_path.as_ref(), a_repo.clone());
+ }
+ }
+ }
+
+ let mut updated = HashMap::<&Path, GitRepositoryEntry>::default();
+
+ diff(old_repos, new_repos, &mut updated);
+ diff(new_repos, old_repos, &mut updated);
+
+ updated.into_values().collect()
+ }
+
pub fn scan_complete(&self) -> impl Future<Output = ()> {
let mut scan_state_rx = self.last_scan_state_rx.clone();
async move {
@@ -558,13 +653,31 @@ impl LocalWorktree {
}
}
- fn load(&self, path: &Path, cx: &mut ModelContext<Worktree>) -> Task<Result<(File, String)>> {
+ fn load(
+ &self,
+ path: &Path,
+ cx: &mut ModelContext<Worktree>,
+ ) -> Task<Result<(File, String, Option<String>)>> {
let handle = cx.handle();
let path = Arc::from(path);
let abs_path = self.absolutize(&path);
let fs = self.fs.clone();
+ let snapshot = self.snapshot();
+
cx.spawn(|this, mut cx| async move {
let text = fs.load(&abs_path).await?;
+
+ let diff_base = if let Some(repo) = snapshot.repo_for(&abs_path) {
+ cx.background()
+ .spawn({
+ let path = path.clone();
+ async move { repo.repo.lock().load_index(&path) }
+ })
+ .await
+ } else {
+ None
+ };
+
// Eagerly populate the snapshot with an updated entry for the loaded file
let entry = this
.update(&mut cx, |this, cx| {
@@ -573,6 +686,7 @@ impl LocalWorktree {
.refresh_entry(path, abs_path, None, cx)
})
.await?;
+
Ok((
File {
entry_id: Some(entry.id),
@@ -582,6 +696,7 @@ impl LocalWorktree {
is_local: true,
},
text,
+ diff_base,
))
})
}
@@ -1248,6 +1363,22 @@ impl LocalSnapshot {
&self.extension_counts
}
+ // Gives the most specific git repository for a given path
+ pub(crate) fn repo_for(&self, path: &Path) -> Option<GitRepositoryEntry> {
+ self.git_repositories
+ .iter()
+ .rev() //git_repository is ordered lexicographically
+ .find(|repo| repo.manages(path))
+ .cloned()
+ }
+
+ pub(crate) fn in_dot_git(&mut self, path: &Path) -> Option<&mut GitRepositoryEntry> {
+ // Git repositories cannot be nested, so we don't need to reverse the order
+ self.git_repositories
+ .iter_mut()
+ .find(|repo| repo.in_dot_git(path))
+ }
+
#[cfg(test)]
pub(crate) fn build_initial_update(&self, project_id: u64) -> proto::UpdateWorktree {
let root_name = self.root_name.clone();
@@ -1330,7 +1461,7 @@ impl LocalSnapshot {
}
fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry {
- if !entry.is_dir() && entry.path.file_name() == Some(&GITIGNORE) {
+ if entry.is_file() && entry.path.file_name() == Some(&GITIGNORE) {
let abs_path = self.abs_path.join(&entry.path);
match smol::block_on(build_gitignore(&abs_path, fs)) {
Ok(ignore) => {
@@ -1384,6 +1515,7 @@ impl LocalSnapshot {
parent_path: Arc<Path>,
entries: impl IntoIterator<Item = Entry>,
ignore: Option<Arc<Gitignore>>,
+ fs: &dyn Fs,
) {
let mut parent_entry = if let Some(parent_entry) =
self.entries_by_path.get(&PathKey(parent_path.clone()), &())
@@ -1409,6 +1541,27 @@ impl LocalSnapshot {
unreachable!();
}
+ if parent_path.file_name() == Some(&DOT_GIT) {
+ let abs_path = self.abs_path.join(&parent_path);
+ let content_path: Arc<Path> = parent_path.parent().unwrap().into();
+ if let Err(ix) = self
+ .git_repositories
+ .binary_search_by_key(&&content_path, |repo| &repo.content_path)
+ {
+ if let Some(repo) = fs.open_repo(abs_path.as_path()) {
+ self.git_repositories.insert(
+ ix,
+ GitRepositoryEntry {
+ repo,
+ scan_id: 0,
+ content_path,
+ git_dir_path: parent_path,
+ },
+ );
+ }
+ }
+ }
+
let mut entries_by_path_edits = vec![Edit::Insert(parent_entry)];
let mut entries_by_id_edits = Vec::new();
@@ -1493,6 +1646,14 @@ impl LocalSnapshot {
{
*scan_id = self.snapshot.scan_id;
}
+ } else if path.file_name() == Some(&DOT_GIT) {
+ let parent_path = path.parent().unwrap();
+ if let Ok(ix) = self
+ .git_repositories
+ .binary_search_by_key(&parent_path, |repo| repo.git_dir_path.as_ref())
+ {
+ self.git_repositories[ix].scan_id = self.snapshot.scan_id;
+ }
}
}
@@ -1532,6 +1693,22 @@ impl LocalSnapshot {
ignore_stack
}
+
+ pub fn git_repo_entries(&self) -> &[GitRepositoryEntry] {
+ &self.git_repositories
+ }
+}
+
+impl GitRepositoryEntry {
+ // Note that these paths should be relative to the worktree root.
+ pub(crate) fn manages(&self, path: &Path) -> bool {
+ path.starts_with(self.content_path.as_ref())
+ }
+
+ // Note that theis path should be relative to the worktree root.
+ pub(crate) fn in_dot_git(&self, path: &Path) -> bool {
+ path.starts_with(self.git_dir_path.as_ref())
+ }
}
async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result<Gitignore> {
@@ -2244,9 +2421,12 @@ impl BackgroundScanner {
new_entries.push(child_entry);
}
- self.snapshot
- .lock()
- .populate_dir(job.path.clone(), new_entries, new_ignore);
+ self.snapshot.lock().populate_dir(
+ job.path.clone(),
+ new_entries,
+ new_ignore,
+ self.fs.as_ref(),
+ );
for new_job in new_jobs {
job.scan_queue.send(new_job).await.unwrap();
}
@@ -2321,6 +2501,11 @@ impl BackgroundScanner {
fs_entry.is_ignored = ignore_stack.is_all();
snapshot.insert_entry(fs_entry, self.fs.as_ref());
+ let scan_id = snapshot.scan_id;
+ if let Some(repo) = snapshot.in_dot_git(&path) {
+ repo.scan_id = scan_id;
+ }
+
let mut ancestor_inodes = snapshot.ancestor_inodes_for_path(&path);
if metadata.is_dir && !ancestor_inodes.contains(&metadata.inode) {
ancestor_inodes.insert(metadata.inode);
@@ -2367,6 +2552,7 @@ impl BackgroundScanner {
self.snapshot.lock().removed_entry_ids.clear();
self.update_ignore_statuses().await;
+ self.update_git_repositories();
true
}
@@ -2432,6 +2618,13 @@ impl BackgroundScanner {
.await;
}
+ fn update_git_repositories(&self) {
+ let mut snapshot = self.snapshot.lock();
+ let mut git_repositories = mem::take(&mut snapshot.git_repositories);
+ git_repositories.retain(|repo| snapshot.entry_for_path(&repo.git_dir_path).is_some());
+ snapshot.git_repositories = git_repositories;
+ }
+
async fn update_ignore_status(&self, job: UpdateIgnoreStatusJob, snapshot: &LocalSnapshot) {
let mut ignore_stack = job.ignore_stack;
if let Some((ignore, _)) = snapshot.ignores_by_parent_abs_path.get(&job.abs_path) {
@@ -2778,6 +2971,7 @@ mod tests {
use anyhow::Result;
use client::test::FakeHttpClient;
use fs::RealFs;
+ use git::repository::FakeGitRepository;
use gpui::{executor::Deterministic, TestAppContext};
use rand::prelude::*;
use serde_json::json;
@@ -2786,6 +2980,7 @@ mod tests {
fmt::Write,
time::{SystemTime, UNIX_EPOCH},
};
+
use util::test::temp_tree;
#[gpui::test]
@@ -3005,6 +3200,135 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_git_repository_for_path(cx: &mut TestAppContext) {
+ let root = temp_tree(json!({
+ "dir1": {
+ ".git": {},
+ "deps": {
+ "dep1": {
+ ".git": {},
+ "src": {
+ "a.txt": ""
+ }
+ }
+ },
+ "src": {
+ "b.txt": ""
+ }
+ },
+ "c.txt": "",
+
+ }));
+
+ let http_client = FakeHttpClient::with_404_response();
+ let client = cx.read(|cx| Client::new(http_client, cx));
+ let tree = Worktree::local(
+ client,
+ root.path(),
+ true,
+ Arc::new(RealFs),
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+ tree.flush_fs_events(cx).await;
+
+ tree.read_with(cx, |tree, _cx| {
+ let tree = tree.as_local().unwrap();
+
+ assert!(tree.repo_for("c.txt".as_ref()).is_none());
+
+ let repo = tree.repo_for("dir1/src/b.txt".as_ref()).unwrap();
+ assert_eq!(repo.content_path.as_ref(), Path::new("dir1"));
+ assert_eq!(repo.git_dir_path.as_ref(), Path::new("dir1/.git"));
+
+ let repo = tree.repo_for("dir1/deps/dep1/src/a.txt".as_ref()).unwrap();
+ assert_eq!(repo.content_path.as_ref(), Path::new("dir1/deps/dep1"));
+ assert_eq!(repo.git_dir_path.as_ref(), Path::new("dir1/deps/dep1/.git"),);
+ });
+
+ let original_scan_id = tree.read_with(cx, |tree, _cx| {
+ let tree = tree.as_local().unwrap();
+ tree.repo_for("dir1/src/b.txt".as_ref()).unwrap().scan_id
+ });
+
+ std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap();
+ tree.flush_fs_events(cx).await;
+
+ tree.read_with(cx, |tree, _cx| {
+ let tree = tree.as_local().unwrap();
+ let new_scan_id = tree.repo_for("dir1/src/b.txt".as_ref()).unwrap().scan_id;
+ assert_ne!(
+ original_scan_id, new_scan_id,
+ "original {original_scan_id}, new {new_scan_id}"
+ );
+ });
+
+ std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap();
+ tree.flush_fs_events(cx).await;
+
+ tree.read_with(cx, |tree, _cx| {
+ let tree = tree.as_local().unwrap();
+
+ assert!(tree.repo_for("dir1/src/b.txt".as_ref()).is_none());
+ });
+ }
+
+ #[test]
+ fn test_changed_repos() {
+ fn fake_entry(git_dir_path: impl AsRef<Path>, scan_id: usize) -> GitRepositoryEntry {
+ GitRepositoryEntry {
+ repo: Arc::new(Mutex::new(FakeGitRepository::default())),
+ scan_id,
+ content_path: git_dir_path.as_ref().parent().unwrap().into(),
+ git_dir_path: git_dir_path.as_ref().into(),
+ }
+ }
+
+ let prev_repos: Vec<GitRepositoryEntry> = vec![
+ fake_entry("/.git", 0),
+ fake_entry("/a/.git", 0),
+ fake_entry("/a/b/.git", 0),
+ ];
+
+ let new_repos: Vec<GitRepositoryEntry> = vec![
+ fake_entry("/a/.git", 1),
+ fake_entry("/a/b/.git", 0),
+ fake_entry("/a/c/.git", 0),
+ ];
+
+ let res = LocalWorktree::changed_repos(&prev_repos, &new_repos);
+
+ // Deletion retained
+ assert!(res
+ .iter()
+ .find(|repo| repo.git_dir_path.as_ref() == Path::new("/.git") && repo.scan_id == 0)
+ .is_some());
+
+ // Update retained
+ assert!(res
+ .iter()
+ .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/.git") && repo.scan_id == 1)
+ .is_some());
+
+ // Addition retained
+ assert!(res
+ .iter()
+ .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/c/.git") && repo.scan_id == 0)
+ .is_some());
+
+ // Nochange, not retained
+ assert!(res
+ .iter()
+ .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/b/.git") && repo.scan_id == 0)
+ .is_none());
+ }
+
#[gpui::test]
async fn test_write_file(cx: &mut TestAppContext) {
let dir = temp_tree(json!({
@@ -3123,6 +3447,7 @@ mod tests {
abs_path: root_dir.path().into(),
removed_entry_ids: Default::default(),
ignores_by_parent_abs_path: Default::default(),
+ git_repositories: Default::default(),
next_entry_id: next_entry_id.clone(),
snapshot: Snapshot {
id: WorktreeId::from_usize(0),
@@ -108,9 +108,9 @@ message Envelope {
FollowResponse follow_response = 93;
UpdateFollowers update_followers = 94;
Unfollow unfollow = 95;
-
GetPrivateUserInfo get_private_user_info = 96;
GetPrivateUserInfoResponse get_private_user_info_response = 97;
+ UpdateDiffBase update_diff_base = 98;
}
}
@@ -831,7 +831,8 @@ message BufferState {
uint64 id = 1;
optional File file = 2;
string base_text = 3;
- LineEnding line_ending = 4;
+ optional string diff_base = 4;
+ LineEnding line_ending = 5;
}
message BufferChunk {
@@ -1001,3 +1002,9 @@ message WorktreeMetadata {
string root_name = 2;
bool visible = 3;
}
+
+message UpdateDiffBase {
+ uint64 project_id = 1;
+ uint64 buffer_id = 2;
+ optional string diff_base = 3;
+}
@@ -167,6 +167,7 @@ messages!(
(UpdateProject, Foreground),
(UpdateWorktree, Foreground),
(UpdateWorktreeExtensions, Background),
+ (UpdateDiffBase, Background),
(GetPrivateUserInfo, Foreground),
(GetPrivateUserInfoResponse, Foreground),
);
@@ -266,6 +267,7 @@ entity_messages!(
UpdateProject,
UpdateWorktree,
UpdateWorktreeExtensions,
+ UpdateDiffBase
);
entity_messages!(channel_id, ChannelMessageSent);
@@ -6,4 +6,4 @@ pub use conn::Connection;
pub use peer::*;
mod macros;
-pub const PROTOCOL_VERSION: u32 = 32;
+pub const PROTOCOL_VERSION: u32 = 34;
@@ -32,6 +32,8 @@ pub struct Settings {
pub default_dock_anchor: DockAnchor,
pub editor_defaults: EditorSettings,
pub editor_overrides: EditorSettings,
+ pub git: GitSettings,
+ pub git_overrides: GitSettings,
pub terminal_defaults: TerminalSettings,
pub terminal_overrides: TerminalSettings,
pub language_defaults: HashMap<Arc<str>, EditorSettings>,
@@ -52,6 +54,22 @@ impl FeatureFlags {
}
}
+#[derive(Copy, Clone, Debug, Default, Deserialize, JsonSchema)]
+pub struct GitSettings {
+ pub git_gutter: Option<GitGutter>,
+ pub gutter_debounce: Option<u64>,
+}
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum GitGutter {
+ #[default]
+ TrackedFiles,
+ Hide,
+}
+
+pub struct GitGutterConfig {}
+
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
pub struct EditorSettings {
pub tab_size: Option<NonZeroU32>,
@@ -196,6 +214,8 @@ pub struct SettingsFileContent {
#[serde(default)]
pub terminal: TerminalSettings,
#[serde(default)]
+ pub git: Option<GitSettings>,
+ #[serde(default)]
#[serde(alias = "language_overrides")]
pub languages: HashMap<Arc<str>, EditorSettings>,
#[serde(default)]
@@ -252,6 +272,8 @@ impl Settings {
enable_language_server: required(defaults.editor.enable_language_server),
},
editor_overrides: Default::default(),
+ git: defaults.git.unwrap(),
+ git_overrides: Default::default(),
terminal_defaults: Default::default(),
terminal_overrides: Default::default(),
language_defaults: defaults.languages,
@@ -303,6 +325,7 @@ impl Settings {
}
self.editor_overrides = data.editor;
+ self.git_overrides = data.git.unwrap_or_default();
self.terminal_defaults.font_size = data.terminal.font_size;
self.terminal_overrides = data.terminal;
self.language_overrides = data.languages;
@@ -358,6 +381,14 @@ impl Settings {
.expect("missing default")
}
+ pub fn git_gutter(&self) -> GitGutter {
+ self.git_overrides.git_gutter.unwrap_or_else(|| {
+ self.git
+ .git_gutter
+ .expect("git_gutter should be some by setting setup")
+ })
+ }
+
#[cfg(any(test, feature = "test-support"))]
pub fn test(cx: &gpui::AppContext) -> Settings {
Settings {
@@ -382,6 +413,8 @@ impl Settings {
editor_overrides: Default::default(),
terminal_defaults: Default::default(),
terminal_overrides: Default::default(),
+ git: Default::default(),
+ git_overrides: Default::default(),
language_defaults: Default::default(),
language_overrides: Default::default(),
lsp: Default::default(),
@@ -101,6 +101,12 @@ pub enum Bias {
Right,
}
+impl Default for Bias {
+ fn default() -> Self {
+ Bias::Left
+ }
+}
+
impl PartialOrd for Bias {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
@@ -4,7 +4,7 @@ use anyhow::Result;
use std::{cmp::Ordering, fmt::Debug, ops::Range};
use sum_tree::Bias;
-#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash)]
+#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash, Default)]
pub struct Anchor {
pub timestamp: clock::Local,
pub offset: usize,
@@ -54,6 +54,13 @@ impl Rope {
cursor.slice(range.end)
}
+ pub fn slice_rows(&self, range: Range<u32>) -> Rope {
+ //This would be more efficient with a forward advance after the first, but it's fine
+ let start = self.point_to_offset(Point::new(range.start, 0));
+ let end = self.point_to_offset(Point::new(range.end, 0));
+ self.slice(start..end)
+ }
+
pub fn push(&mut self, text: &str) {
let mut new_chunks = SmallVec::<[_; 16]>::new();
let mut new_chunk = ArrayString::new();
@@ -488,8 +488,7 @@ pub struct Editor {
pub rename_fade: f32,
pub document_highlight_read_background: Color,
pub document_highlight_write_background: Color,
- pub diff_background_deleted: Color,
- pub diff_background_inserted: Color,
+ pub diff: DiffStyle,
pub line_number: Color,
pub line_number_active: Color,
pub guest_selections: Vec<SelectionStyle>,
@@ -573,6 +572,16 @@ pub struct CodeActions {
pub vertical_scale: f32,
}
+#[derive(Clone, Deserialize, Default)]
+pub struct DiffStyle {
+ pub inserted: Color,
+ pub modified: Color,
+ pub deleted: Color,
+ pub removed_width_em: f32,
+ pub width_em: f32,
+ pub corner_radius: f32,
+}
+
#[derive(Debug, Default, Clone, Copy)]
pub struct Interactive<T> {
pub default: T,
@@ -7,17 +7,21 @@ edition = "2021"
doctest = false
[features]
-test-support = ["rand", "serde_json", "tempdir"]
+test-support = ["rand", "serde_json", "tempdir", "git2"]
[dependencies]
anyhow = "1.0.38"
futures = "0.3"
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
+lazy_static = "1.4.0"
rand = { version = "0.8", optional = true }
tempdir = { version = "0.3.7", optional = true }
serde_json = { version = "1.0", features = ["preserve_order"], optional = true }
+git2 = { version = "0.15", default-features = false, optional = true }
+
[dev-dependencies]
rand = { version = "0.8" }
tempdir = { version = "0.3.7" }
serde_json = { version = "1.0", features = ["preserve_order"] }
+git2 = { version = "0.15", default-features = false }
@@ -1,7 +1,11 @@
mod assertions;
mod marked_text;
-use std::path::{Path, PathBuf};
+use git2;
+use std::{
+ ffi::OsStr,
+ path::{Path, PathBuf},
+};
use tempdir::TempDir;
pub use assertions::*;
@@ -24,6 +28,11 @@ fn write_tree(path: &Path, tree: serde_json::Value) {
match contents {
Value::Object(_) => {
fs::create_dir(&path).unwrap();
+
+ if path.file_name() == Some(&OsStr::new(".git")) {
+ git2::Repository::init(&path.parent().unwrap()).unwrap();
+ }
+
write_tree(&path, contents);
}
Value::Null => {
@@ -52,7 +52,6 @@ use std::{
cell::RefCell,
fmt,
future::Future,
- mem,
ops::Range,
path::{Path, PathBuf},
rc::Rc,
@@ -318,7 +317,23 @@ pub trait Item: View {
project: ModelHandle<Project>,
cx: &mut ViewContext<Self>,
) -> Task<Result<()>>;
+ fn git_diff_recalc(
+ &mut self,
+ _project: ModelHandle<Project>,
+ _cx: &mut ViewContext<Self>,
+ ) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
fn to_item_events(event: &Self::Event) -> Vec<ItemEvent>;
+ fn should_close_item_on_event(_: &Self::Event) -> bool {
+ false
+ }
+ fn should_update_tab_on_event(_: &Self::Event) -> bool {
+ false
+ }
+ fn is_edit_event(_: &Self::Event) -> bool {
+ false
+ }
fn act_as_type(
&self,
type_id: TypeId,
@@ -435,6 +450,57 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
}
}
+struct DelayedDebouncedEditAction {
+ task: Option<Task<()>>,
+ cancel_channel: Option<oneshot::Sender<()>>,
+}
+
+impl DelayedDebouncedEditAction {
+ fn new() -> DelayedDebouncedEditAction {
+ DelayedDebouncedEditAction {
+ task: None,
+ cancel_channel: None,
+ }
+ }
+
+ fn fire_new<F, Fut>(
+ &mut self,
+ delay: Duration,
+ workspace: &Workspace,
+ cx: &mut ViewContext<Workspace>,
+ f: F,
+ ) where
+ F: FnOnce(ModelHandle<Project>, AsyncAppContext) -> Fut + 'static,
+ Fut: 'static + Future<Output = ()>,
+ {
+ if let Some(channel) = self.cancel_channel.take() {
+ _ = channel.send(());
+ }
+
+ let project = workspace.project().downgrade();
+
+ let (sender, mut receiver) = oneshot::channel::<()>();
+ self.cancel_channel = Some(sender);
+
+ let previous_task = self.task.take();
+ self.task = Some(cx.spawn_weak(|_, cx| async move {
+ let mut timer = cx.background().timer(delay).fuse();
+ if let Some(previous_task) = previous_task {
+ previous_task.await;
+ }
+
+ futures::select_biased! {
+ _ = receiver => return,
+ _ = timer => {}
+ }
+
+ if let Some(project) = project.upgrade(&cx) {
+ (f)(project, cx).await;
+ }
+ }));
+ }
+}
+
pub trait ItemHandle: 'static + fmt::Debug {
fn subscribe_to_item_events(
&self,
@@ -473,6 +539,11 @@ pub trait ItemHandle: 'static + fmt::Debug {
) -> Task<Result<()>>;
fn reload(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext)
-> Task<Result<()>>;
+ fn git_diff_recalc(
+ &self,
+ project: ModelHandle<Project>,
+ cx: &mut MutableAppContext,
+ ) -> Task<Result<()>>;
fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle>;
fn to_followable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn FollowableItemHandle>>;
fn on_release(
@@ -578,8 +649,8 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
.insert(self.id(), pane.downgrade())
.is_none()
{
- let mut pending_autosave = None;
- let mut cancel_pending_autosave = oneshot::channel::<()>().0;
+ let mut pending_autosave = DelayedDebouncedEditAction::new();
+ let mut pending_git_update = DelayedDebouncedEditAction::new();
let pending_update = Rc::new(RefCell::new(None));
let pending_update_scheduled = Rc::new(AtomicBool::new(false));
@@ -637,45 +708,66 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
.detach_and_log_err(cx);
return;
}
+
ItemEvent::UpdateTab => {
pane.update(cx, |_, cx| {
cx.emit(pane::Event::ChangeItemTitle);
cx.notify();
});
}
+
ItemEvent::Edit => {
if let Autosave::AfterDelay { milliseconds } =
cx.global::<Settings>().autosave
{
- let prev_autosave = pending_autosave
- .take()
- .unwrap_or_else(|| Task::ready(Some(())));
- let (cancel_tx, mut cancel_rx) = oneshot::channel::<()>();
- let prev_cancel_tx =
- mem::replace(&mut cancel_pending_autosave, cancel_tx);
- let project = workspace.project.downgrade();
- let _ = prev_cancel_tx.send(());
+ let delay = Duration::from_millis(milliseconds);
let item = item.clone();
- pending_autosave =
- Some(cx.spawn_weak(|_, mut cx| async move {
- let mut timer = cx
- .background()
- .timer(Duration::from_millis(milliseconds))
- .fuse();
- prev_autosave.await;
- futures::select_biased! {
- _ = cancel_rx => return None,
- _ = timer => {}
- }
-
- let project = project.upgrade(&cx)?;
+ pending_autosave.fire_new(
+ delay,
+ workspace,
+ cx,
+ |project, mut cx| async move {
cx.update(|cx| Pane::autosave_item(&item, project, cx))
.await
.log_err();
- None
- }));
+ },
+ );
+ }
+
+ let settings = cx.global::<Settings>();
+ let debounce_delay = settings.git_overrides.gutter_debounce;
+
+ let item = item.clone();
+
+ if let Some(delay) = debounce_delay {
+ const MIN_GIT_DELAY: u64 = 50;
+
+ let delay = delay.max(MIN_GIT_DELAY);
+ let duration = Duration::from_millis(delay);
+
+ pending_git_update.fire_new(
+ duration,
+ workspace,
+ cx,
+ |project, mut cx| async move {
+ cx.update(|cx| item.git_diff_recalc(project, cx))
+ .await
+ .log_err();
+ },
+ );
+ } else {
+ let project = workspace.project().downgrade();
+ cx.spawn_weak(|_, mut cx| async move {
+ if let Some(project) = project.upgrade(&cx) {
+ cx.update(|cx| item.git_diff_recalc(project, cx))
+ .await
+ .log_err();
+ }
+ })
+ .detach();
}
}
+
_ => {}
}
}
@@ -755,6 +847,14 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
self.update(cx, |item, cx| item.reload(project, cx))
}
+ fn git_diff_recalc(
+ &self,
+ project: ModelHandle<Project>,
+ cx: &mut MutableAppContext,
+ ) -> Task<Result<()>> {
+ self.update(cx, |item, cx| item.git_diff_recalc(project, cx))
+ }
+
fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle> {
self.read(cx).act_as_type(type_id, self, cx)
}
@@ -7,6 +7,7 @@ import {
player,
popoverShadow,
text,
+ textColor,
TextColor,
} from "./components";
import hoverPopover from "./hoverPopover";
@@ -59,8 +60,14 @@ export default function editor(theme: Theme) {
indicator: iconColor(theme, "secondary"),
verticalScale: 0.618
},
- diffBackgroundDeleted: backgroundColor(theme, "error"),
- diffBackgroundInserted: backgroundColor(theme, "ok"),
+ diff: {
+ deleted: theme.iconColor.error,
+ inserted: theme.iconColor.ok,
+ modified: theme.iconColor.warning,
+ removedWidthEm: 0.275,
+ widthEm: 0.16,
+ cornerRadius: 0.05,
+ },
documentHighlightReadBackground: theme.editor.highlight.occurrence,
documentHighlightWriteBackground: theme.editor.highlight.activeOccurrence,
errorColor: theme.textColor.error,
@@ -113,6 +113,11 @@ export function createTheme(
hovered: sample(ramps.blue, 0.1),
active: sample(ramps.blue, 0.15),
},
+ on500Ok: {
+ base: sample(ramps.green, 0.05),
+ hovered: sample(ramps.green, 0.1),
+ active: sample(ramps.green, 0.15)
+ }
};
const borderColor = {
@@ -78,6 +78,7 @@ export default interface Theme {
// Hacks for elements on top of the editor
on500: BackgroundColorSet;
ok: BackgroundColorSet;
+ on500Ok: BackgroundColorSet;
error: BackgroundColorSet;
on500Error: BackgroundColorSet;
warning: BackgroundColorSet;