diff --git a/Cargo.lock b/Cargo.lock index 26675a05962945419c9f169ef53016e69f4ba928..3ac606d92391f96bdba8e9987a99ea5e240ebd49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/assets/settings/default.json b/assets/settings/default.json index a12cf44d94ae29c45851d3b39a3c4caa32008f96..fddac662a5be681b9e6785ac9a93f4712bcb9bd3 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.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: diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 9b3603e6e43e29acb6071a9c7670d07d412a91ba..47c86e0fe7604abe48004ce9870354ae9fe512d4 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -1,5 +1,5 @@ [package] -authors = ["Nathan Sobo "] +authors = ["Nathan Sobo "] 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"] diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs index e9643d3debbec20ff6a5bb2cb79a60de2f36e5a3..58a8efc411f739e73d3b32c233902cd07ea2cbc4 100644 --- a/crates/collab/src/integration_tests.rs +++ b/crates/collab/src/integration_tests.rs @@ -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, + 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, diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index e42b0812ab128f67a28c8d6e301e4795ef449bd4..da194eb012cf35e7f9160cdedcab14122f0fe3dd 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -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, + request: TypedEnvelope, + ) -> 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, request: TypedEnvelope, diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index dfd4938742d3a365d477c01c8d54e2ce9bdb2e9b..2ea7473b59c1026a8c1bc01519da67e6ba91e1fb 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -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" } diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index 970910f969ee42fc30048b80fd5735ffa971ca30..c17cfa39f2a7292198a1c953339e315824fe73b8 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -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() { diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 1e1ab83063bfc31f9aa7aa964147007fa9e895ed..2b93255972c41eaa446f01fa77c8ed57a26279bb 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -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>, + } + + fn diff_quad( + hunk: &DiffHunk, + 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::().theme.editor.diff.clone(); + let show_gutter = matches!( + &cx.global::() + .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]) -> Option<&DiffHunk> { + 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, Color)>, selections: Vec<(ReplicaId, Vec)>, context_menu: Option<(DisplayPoint, ElementBox)>, + diff_hunks: Vec>, code_actions_indicator: Option<(u32, ElementBox)>, hover_popovers: Option<(DisplayPoint, Vec)>, } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index f63ffc3d7cce0eb920818b36747961290d2865b2..c1082020e5f254cec81946cb62ec372f9651bd0d 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -478,6 +478,17 @@ impl Item for Editor { }) } + fn git_diff_recalc( + &mut self, + _project: ModelHandle, + cx: &mut ViewContext, + ) -> Task> { + self.buffer().update(cx, |multibuffer, cx| { + multibuffer.git_diff_recalc(cx); + }); + Task::ready(Ok(())) + } + fn to_item_events(event: &Self::Event) -> Vec { let mut result = Vec::new(); match event { diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 4ee9526a6797a2d84bfe5776a72a940b9f71466e..b4e302e3c30659ce5cd4b6adee68df3e7d80a579 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -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, _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) { + 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( &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, + ) -> impl 'a + Iterator> { + self.as_singleton() + .into_iter() + .flat_map(move |(_, _, buffer)| buffer.git_diff_hunks_in_range(row_range.clone())) + } + pub fn range_for_syntax_ancestor(&self, range: Range) -> Option> { let range = range.start.to_offset(self)..range.end.to_offset(self); diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..b8f3aac0b9b2970a4c13ebb72ecd150e5535c6ca --- /dev/null +++ b/crates/git/Cargo.toml @@ -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 = [] diff --git a/crates/git/src/diff.rs b/crates/git/src/diff.rs new file mode 100644 index 0000000000000000000000000000000000000000..abf874e2bb149659b66a1c81c731a6e9b2bae91d --- /dev/null +++ b/crates/git/src/diff.rs @@ -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 { + pub buffer_range: Range, + pub head_byte_range: Range, +} + +impl DiffHunk { + 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 { + 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, +} + +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, + tree: SumTree>, +} + +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, + buffer: &'a BufferSnapshot, + ) -> impl 'a + Iterator> { + 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> { + self.hunks_in_range(0..u32::MAX, text) + } + + fn diff<'a>(head: &'a str, current: &'a str) -> Option> { + 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 { + let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap(); + assert!(line_item_count > 0); + + let mut first_deletion_buffer_row: Option = None; + let mut buffer_row_range: Option> = None; + let mut head_byte_range: Option> = 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( + diff_hunks: Iter, + buffer: &BufferSnapshot, + diff_base: &str, + expected_hunks: &[(Range, &str, &str)], +) where + Iter: Iterator>, +{ + 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::(), + ) + }) + .collect::>(); + + 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"), + ], + ); + } +} diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs new file mode 100644 index 0000000000000000000000000000000000000000..36f54e706a4401b7798bbd27ca3363351428e5c8 --- /dev/null +++ b/crates/git/src/git.rs @@ -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"); +} diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs new file mode 100644 index 0000000000000000000000000000000000000000..67e93416aebb2caa8b3b3d6611a8a5637b0e7ba5 --- /dev/null +++ b/crates/git/src/repository.rs @@ -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; +} + +#[async_trait::async_trait] +impl GitRepository for LibGitRepository { + fn load_index(&self, relative_file_path: &Path) -> Option { + fn logic(repo: &LibGitRepository, relative_file_path: &Path) -> Result> { + 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>, +} + +#[derive(Debug, Clone, Default)] +pub struct FakeGitRepositoryState { + pub index_contents: HashMap, +} + +impl FakeGitRepository { + pub fn open(state: Arc>) -> Arc> { + Arc::new(Mutex::new(FakeGitRepository { state })) + } +} + +#[async_trait::async_trait] +impl GitRepository for FakeGitRepository { + fn load_index(&self, path: &Path) -> Option { + let state = self.state.lock(); + state.index_contents.get(path).cloned() + } +} diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 6e9f368e77be909c1a8fb2d149be679b3aa0b66b..7a218acc8e59f3c655962668e1ef7e9e67de17a5 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -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" } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 08843aacfe2e0a5babf44706c980692c3ae9b198..11ca4fa52ae5d3cf0a66022be66c398a3d6d893f 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -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, + git_diff_status: GitDiffStatus, file: Option>, 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, completion_triggers_timestamp: clock::Lamport, deferred_ops: OperationQueue, @@ -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>, diagnostics: DiagnosticSet, diagnostics_update_count: usize, file_update_count: usize, + git_diff_update_count: usize, remote_selections: TreeMap, selections_update_count: usize, language: Option>, @@ -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>( replica_id: ReplicaId, base_text: T, + diff_base: Option, file: Arc, cx: &mut ModelContext, ) -> 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>, ) -> Result { 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>) -> Self { + fn build(buffer: TextBuffer, diff_base: Option, file: Option>) -> 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, cx: &mut ModelContext) { + 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) { + 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) { 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, + ) -> impl 'a + Iterator> { + self.git_diff.hunks_in_range(query_row_range, self) + } + pub fn diagnostics_in_range<'a, T, O>( &'a self, search_range: Range, @@ -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, } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index a4ea6f22864df3a51a99d59994fe0755bbaf576a..1e45e3c6ed9d654ee720f3cc3d6e93fa9ced0dad 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -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" } diff --git a/crates/project/src/fs.rs b/crates/project/src/fs.rs index f2d62fae87e54b84ffb916e8764a5e07871d575d..812842a354c10f9ec4e035edd8ec5c1744a0f6db 100644 --- a/crates/project/src/fs.rs +++ b/crates/project/src/fs.rs @@ -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>>>; + fn open_repo(&self, abs_dot_git: &Path) -> Option>>; 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>> { + LibGitRepository::open(&dotgit_path) + .log_err() + .and_then::>, _>(|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>>, + git_repo_state: Option>>, }, 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 { 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>> { + 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 } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 6841c561d01bc0cfb21b3e6301934ef80829da50..dc783f181834cb074867c5096f6f818c4ad95f2f 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -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, cx: &mut ModelContext) { 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, + repos: &[GitRepositoryEntry], + cx: &mut ModelContext, + ) { + 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, cx: &mut ModelContext) { 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, + envelope: TypedEnvelope, + _: Arc, + 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, envelope: TypedEnvelope, @@ -5780,7 +5861,7 @@ impl Project { cx: &mut ModelContext, ) -> Task>> { 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) }) } diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 74c50e0c5fc6d4a6a5d8f949e0d587ad604ee50d..6880ec4ff11c1ac4df4ffb7e0c163626e0fc11a8 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -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>, + + pub(crate) scan_id: usize, + // Path to folder containing the .git file or directory + pub(crate) content_path: Arc, + // 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, +} + +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, ignores_by_parent_abs_path: HashMap, (Arc, usize)>, + git_repositories: Vec, removed_entry_ids: HashMap, next_entry_id: Arc, snapshot: Snapshot, extension_counts: HashMap, } +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), } 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>> { 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) { 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 { + 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 { 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) -> Task> { + fn load( + &self, + path: &Path, + cx: &mut ModelContext, + ) -> Task)>> { 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 { + 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, entries: impl IntoIterator, ignore: Option>, + 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 = 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 { @@ -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, 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 = vec![ + fake_entry("/.git", 0), + fake_entry("/a/.git", 0), + fake_entry("/a/b/.git", 0), + ]; + + let new_repos: Vec = 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), diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 37434a6d4e7252f589a81564b5736558b8865d32..e8d363aca01717cf391960c25795e71eefb59e0e 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -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; +} diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 001753c7092c4d75605fb7c50722fc35bf2b0f28..8d9d715b6c37739f03530fd69f9a5c2f9f6d4a5e 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -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); diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index b9f6e6a7390a759b4317ed53bd7309d47a9e37b3..640271d4a2f4e496dd10b87ce460a82946c8aabd 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -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; diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index e346ff60e6ba89a304e43b7e8696c90d09ac88cb..fd04fc0aa66ad7741b598aa84f5c9d66e7d45800 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -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, EditorSettings>, @@ -52,6 +54,22 @@ impl FeatureFlags { } } +#[derive(Copy, Clone, Debug, Default, Deserialize, JsonSchema)] +pub struct GitSettings { + pub git_gutter: Option, + pub gutter_debounce: Option, +} + +#[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, @@ -196,6 +214,8 @@ pub struct SettingsFileContent { #[serde(default)] pub terminal: TerminalSettings, #[serde(default)] + pub git: Option, + #[serde(default)] #[serde(alias = "language_overrides")] pub languages: HashMap, 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(), diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index cb05dff9673579bc51383cfaf67881560dbe0ef8..7beab3b7c59319a2cc6d44eea920070e953c4e7e 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -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 { Some(self.cmp(other)) diff --git a/crates/text/src/anchor.rs b/crates/text/src/anchor.rs index dca95ce5d5ffaa8ab50cea80ca953160265c5e95..9f70ae1cc7b130c02f0edf01f06002e9f3b8dce1 100644 --- a/crates/text/src/anchor.rs +++ b/crates/text/src/anchor.rs @@ -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, diff --git a/crates/text/src/rope.rs b/crates/text/src/rope.rs index d35ac46f45ec92c8c13f8c0cd90b3c08ca7c11bf..e148c048bbc57f8d8ef252cf8c001463822e6f46 100644 --- a/crates/text/src/rope.rs +++ b/crates/text/src/rope.rs @@ -54,6 +54,13 @@ impl Rope { cursor.slice(range.end) } + pub fn slice_rows(&self, range: Range) -> 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(); diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 739a4c76869b82b9ab066bb32eff8bb58a0cd253..d8c829648156654d51cad54ea5b5d000f0cb3f08 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -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, @@ -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 { pub default: T, diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 4ec214fef10d2c4e1a95212fce84b973de0d8336..78416aa5b506556cf76f8d8782489917615a74ba 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -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 } diff --git a/crates/util/src/test.rs b/crates/util/src/test.rs index 7b2e00d57b00283d0cfb31dfbd4369b041514ff3..96d13f4c8158278d2b9e625827d820d39a03c866 100644 --- a/crates/util/src/test.rs +++ b/crates/util/src/test.rs @@ -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 => { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b9cface656b6d35060d2c74c6111cf3c9f36faa1..2ae498d7015053652497bcfce07b11ae750117fd 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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, cx: &mut ViewContext, ) -> Task>; + fn git_diff_recalc( + &mut self, + _project: ModelHandle, + _cx: &mut ViewContext, + ) -> Task> { + Task::ready(Ok(())) + } fn to_item_events(event: &Self::Event) -> Vec; + 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 FollowableItemHandle for ViewHandle { } } +struct DelayedDebouncedEditAction { + task: Option>, + cancel_channel: Option>, +} + +impl DelayedDebouncedEditAction { + fn new() -> DelayedDebouncedEditAction { + DelayedDebouncedEditAction { + task: None, + cancel_channel: None, + } + } + + fn fire_new( + &mut self, + delay: Duration, + workspace: &Workspace, + cx: &mut ViewContext, + f: F, + ) where + F: FnOnce(ModelHandle, AsyncAppContext) -> Fut + 'static, + Fut: 'static + Future, + { + 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>; fn reload(&self, project: ModelHandle, cx: &mut MutableAppContext) -> Task>; + fn git_diff_recalc( + &self, + project: ModelHandle, + cx: &mut MutableAppContext, + ) -> Task>; fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option; fn to_followable_item_handle(&self, cx: &AppContext) -> Option>; fn on_release( @@ -578,8 +649,8 @@ impl ItemHandle for ViewHandle { .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 ItemHandle for ViewHandle { .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::().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::(); + 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 ItemHandle for ViewHandle { self.update(cx, |item, cx| item.reload(project, cx)) } + fn git_diff_recalc( + &self, + project: ModelHandle, + cx: &mut MutableAppContext, + ) -> Task> { + self.update(cx, |item, cx| item.git_diff_recalc(project, cx)) + } + fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option { self.read(cx).act_as_type(type_id, self, cx) } diff --git a/styles/src/styleTree/editor.ts b/styles/src/styleTree/editor.ts index 62f7a0efdfcbb68b1f0503edd3ea326f7eafa97d..04a5bafbd57276f4dbacf6b44dfa6a29aec68378 100644 --- a/styles/src/styleTree/editor.ts +++ b/styles/src/styleTree/editor.ts @@ -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, diff --git a/styles/src/themes/common/base16.ts b/styles/src/themes/common/base16.ts index 7aa72ef1377ea40656a45e50bc4155f28ac7f8a5..326928252e837c7784cc4db2217a00f2905f0fae 100644 --- a/styles/src/themes/common/base16.ts +++ b/styles/src/themes/common/base16.ts @@ -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 = { diff --git a/styles/src/themes/common/theme.ts b/styles/src/themes/common/theme.ts index e01435b846c4d4a5d1fdbe8366166c38de361f07..b93148ae2cff53809a7a4fe390c532e8ef726fa7 100644 --- a/styles/src/themes/common/theme.ts +++ b/styles/src/themes/common/theme.ts @@ -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;