Merge pull request #1632 from zed-industries/git-gutter

Tracking PR: Git gutter
This commit is contained in:
Julia 2022-10-04 15:12:28 -04:00 committed by GitHub
commit 836b536a90
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1687 additions and 73 deletions

52
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
{

View file

@ -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),
);
line.paint(
line_origin,
visible_bounds,
layout.position_map.line_height,
cx,
ix as f32 * gutter_layout.line_height
- (gutter_layout.scroll_top % gutter_layout.line_height),
);
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>)>,
}

View file

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

View file

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

28
crates/git/Cargo.toml Normal file
View file

@ -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 = []

354
crates/git/src/diff.rs Normal file
View file

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

12
crates/git/src/git.rs Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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