diff --git a/Cargo.lock b/Cargo.lock index 9948c793f5..120fade92b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3295,6 +3295,8 @@ dependencies = [ "sum_tree", "text", "theme", + "time", + "time_format", "tree-sitter-html", "tree-sitter-rust", "tree-sitter-typescript", @@ -3942,6 +3944,7 @@ dependencies = [ "collections", "fsevent", "futures 0.3.28", + "git", "git2", "gpui", "lazy_static", @@ -4249,14 +4252,21 @@ dependencies = [ name = "git" version = "0.1.0" dependencies = [ + "anyhow", "clock", + "collections", "git2", "lazy_static", "log", + "pretty_assertions", + "serde", + "serde_json", "smol", "sum_tree", "text", + "time", "unindent", + "url", ] [[package]] @@ -7178,6 +7188,7 @@ dependencies = [ "fs", "futures 0.3.28", "fuzzy", + "git", "git2", "globset", "gpui", diff --git a/Cargo.toml b/Cargo.toml index b258d88697..5859c994ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -280,6 +280,8 @@ tempfile = "3.9.0" thiserror = "1.0.29" tiktoken-rs = "0.5.7" time = { version = "0.3", features = [ + "macros", + "parsing", "serde", "serde-well-known", "formatting", diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 1cd2614129..84c8ba465c 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -366,6 +366,7 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_message_handler(create_buffer_for_peer) .add_request_handler(update_buffer) .add_message_handler(broadcast_project_message_from_host::) diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 689a6a3380..20db56bbe8 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -23,6 +23,7 @@ use rpc::RECEIVE_TIMEOUT; use serde_json::json; use settings::SettingsStore; use std::{ + ops::Range, path::Path, sync::{ atomic::{self, AtomicBool, AtomicUsize}, @@ -1986,6 +1987,187 @@ struct Row10;"#}; struct Row1220;"#}); } +#[gpui::test(iterations = 10)] +async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + "/my-repo", + json!({ + ".git": {}, + "file.txt": "line1\nline2\nline3\nline\n", + }), + ) + .await; + + let blame = git::blame::Blame { + entries: vec![ + blame_entry("1b1b1b", 0..1), + blame_entry("0d0d0d", 1..2), + blame_entry("3a3a3a", 2..3), + blame_entry("4c4c4c", 3..4), + ], + permalinks: [ + ("1b1b1b", "http://example.com/codehost/idx-0"), + ("0d0d0d", "http://example.com/codehost/idx-1"), + ("3a3a3a", "http://example.com/codehost/idx-2"), + ("4c4c4c", "http://example.com/codehost/idx-3"), + ] + .into_iter() + .map(|(sha, url)| (sha.parse().unwrap(), url.parse().unwrap())) + .collect(), + messages: [ + ("1b1b1b", "message for idx-0"), + ("0d0d0d", "message for idx-1"), + ("3a3a3a", "message for idx-2"), + ("4c4c4c", "message for idx-3"), + ] + .into_iter() + .map(|(sha, message)| (sha.parse().unwrap(), message.into())) + .collect(), + }; + client_a.fs().set_blame_for_repo( + Path::new("/my-repo/.git"), + vec![(Path::new("file.txt"), blame)], + ); + + let (project_a, worktree_id) = client_a.build_local_project("/my-repo", cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + // Create editor_a + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let editor_a = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "file.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Join the project as client B. + let project_b = client_b.build_remote_project(project_id, cx_b).await; + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let editor_b = workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "file.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // client_b now requests git blame for the open buffer + editor_b.update(cx_b, |editor_b, cx| { + assert!(editor_b.blame().is_none()); + editor_b.toggle_git_blame(&editor::actions::ToggleGitBlame {}, cx); + }); + + cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); + + editor_b.update(cx_b, |editor_b, cx| { + let blame = editor_b.blame().expect("editor_b should have blame now"); + let entries = blame.update(cx, |blame, cx| { + blame + .blame_for_rows((0..4).map(Some), cx) + .collect::>() + }); + + assert_eq!( + entries, + vec![ + Some(blame_entry("1b1b1b", 0..1)), + Some(blame_entry("0d0d0d", 1..2)), + Some(blame_entry("3a3a3a", 2..3)), + Some(blame_entry("4c4c4c", 3..4)), + ] + ); + + blame.update(cx, |blame, _| { + for (idx, entry) in entries.iter().flatten().enumerate() { + assert_eq!( + blame.permalink_for_entry(entry).unwrap().to_string(), + format!("http://example.com/codehost/idx-{}", idx) + ); + assert_eq!( + blame.message_for_entry(entry).unwrap(), + format!("message for idx-{}", idx) + ); + } + }); + }); + + // editor_b updates the file, which gets sent to client_a, which updates git blame, + // which gets back to client_b. + editor_b.update(cx_b, |editor_b, cx| { + editor_b.edit([(Point::new(0, 3)..Point::new(0, 3), "FOO")], cx); + }); + + cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); + + editor_b.update(cx_b, |editor_b, cx| { + let blame = editor_b.blame().expect("editor_b should have blame now"); + let entries = blame.update(cx, |blame, cx| { + blame + .blame_for_rows((0..4).map(Some), cx) + .collect::>() + }); + + assert_eq!( + entries, + vec![ + None, + Some(blame_entry("0d0d0d", 1..2)), + Some(blame_entry("3a3a3a", 2..3)), + Some(blame_entry("4c4c4c", 3..4)), + ] + ); + }); + + // Now editor_a also updates the file + editor_a.update(cx_a, |editor_a, cx| { + editor_a.edit([(Point::new(1, 3)..Point::new(1, 3), "FOO")], cx); + }); + + cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); + + editor_b.update(cx_b, |editor_b, cx| { + let blame = editor_b.blame().expect("editor_b should have blame now"); + let entries = blame.update(cx, |blame, cx| { + blame + .blame_for_rows((0..4).map(Some), cx) + .collect::>() + }); + + assert_eq!( + entries, + vec![ + None, + None, + Some(blame_entry("3a3a3a", 2..3)), + Some(blame_entry("4c4c4c", 3..4)), + ] + ); + }); +} + fn extract_hint_labels(editor: &Editor) -> Vec { let mut labels = Vec::new(); for hint in editor.inlay_hint_cache().hints() { @@ -1996,3 +2178,11 @@ fn extract_hint_labels(editor: &Editor) -> Vec { } labels } + +fn blame_entry(sha: &str, range: Range) -> git::blame::BlameEntry { + git::blame::BlameEntry { + sha: sha.parse().unwrap(), + range, + ..Default::default() + } +} diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index a1349a00a3..cd851042aa 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -61,6 +61,8 @@ smol.workspace = true snippet.workspace = true sum_tree.workspace = true text.workspace = true +time.workspace = true +time_format.workspace = true theme.workspace = true tree-sitter-html = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 1a5bee8f5d..6520fd2af8 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -244,6 +244,7 @@ gpui::actions!( SplitSelectionIntoLines, Tab, TabPrev, + ToggleGitBlame, ToggleInlayHints, ToggleLineNumbers, ToggleSoftWrap, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4ced3244eb..17928bf3a8 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -38,6 +38,7 @@ mod editor_tests; #[cfg(any(test, feature = "test-support"))] pub mod test; use ::git::diff::{DiffHunk, DiffHunkStatus}; +use ::git::permalink::{build_permalink, BuildPermalinkParams}; pub(crate) use actions::*; use aho_corasick::AhoCorasick; use anyhow::{anyhow, Context as _, Result}; @@ -56,6 +57,7 @@ pub use element::{ }; use futures::FutureExt; use fuzzy::{StringMatch, StringMatchCandidate}; +use git::blame::GitBlame; use git::diff_hunk_to_display; use gpui::{ div, impl_actions, point, prelude::*, px, relative, rems, size, uniform_list, Action, @@ -92,8 +94,7 @@ pub use multi_buffer::{ use ordered_float::OrderedFloat; use parking_lot::{Mutex, RwLock}; use project::project_settings::{GitGutterSetting, ProjectSettings}; -use project::Item; -use project::{FormatTrigger, Location, Project, ProjectPath, ProjectTransaction}; +use project::{FormatTrigger, Item, Location, Project, ProjectPath, ProjectTransaction}; use rand::prelude::*; use rpc::proto::*; use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide}; @@ -432,6 +433,9 @@ pub struct Editor { editor_actions: Vec)>>, use_autoclose: bool, auto_replace_emoji_shortcode: bool, + show_git_blame: bool, + blame: Option>, + blame_subscription: Option, custom_context_menu: Option< Box< dyn 'static @@ -443,6 +447,7 @@ pub struct Editor { pub struct EditorSnapshot { pub mode: EditorMode, show_gutter: bool, + show_git_blame: bool, pub display_snapshot: DisplaySnapshot, pub placeholder_text: Option>, is_focused: bool, @@ -450,11 +455,14 @@ pub struct EditorSnapshot { ongoing_scroll: OngoingScroll, } +const GIT_BLAME_GUTTER_WIDTH_CHARS: f32 = 53.; + pub struct GutterDimensions { pub left_padding: Pixels, pub right_padding: Pixels, pub width: Pixels, pub margin: Pixels, + pub git_blame_entries_width: Option, } impl Default for GutterDimensions { @@ -464,6 +472,7 @@ impl Default for GutterDimensions { right_padding: Pixels::ZERO, width: Pixels::ZERO, margin: Pixels::ZERO, + git_blame_entries_width: None, } } } @@ -1471,6 +1480,9 @@ impl Editor { vim_replace_map: Default::default(), show_inline_completions: mode == EditorMode::Full, custom_context_menu: None, + show_git_blame: false, + blame: None, + blame_subscription: None, _subscriptions: vec![ cx.observe(&buffer, Self::on_buffer_changed), cx.subscribe(&buffer, Self::on_buffer_event), @@ -1616,6 +1628,10 @@ impl Editor { EditorSnapshot { mode: self.mode, show_gutter: self.show_gutter, + show_git_blame: self + .blame + .as_ref() + .map_or(false, |blame| blame.read(cx).has_generated_entries()), display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)), scroll_anchor: self.scroll_manager.anchor(), ongoing_scroll: self.scroll_manager.ongoing_scroll(), @@ -8832,9 +8848,42 @@ impl Editor { } } - fn get_permalink_to_line(&mut self, cx: &mut ViewContext) -> Result { - use git::permalink::{build_permalink, BuildPermalinkParams}; + pub fn toggle_git_blame(&mut self, _: &ToggleGitBlame, cx: &mut ViewContext) { + if !self.show_git_blame { + if let Err(error) = self.show_git_blame_internal(cx) { + log::error!("failed to toggle on 'git blame': {}", error); + return; + } + self.show_git_blame = true + } else { + self.blame_subscription.take(); + self.blame.take(); + self.show_git_blame = false + } + cx.notify(); + } + + fn show_git_blame_internal(&mut self, cx: &mut ViewContext) -> Result<()> { + if let Some(project) = self.project.as_ref() { + let Some(buffer) = self.buffer().read(cx).as_singleton() else { + anyhow::bail!("git blame not available in multi buffers") + }; + + let project = project.clone(); + let blame = cx.new_model(|cx| GitBlame::new(buffer, project, cx)); + self.blame_subscription = Some(cx.observe(&blame, |_, _, cx| cx.notify())); + self.blame = Some(blame); + } + + Ok(()) + } + + pub fn blame(&self) -> Option<&Model> { + self.blame.as_ref() + } + + fn get_permalink_to_line(&mut self, cx: &mut ViewContext) -> Result { let (path, repo) = maybe!({ let project_handle = self.project.as_ref()?.clone(); let project = project_handle.read(cx); @@ -8867,7 +8916,12 @@ impl Editor { remote_url: &origin_url, sha: &sha, path: &path, - selection: selection.map(|selection| selection.range()), + selection: selection.map(|selection| { + let range = selection.range(); + let start = range.start.row; + let end = range.end.row; + start..end + }), }) } @@ -9978,7 +10032,12 @@ impl EditorSnapshot { 0.0.into() }; - let left_padding = if gutter_settings.code_actions { + let git_blame_entries_width = self + .show_git_blame + .then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS); + + let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); + left_padding += if gutter_settings.code_actions { em_width * 3.0 } else if show_git_gutter && gutter_settings.line_numbers { em_width * 2.0 @@ -10003,6 +10062,7 @@ impl EditorSnapshot { right_padding, width: line_gutter_width + left_padding + right_padding, margin: -descent, + git_blame_entries_width, } } } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5748741af4..58de6c9ce8 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4,12 +4,12 @@ use crate::{ TransformBlock, }, editor_settings::{DoubleClickInMultibuffer, MultiCursorModifier, ShowScrollbar}, - git::{diff_hunk_to_display, DisplayDiffHunk}, + git::{blame::GitBlame, diff_hunk_to_display, DisplayDiffHunk}, hover_popover::{ self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT, }, items::BufferSearchHighlights, - mouse_context_menu, + mouse_context_menu::{self, MouseContextMenu}, scroll::scroll_amount::ScrollAmount, CursorShape, DisplayPoint, DocumentHighlightRead, DocumentHighlightWrite, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, GutterDimensions, HalfPageDown, HalfPageUp, @@ -18,15 +18,15 @@ use crate::{ }; use anyhow::Result; use collections::{BTreeMap, HashMap}; -use git::diff::DiffHunkStatus; +use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid}; use gpui::{ div, fill, outline, overlay, point, px, quad, relative, size, svg, transparent_black, Action, - AnchorCorner, AnyElement, AvailableSpace, Bounds, ContentMask, Corners, CursorStyle, - DispatchPhase, Edges, Element, ElementContext, ElementInputHandler, Entity, Hitbox, Hsla, - InteractiveElement, IntoElement, ModifiersChangedEvent, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, - SharedString, Size, Stateful, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, - TextStyleRefinement, View, ViewContext, WindowContext, + AnchorCorner, AnyElement, AnyView, AvailableSpace, Bounds, ClipboardItem, ContentMask, Corners, + CursorStyle, DispatchPhase, Edges, Element, ElementContext, ElementInputHandler, Entity, + Hitbox, Hsla, InteractiveElement, IntoElement, ModifiersChangedEvent, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ScrollDelta, + ScrollWheelEvent, ShapedLine, SharedString, Size, Stateful, StatefulInteractiveElement, Style, + Styled, TextRun, TextStyle, TextStyleRefinement, View, ViewContext, WindowContext, }; use itertools::Itertools; use language::language_settings::ShowWhitespaceSetting; @@ -49,8 +49,8 @@ use std::{ }; use sum_tree::Bias; use theme::{ActiveTheme, PlayerColor}; -use ui::prelude::*; -use ui::{h_flex, ButtonLike, ButtonStyle, Tooltip}; +use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip}; +use ui::{prelude::*, tooltip_container}; use util::ResultExt; use workspace::item::Item; @@ -301,6 +301,7 @@ impl EditorElement { register_action(view, cx, Editor::copy_highlight_json); register_action(view, cx, Editor::copy_permalink_to_line); register_action(view, cx, Editor::open_permalink_to_line); + register_action(view, cx, Editor::toggle_git_blame); register_action(view, cx, |editor, action, cx| { if let Some(task) = editor.format(action, cx) { task.detach_and_log_err(cx); @@ -1082,6 +1083,64 @@ impl EditorElement { .collect() } + #[allow(clippy::too_many_arguments)] + fn layout_blame_entries( + &self, + buffer_rows: impl Iterator>, + em_width: Pixels, + scroll_position: gpui::Point, + line_height: Pixels, + gutter_hitbox: &Hitbox, + max_width: Option, + cx: &mut ElementContext, + ) -> Option> { + let Some(blame) = self.editor.read(cx).blame.as_ref().cloned() else { + return None; + }; + + let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| { + blame.blame_for_rows(buffer_rows, cx).collect() + }); + + let width = if let Some(max_width) = max_width { + AvailableSpace::Definite(max_width) + } else { + AvailableSpace::MaxContent + }; + let scroll_top = scroll_position.y * line_height; + let start_x = em_width * 1; + + let mut last_used_color: Option<(PlayerColor, Oid)> = None; + + let shaped_lines = blamed_rows + .into_iter() + .enumerate() + .flat_map(|(ix, blame_entry)| { + if let Some(blame_entry) = blame_entry { + let mut element = render_blame_entry( + ix, + &blame, + blame_entry, + &mut last_used_color, + self.editor.clone(), + cx, + ); + + let start_y = ix as f32 * line_height - (scroll_top % line_height); + let absolute_offset = gutter_hitbox.origin + point(start_x, start_y); + + element.layout(absolute_offset, size(width, AvailableSpace::MinContent), cx); + + Some(element) + } else { + None + } + }) + .collect(); + + Some(shaped_lines) + } + fn layout_code_actions_indicator( &self, line_height: Pixels, @@ -1108,19 +1167,26 @@ impl EditorElement { ); let indicator_size = button.measure(available_space, cx); - let mut x = Pixels::ZERO; + let blame_width = gutter_dimensions + .git_blame_entries_width + .unwrap_or(Pixels::ZERO); + + let mut x = blame_width; + let available_width = gutter_dimensions.margin + gutter_dimensions.left_padding + - indicator_size.width + - blame_width; + x += available_width / 2.; + let mut y = newest_selection_head.row() as f32 * line_height - scroll_pixel_position.y; - // Center indicator. - x += - (gutter_dimensions.margin + gutter_dimensions.left_padding - indicator_size.width) / 2.; y += (line_height - indicator_size.height) / 2.; + button.layout(gutter_hitbox.origin + point(x, y), available_space, cx); Some(button) } fn calculate_relative_line_numbers( &self, - snapshot: &EditorSnapshot, + buffer_rows: Vec>, rows: &Range, relative_to: Option, ) -> HashMap { @@ -1130,12 +1196,6 @@ impl EditorElement { }; let start = rows.start.min(relative_to); - let end = rows.end.max(relative_to); - - let buffer_rows = snapshot - .buffer_rows(start) - .take(1 + (end - start) as usize) - .collect::>(); let head_idx = relative_to - start; let mut delta = 1; @@ -1171,6 +1231,7 @@ impl EditorElement { fn layout_line_numbers( &self, rows: Range, + buffer_rows: impl Iterator>, active_rows: &BTreeMap, newest_selection_head: Option, snapshot: &EditorSnapshot, @@ -1209,13 +1270,11 @@ impl EditorElement { None }; - let relative_rows = self.calculate_relative_line_numbers(&snapshot, &rows, relative_to); + let buffer_rows = buffer_rows.collect::>(); + let relative_rows = + self.calculate_relative_line_numbers(buffer_rows.clone(), &rows, relative_to); - for (ix, row) in snapshot - .buffer_rows(rows.start) - .take((rows.end - rows.start) as usize) - .enumerate() - { + for (ix, row) in buffer_rows.into_iter().enumerate() { let display_row = rows.start + ix as u32; let (active, color) = if active_rows.contains_key(&display_row) { (true, cx.theme().colors().editor_active_line_number) @@ -1986,6 +2045,10 @@ impl EditorElement { Self::paint_diff_hunks(layout, cx); } + if layout.blamed_display_rows.is_some() { + self.paint_blamed_display_rows(layout, cx); + } + for (ix, line) in layout.line_numbers.iter().enumerate() { if let Some(line) = line { let line_origin = layout.gutter_hitbox.origin @@ -2119,6 +2182,18 @@ impl EditorElement { }) } + fn paint_blamed_display_rows(&self, layout: &mut EditorLayout, cx: &mut ElementContext) { + let Some(blamed_display_rows) = layout.blamed_display_rows.take() else { + return; + }; + + cx.paint_layer(layout.gutter_hitbox.bounds, |cx| { + for mut blame_element in blamed_display_rows.into_iter() { + blame_element.paint(cx); + } + }) + } + fn paint_text(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) { cx.with_content_mask( Some(ContentMask { @@ -2766,6 +2841,188 @@ impl EditorElement { } } +fn render_blame_entry( + ix: usize, + blame: &gpui::Model, + blame_entry: BlameEntry, + last_used_color: &mut Option<(PlayerColor, Oid)>, + editor: View, + cx: &mut ElementContext<'_>, +) -> AnyElement { + let mut sha_color = cx + .theme() + .players() + .color_for_participant(blame_entry.sha.into()); + // If the last color we used is the same as the one we get for this line, but + // the commit SHAs are different, then we try again to get a different color. + match *last_used_color { + Some((color, sha)) if sha != blame_entry.sha && color.cursor == sha_color.cursor => { + let index: u32 = blame_entry.sha.into(); + sha_color = cx.theme().players().color_for_participant(index + 1); + } + _ => {} + }; + last_used_color.replace((sha_color, blame_entry.sha)); + + let relative_timestamp = match blame_entry.author_offset_date_time() { + Ok(timestamp) => time_format::format_localized_timestamp( + timestamp, + time::OffsetDateTime::now_utc(), + cx.local_timezone(), + time_format::TimestampFormat::Relative, + ), + Err(_) => "Error parsing date".to_string(), + }; + + let pretty_commit_id = format!("{}", blame_entry.sha); + let short_commit_id = pretty_commit_id.clone().chars().take(6).collect::(); + + let name = blame_entry.author.as_deref().unwrap_or(""); + let name = if name.len() > 20 { + format!("{}...", &name[..16]) + } else { + name.to_string() + }; + + let permalink = blame.read(cx).permalink_for_entry(&blame_entry); + let commit_message = blame.read(cx).message_for_entry(&blame_entry); + + h_flex() + .id(("blame", ix)) + .children([ + div() + .text_color(sha_color.cursor) + .child(short_commit_id) + .mr_2(), + div() + .text_color(cx.theme().status().hint) + .child(format!("{:20} {: >14}", name, relative_timestamp)), + ]) + .on_mouse_down(MouseButton::Right, { + let blame_entry = blame_entry.clone(); + move |event, cx| { + deploy_blame_entry_context_menu(&blame_entry, editor.clone(), event.position, cx); + } + }) + .hover(|style| style.bg(cx.theme().colors().element_hover)) + .when_some(permalink, |this, url| { + let url = url.clone(); + this.cursor_pointer().on_click(move |_, cx| { + cx.stop_propagation(); + cx.open_url(url.as_str()) + }) + }) + .tooltip(move |cx| { + BlameEntryTooltip::new( + sha_color.cursor, + commit_message.clone(), + blame_entry.clone(), + cx, + ) + }) + .into_any() +} + +fn deploy_blame_entry_context_menu( + blame_entry: &BlameEntry, + editor: View, + position: gpui::Point, + cx: &mut WindowContext<'_>, +) { + let context_menu = ContextMenu::build(cx, move |this, _| { + let sha = format!("{}", blame_entry.sha); + this.entry("Copy commit SHA", None, move |cx| { + cx.write_to_clipboard(ClipboardItem::new(sha.clone())); + }) + }); + + editor.update(cx, move |editor, cx| { + editor.mouse_context_menu = Some(MouseContextMenu::new(position, context_menu, cx)); + cx.notify(); + }); +} + +struct BlameEntryTooltip { + color: Hsla, + commit_message: Option, + blame_entry: BlameEntry, +} + +impl BlameEntryTooltip { + fn new( + color: Hsla, + commit_message: Option, + blame_entry: BlameEntry, + cx: &mut WindowContext, + ) -> AnyView { + cx.new_view(|_cx| Self { + color, + commit_message, + blame_entry, + }) + .into() + } +} + +impl Render for BlameEntryTooltip { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let author = self + .blame_entry + .author + .clone() + .unwrap_or("".to_string()); + let author_email = self.blame_entry.author_mail.clone().unwrap_or_default(); + let absolute_timestamp = match self.blame_entry.author_offset_date_time() { + Ok(timestamp) => time_format::format_localized_timestamp( + timestamp, + time::OffsetDateTime::now_utc(), + cx.local_timezone(), + time_format::TimestampFormat::Absolute, + ), + Err(_) => "Error parsing date".to_string(), + }; + + let message = match &self.commit_message { + Some(message) => message.clone(), + None => { + println!("can't find commit message"); + self.blame_entry.summary.clone().unwrap_or_default() + } + }; + + let pretty_commit_id = format!("{}", self.blame_entry.sha); + + tooltip_container(cx, move |this, cx| { + this.occlude() + .on_mouse_move(|_, cx| cx.stop_propagation()) + .child( + v_flex() + .child( + h_flex() + .child( + div() + .text_color(cx.theme().colors().text_muted) + .child("Commit") + .pr_2(), + ) + .child( + div().text_color(self.color).child(pretty_commit_id.clone()), + ), + ) + .child( + div() + .child(format!( + "{} {} - {}", + author, author_email, absolute_timestamp + )) + .text_color(cx.theme().colors().text_muted), + ) + .child(div().child(message)), + ) + }) + } +} + #[derive(Debug)] pub(crate) struct LineWithInvisibles { pub line: ShapedLine, @@ -3124,6 +3381,10 @@ impl Element for EditorElement { let end_row = 1 + cmp::min((scroll_position.y + height_in_lines).ceil() as u32, max_row); + let buffer_rows = snapshot + .buffer_rows(start_row) + .take((start_row..end_row).len()); + let start_anchor = if start_row == 0 { Anchor::min() } else { @@ -3165,6 +3426,7 @@ impl Element for EditorElement { let (line_numbers, fold_statuses) = self.layout_line_numbers( start_row..end_row, + buffer_rows.clone(), &active_rows, newest_selection_head, &snapshot, @@ -3173,6 +3435,16 @@ impl Element for EditorElement { let display_hunks = self.layout_git_gutters(start_row..end_row, &snapshot); + let blamed_display_rows = self.layout_blame_entries( + buffer_rows, + em_width, + scroll_position, + line_height, + &gutter_hitbox, + gutter_dimensions.git_blame_entries_width, + cx, + ); + let mut max_visible_line_width = Pixels::ZERO; let line_layouts = self.layout_lines(start_row..end_row, &line_numbers, &snapshot, cx); @@ -3400,6 +3672,7 @@ impl Element for EditorElement { redacted_ranges, line_numbers, display_hunks, + blamed_display_rows, folds, blocks, cursors, @@ -3486,6 +3759,7 @@ pub struct EditorLayout { highlighted_rows: BTreeMap, line_numbers: Vec>, display_hunks: Vec, + blamed_display_rows: Option>, folds: Vec, blocks: Vec, highlighted_ranges: Vec<(Range, Hsla)>, @@ -3958,6 +4232,7 @@ mod tests { element .layout_line_numbers( 0..6, + (0..6).map(Some), &Default::default(), Some(DisplayPoint::new(0, 0)), &snapshot, @@ -3969,12 +4244,8 @@ mod tests { .unwrap(); assert_eq!(layouts.len(), 6); - let relative_rows = window - .update(cx, |editor, cx| { - let snapshot = editor.snapshot(cx); - element.calculate_relative_line_numbers(&snapshot, &(0..6), Some(3)) - }) - .unwrap(); + let relative_rows = + element.calculate_relative_line_numbers((0..6).map(Some).collect(), &(0..6), Some(3)); assert_eq!(relative_rows[&0], 3); assert_eq!(relative_rows[&1], 2); assert_eq!(relative_rows[&2], 1); @@ -3983,26 +4254,16 @@ mod tests { assert_eq!(relative_rows[&5], 2); // works if cursor is before screen - let relative_rows = window - .update(cx, |editor, cx| { - let snapshot = editor.snapshot(cx); - - element.calculate_relative_line_numbers(&snapshot, &(3..6), Some(1)) - }) - .unwrap(); + let relative_rows = + element.calculate_relative_line_numbers((0..6).map(Some).collect(), &(3..6), Some(1)); assert_eq!(relative_rows.len(), 3); assert_eq!(relative_rows[&3], 2); assert_eq!(relative_rows[&4], 3); assert_eq!(relative_rows[&5], 4); // works if cursor is after screen - let relative_rows = window - .update(cx, |editor, cx| { - let snapshot = editor.snapshot(cx); - - element.calculate_relative_line_numbers(&snapshot, &(0..3), Some(6)) - }) - .unwrap(); + let relative_rows = + element.calculate_relative_line_numbers((0..6).map(Some).collect(), &(0..3), Some(6)); assert_eq!(relative_rows.len(), 3); assert_eq!(relative_rows[&0], 5); assert_eq!(relative_rows[&1], 4); diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index c02ad2c7f2..fd82ff5b10 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1,4 +1,4 @@ -pub mod permalink; +pub mod blame; use std::ops::Range; diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs new file mode 100644 index 0000000000..b0c25d73c4 --- /dev/null +++ b/crates/editor/src/git/blame.rs @@ -0,0 +1,706 @@ +use anyhow::Result; +use collections::HashMap; +use git::{ + blame::{Blame, BlameEntry}, + Oid, +}; +use gpui::{Model, ModelContext, Subscription, Task}; +use language::{Bias, Buffer, BufferSnapshot, Edit}; +use project::{Item, Project}; +use smallvec::SmallVec; +use sum_tree::SumTree; +use url::Url; + +#[derive(Clone, Debug, Default)] +pub struct GitBlameEntry { + pub rows: u32, + pub blame: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct GitBlameEntrySummary { + rows: u32, +} + +impl sum_tree::Item for GitBlameEntry { + type Summary = GitBlameEntrySummary; + + fn summary(&self) -> Self::Summary { + GitBlameEntrySummary { rows: self.rows } + } +} + +impl sum_tree::Summary for GitBlameEntrySummary { + type Context = (); + + fn add_summary(&mut self, summary: &Self, _cx: &()) { + self.rows += summary.rows; + } +} + +impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 { + fn add_summary(&mut self, summary: &'a GitBlameEntrySummary, _cx: &()) { + *self += summary.rows; + } +} + +pub struct GitBlame { + project: Model, + buffer: Model, + entries: SumTree, + permalinks: HashMap, + messages: HashMap, + buffer_snapshot: BufferSnapshot, + buffer_edits: text::Subscription, + task: Task>, + generated: bool, + _refresh_subscription: Subscription, +} + +impl GitBlame { + pub fn new( + buffer: Model, + project: Model, + cx: &mut ModelContext, + ) -> Self { + let entries = SumTree::from_item( + GitBlameEntry { + rows: buffer.read(cx).max_point().row + 1, + blame: None, + }, + &(), + ); + + let refresh_subscription = cx.subscribe(&project, { + let buffer = buffer.clone(); + + move |this, _, event, cx| match event { + project::Event::WorktreeUpdatedEntries(_, updated) => { + let project_entry_id = buffer.read(cx).entry_id(cx); + if updated + .iter() + .any(|(_, entry_id, _)| project_entry_id == Some(*entry_id)) + { + log::debug!("Updated buffers. Regenerating blame data...",); + this.generate(cx); + } + } + project::Event::WorktreeUpdatedGitRepositories => { + log::debug!("Status of git repositories updated. Regenerating blame data...",); + this.generate(cx); + } + _ => {} + } + }); + + let buffer_snapshot = buffer.read(cx).snapshot(); + let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); + + let mut this = Self { + project, + buffer, + buffer_snapshot, + entries, + buffer_edits, + permalinks: HashMap::default(), + messages: HashMap::default(), + task: Task::ready(Ok(())), + generated: false, + _refresh_subscription: refresh_subscription, + }; + this.generate(cx); + this + } + + pub fn has_generated_entries(&self) -> bool { + self.generated + } + + pub fn permalink_for_entry(&self, entry: &BlameEntry) -> Option { + self.permalinks.get(&entry.sha).cloned() + } + + pub fn message_for_entry(&self, entry: &BlameEntry) -> Option { + self.messages.get(&entry.sha).cloned() + } + + pub fn blame_for_rows<'a>( + &'a mut self, + rows: impl 'a + IntoIterator>, + cx: &mut ModelContext, + ) -> impl 'a + Iterator> { + self.sync(cx); + + let mut cursor = self.entries.cursor::(); + rows.into_iter().map(move |row| { + let row = row?; + cursor.seek_forward(&row, Bias::Right, &()); + cursor.item()?.blame.clone() + }) + } + + fn sync(&mut self, cx: &mut ModelContext) { + let edits = self.buffer_edits.consume(); + let new_snapshot = self.buffer.read(cx).snapshot(); + + let mut row_edits = edits + .into_iter() + .map(|edit| { + let old_point_range = self.buffer_snapshot.offset_to_point(edit.old.start) + ..self.buffer_snapshot.offset_to_point(edit.old.end); + let new_point_range = new_snapshot.offset_to_point(edit.new.start) + ..new_snapshot.offset_to_point(edit.new.end); + + if old_point_range.start.column + == self.buffer_snapshot.line_len(old_point_range.start.row) + && (new_snapshot.chars_at(edit.new.start).next() == Some('\n') + || self.buffer_snapshot.line_len(old_point_range.end.row) == 0) + { + Edit { + old: old_point_range.start.row + 1..old_point_range.end.row + 1, + new: new_point_range.start.row + 1..new_point_range.end.row + 1, + } + } else if old_point_range.start.column == 0 + && old_point_range.end.column == 0 + && new_point_range.end.column == 0 + { + Edit { + old: old_point_range.start.row..old_point_range.end.row, + new: new_point_range.start.row..new_point_range.end.row, + } + } else { + Edit { + old: old_point_range.start.row..old_point_range.end.row + 1, + new: new_point_range.start.row..new_point_range.end.row + 1, + } + } + }) + .peekable(); + + let mut new_entries = SumTree::new(); + let mut cursor = self.entries.cursor::(); + + while let Some(mut edit) = row_edits.next() { + while let Some(next_edit) = row_edits.peek() { + if edit.old.end >= next_edit.old.start { + edit.old.end = next_edit.old.end; + edit.new.end = next_edit.new.end; + row_edits.next(); + } else { + break; + } + } + + new_entries.append(cursor.slice(&edit.old.start, Bias::Right, &()), &()); + + if edit.new.start > new_entries.summary().rows { + new_entries.push( + GitBlameEntry { + rows: edit.new.start - new_entries.summary().rows, + blame: cursor.item().and_then(|entry| entry.blame.clone()), + }, + &(), + ); + } + + cursor.seek(&edit.old.end, Bias::Right, &()); + if !edit.new.is_empty() { + new_entries.push( + GitBlameEntry { + rows: edit.new.len() as u32, + blame: None, + }, + &(), + ); + } + + let old_end = cursor.end(&()); + if row_edits + .peek() + .map_or(true, |next_edit| next_edit.old.start >= old_end) + { + if let Some(entry) = cursor.item() { + if old_end > edit.old.end { + new_entries.push( + GitBlameEntry { + rows: cursor.end(&()) - edit.old.end, + blame: entry.blame.clone(), + }, + &(), + ); + } + + cursor.next(&()); + } + } + } + new_entries.append(cursor.suffix(&()), &()); + drop(cursor); + + self.buffer_snapshot = new_snapshot; + self.entries = new_entries; + } + + #[cfg(test)] + fn check_invariants(&mut self, cx: &mut ModelContext) { + self.sync(cx); + assert_eq!( + self.entries.summary().rows, + self.buffer.read(cx).max_point().row + 1 + ); + } + + fn generate(&mut self, cx: &mut ModelContext) { + let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe()); + let snapshot = self.buffer.read(cx).snapshot(); + let blame = self.project.read(cx).blame_buffer(&self.buffer, None, cx); + + self.task = cx.spawn(|this, mut cx| async move { + let (entries, permalinks, messages) = cx + .background_executor() + .spawn({ + let snapshot = snapshot.clone(); + async move { + let Blame { + entries, + permalinks, + messages, + } = blame.await?; + + let mut current_row = 0; + let mut entries = SumTree::from_iter( + entries.into_iter().flat_map(|entry| { + let mut entries = SmallVec::<[GitBlameEntry; 2]>::new(); + + if entry.range.start > current_row { + let skipped_rows = entry.range.start - current_row; + entries.push(GitBlameEntry { + rows: skipped_rows, + blame: None, + }); + } + entries.push(GitBlameEntry { + rows: entry.range.len() as u32, + blame: Some(entry.clone()), + }); + + current_row = entry.range.end; + entries + }), + &(), + ); + + let max_row = snapshot.max_point().row; + if max_row >= current_row { + entries.push( + GitBlameEntry { + rows: (max_row + 1) - current_row, + blame: None, + }, + &(), + ); + } + + anyhow::Ok((entries, permalinks, messages)) + } + }) + .await?; + + this.update(&mut cx, |this, cx| { + this.buffer_edits = buffer_edits; + this.buffer_snapshot = snapshot; + this.entries = entries; + this.permalinks = permalinks; + this.messages = messages; + this.generated = true; + cx.notify(); + }) + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::Context; + use language::{Point, Rope}; + use project::FakeFs; + use rand::prelude::*; + use serde_json::json; + use settings::SettingsStore; + use std::{cmp, env, ops::Range, path::Path}; + use unindent::Unindent as _; + use util::RandomCharIter; + + macro_rules! assert_blame_rows { + ($blame:expr, $rows:expr, $expected:expr, $cx:expr) => { + assert_eq!( + $blame + .blame_for_rows($rows.map(Some), $cx) + .collect::>(), + $expected + ); + }; + } + + fn init_test(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + + theme::init(theme::LoadThemes::JustBase, cx); + + language::init(cx); + client::init_settings(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + + crate::init(cx); + }); + } + + #[gpui::test] + async fn test_blame_for_rows(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/my-repo", + json!({ + ".git": {}, + "file.txt": r#" + AAA Line 1 + BBB Line 2 - Modified 1 + CCC Line 3 - Modified 2 + modified in memory 1 + modified in memory 1 + DDD Line 4 - Modified 2 + EEE Line 5 - Modified 1 + FFF Line 6 - Modified 2 + "# + .unindent() + }), + ) + .await; + + fs.set_blame_for_repo( + Path::new("/my-repo/.git"), + vec![( + Path::new("file.txt"), + Blame { + entries: vec![ + blame_entry("1b1b1b", 0..1), + blame_entry("0d0d0d", 1..2), + blame_entry("3a3a3a", 2..3), + blame_entry("3a3a3a", 5..6), + blame_entry("0d0d0d", 6..7), + blame_entry("3a3a3a", 7..8), + ], + ..Default::default() + }, + )], + ); + let project = Project::test(fs, ["/my-repo".as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/my-repo/file.txt", cx) + }) + .await + .unwrap(); + + let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx)); + + cx.executor().run_until_parked(); + + git_blame.update(cx, |blame, cx| { + // All lines + assert_eq!( + blame + .blame_for_rows((0..8).map(Some), cx) + .collect::>(), + vec![ + Some(blame_entry("1b1b1b", 0..1)), + Some(blame_entry("0d0d0d", 1..2)), + Some(blame_entry("3a3a3a", 2..3)), + None, + None, + Some(blame_entry("3a3a3a", 5..6)), + Some(blame_entry("0d0d0d", 6..7)), + Some(blame_entry("3a3a3a", 7..8)), + ] + ); + // Subset of lines + assert_eq!( + blame + .blame_for_rows((1..4).map(Some), cx) + .collect::>(), + vec![ + Some(blame_entry("0d0d0d", 1..2)), + Some(blame_entry("3a3a3a", 2..3)), + None + ] + ); + // Subset of lines, with some not displayed + assert_eq!( + blame + .blame_for_rows(vec![Some(1), None, None], cx) + .collect::>(), + vec![Some(blame_entry("0d0d0d", 1..2)), None, None] + ); + }); + } + + #[gpui::test] + async fn test_blame_for_rows_with_edits(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/my-repo", + json!({ + ".git": {}, + "file.txt": r#" + Line 1 + Line 2 + Line 3 + "# + .unindent() + }), + ) + .await; + + fs.set_blame_for_repo( + Path::new("/my-repo/.git"), + vec![( + Path::new("file.txt"), + Blame { + entries: vec![blame_entry("1b1b1b", 0..4)], + ..Default::default() + }, + )], + ); + + let project = Project::test(fs, ["/my-repo".as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/my-repo/file.txt", cx) + }) + .await + .unwrap(); + + let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx)); + + cx.executor().run_until_parked(); + + git_blame.update(cx, |blame, cx| { + // Sanity check before edits: make sure that we get the same blame entry for all + // lines. + assert_blame_rows!( + blame, + (0..4), + vec![ + Some(blame_entry("1b1b1b", 0..4)), + Some(blame_entry("1b1b1b", 0..4)), + Some(blame_entry("1b1b1b", 0..4)), + Some(blame_entry("1b1b1b", 0..4)), + ], + cx + ); + }); + + // Modify a single line, at the start of the line + buffer.update(cx, |buffer, cx| { + buffer.edit([(Point::new(0, 0)..Point::new(0, 0), "X")], None, cx); + }); + git_blame.update(cx, |blame, cx| { + assert_blame_rows!( + blame, + (0..2), + vec![None, Some(blame_entry("1b1b1b", 0..4))], + cx + ); + }); + // Modify a single line, in the middle of the line + buffer.update(cx, |buffer, cx| { + buffer.edit([(Point::new(1, 2)..Point::new(1, 2), "X")], None, cx); + }); + git_blame.update(cx, |blame, cx| { + assert_blame_rows!( + blame, + (1..4), + vec![ + None, + Some(blame_entry("1b1b1b", 0..4)), + Some(blame_entry("1b1b1b", 0..4)) + ], + cx + ); + }); + + // Before we insert a newline at the end, sanity check: + git_blame.update(cx, |blame, cx| { + assert_blame_rows!(blame, (3..4), vec![Some(blame_entry("1b1b1b", 0..4))], cx); + }); + // Insert a newline at the end + buffer.update(cx, |buffer, cx| { + buffer.edit([(Point::new(3, 6)..Point::new(3, 6), "\n")], None, cx); + }); + // Only the new line is marked as edited: + git_blame.update(cx, |blame, cx| { + assert_blame_rows!( + blame, + (3..5), + vec![Some(blame_entry("1b1b1b", 0..4)), None], + cx + ); + }); + + // Before we insert a newline at the start, sanity check: + git_blame.update(cx, |blame, cx| { + assert_blame_rows!(blame, (2..3), vec![Some(blame_entry("1b1b1b", 0..4)),], cx); + }); + + // Usage example + // Insert a newline at the start of the row + buffer.update(cx, |buffer, cx| { + buffer.edit([(Point::new(2, 0)..Point::new(2, 0), "\n")], None, cx); + }); + // Only the new line is marked as edited: + git_blame.update(cx, |blame, cx| { + assert_blame_rows!( + blame, + (2..4), + vec![None, Some(blame_entry("1b1b1b", 0..4)),], + cx + ); + }); + } + + #[gpui::test(iterations = 100)] + async fn test_blame_random(mut rng: StdRng, cx: &mut gpui::TestAppContext) { + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(10); + let max_edits_per_operation = env::var("MAX_EDITS_PER_OPERATION") + .map(|i| { + i.parse() + .expect("invalid `MAX_EDITS_PER_OPERATION` variable") + }) + .unwrap_or(5); + + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let buffer_initial_text_len = rng.gen_range(5..15); + let mut buffer_initial_text = Rope::from( + RandomCharIter::new(&mut rng) + .take(buffer_initial_text_len) + .collect::() + .as_str(), + ); + + let mut newline_ixs = (0..buffer_initial_text_len).choose_multiple(&mut rng, 5); + newline_ixs.sort_unstable(); + for newline_ix in newline_ixs.into_iter().rev() { + let newline_ix = buffer_initial_text.clip_offset(newline_ix, Bias::Right); + buffer_initial_text.replace(newline_ix..newline_ix, "\n"); + } + log::info!("initial buffer text: {:?}", buffer_initial_text); + + fs.insert_tree( + "/my-repo", + json!({ + ".git": {}, + "file.txt": buffer_initial_text.to_string() + }), + ) + .await; + + let blame_entries = gen_blame_entries(buffer_initial_text.max_point().row, &mut rng); + log::info!("initial blame entries: {:?}", blame_entries); + fs.set_blame_for_repo( + Path::new("/my-repo/.git"), + vec![( + Path::new("file.txt"), + Blame { + entries: blame_entries, + ..Default::default() + }, + )], + ); + + let project = Project::test(fs.clone(), ["/my-repo".as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/my-repo/file.txt", cx) + }) + .await + .unwrap(); + + let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx)); + cx.executor().run_until_parked(); + git_blame.update(cx, |blame, cx| blame.check_invariants(cx)); + + for _ in 0..operations { + match rng.gen_range(0..100) { + 0..=19 => { + log::info!("quiescing"); + cx.executor().run_until_parked(); + } + 20..=69 => { + log::info!("editing buffer"); + buffer.update(cx, |buffer, cx| { + buffer.randomly_edit(&mut rng, max_edits_per_operation, cx); + log::info!("buffer text: {:?}", buffer.text()); + }); + + let blame_entries = gen_blame_entries( + buffer.read_with(cx, |buffer, _| buffer.max_point().row), + &mut rng, + ); + log::info!("regenerating blame entries: {:?}", blame_entries); + + fs.set_blame_for_repo( + Path::new("/my-repo/.git"), + vec![( + Path::new("file.txt"), + Blame { + entries: blame_entries, + ..Default::default() + }, + )], + ); + } + _ => { + git_blame.update(cx, |blame, cx| blame.check_invariants(cx)); + } + } + } + + git_blame.update(cx, |blame, cx| blame.check_invariants(cx)); + } + + fn gen_blame_entries(max_row: u32, rng: &mut StdRng) -> Vec { + let mut last_row = 0; + let mut blame_entries = Vec::new(); + for ix in 0..5 { + if last_row < max_row { + let row_start = rng.gen_range(last_row..max_row); + let row_end = rng.gen_range(row_start + 1..cmp::min(row_start + 3, max_row) + 1); + blame_entries.push(blame_entry(&ix.to_string(), row_start..row_end)); + last_row = row_end; + } else { + break; + } + } + blame_entries + } + + fn blame_entry(sha: &str, range: Range) -> BlameEntry { + BlameEntry { + sha: sha.parse().unwrap(), + range, + ..Default::default() + } + } +} diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index e7f0397485..cc50e6a603 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -10,6 +10,31 @@ pub struct MouseContextMenu { _subscription: Subscription, } +impl MouseContextMenu { + pub(crate) fn new( + position: Point, + context_menu: View, + cx: &mut ViewContext, + ) -> Self { + let context_menu_focus = context_menu.focus_handle(cx); + cx.focus(&context_menu_focus); + + let _subscription = + cx.subscribe(&context_menu, move |this, _, _event: &DismissEvent, cx| { + this.mouse_context_menu.take(); + if context_menu_focus.contains_focused(cx) { + this.focus(cx); + } + }); + + Self { + position, + context_menu, + _subscription, + } + } +} + pub fn deploy_context_menu( editor: &mut Editor, position: Point, @@ -60,21 +85,8 @@ pub fn deploy_context_menu( .action("Reveal in Finder", Box::new(RevealInFinder)) }) }; - let context_menu_focus = context_menu.focus_handle(cx); - cx.focus(&context_menu_focus); - - let _subscription = cx.subscribe(&context_menu, move |this, _, _event: &DismissEvent, cx| { - this.mouse_context_menu.take(); - if context_menu_focus.contains_focused(cx) { - this.focus(cx); - } - }); - - editor.mouse_context_menu = Some(MouseContextMenu { - position, - context_menu, - _subscription, - }); + let mouse_context_menu = MouseContextMenu::new(position, context_menu, cx); + editor.mouse_context_menu = Some(mouse_context_menu); cx.notify(); } diff --git a/crates/extension/src/extension_store_test.rs b/crates/extension/src/extension_store_test.rs index 62643130e0..079634f191 100644 --- a/crates/extension/src/extension_store_test.rs +++ b/crates/extension/src/extension_store_test.rs @@ -449,7 +449,7 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) { let cache_dir = root_dir.join("target"); let gleam_extension_dir = root_dir.join("extensions").join("gleam"); - let fs = Arc::new(RealFs); + let fs = Arc::new(RealFs::default()); let extensions_dir = temp_tree(json!({ "installed": {}, "work": {} diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 24da6dac96..509e4514cd 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -36,7 +36,7 @@ async fn main() -> Result<()> { env_logger::init(); let args = Args::parse(); - let fs = Arc::new(RealFs); + let fs = Arc::new(RealFs::default()); let engine = wasmtime::Engine::default(); let mut wasm_store = WasmStore::new(engine)?; diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 1ad427de5c..9d1ec496f5 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -26,6 +26,7 @@ tempfile.workspace = true lazy_static.workspace = true parking_lot.workspace = true smol.workspace = true +git.workspace = true git2.workspace = true serde.workspace = true serde_derive.workspace = true diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 62bf24864d..7837ec034d 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -9,7 +9,7 @@ use async_tar::Archive; use futures::{future::BoxFuture, AsyncRead, Stream, StreamExt}; use git2::Repository as LibGitRepository; use parking_lot::Mutex; -use repository::GitRepository; +use repository::{GitRepository, RealGitRepository}; use rope::Rope; #[cfg(any(test, feature = "test-support"))] use smol::io::AsyncReadExt; @@ -111,7 +111,16 @@ pub struct Metadata { pub is_dir: bool, } -pub struct RealFs; +#[derive(Default)] +pub struct RealFs { + git_binary_path: Option, +} + +impl RealFs { + pub fn new(git_binary_path: Option) -> Self { + Self { git_binary_path } + } +} #[async_trait::async_trait] impl Fs for RealFs { @@ -431,7 +440,10 @@ impl Fs for RealFs { LibGitRepository::open(dotgit_path) .log_err() .map::>, _>(|libgit_repository| { - Arc::new(Mutex::new(libgit_repository)) + Arc::new(Mutex::new(RealGitRepository::new( + libgit_repository, + self.git_binary_path.clone(), + ))) }) } @@ -824,6 +836,17 @@ impl FakeFs { }); } + pub fn set_blame_for_repo(&self, dot_git: &Path, blames: Vec<(&Path, git::blame::Blame)>) { + self.with_git_state(dot_git, true, |state| { + state.blames.clear(); + state.blames.extend( + blames + .into_iter() + .map(|(path, blame)| (path.to_path_buf(), blame)), + ); + }); + } + pub fn set_status_for_repo_via_working_copy_change( &self, dot_git: &Path, diff --git a/crates/fs/src/repository.rs b/crates/fs/src/repository.rs index ae69025f0a..3eca321d66 100644 --- a/crates/fs/src/repository.rs +++ b/crates/fs/src/repository.rs @@ -1,7 +1,9 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use collections::HashMap; +use git::blame::Blame; use git2::{BranchType, StatusShow}; use parking_lot::Mutex; +use rope::Rope; use serde_derive::{Deserialize, Serialize}; use std::{ cmp::Ordering, @@ -53,6 +55,8 @@ pub trait GitRepository: Send { fn branches(&self) -> Result>; fn change_branch(&self, _: &str) -> Result<()>; fn create_branch(&self, _: &str) -> Result<()>; + + fn blame(&self, path: &Path, content: Rope) -> Result; } impl std::fmt::Debug for dyn GitRepository { @@ -61,9 +65,23 @@ impl std::fmt::Debug for dyn GitRepository { } } -impl GitRepository for LibGitRepository { +pub struct RealGitRepository { + pub repository: LibGitRepository, + pub git_binary_path: PathBuf, +} + +impl RealGitRepository { + pub fn new(repository: LibGitRepository, git_binary_path: Option) -> Self { + Self { + repository, + git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")), + } + } +} + +impl GitRepository for RealGitRepository { fn reload_index(&self) { - if let Ok(mut index) = self.index() { + if let Ok(mut index) = self.repository.index() { _ = index.read(false); } } @@ -85,7 +103,7 @@ impl GitRepository for LibGitRepository { Ok(Some(String::from_utf8(content)?)) } - match logic(self, relative_file_path) { + match logic(&self.repository, relative_file_path) { Ok(value) => return value, Err(err) => log::error!("Error loading head text: {:?}", err), } @@ -93,18 +111,18 @@ impl GitRepository for LibGitRepository { } fn remote_url(&self, name: &str) -> Option { - let remote = self.find_remote(name).ok()?; + let remote = self.repository.find_remote(name).ok()?; remote.url().map(|url| url.to_string()) } fn branch_name(&self) -> Option { - let head = self.head().log_err()?; + let head = self.repository.head().log_err()?; let branch = String::from_utf8_lossy(head.shorthand_bytes()); Some(branch.to_string()) } fn head_sha(&self) -> Option { - let head = self.head().ok()?; + let head = self.repository.head().ok()?; head.target().map(|oid| oid.to_string()) } @@ -115,7 +133,7 @@ impl GitRepository for LibGitRepository { options.pathspec(path_prefix); options.show(StatusShow::Index); - if let Some(statuses) = self.statuses(Some(&mut options)).log_err() { + if let Some(statuses) = self.repository.statuses(Some(&mut options)).log_err() { for status in statuses.iter() { let path = RepoPath(PathBuf::try_from_bytes(status.path_bytes()).unwrap()); let status = status.status(); @@ -132,7 +150,7 @@ impl GitRepository for LibGitRepository { fn unstaged_status(&self, path: &RepoPath, mtime: SystemTime) -> Option { // If the file has not changed since it was added to the index, then // there can't be any changes. - if matches_index(self, path, mtime) { + if matches_index(&self.repository, path, mtime) { return None; } @@ -144,7 +162,7 @@ impl GitRepository for LibGitRepository { options.include_unmodified(true); options.show(StatusShow::Workdir); - let statuses = self.statuses(Some(&mut options)).log_err()?; + let statuses = self.repository.statuses(Some(&mut options)).log_err()?; let status = statuses.get(0).and_then(|s| read_status(s.status())); status } @@ -160,17 +178,17 @@ impl GitRepository for LibGitRepository { // If the file has not changed since it was added to the index, then // there's no need to examine the working directory file: just compare // the blob in the index to the one in the HEAD commit. - if matches_index(self, path, mtime) { + if matches_index(&self.repository, path, mtime) { options.show(StatusShow::Index); } - let statuses = self.statuses(Some(&mut options)).log_err()?; + let statuses = self.repository.statuses(Some(&mut options)).log_err()?; let status = statuses.get(0).and_then(|s| read_status(s.status())); status } fn branches(&self) -> Result> { - let local_branches = self.branches(Some(BranchType::Local))?; + let local_branches = self.repository.branches(Some(BranchType::Local))?; let valid_branches = local_branches .filter_map(|branch| { branch.ok().and_then(|(branch, _)| { @@ -192,11 +210,11 @@ impl GitRepository for LibGitRepository { Ok(valid_branches) } fn change_branch(&self, name: &str) -> Result<()> { - let revision = self.find_branch(name, BranchType::Local)?; + let revision = self.repository.find_branch(name, BranchType::Local)?; let revision = revision.get(); let as_tree = revision.peel_to_tree()?; - self.checkout_tree(as_tree.as_object(), None)?; - self.set_head( + self.repository.checkout_tree(as_tree.as_object(), None)?; + self.repository.set_head( revision .name() .ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?, @@ -204,11 +222,29 @@ impl GitRepository for LibGitRepository { Ok(()) } fn create_branch(&self, name: &str) -> Result<()> { - let current_commit = self.head()?.peel_to_commit()?; - self.branch(name, ¤t_commit, false)?; + let current_commit = self.repository.head()?.peel_to_commit()?; + self.repository.branch(name, ¤t_commit, false)?; Ok(()) } + + fn blame(&self, path: &Path, content: Rope) -> Result { + let git_dir_path = self.repository.path(); + let working_directory = git_dir_path.parent().with_context(|| { + format!("failed to get git working directory for {:?}", git_dir_path) + })?; + + const REMOTE_NAME: &str = "origin"; + let remote_url = self.remote_url(REMOTE_NAME); + + git::blame::Blame::for_path( + &self.git_binary_path, + working_directory, + path, + &content, + remote_url, + ) + } } fn matches_index(repo: &LibGitRepository, path: &RepoPath, mtime: SystemTime) -> bool { @@ -251,6 +287,7 @@ pub struct FakeGitRepository { #[derive(Debug, Clone, Default)] pub struct FakeGitRepositoryState { pub index_contents: HashMap, + pub blames: HashMap, pub worktree_statuses: HashMap, pub branch_name: Option, } @@ -317,6 +354,15 @@ impl GitRepository for FakeGitRepository { state.branch_name = Some(name.to_owned()); Ok(()) } + + fn blame(&self, path: &Path, _content: Rope) -> Result { + let state = self.state.lock(); + state + .blames + .get(path) + .with_context(|| format!("failed to get blame for {:?}", path)) + .cloned() + } } fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 4a47b74d59..d17dc7aa2a 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -12,16 +12,23 @@ workspace = true path = "src/git.rs" [dependencies] +anyhow.workspace = true clock.workspace = true +collections.workspace = true git2.workspace = true lazy_static.workspace = true log.workspace = true smol.workspace = true sum_tree.workspace = true text.workspace = true +time.workspace = true +url.workspace = true +serde.workspace = true [dev-dependencies] unindent.workspace = true +serde_json.workspace = true +pretty_assertions.workspace = true [features] test-support = [] diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs new file mode 100644 index 0000000000..45b2e0c7ef --- /dev/null +++ b/crates/git/src/blame.rs @@ -0,0 +1,358 @@ +use crate::commit::get_messages; +use crate::permalink::{build_commit_permalink, parse_git_remote_url, BuildCommitPermalinkParams}; +use crate::Oid; +use anyhow::{anyhow, Context, Result}; +use collections::{HashMap, HashSet}; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::process::{Command, Stdio}; +use std::{ops::Range, path::Path}; +use text::Rope; +use time; +use time::macros::format_description; +use time::OffsetDateTime; +use time::UtcOffset; +use url::Url; + +pub use git2 as libgit; + +#[derive(Debug, Clone, Default)] +pub struct Blame { + pub entries: Vec, + pub messages: HashMap, + pub permalinks: HashMap, +} + +impl Blame { + pub fn for_path( + git_binary: &Path, + working_directory: &Path, + path: &Path, + content: &Rope, + remote_url: Option, + ) -> Result { + let output = run_git_blame(git_binary, working_directory, path, &content)?; + let mut entries = parse_git_blame(&output)?; + entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); + + let mut permalinks = HashMap::default(); + let mut unique_shas = HashSet::default(); + let parsed_remote_url = remote_url.as_deref().and_then(parse_git_remote_url); + + for entry in entries.iter_mut() { + unique_shas.insert(entry.sha); + if let Some(remote) = parsed_remote_url.as_ref() { + permalinks.entry(entry.sha).or_insert_with(|| { + build_commit_permalink(BuildCommitPermalinkParams { + remote, + sha: entry.sha.to_string().as_str(), + }) + }); + } + } + + let shas = unique_shas.into_iter().collect::>(); + let messages = + get_messages(&working_directory, &shas).context("failed to get commit messages")?; + + Ok(Self { + entries, + permalinks, + messages, + }) + } +} + +fn run_git_blame( + git_binary: &Path, + working_directory: &Path, + path: &Path, + contents: &Rope, +) -> Result { + let child = Command::new(git_binary) + .current_dir(working_directory) + .arg("blame") + .arg("--incremental") + .arg("--contents") + .arg("-") + .arg(path.as_os_str()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|e| anyhow!("Failed to start git blame process: {}", e))?; + + let mut stdin = child + .stdin + .as_ref() + .context("failed to get pipe to stdin of git blame command")?; + + for chunk in contents.chunks() { + stdin.write_all(chunk.as_bytes())?; + } + stdin.flush()?; + + let output = child + .wait_with_output() + .map_err(|e| anyhow!("Failed to read git blame output: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("git blame process failed: {}", stderr)); + } + + Ok(String::from_utf8(output.stdout)?) +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +pub struct BlameEntry { + pub sha: Oid, + + pub range: Range, + + pub original_line_number: u32, + + pub author: Option, + pub author_mail: Option, + pub author_time: Option, + pub author_tz: Option, + + pub committer: Option, + pub committer_mail: Option, + pub committer_time: Option, + pub committer_tz: Option, + + pub summary: Option, + + pub previous: Option, + pub filename: String, +} + +impl BlameEntry { + // Returns a BlameEntry by parsing the first line of a `git blame --incremental` + // entry. The line MUST have this format: + // + // <40-byte-hex-sha1> + fn new_from_blame_line(line: &str) -> Result { + let mut parts = line.split_whitespace(); + + let sha = parts + .next() + .and_then(|line| line.parse::().ok()) + .ok_or_else(|| anyhow!("failed to parse sha"))?; + + let original_line_number = parts + .next() + .and_then(|line| line.parse::().ok()) + .ok_or_else(|| anyhow!("Failed to parse original line number"))?; + let final_line_number = parts + .next() + .and_then(|line| line.parse::().ok()) + .ok_or_else(|| anyhow!("Failed to parse final line number"))?; + + let line_count = parts + .next() + .and_then(|line| line.parse::().ok()) + .ok_or_else(|| anyhow!("Failed to parse final line number"))?; + + let start_line = final_line_number.saturating_sub(1); + let end_line = start_line + line_count; + let range = start_line..end_line; + + Ok(Self { + sha, + range, + original_line_number, + ..Default::default() + }) + } + + pub fn author_offset_date_time(&self) -> Result { + if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) { + let format = format_description!("[offset_hour][offset_minute]"); + let offset = UtcOffset::parse(author_tz, &format)?; + let date_time_utc = OffsetDateTime::from_unix_timestamp(author_time)?; + + Ok(date_time_utc.to_offset(offset)) + } else { + // Directly return current time in UTC if there's no committer time or timezone + Ok(time::OffsetDateTime::now_utc()) + } + } +} + +// parse_git_blame parses the output of `git blame --incremental`, which returns +// all the blame-entries for a given path incrementally, as it finds them. +// +// Each entry *always* starts with: +// +// <40-byte-hex-sha1> +// +// Each entry *always* ends with: +// +// filename +// +// Line numbers are 1-indexed. +// +// A `git blame --incremental` entry looks like this: +// +// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1 +// author Joe Schmoe +// author-mail +// author-time 1709741400 +// author-tz +0100 +// committer Joe Schmoe +// committer-mail +// committer-time 1709741400 +// committer-tz +0100 +// summary Joe's cool commit +// previous 486c2409237a2c627230589e567024a96751d475 index.js +// filename index.js +// +// If the entry has the same SHA as an entry that was already printed then no +// signature information is printed: +// +// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1 +// previous 486c2409237a2c627230589e567024a96751d475 index.js +// filename index.js +// +// More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html +fn parse_git_blame(output: &str) -> Result> { + let mut entries: Vec = Vec::new(); + let mut index: HashMap = HashMap::default(); + + let mut current_entry: Option = None; + + for line in output.lines() { + let mut done = false; + + match &mut current_entry { + None => { + let mut new_entry = BlameEntry::new_from_blame_line(line)?; + + if let Some(existing_entry) = index + .get(&new_entry.sha) + .and_then(|slot| entries.get(*slot)) + { + new_entry.author = existing_entry.author.clone(); + new_entry.author_mail = existing_entry.author_mail.clone(); + new_entry.author_time = existing_entry.author_time; + new_entry.author_tz = existing_entry.author_tz.clone(); + new_entry.committer = existing_entry.committer.clone(); + new_entry.committer_mail = existing_entry.committer_mail.clone(); + new_entry.committer_time = existing_entry.committer_time; + new_entry.committer_tz = existing_entry.committer_tz.clone(); + new_entry.summary = existing_entry.summary.clone(); + } + + current_entry.replace(new_entry); + } + Some(entry) => { + let Some((key, value)) = line.split_once(' ') else { + continue; + }; + let is_committed = !entry.sha.is_zero(); + match key { + "filename" => { + entry.filename = value.into(); + done = true; + } + "previous" => entry.previous = Some(value.into()), + + "summary" if is_committed => entry.summary = Some(value.into()), + "author" if is_committed => entry.author = Some(value.into()), + "author-mail" if is_committed => entry.author_mail = Some(value.into()), + "author-time" if is_committed => { + entry.author_time = Some(value.parse::()?) + } + "author-tz" if is_committed => entry.author_tz = Some(value.into()), + + "committer" if is_committed => entry.committer = Some(value.into()), + "committer-mail" if is_committed => entry.committer_mail = Some(value.into()), + "committer-time" if is_committed => { + entry.committer_time = Some(value.parse::()?) + } + "committer-tz" if is_committed => entry.committer_tz = Some(value.into()), + _ => {} + } + } + }; + + if done { + if let Some(entry) = current_entry.take() { + index.insert(entry.sha, entries.len()); + + // We only want annotations that have a commit. + if !entry.sha.is_zero() { + entries.push(entry); + } + } + } + } + + Ok(entries) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::parse_git_blame; + use super::BlameEntry; + + fn read_test_data(filename: &str) -> String { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("test_data"); + path.push(filename); + + std::fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("Could not read test data at {:?}. Is it generated?", path)) + } + + fn assert_eq_golden(entries: &Vec, golden_filename: &str) { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("test_data"); + path.push("golden"); + path.push(format!("{}.json", golden_filename)); + + let have_json = + serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON"); + + let update = std::env::var("UPDATE_GOLDEN") + .map(|val| val.to_ascii_lowercase() == "true") + .unwrap_or(false); + + if update { + std::fs::create_dir_all(path.parent().unwrap()) + .expect("could not create golden test data directory"); + std::fs::write(&path, have_json).expect("could not write out golden data"); + } else { + let want_json = + std::fs::read_to_string(&path).unwrap_or_else(|_| { + panic!("could not read golden test data file at {:?}. Did you run the test with UPDATE_GOLDEN=true before?", path); + }); + + pretty_assertions::assert_eq!(have_json, want_json, "wrong blame entries"); + } + } + + #[test] + fn test_parse_git_blame_not_committed() { + let output = read_test_data("blame_incremental_not_committed"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_not_committed"); + } + + #[test] + fn test_parse_git_blame_simple() { + let output = read_test_data("blame_incremental_simple"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_simple"); + } + + #[test] + fn test_parse_git_blame_complex() { + let output = read_test_data("blame_incremental_complex"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_complex"); + } +} diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs new file mode 100644 index 0000000000..ad7aeb48f2 --- /dev/null +++ b/crates/git/src/commit.rs @@ -0,0 +1,35 @@ +use crate::Oid; +use anyhow::{anyhow, Result}; +use collections::HashMap; +use std::path::Path; +use std::process::Command; + +pub fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result> { + const MARKER: &'static str = ""; + + let output = Command::new("git") + .current_dir(working_directory) + .arg("show") + .arg("-s") + .arg(format!("--format=%B{}", MARKER)) + .args(shas.iter().map(ToString::to_string)) + .output() + .map_err(|e| anyhow!("Failed to start git blame process: {}", e))?; + + anyhow::ensure!( + output.status.success(), + "'git show' failed with error {:?}", + output.status + ); + + Ok(shas + .iter() + .cloned() + .zip( + String::from_utf8_lossy(&output.stdout) + .trim() + .split_terminator(MARKER) + .map(|str| String::from(str.trim())), + ) + .collect::>()) +} diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index b1b885eca2..e48c677d97 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -1,11 +1,107 @@ +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; use std::ffi::OsStr; +use std::fmt; +use std::str::FromStr; pub use git2 as libgit; pub use lazy_static::lazy_static; +pub mod blame; +pub mod commit; pub mod diff; +pub mod permalink; lazy_static! { pub static ref DOT_GIT: &'static OsStr = OsStr::new(".git"); pub static ref GITIGNORE: &'static OsStr = OsStr::new(".gitignore"); } + +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub struct Oid(libgit::Oid); + +impl Oid { + pub fn from_bytes(bytes: &[u8]) -> Result { + let oid = libgit::Oid::from_bytes(bytes).context("failed to parse bytes into git oid")?; + Ok(Self(oid)) + } + + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + pub(crate) fn is_zero(&self) -> bool { + self.0.is_zero() + } +} + +impl FromStr for Oid { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::prelude::v1::Result { + libgit::Oid::from_str(s) + .map_err(|error| anyhow!("failed to parse git oid: {}", error)) + .map(|oid| Self(oid)) + } +} + +impl fmt::Debug for Oid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl fmt::Display for Oid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl Serialize for Oid { + fn serialize(&self, serializer: S) -> std::prelude::v1::Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl<'de> Deserialize<'de> for Oid { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + s.parse::().map_err(serde::de::Error::custom) + } +} + +impl Default for Oid { + fn default() -> Self { + Self(libgit::Oid::zero()) + } +} + +impl From for u32 { + fn from(oid: Oid) -> Self { + let bytes = oid.0.as_bytes(); + debug_assert!(bytes.len() > 4); + + let mut u32_bytes: [u8; 4] = [0; 4]; + u32_bytes.copy_from_slice(&bytes[..4]); + + u32::from_ne_bytes(u32_bytes) + } +} + +impl From for usize { + fn from(oid: Oid) -> Self { + let bytes = oid.0.as_bytes(); + debug_assert!(bytes.len() > 8); + + let mut u64_bytes: [u8; 8] = [0; 8]; + u64_bytes.copy_from_slice(&bytes[..8]); + + u64::from_ne_bytes(u64_bytes) as usize + } +} diff --git a/crates/editor/src/git/permalink.rs b/crates/git/src/permalink.rs similarity index 92% rename from crates/editor/src/git/permalink.rs rename to crates/git/src/permalink.rs index 90704b43d0..87603e5773 100644 --- a/crates/editor/src/git/permalink.rs +++ b/crates/git/src/permalink.rs @@ -1,10 +1,9 @@ use std::ops::Range; use anyhow::{anyhow, Result}; -use language::Point; use url::Url; -enum GitHostingProvider { +pub(crate) enum GitHostingProvider { Github, Gitlab, Gitee, @@ -29,9 +28,9 @@ impl GitHostingProvider { /// Returns the fragment portion of the URL for the selected lines in /// the representation the [`GitHostingProvider`] expects. - fn line_fragment(&self, selection: &Range) -> String { - if selection.start.row == selection.end.row { - let line = selection.start.row + 1; + fn line_fragment(&self, selection: &Range) -> String { + if selection.start == selection.end { + let line = selection.start + 1; match self { Self::Github | Self::Gitlab | Self::Gitee | Self::Sourcehut | Self::Codeberg => { @@ -40,8 +39,8 @@ impl GitHostingProvider { Self::Bitbucket => format!("lines-{}", line), } } else { - let start_line = selection.start.row + 1; - let end_line = selection.end.row + 1; + let start_line = selection.start + 1; + let end_line = selection.end + 1; match self { Self::Github | Self::Codeberg => format!("L{}-L{}", start_line, end_line), @@ -58,7 +57,7 @@ pub struct BuildPermalinkParams<'a> { pub remote_url: &'a str, pub sha: &'a str, pub path: &'a str, - pub selection: Option>, + pub selection: Option>, } pub fn build_permalink(params: BuildPermalinkParams) -> Result { @@ -88,17 +87,42 @@ pub fn build_permalink(params: BuildPermalinkParams) -> Result { let mut permalink = provider.base_url().join(&path).unwrap(); permalink.set_fragment(line_fragment.as_deref()); - Ok(permalink) } -struct ParsedGitRemote<'a> { +pub(crate) struct ParsedGitRemote<'a> { pub provider: GitHostingProvider, pub owner: &'a str, pub repo: &'a str, } -fn parse_git_remote_url(url: &str) -> Option { +pub(crate) struct BuildCommitPermalinkParams<'a> { + pub remote: &'a ParsedGitRemote<'a>, + pub sha: &'a str, +} + +pub(crate) fn build_commit_permalink(params: BuildCommitPermalinkParams) -> Url { + let BuildCommitPermalinkParams { sha, remote } = params; + + let ParsedGitRemote { + provider, + owner, + repo, + } = remote; + + let path = match provider { + GitHostingProvider::Github => format!("{owner}/{repo}/commits/{sha}"), + GitHostingProvider::Gitlab => format!("{owner}/{repo}/-/commit/{sha}"), + GitHostingProvider::Gitee => format!("{owner}/{repo}/commit/{sha}"), + GitHostingProvider::Bitbucket => format!("{owner}/{repo}/commits/{sha}"), + GitHostingProvider::Sourcehut => format!("~{owner}/{repo}/commit/{sha}"), + GitHostingProvider::Codeberg => format!("{owner}/{repo}/commit/{sha}"), + }; + + provider.base_url().join(&path).unwrap() +} + +pub(crate) fn parse_git_remote_url(url: &str) -> Option { if url.starts_with("git@github.com:") || url.starts_with("https://github.com/") { let repo_with_owner = url .trim_start_matches("git@github.com:") @@ -217,7 +241,7 @@ mod tests { remote_url: "git@github.com:zed-industries/zed.git", sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", path: "crates/editor/src/git/permalink.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -231,7 +255,7 @@ mod tests { remote_url: "git@github.com:zed-industries/zed.git", sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", path: "crates/editor/src/git/permalink.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); @@ -259,7 +283,7 @@ mod tests { remote_url: "https://github.com/zed-industries/zed.git", sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", path: "crates/zed/src/main.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -273,7 +297,7 @@ mod tests { remote_url: "https://github.com/zed-industries/zed.git", sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", path: "crates/zed/src/main.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); @@ -301,7 +325,7 @@ mod tests { remote_url: "git@gitlab.com:zed-industries/zed.git", sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", path: "crates/editor/src/git/permalink.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -315,7 +339,7 @@ mod tests { remote_url: "git@gitlab.com:zed-industries/zed.git", sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", path: "crates/editor/src/git/permalink.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); @@ -343,7 +367,7 @@ mod tests { remote_url: "https://gitlab.com/zed-industries/zed.git", sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", path: "crates/zed/src/main.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -357,7 +381,7 @@ mod tests { remote_url: "https://gitlab.com/zed-industries/zed.git", sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", path: "crates/zed/src/main.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); @@ -385,7 +409,7 @@ mod tests { remote_url: "git@gitee.com:libkitten/zed.git", sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", path: "crates/editor/src/git/permalink.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -399,7 +423,7 @@ mod tests { remote_url: "git@gitee.com:libkitten/zed.git", sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", path: "crates/editor/src/git/permalink.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); @@ -427,7 +451,7 @@ mod tests { remote_url: "https://gitee.com/libkitten/zed.git", sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", path: "crates/zed/src/main.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -441,7 +465,7 @@ mod tests { remote_url: "https://gitee.com/libkitten/zed.git", sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", path: "crates/zed/src/main.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L24-48"; @@ -495,7 +519,7 @@ mod tests { remote_url: "git@bitbucket.org:thorstenzed/testingrepo.git", sha: "f00b4r", path: "main.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -510,7 +534,7 @@ mod tests { remote_url: "git@bitbucket.org:thorstenzed/testingrepo.git", sha: "f00b4r", path: "main.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); @@ -553,7 +577,7 @@ mod tests { remote_url: "git@git.sr.ht:~rajveermalviya/zed", sha: "faa6f979be417239b2e070dbbf6392b909224e0b", path: "crates/editor/src/git/permalink.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -567,7 +591,7 @@ mod tests { remote_url: "git@git.sr.ht:~rajveermalviya/zed", sha: "faa6f979be417239b2e070dbbf6392b909224e0b", path: "crates/editor/src/git/permalink.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); @@ -595,7 +619,7 @@ mod tests { remote_url: "https://git.sr.ht/~rajveermalviya/zed", sha: "faa6f979be417239b2e070dbbf6392b909224e0b", path: "crates/zed/src/main.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -609,7 +633,7 @@ mod tests { remote_url: "https://git.sr.ht/~rajveermalviya/zed", sha: "faa6f979be417239b2e070dbbf6392b909224e0b", path: "crates/zed/src/main.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); @@ -637,7 +661,7 @@ mod tests { remote_url: "git@codeberg.org:rajveermalviya/zed.git", sha: "faa6f979be417239b2e070dbbf6392b909224e0b", path: "crates/editor/src/git/permalink.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -651,7 +675,7 @@ mod tests { remote_url: "git@codeberg.org:rajveermalviya/zed.git", sha: "faa6f979be417239b2e070dbbf6392b909224e0b", path: "crates/editor/src/git/permalink.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); @@ -679,7 +703,7 @@ mod tests { remote_url: "https://codeberg.org/rajveermalviya/zed.git", sha: "faa6f979be417239b2e070dbbf6392b909224e0b", path: "crates/zed/src/main.rs", - selection: Some(Point::new(6, 1)..Point::new(6, 10)), + selection: Some(6..6), }) .unwrap(); @@ -693,7 +717,7 @@ mod tests { remote_url: "https://codeberg.org/rajveermalviya/zed.git", sha: "faa6f979be417239b2e070dbbf6392b909224e0b", path: "crates/zed/src/main.rs", - selection: Some(Point::new(23, 1)..Point::new(47, 10)), + selection: Some(23..47), }) .unwrap(); diff --git a/crates/git/test_data/blame_incremental_complex b/crates/git/test_data/blame_incremental_complex new file mode 100644 index 0000000000..76829b6481 --- /dev/null +++ b/crates/git/test_data/blame_incremental_complex @@ -0,0 +1,194 @@ +5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206 6 6 3 +author Mahdy M. Karam +author-mail <64036912+mmkaram@users.noreply.github.com> +author-time 1708621949 +author-tz -0800 +committer GitHub +committer-mail +committer-time 1708621949 +committer-tz -0700 +summary Add option to either use system clipboard or vim clipboard (#7936) +previous c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206 12 12 2 +previous c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206 18 18 1 +previous c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206 21 21 7 +previous c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206 31 31 1 +previous c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206 34 34 1 +previous c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206 86 86 16 +previous c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 1 1 2 +author Conrad Irwin +author-mail +author-time 1707520689 +author-tz -0700 +committer GitHub +committer-mail +committer-time 1707520689 +committer-tz -0700 +summary Highlight selections on vim yank (#7638) +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 4 4 1 +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 7 10 2 +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 10 14 4 +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 15 19 2 +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 19 28 3 +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 22 32 2 +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 25 35 2 +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 31 41 1 +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 57 67 5 +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +3635d2dcedd83f1b6702f33ca2673317f7fa4695 78 102 18 +previous efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +b65cf6d2d9576171edb407f5bbaa231c33af1f71 3 5 1 +author Max Brunsfeld +author-mail +author-time 1705619094 +author-tz -0800 +committer Max Brunsfeld +committer-mail +committer-time 1705619205 +committer-tz -0800 +summary Merge branch 'main' into language-api-docs +previous 6457ccf9ece3b36a37e675783abee9748a443115 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +b65cf6d2d9576171edb407f5bbaa231c33af1f71 51 121 8 +previous 6457ccf9ece3b36a37e675783abee9748a443115 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +b02bd9bce1db3a68dcd606718fa02709020860af 29 61 1 +author Conrad Irwin +author-mail +author-time 1694798044 +author-tz -0600 +committer Conrad Irwin +committer-mail +committer-time 1694798044 +committer-tz -0600 +summary Fix Y on last line with no trailing new line +previous 7c77baa7c17eea106330622e70513ea9389d50a1 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +b02bd9bce1db3a68dcd606718fa02709020860af 33 65 1 +previous 7c77baa7c17eea106330622e70513ea9389d50a1 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +e4794e3134b6449e36ed2771a8849046489cc252 13 45 1 +author Conrad Irwin +author-mail +author-time 1692855942 +author-tz -0600 +committer Conrad Irwin +committer-mail +committer-time 1692856812 +committer-tz -0600 +summary vim: Fix linewise copy of last line with no trailing newline +previous 26c3312049a9c73bc3350528c1defd3820a7a8c7 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +e4794e3134b6449e36ed2771a8849046489cc252 21 53 8 +previous 26c3312049a9c73bc3350528c1defd3820a7a8c7 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +e4794e3134b6449e36ed2771a8849046489cc252 29 62 3 +previous 26c3312049a9c73bc3350528c1defd3820a7a8c7 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +e4794e3134b6449e36ed2771a8849046489cc252 33 66 1 +previous 26c3312049a9c73bc3350528c1defd3820a7a8c7 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +e4794e3134b6449e36ed2771a8849046489cc252 37 75 3 +previous 26c3312049a9c73bc3350528c1defd3820a7a8c7 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +33d7fe02ee560f6ed57d1425c43e60aef3b66e64 10 43 1 +author Conrad Irwin +author-mail +author-time 1692644159 +author-tz -0600 +committer Conrad Irwin +committer-mail +committer-time 1692732477 +committer-tz -0600 +summary Rewrite paste +previous 31db5e4f62e8fca75aa0870f903ae044524c3580 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +33d7fe02ee560f6ed57d1425c43e60aef3b66e64 14 47 6 +previous 31db5e4f62e8fca75aa0870f903ae044524c3580 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +868c46062008bb0bcab2d41a38b4295996b9b958 20 81 1 +author Max Brunsfeld +author-mail +author-time 1659072896 +author-tz -0700 +committer Max Brunsfeld +committer-mail +committer-time 1659073230 +committer-tz -0700 +summary :art: Rename and simplify some autoindent stuff +previous 7a26fa18c7fee3fe031b991e18b55fd8f9c4eb1b crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +e93c49f4f02b3edaddae6a6a4cc0ac433f242357 5 37 1 +author Kaylee Simmons +author-mail +author-time 1653424557 +author-tz -0700 +committer Kaylee Simmons +committer-mail +committer-time 1653609725 +committer-tz -0700 +summary Unify visual line_mode and non line_mode operators +previous 11569a869a72f786a9798c53266e28c05c79f824 crates/vim/src/utils.rs +filename crates/vim/src/utils.rs +082036161fd3815c831ceedfd28ba15b0ed6eb9f 1 3 1 +author Kaylee Simmons +author-mail +author-time 1653007350 +author-tz -0700 +committer Kaylee Simmons +committer-mail +committer-time 1653609725 +committer-tz -0700 +summary Enable copy and paste in vim mode +filename crates/vim/src/utils.rs +082036161fd3815c831ceedfd28ba15b0ed6eb9f 4 9 1 +filename crates/vim/src/utils.rs +082036161fd3815c831ceedfd28ba15b0ed6eb9f 7 38 3 +filename crates/vim/src/utils.rs +082036161fd3815c831ceedfd28ba15b0ed6eb9f 10 42 1 +filename crates/vim/src/utils.rs +082036161fd3815c831ceedfd28ba15b0ed6eb9f 11 44 1 +filename crates/vim/src/utils.rs +082036161fd3815c831ceedfd28ba15b0ed6eb9f 14 46 1 +filename crates/vim/src/utils.rs +082036161fd3815c831ceedfd28ba15b0ed6eb9f 15 72 3 +filename crates/vim/src/utils.rs +082036161fd3815c831ceedfd28ba15b0ed6eb9f 18 78 3 +filename crates/vim/src/utils.rs +082036161fd3815c831ceedfd28ba15b0ed6eb9f 21 82 4 +filename crates/vim/src/utils.rs +082036161fd3815c831ceedfd28ba15b0ed6eb9f 26 120 1 +filename crates/vim/src/utils.rs diff --git a/crates/git/test_data/blame_incremental_not_committed b/crates/git/test_data/blame_incremental_not_committed new file mode 100644 index 0000000000..dcac2ac742 --- /dev/null +++ b/crates/git/test_data/blame_incremental_not_committed @@ -0,0 +1,64 @@ +0000000000000000000000000000000000000000 4 4 3 +author External file (--contents) +author-mail +author-time 1710764166 +author-tz +0100 +committer External file (--contents) +committer-mail +committer-time 1710764166 +committer-tz +0100 +summary Version of file_b.txt from file_b_memory.txt +previous 4aaba34cb86b12f3a749dd6ddbf185de88de6527 file_b.txt +filename file_b.txt +0000000000000000000000000000000000000000 10 10 1 +previous 4aaba34cb86b12f3a749dd6ddbf185de88de6527 file_b.txt +filename file_b.txt +0000000000000000000000000000000000000000 15 15 2 +previous 4aaba34cb86b12f3a749dd6ddbf185de88de6527 file_b.txt +filename file_b.txt +4aaba34cb86b12f3a749dd6ddbf185de88de6527 4 7 2 +author Thorsten Ball +author-mail +author-time 1710764113 +author-tz +0100 +committer Thorsten Ball +committer-mail +committer-time 1710764113 +committer-tz +0100 +summary Another commit +previous b2f6b0d982a9e7654bbb6d979c9ccb00b1d2e913 file_b.txt +filename file_b.txt +4aaba34cb86b12f3a749dd6ddbf185de88de6527 11 17 2 +previous b2f6b0d982a9e7654bbb6d979c9ccb00b1d2e913 file_b.txt +filename file_b.txt +b2f6b0d982a9e7654bbb6d979c9ccb00b1d2e913 6 9 1 +author Thorsten Ball +author-mail +author-time 1710764087 +author-tz +0100 +committer Thorsten Ball +committer-mail +committer-time 1710764087 +committer-tz +0100 +summary Another commit +previous 7aef8a9a211ebc86824769c89f48e4f64aa6183f file_b.txt +filename file_b.txt +b2f6b0d982a9e7654bbb6d979c9ccb00b1d2e913 7 11 2 +previous 7aef8a9a211ebc86824769c89f48e4f64aa6183f file_b.txt +filename file_b.txt +e6d34e8fb494fe2b576d16037c61ba9d10722ba3 1 1 3 +author Thorsten Ball +author-mail +author-time 1709299737 +author-tz +0100 +committer Thorsten Ball +committer-mail +committer-time 1709299737 +committer-tz +0100 +summary Initial +boundary +filename file_b.txt +e6d34e8fb494fe2b576d16037c61ba9d10722ba3 9 13 2 +filename file_b.txt +e6d34e8fb494fe2b576d16037c61ba9d10722ba3 13 19 1 +filename file_b.txt diff --git a/crates/git/test_data/blame_incremental_simple b/crates/git/test_data/blame_incremental_simple new file mode 100644 index 0000000000..58b0e3d5d4 --- /dev/null +++ b/crates/git/test_data/blame_incremental_simple @@ -0,0 +1,70 @@ +0000000000000000000000000000000000000000 3 3 1 +author Not Committed Yet +author-mail +author-time 1709895274 +author-tz +0100 +committer Not Committed Yet +committer-mail +committer-time 1709895274 +committer-tz +0100 +summary Version of index.js from index.js +previous a7037b4567dd171bfe563c761354ec9236c803b3 index.js +filename index.js +0000000000000000000000000000000000000000 7 7 2 +previous a7037b4567dd171bfe563c761354ec9236c803b3 index.js +filename index.js +c8d34ae30c87e59aaa5eb65f6c64d6206f525d7c 7 6 1 +author Thorsten Ball +author-mail +author-time 1709808710 +author-tz +0100 +committer Thorsten Ball +committer-mail +committer-time 1709808710 +committer-tz +0100 +summary Make a commit +previous 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 index.js +filename index.js +6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1 +author Joe Schmoe +author-mail +author-time 1709741400 +author-tz +0100 +committer Joe Schmoe +committer-mail +committer-time 1709741400 +committer-tz +0100 +summary Joe's cool commit +previous 486c2409237a2c627230589e567024a96751d475 index.js +filename index.js +6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1 +previous 486c2409237a2c627230589e567024a96751d475 index.js +filename index.js +6ad46b5257ba16d12c5ca9f0d4900320959df7f4 13 9 1 +previous 486c2409237a2c627230589e567024a96751d475 index.js +filename index.js +486c2409237a2c627230589e567024a96751d475 3 1 1 +author Thorsten Ball +author-mail +author-time 1709129122 +author-tz +0100 +committer Thorsten Ball +committer-mail +committer-time 1709129122 +committer-tz +0100 +summary Get to a state where eslint would change code and imports +previous 504065e448b467e79920040f22153e9d2ea0fd6e index.js +filename index.js +504065e448b467e79920040f22153e9d2ea0fd6e 3 5 1 +author Thorsten Ball +author-mail +author-time 1709128963 +author-tz +0100 +committer Thorsten Ball +committer-mail +committer-time 1709128963 +committer-tz +0100 +summary Add some stuff +filename index.js +504065e448b467e79920040f22153e9d2ea0fd6e 21 10 1 +filename index.js diff --git a/crates/git/test_data/golden/blame_incremental_complex.json b/crates/git/test_data/golden/blame_incremental_complex.json new file mode 100644 index 0000000000..84d49d847b --- /dev/null +++ b/crates/git/test_data/golden/blame_incremental_complex.json @@ -0,0 +1,781 @@ +[ + { + "sha": "5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206", + "range": { + "start": 5, + "end": 8 + }, + "original_line_number": 6, + "author": "Mahdy M. Karam", + "author_mail": "<64036912+mmkaram@users.noreply.github.com>", + "author_time": 1708621949, + "author_tz": "-0800", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1708621949, + "committer_tz": "-0700", + "summary": "Add option to either use system clipboard or vim clipboard (#7936)", + "previous": "c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206", + "range": { + "start": 11, + "end": 13 + }, + "original_line_number": 12, + "author": "Mahdy M. Karam", + "author_mail": "<64036912+mmkaram@users.noreply.github.com>", + "author_time": 1708621949, + "author_tz": "-0800", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1708621949, + "committer_tz": "-0700", + "summary": "Add option to either use system clipboard or vim clipboard (#7936)", + "previous": "c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206", + "range": { + "start": 17, + "end": 18 + }, + "original_line_number": 18, + "author": "Mahdy M. Karam", + "author_mail": "<64036912+mmkaram@users.noreply.github.com>", + "author_time": 1708621949, + "author_tz": "-0800", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1708621949, + "committer_tz": "-0700", + "summary": "Add option to either use system clipboard or vim clipboard (#7936)", + "previous": "c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206", + "range": { + "start": 20, + "end": 27 + }, + "original_line_number": 21, + "author": "Mahdy M. Karam", + "author_mail": "<64036912+mmkaram@users.noreply.github.com>", + "author_time": 1708621949, + "author_tz": "-0800", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1708621949, + "committer_tz": "-0700", + "summary": "Add option to either use system clipboard or vim clipboard (#7936)", + "previous": "c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206", + "range": { + "start": 30, + "end": 31 + }, + "original_line_number": 31, + "author": "Mahdy M. Karam", + "author_mail": "<64036912+mmkaram@users.noreply.github.com>", + "author_time": 1708621949, + "author_tz": "-0800", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1708621949, + "committer_tz": "-0700", + "summary": "Add option to either use system clipboard or vim clipboard (#7936)", + "previous": "c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206", + "range": { + "start": 33, + "end": 34 + }, + "original_line_number": 34, + "author": "Mahdy M. Karam", + "author_mail": "<64036912+mmkaram@users.noreply.github.com>", + "author_time": 1708621949, + "author_tz": "-0800", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1708621949, + "committer_tz": "-0700", + "summary": "Add option to either use system clipboard or vim clipboard (#7936)", + "previous": "c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "5c4f3c0ceaa0b2270c8f4fc8ee32b85c70810206", + "range": { + "start": 85, + "end": 101 + }, + "original_line_number": 86, + "author": "Mahdy M. Karam", + "author_mail": "<64036912+mmkaram@users.noreply.github.com>", + "author_time": 1708621949, + "author_tz": "-0800", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1708621949, + "committer_tz": "-0700", + "summary": "Add option to either use system clipboard or vim clipboard (#7936)", + "previous": "c6826a61a0a947acf09d65ada568c9c4e4494cb2 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 0, + "end": 2 + }, + "original_line_number": 1, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 3, + "end": 4 + }, + "original_line_number": 4, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 9, + "end": 11 + }, + "original_line_number": 7, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 13, + "end": 17 + }, + "original_line_number": 10, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 18, + "end": 20 + }, + "original_line_number": 15, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 27, + "end": 30 + }, + "original_line_number": 19, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 31, + "end": 33 + }, + "original_line_number": 22, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 34, + "end": 36 + }, + "original_line_number": 25, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 40, + "end": 41 + }, + "original_line_number": 31, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 66, + "end": 71 + }, + "original_line_number": 57, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "3635d2dcedd83f1b6702f33ca2673317f7fa4695", + "range": { + "start": 101, + "end": 119 + }, + "original_line_number": 78, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1707520689, + "author_tz": "-0700", + "committer": "GitHub", + "committer_mail": "", + "committer_time": 1707520689, + "committer_tz": "-0700", + "summary": "Highlight selections on vim yank (#7638)", + "previous": "efe23ebfcdd653b13be79132b1e2925bcd7bde45 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "b65cf6d2d9576171edb407f5bbaa231c33af1f71", + "range": { + "start": 4, + "end": 5 + }, + "original_line_number": 3, + "author": "Max Brunsfeld", + "author_mail": "", + "author_time": 1705619094, + "author_tz": "-0800", + "committer": "Max Brunsfeld", + "committer_mail": "", + "committer_time": 1705619205, + "committer_tz": "-0800", + "summary": "Merge branch 'main' into language-api-docs", + "previous": "6457ccf9ece3b36a37e675783abee9748a443115 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "b65cf6d2d9576171edb407f5bbaa231c33af1f71", + "range": { + "start": 120, + "end": 128 + }, + "original_line_number": 51, + "author": "Max Brunsfeld", + "author_mail": "", + "author_time": 1705619094, + "author_tz": "-0800", + "committer": "Max Brunsfeld", + "committer_mail": "", + "committer_time": 1705619205, + "committer_tz": "-0800", + "summary": "Merge branch 'main' into language-api-docs", + "previous": "6457ccf9ece3b36a37e675783abee9748a443115 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "b02bd9bce1db3a68dcd606718fa02709020860af", + "range": { + "start": 60, + "end": 61 + }, + "original_line_number": 29, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1694798044, + "author_tz": "-0600", + "committer": "Conrad Irwin", + "committer_mail": "", + "committer_time": 1694798044, + "committer_tz": "-0600", + "summary": "Fix Y on last line with no trailing new line", + "previous": "7c77baa7c17eea106330622e70513ea9389d50a1 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "b02bd9bce1db3a68dcd606718fa02709020860af", + "range": { + "start": 64, + "end": 65 + }, + "original_line_number": 33, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1694798044, + "author_tz": "-0600", + "committer": "Conrad Irwin", + "committer_mail": "", + "committer_time": 1694798044, + "committer_tz": "-0600", + "summary": "Fix Y on last line with no trailing new line", + "previous": "7c77baa7c17eea106330622e70513ea9389d50a1 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "e4794e3134b6449e36ed2771a8849046489cc252", + "range": { + "start": 44, + "end": 45 + }, + "original_line_number": 13, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1692855942, + "author_tz": "-0600", + "committer": "Conrad Irwin", + "committer_mail": "", + "committer_time": 1692856812, + "committer_tz": "-0600", + "summary": "vim: Fix linewise copy of last line with no trailing newline", + "previous": "26c3312049a9c73bc3350528c1defd3820a7a8c7 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "e4794e3134b6449e36ed2771a8849046489cc252", + "range": { + "start": 52, + "end": 60 + }, + "original_line_number": 21, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1692855942, + "author_tz": "-0600", + "committer": "Conrad Irwin", + "committer_mail": "", + "committer_time": 1692856812, + "committer_tz": "-0600", + "summary": "vim: Fix linewise copy of last line with no trailing newline", + "previous": "26c3312049a9c73bc3350528c1defd3820a7a8c7 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "e4794e3134b6449e36ed2771a8849046489cc252", + "range": { + "start": 61, + "end": 64 + }, + "original_line_number": 29, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1692855942, + "author_tz": "-0600", + "committer": "Conrad Irwin", + "committer_mail": "", + "committer_time": 1692856812, + "committer_tz": "-0600", + "summary": "vim: Fix linewise copy of last line with no trailing newline", + "previous": "26c3312049a9c73bc3350528c1defd3820a7a8c7 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "e4794e3134b6449e36ed2771a8849046489cc252", + "range": { + "start": 65, + "end": 66 + }, + "original_line_number": 33, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1692855942, + "author_tz": "-0600", + "committer": "Conrad Irwin", + "committer_mail": "", + "committer_time": 1692856812, + "committer_tz": "-0600", + "summary": "vim: Fix linewise copy of last line with no trailing newline", + "previous": "26c3312049a9c73bc3350528c1defd3820a7a8c7 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "e4794e3134b6449e36ed2771a8849046489cc252", + "range": { + "start": 74, + "end": 77 + }, + "original_line_number": 37, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1692855942, + "author_tz": "-0600", + "committer": "Conrad Irwin", + "committer_mail": "", + "committer_time": 1692856812, + "committer_tz": "-0600", + "summary": "vim: Fix linewise copy of last line with no trailing newline", + "previous": "26c3312049a9c73bc3350528c1defd3820a7a8c7 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "33d7fe02ee560f6ed57d1425c43e60aef3b66e64", + "range": { + "start": 42, + "end": 43 + }, + "original_line_number": 10, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1692644159, + "author_tz": "-0600", + "committer": "Conrad Irwin", + "committer_mail": "", + "committer_time": 1692732477, + "committer_tz": "-0600", + "summary": "Rewrite paste", + "previous": "31db5e4f62e8fca75aa0870f903ae044524c3580 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "33d7fe02ee560f6ed57d1425c43e60aef3b66e64", + "range": { + "start": 46, + "end": 52 + }, + "original_line_number": 14, + "author": "Conrad Irwin", + "author_mail": "", + "author_time": 1692644159, + "author_tz": "-0600", + "committer": "Conrad Irwin", + "committer_mail": "", + "committer_time": 1692732477, + "committer_tz": "-0600", + "summary": "Rewrite paste", + "previous": "31db5e4f62e8fca75aa0870f903ae044524c3580 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "868c46062008bb0bcab2d41a38b4295996b9b958", + "range": { + "start": 80, + "end": 81 + }, + "original_line_number": 20, + "author": "Max Brunsfeld", + "author_mail": "", + "author_time": 1659072896, + "author_tz": "-0700", + "committer": "Max Brunsfeld", + "committer_mail": "", + "committer_time": 1659073230, + "committer_tz": "-0700", + "summary": ":art: Rename and simplify some autoindent stuff", + "previous": "7a26fa18c7fee3fe031b991e18b55fd8f9c4eb1b crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "e93c49f4f02b3edaddae6a6a4cc0ac433f242357", + "range": { + "start": 36, + "end": 37 + }, + "original_line_number": 5, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653424557, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Unify visual line_mode and non line_mode operators", + "previous": "11569a869a72f786a9798c53266e28c05c79f824 crates/vim/src/utils.rs", + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "082036161fd3815c831ceedfd28ba15b0ed6eb9f", + "range": { + "start": 2, + "end": 3 + }, + "original_line_number": 1, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653007350, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Enable copy and paste in vim mode", + "previous": null, + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "082036161fd3815c831ceedfd28ba15b0ed6eb9f", + "range": { + "start": 8, + "end": 9 + }, + "original_line_number": 4, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653007350, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Enable copy and paste in vim mode", + "previous": null, + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "082036161fd3815c831ceedfd28ba15b0ed6eb9f", + "range": { + "start": 37, + "end": 40 + }, + "original_line_number": 7, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653007350, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Enable copy and paste in vim mode", + "previous": null, + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "082036161fd3815c831ceedfd28ba15b0ed6eb9f", + "range": { + "start": 41, + "end": 42 + }, + "original_line_number": 10, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653007350, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Enable copy and paste in vim mode", + "previous": null, + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "082036161fd3815c831ceedfd28ba15b0ed6eb9f", + "range": { + "start": 43, + "end": 44 + }, + "original_line_number": 11, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653007350, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Enable copy and paste in vim mode", + "previous": null, + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "082036161fd3815c831ceedfd28ba15b0ed6eb9f", + "range": { + "start": 45, + "end": 46 + }, + "original_line_number": 14, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653007350, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Enable copy and paste in vim mode", + "previous": null, + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "082036161fd3815c831ceedfd28ba15b0ed6eb9f", + "range": { + "start": 71, + "end": 74 + }, + "original_line_number": 15, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653007350, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Enable copy and paste in vim mode", + "previous": null, + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "082036161fd3815c831ceedfd28ba15b0ed6eb9f", + "range": { + "start": 77, + "end": 80 + }, + "original_line_number": 18, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653007350, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Enable copy and paste in vim mode", + "previous": null, + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "082036161fd3815c831ceedfd28ba15b0ed6eb9f", + "range": { + "start": 81, + "end": 85 + }, + "original_line_number": 21, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653007350, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Enable copy and paste in vim mode", + "previous": null, + "filename": "crates/vim/src/utils.rs" + }, + { + "sha": "082036161fd3815c831ceedfd28ba15b0ed6eb9f", + "range": { + "start": 119, + "end": 120 + }, + "original_line_number": 26, + "author": "Kaylee Simmons", + "author_mail": "", + "author_time": 1653007350, + "author_tz": "-0700", + "committer": "Kaylee Simmons", + "committer_mail": "", + "committer_time": 1653609725, + "committer_tz": "-0700", + "summary": "Enable copy and paste in vim mode", + "previous": null, + "filename": "crates/vim/src/utils.rs" + } +] \ No newline at end of file diff --git a/crates/git/test_data/golden/blame_incremental_not_committed.json b/crates/git/test_data/golden/blame_incremental_not_committed.json new file mode 100644 index 0000000000..0298fb05d3 --- /dev/null +++ b/crates/git/test_data/golden/blame_incremental_not_committed.json @@ -0,0 +1,135 @@ +[ + { + "sha": "4aaba34cb86b12f3a749dd6ddbf185de88de6527", + "range": { + "start": 6, + "end": 8 + }, + "original_line_number": 4, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1710764113, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1710764113, + "committer_tz": "+0100", + "summary": "Another commit", + "previous": "b2f6b0d982a9e7654bbb6d979c9ccb00b1d2e913 file_b.txt", + "filename": "file_b.txt" + }, + { + "sha": "4aaba34cb86b12f3a749dd6ddbf185de88de6527", + "range": { + "start": 16, + "end": 18 + }, + "original_line_number": 11, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1710764113, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1710764113, + "committer_tz": "+0100", + "summary": "Another commit", + "previous": "b2f6b0d982a9e7654bbb6d979c9ccb00b1d2e913 file_b.txt", + "filename": "file_b.txt" + }, + { + "sha": "b2f6b0d982a9e7654bbb6d979c9ccb00b1d2e913", + "range": { + "start": 8, + "end": 9 + }, + "original_line_number": 6, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1710764087, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1710764087, + "committer_tz": "+0100", + "summary": "Another commit", + "previous": "7aef8a9a211ebc86824769c89f48e4f64aa6183f file_b.txt", + "filename": "file_b.txt" + }, + { + "sha": "b2f6b0d982a9e7654bbb6d979c9ccb00b1d2e913", + "range": { + "start": 10, + "end": 12 + }, + "original_line_number": 7, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1710764087, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1710764087, + "committer_tz": "+0100", + "summary": "Another commit", + "previous": "7aef8a9a211ebc86824769c89f48e4f64aa6183f file_b.txt", + "filename": "file_b.txt" + }, + { + "sha": "e6d34e8fb494fe2b576d16037c61ba9d10722ba3", + "range": { + "start": 0, + "end": 3 + }, + "original_line_number": 1, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1709299737, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1709299737, + "committer_tz": "+0100", + "summary": "Initial", + "previous": null, + "filename": "file_b.txt" + }, + { + "sha": "e6d34e8fb494fe2b576d16037c61ba9d10722ba3", + "range": { + "start": 12, + "end": 14 + }, + "original_line_number": 9, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1709299737, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1709299737, + "committer_tz": "+0100", + "summary": "Initial", + "previous": null, + "filename": "file_b.txt" + }, + { + "sha": "e6d34e8fb494fe2b576d16037c61ba9d10722ba3", + "range": { + "start": 18, + "end": 19 + }, + "original_line_number": 13, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1709299737, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1709299737, + "committer_tz": "+0100", + "summary": "Initial", + "previous": null, + "filename": "file_b.txt" + } +] \ No newline at end of file diff --git a/crates/git/test_data/golden/blame_incremental_simple.json b/crates/git/test_data/golden/blame_incremental_simple.json new file mode 100644 index 0000000000..4d6e9124d6 --- /dev/null +++ b/crates/git/test_data/golden/blame_incremental_simple.json @@ -0,0 +1,135 @@ +[ + { + "sha": "c8d34ae30c87e59aaa5eb65f6c64d6206f525d7c", + "range": { + "start": 5, + "end": 6 + }, + "original_line_number": 7, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1709808710, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1709808710, + "committer_tz": "+0100", + "summary": "Make a commit", + "previous": "6ad46b5257ba16d12c5ca9f0d4900320959df7f4 index.js", + "filename": "index.js" + }, + { + "sha": "6ad46b5257ba16d12c5ca9f0d4900320959df7f4", + "range": { + "start": 1, + "end": 2 + }, + "original_line_number": 2, + "author": "Joe Schmoe", + "author_mail": "", + "author_time": 1709741400, + "author_tz": "+0100", + "committer": "Joe Schmoe", + "committer_mail": "", + "committer_time": 1709741400, + "committer_tz": "+0100", + "summary": "Joe's cool commit", + "previous": "486c2409237a2c627230589e567024a96751d475 index.js", + "filename": "index.js" + }, + { + "sha": "6ad46b5257ba16d12c5ca9f0d4900320959df7f4", + "range": { + "start": 3, + "end": 4 + }, + "original_line_number": 3, + "author": "Joe Schmoe", + "author_mail": "", + "author_time": 1709741400, + "author_tz": "+0100", + "committer": "Joe Schmoe", + "committer_mail": "", + "committer_time": 1709741400, + "committer_tz": "+0100", + "summary": "Joe's cool commit", + "previous": "486c2409237a2c627230589e567024a96751d475 index.js", + "filename": "index.js" + }, + { + "sha": "6ad46b5257ba16d12c5ca9f0d4900320959df7f4", + "range": { + "start": 8, + "end": 9 + }, + "original_line_number": 13, + "author": "Joe Schmoe", + "author_mail": "", + "author_time": 1709741400, + "author_tz": "+0100", + "committer": "Joe Schmoe", + "committer_mail": "", + "committer_time": 1709741400, + "committer_tz": "+0100", + "summary": "Joe's cool commit", + "previous": "486c2409237a2c627230589e567024a96751d475 index.js", + "filename": "index.js" + }, + { + "sha": "486c2409237a2c627230589e567024a96751d475", + "range": { + "start": 0, + "end": 1 + }, + "original_line_number": 3, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1709129122, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1709129122, + "committer_tz": "+0100", + "summary": "Get to a state where eslint would change code and imports", + "previous": "504065e448b467e79920040f22153e9d2ea0fd6e index.js", + "filename": "index.js" + }, + { + "sha": "504065e448b467e79920040f22153e9d2ea0fd6e", + "range": { + "start": 4, + "end": 5 + }, + "original_line_number": 3, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1709128963, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1709128963, + "committer_tz": "+0100", + "summary": "Add some stuff", + "previous": null, + "filename": "index.js" + }, + { + "sha": "504065e448b467e79920040f22153e9d2ea0fd6e", + "range": { + "start": 9, + "end": 10 + }, + "original_line_number": 21, + "author": "Thorsten Ball", + "author_mail": "", + "author_time": 1709128963, + "author_tz": "+0100", + "committer": "Thorsten Ball", + "committer_mail": "", + "committer_time": 1709128963, + "committer_tz": "+0100", + "summary": "Add some stuff", + "previous": null, + "filename": "index.js" + } +] \ No newline at end of file diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index c03770b5c0..89e6f2611c 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -189,6 +189,11 @@ impl App { pub fn text_system(&self) -> Arc { self.0.borrow().text_system.clone() } + + /// Returns the file URL of the executable with the specified name in the application bundle + pub fn path_for_auxiliary_executable(&self, name: &str) -> Result { + self.0.borrow().path_for_auxiliary_executable(name) + } } type Handler = Box bool + 'static>; diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index ae0f78a686..30766a7b6f 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -34,6 +34,7 @@ copilot.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true +git.workspace = true globset.workspace = true gpui.workspace = true itertools.workspace = true diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 430b9732dc..8f1cba6f08 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -30,6 +30,7 @@ use futures::{ stream::FuturesUnordered, AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt, }; +use git::blame::Blame; use globset::{Glob, GlobSet, GlobSetBuilder}; use gpui::{ AnyModel, AppContext, AsyncAppContext, BackgroundExecutor, BorrowAppContext, Context, Entity, @@ -83,7 +84,7 @@ use std::{ ops::Range, path::{self, Component, Path, PathBuf}, process::Stdio, - str, + str::{self, FromStr}, sync::{ atomic::{AtomicUsize, Ordering::SeqCst}, Arc, @@ -95,7 +96,7 @@ use terminals::Terminals; use text::{Anchor, BufferId, RopeFingerprint}; use util::{ debug_panic, defer, - http::HttpClient, + http::{HttpClient, Url}, maybe, merge_json_value_into, paths::{ LOCAL_SETTINGS_RELATIVE_PATH, LOCAL_TASKS_RELATIVE_PATH, LOCAL_VSCODE_TASKS_RELATIVE_PATH, @@ -304,6 +305,7 @@ pub enum Event { WorktreeAdded, WorktreeRemoved(WorktreeId), WorktreeUpdatedEntries(WorktreeId, UpdatedEntriesSet), + WorktreeUpdatedGitRepositories, DiskBasedDiagnosticsStarted { language_server_id: LanguageServerId, }, @@ -593,6 +595,7 @@ impl Project { client.add_model_request_handler(Self::handle_save_buffer); client.add_model_message_handler(Self::handle_update_diff_base); client.add_model_request_handler(Self::handle_lsp_command::); + client.add_model_request_handler(Self::handle_blame_buffer); } pub fn local( @@ -6746,8 +6749,13 @@ impl Project { } worktree::Event::UpdatedGitRepositories(updated_repos) => { if is_local { - this.update_local_worktree_buffers_git_repos(worktree, updated_repos, cx) + this.update_local_worktree_buffers_git_repos( + worktree.clone(), + updated_repos, + cx, + ) } + cx.emit(Event::WorktreeUpdatedGitRepositories); } } }) @@ -7036,9 +7044,10 @@ impl Project { .filter_map(|(buffer, path)| { let (work_directory, repo) = snapshot.repository_and_work_directory_for_path(&path)?; - let repo = snapshot.get_local_repo(&repo)?; + let repo_entry = snapshot.get_local_repo(&repo)?; let relative_path = path.strip_prefix(&work_directory).ok()?; - let base_text = repo.load_index_text(relative_path); + let base_text = repo_entry.repo().lock().load_index_text(relative_path); + Some((buffer, base_text)) }) .collect::>() @@ -7315,6 +7324,19 @@ impl Project { }) } + pub fn get_workspace_root( + &self, + project_path: &ProjectPath, + cx: &AppContext, + ) -> Option { + Some( + self.worktree_for_id(project_path.worktree_id, cx)? + .read(cx) + .abs_path() + .to_path_buf(), + ) + } + pub fn get_repo( &self, project_path: &ProjectPath, @@ -7327,8 +7349,107 @@ impl Project { .local_git_repo(&project_path.path) } + pub fn blame_buffer( + &self, + buffer: &Model, + version: Option, + cx: &AppContext, + ) -> Task> { + if self.is_local() { + let blame_params = maybe!({ + let buffer = buffer.read(cx); + let buffer_project_path = buffer + .project_path(cx) + .context("failed to get buffer project path")?; + + let worktree = self + .worktree_for_id(buffer_project_path.worktree_id, cx) + .context("failed to get worktree")? + .read(cx) + .as_local() + .context("worktree was not local")? + .snapshot(); + let (work_directory, repo) = worktree + .repository_and_work_directory_for_path(&buffer_project_path.path) + .context("failed to get repo for blamed buffer")?; + + let repo_entry = worktree + .get_local_repo(&repo) + .context("failed to get repo for blamed buffer")?; + + let relative_path = buffer_project_path + .path + .strip_prefix(&work_directory)? + .to_path_buf(); + + let content = match version { + Some(version) => buffer.rope_for_version(&version).clone(), + None => buffer.as_rope().clone(), + }; + let repo = repo_entry.repo().clone(); + + anyhow::Ok((repo, relative_path, content)) + }); + + cx.background_executor().spawn(async move { + let (repo, relative_path, content) = blame_params?; + let lock = repo.lock(); + lock.blame(&relative_path, content) + }) + } else { + let project_id = self.remote_id(); + let buffer_id = buffer.read(cx).remote_id(); + let client = self.client.clone(); + let version = buffer.read(cx).version(); + + cx.spawn(|_| async move { + let project_id = project_id.context("unable to get project id for buffer")?; + let response = client + .request(proto::BlameBuffer { + project_id, + buffer_id: buffer_id.into(), + version: serialize_version(&version), + }) + .await?; + + Ok(deserialize_blame_buffer_response(response)) + }) + } + } + // RPC message handlers + async fn handle_blame_buffer( + this: Model, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result { + let buffer_id = BufferId::new(envelope.payload.buffer_id)?; + let version = deserialize_version(&envelope.payload.version); + + let buffer = this.update(&mut cx, |this, _cx| { + this.opened_buffers + .get(&buffer_id) + .and_then(|buffer| buffer.upgrade()) + .ok_or_else(|| anyhow!("unknown buffer id {}", buffer_id)) + })??; + + buffer + .update(&mut cx, |buffer, _| { + buffer.wait_for_version(version.clone()) + })? + .await?; + + let blame = this + .update(&mut cx, |this, cx| { + this.blame_buffer(&buffer, Some(version), cx) + })? + .await?; + + Ok(serialize_blame_buffer_response(blame)) + } + async fn handle_unshare_project( this: Model, _: TypedEnvelope, @@ -9768,3 +9889,99 @@ async fn load_shell_environment(dir: &Path) -> Result> { } Ok(parsed_env) } + +fn serialize_blame_buffer_response(blame: git::blame::Blame) -> proto::BlameBufferResponse { + let entries = blame + .entries + .into_iter() + .map(|entry| proto::BlameEntry { + sha: entry.sha.as_bytes().into(), + start_line: entry.range.start, + end_line: entry.range.end, + original_line_number: entry.original_line_number, + author: entry.author.clone(), + author_mail: entry.author_mail.clone(), + author_time: entry.author_time, + author_tz: entry.author_tz.clone(), + committer: entry.committer.clone(), + committer_mail: entry.committer_mail.clone(), + committer_time: entry.committer_time, + committer_tz: entry.committer_tz.clone(), + summary: entry.summary.clone(), + previous: entry.previous.clone(), + filename: entry.filename.clone(), + }) + .collect::>(); + + let messages = blame + .messages + .into_iter() + .map(|(oid, message)| proto::CommitMessage { + oid: oid.as_bytes().into(), + message, + }) + .collect::>(); + + let permalinks = blame + .permalinks + .into_iter() + .map(|(oid, url)| proto::CommitPermalink { + oid: oid.as_bytes().into(), + permalink: url.to_string(), + }) + .collect::>(); + + proto::BlameBufferResponse { + entries, + messages, + permalinks, + } +} + +fn deserialize_blame_buffer_response(response: proto::BlameBufferResponse) -> git::blame::Blame { + let entries = response + .entries + .into_iter() + .filter_map(|entry| { + Some(git::blame::BlameEntry { + sha: git::Oid::from_bytes(&entry.sha).ok()?, + range: entry.start_line..entry.end_line, + original_line_number: entry.original_line_number, + committer: entry.committer, + committer_time: entry.committer_time, + committer_tz: entry.committer_tz, + committer_mail: entry.committer_mail, + author: entry.author, + author_mail: entry.author_mail, + author_time: entry.author_time, + author_tz: entry.author_tz, + summary: entry.summary, + previous: entry.previous, + filename: entry.filename, + }) + }) + .collect::>(); + + let messages = response + .messages + .into_iter() + .filter_map(|message| Some((git::Oid::from_bytes(&message.oid).ok()?, message.message))) + .collect::>(); + + let permalinks = response + .permalinks + .into_iter() + .filter_map(|permalink| { + Some(( + git::Oid::from_bytes(&permalink.oid).ok()?, + Url::from_str(&permalink.permalink).ok()?, + )) + }) + .collect::>(); + + Blame { + entries, + permalinks, + messages, + } +} diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 3f51d5024a..5726770c6c 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -77,7 +77,7 @@ async fn test_symlinks(cx: &mut gpui::TestAppContext) { ) .unwrap(); - let project = Project::test(Arc::new(RealFs), [root_link_path.as_ref()], cx).await; + let project = Project::test(Arc::new(RealFs::default()), [root_link_path.as_ref()], cx).await; project.update(cx, |project, cx| { let tree = project.worktrees().next().unwrap().read(cx); @@ -2844,7 +2844,7 @@ async fn test_rescan_and_remote_updates(cx: &mut gpui::TestAppContext) { } })); - let project = Project::test(Arc::new(RealFs), [dir.path()], cx).await; + let project = Project::test(Arc::new(RealFs::default()), [dir.path()], cx).await; let rpc = project.update(cx, |p, _| p.client.clone()); let buffer_for_path = |path: &'static str, cx: &mut gpui::TestAppContext| { diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index b69c7bbae7..a610e2b850 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -205,7 +205,10 @@ message Envelope { CountTokensWithLanguageModel count_tokens_with_language_model = 168; CountTokensResponse count_tokens_response = 169; UpdateChannelMessage update_channel_message = 170; - ChannelMessageUpdate channel_message_update = 171; // current max + ChannelMessageUpdate channel_message_update = 171; + + BlameBuffer blame_buffer = 172; + BlameBufferResponse blame_buffer_response = 173; // Current max } reserved 158 to 161; @@ -1784,3 +1787,48 @@ message CountTokensWithLanguageModel { message CountTokensResponse { uint32 token_count = 1; } + +message BlameBuffer { + uint64 project_id = 1; + uint64 buffer_id = 2; + repeated VectorClockEntry version = 3; +} + +message BlameEntry { + bytes sha = 1; + + uint32 start_line = 2; + uint32 end_line = 3; + uint32 original_line_number = 4; + + optional string author = 5; + optional string author_mail = 6; + optional int64 author_time = 7; + optional string author_tz = 8; + + optional string committer = 9; + optional string committer_mail = 10; + optional int64 committer_time = 11; + optional string committer_tz = 12; + + optional string summary = 13; + optional string previous = 14; + + string filename = 15; +} + +message CommitMessage { + bytes oid = 1; + string message = 2; +} + +message CommitPermalink { + bytes oid = 1; + string permalink = 2; +} + +message BlameBufferResponse { + repeated BlameEntry entries = 1; + repeated CommitMessage messages = 2; + repeated CommitPermalink permalinks = 3; +} diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 429452d3e1..bc2b44046f 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -296,6 +296,8 @@ messages!( (LspExtExpandMacro, Background), (LspExtExpandMacroResponse, Background), (SetRoomParticipantRole, Foreground), + (BlameBuffer, Foreground), + (BlameBufferResponse, Foreground), ); request_messages!( @@ -386,6 +388,7 @@ request_messages!( (UpdateWorktree, Ack), (LspExtExpandMacro, LspExtExpandMacroResponse), (SetRoomParticipantRole, Ack), + (BlameBuffer, BlameBufferResponse), ); entity_messages!( @@ -393,6 +396,7 @@ entity_messages!( AddProjectCollaborator, ApplyCodeAction, ApplyCompletionAdditionalEdits, + BlameBuffer, BufferReloaded, BufferSaved, CopyProjectEntry, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ca1e81b1c3..489a160759 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -108,6 +108,11 @@ fn test_random_edits(mut rng: StdRng) { } assert_eq!(text.to_string(), buffer.text()); + assert_eq!( + buffer.rope_for_version(old_buffer.version()).to_string(), + old_buffer.text() + ); + for _ in 0..5 { let end_ix = old_buffer.clip_offset(rng.gen_range(0..=old_buffer.len()), Bias::Right); let start_ix = old_buffer.clip_offset(rng.gen_range(0..=end_ix), Bias::Left); diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 20ef6dcf1b..55b6e9eefc 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -1622,6 +1622,49 @@ impl BufferSnapshot { &self.visible_text } + pub fn rope_for_version(&self, version: &clock::Global) -> Rope { + let mut rope = Rope::new(); + + let mut cursor = self + .fragments + .filter::<_, FragmentTextSummary>(move |summary| { + !version.observed_all(&summary.max_version) + }); + cursor.next(&None); + + let mut visible_cursor = self.visible_text.cursor(0); + let mut deleted_cursor = self.deleted_text.cursor(0); + + while let Some(fragment) = cursor.item() { + if cursor.start().visible > visible_cursor.offset() { + let text = visible_cursor.slice(cursor.start().visible); + rope.append(text); + } + + if fragment.was_visible(version, &self.undo_map) { + if fragment.visible { + let text = visible_cursor.slice(cursor.end(&None).visible); + rope.append(text); + } else { + deleted_cursor.seek_forward(cursor.start().deleted); + let text = deleted_cursor.slice(cursor.end(&None).deleted); + rope.append(text); + } + } else if fragment.visible { + visible_cursor.seek_forward(cursor.end(&None).visible); + } + + cursor.next(&None); + } + + if cursor.start().visible > visible_cursor.offset() { + let text = visible_cursor.slice(cursor.start().visible); + rope.append(text); + } + + rope + } + pub fn remote_id(&self) -> BufferId { self.remote_id } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index db1f67abf1..399084d15e 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -277,8 +277,8 @@ pub struct LocalRepositoryEntry { } impl LocalRepositoryEntry { - pub fn load_index_text(&self, relative_file_path: &Path) -> Option { - self.repo_ptr.lock().load_index_text(relative_file_path) + pub fn repo(&self) -> &Arc> { + &self.repo_ptr } } diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index a5e1feb6f9..70facfe150 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -459,7 +459,7 @@ async fn test_renaming_case_only(cx: &mut TestAppContext) { const OLD_NAME: &str = "aaa.rs"; const NEW_NAME: &str = "AAA.rs"; - let fs = Arc::new(RealFs); + let fs = Arc::new(RealFs::default()); let temp_root = temp_tree(json!({ OLD_NAME: "", })); @@ -969,7 +969,7 @@ async fn test_write_file(cx: &mut TestAppContext) { build_client(cx), dir.path(), true, - Arc::new(RealFs), + Arc::new(RealFs::default()), Default::default(), &mut cx.to_async(), ) @@ -1049,7 +1049,7 @@ async fn test_file_scan_exclusions(cx: &mut TestAppContext) { build_client(cx), dir.path(), true, - Arc::new(RealFs), + Arc::new(RealFs::default()), Default::default(), &mut cx.to_async(), ) @@ -1153,7 +1153,7 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { build_client(cx), dir.path(), true, - Arc::new(RealFs), + Arc::new(RealFs::default()), Default::default(), &mut cx.to_async(), ) @@ -1263,7 +1263,7 @@ async fn test_fs_events_in_dot_git_worktree(cx: &mut TestAppContext) { build_client(cx), dot_git_worktree_dir.clone(), true, - Arc::new(RealFs), + Arc::new(RealFs::default()), Default::default(), &mut cx.to_async(), ) @@ -1404,7 +1404,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { ) }); - let fs_real = Arc::new(RealFs); + let fs_real = Arc::new(RealFs::default()); let temp_root = temp_tree(json!({ "a": {} })); @@ -2008,7 +2008,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { build_client(cx), root_path, true, - Arc::new(RealFs), + Arc::new(RealFs::default()), Default::default(), &mut cx.to_async(), ) @@ -2087,7 +2087,7 @@ async fn test_git_repository_for_path(cx: &mut TestAppContext) { build_client(cx), root.path(), true, - Arc::new(RealFs), + Arc::new(RealFs::default()), Default::default(), &mut cx.to_async(), ) @@ -2228,7 +2228,7 @@ async fn test_git_status(cx: &mut TestAppContext) { build_client(cx), root.path(), true, - Arc::new(RealFs), + Arc::new(RealFs::default()), Default::default(), &mut cx.to_async(), ) diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index ccb747f51b..63fe28264e 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -92,7 +92,16 @@ fn main() { let session_id = Uuid::new_v4().to_string(); init_panic_hook(&app, installation_id.clone(), session_id.clone()); - let fs = Arc::new(RealFs); + let git_binary_path = if option_env!("ZED_BUNDLE").as_deref() == Some("true") { + app.path_for_auxiliary_executable("git") + .context("could not find git binary path") + .log_err() + } else { + None + }; + log::info!("Using git binary path: {:?}", git_binary_path); + + let fs = Arc::new(RealFs::new(git_binary_path)); let user_settings_file_rx = watch_config_file( &app.background_executor(), fs.clone(), diff --git a/script/bundle-mac b/script/bundle-mac index b83b51265d..85b9c4c8c3 100755 --- a/script/bundle-mac +++ b/script/bundle-mac @@ -112,11 +112,64 @@ mv Cargo.toml.backup Cargo.toml popd echo "Bundled ${app_path}" +GIT_VERSION="v2.43.3" +GIT_VERSION_SHA="fa29823" + +function download_and_unpack() { + local url=$1 + local path_to_unpack=$2 + local target_path=$3 + + temp_dir=$(mktemp -d) + + if ! command -v curl &> /dev/null; then + echo "curl is not installed. Please install curl to continue." + exit 1 + fi + + curl --silent --fail --location "$url" | tar -xvz -C "$temp_dir" -f - $path_to_unpack + + mv "$temp_dir/$path_to_unpack" "$target_path" + + rm -rf "$temp_dir" +} + +function download_git() { + local architecture=$1 + local target_binary=$2 + + tmp_dir=$(mktemp -d) + pushd "$tmp_dir" + + case "$architecture" in + aarch64-apple-darwin) + download_and_unpack "https://github.com/desktop/dugite-native/releases/download/${GIT_VERSION}/dugite-native-${GIT_VERSION}-${GIT_VERSION_SHA}-macOS-arm64.tar.gz" bin/git ./git + ;; + x86_64-apple-darwin) + download_and_unpack "https://github.com/desktop/dugite-native/releases/download/${GIT_VERSION}/dugite-native-${GIT_VERSION}-${GIT_VERSION_SHA}-macOS-x64.tar.gz" bin/git ./git + ;; + universal) + download_and_unpack "https://github.com/desktop/dugite-native/releases/download/${GIT_VERSION}/dugite-native-${GIT_VERSION}-${GIT_VERSION_SHA}-macOS-arm64.tar.gz" bin/git ./git_arm64 + download_and_unpack "https://github.com/desktop/dugite-native/releases/download/${GIT_VERSION}/dugite-native-${GIT_VERSION}-${GIT_VERSION_SHA}-macOS-x64.tar.gz" bin/git ./git_x64 + lipo -create ./git_arm64 ./git_x64 -output ./git + ;; + *) + echo "Unsupported architecture: $architecture" + exit 1 + ;; + esac + + popd + + mv "${tmp_dir}/git" "${target_binary}" + rm -rf "$tmp_dir" +} + function prepare_binaries() { local architecture=$1 local app_path=$2 - echo "Uploading dSYMs for $architecture" + echo "Unpacking dSYMs for $architecture" dsymutil --flat target/${architecture}/${target_dir}/Zed version="$(cargo metadata --no-deps --manifest-path crates/zed/Cargo.toml --offline --format-version=1 | jq -r '.packages | map(select(.name == "zed"))[0].version')" if [ "$channel" == "nightly" ]; then @@ -139,14 +192,10 @@ function prepare_binaries() { cp target/${architecture}/${target_dir}/cli "${app_path}/Contents/MacOS/cli" } -if [ "$local_arch" = false ]; then - prepare_binaries "aarch64-apple-darwin" "$app_path_aarch64" - prepare_binaries "x86_64-apple-darwin" "$app_path_x64" -fi - function sign_binaries() { local app_path=$1 - local architecture_dir=$2 + local architecture=$2 + local architecture_dir=$3 echo "Copying WebRTC.framework into the frameworks folder" mkdir "${app_path}/Contents/Frameworks" if [ "$local_arch" = false ]; then @@ -156,6 +205,9 @@ function sign_binaries() { cp -R target/${target_dir}/cli "${app_path}/Contents/MacOS/" fi + echo "Downloading git binary" + download_git "${architecture}" "${app_path}/Contents/MacOS/git" + # Note: The app identifier for our development builds is the same as the app identifier for nightly. cp crates/${zed_crate}/contents/$channel/embedded.provisionprofile "${app_path}/Contents/" @@ -172,6 +224,7 @@ function sign_binaries() { # sequence of codesign commands modeled after this example: https://developer.apple.com/forums/thread/701514 /usr/bin/codesign --deep --force --timestamp --sign "Zed Industries, Inc." "${app_path}/Contents/Frameworks/WebRTC.framework" -v /usr/bin/codesign --deep --force --timestamp --options runtime --sign "Zed Industries, Inc." "${app_path}/Contents/MacOS/cli" -v + /usr/bin/codesign --deep --force --timestamp --options runtime --sign "Zed Industries, Inc." "${app_path}/Contents/MacOS/git" -v /usr/bin/codesign --deep --force --timestamp --options runtime --entitlements crates/${zed_crate}/resources/zed.entitlements --sign "Zed Industries, Inc." "${app_path}/Contents/MacOS/${zed_crate}" -v /usr/bin/codesign --force --timestamp --options runtime --entitlements crates/${zed_crate}/resources/zed.entitlements --sign "Zed Industries, Inc." "${app_path}" -v @@ -303,9 +356,12 @@ function sign_binaries() { } if [ "$local_arch" = true ]; then - sign_binaries "$app_path" "$local_target_triple" + sign_binaries "$app_path" "$local_target_triple" "$local_target_triple" else # Create universal binary + prepare_binaries "aarch64-apple-darwin" "$app_path_aarch64" + prepare_binaries "x86_64-apple-darwin" "$app_path_x64" + cp -R "$app_path_x64" target/release/ app_path=target/release/$(basename "$app_path_x64") lipo \ @@ -318,8 +374,8 @@ else target/{x86_64-apple-darwin,aarch64-apple-darwin}/${target_dir}/cli \ -output \ "${app_path}/Contents/MacOS/cli" - sign_binaries "$app_path" "." + sign_binaries "$app_path" "universal" "." - sign_binaries "$app_path_x64" "x86_64-apple-darwin" - sign_binaries "$app_path_aarch64" "aarch64-apple-darwin" + sign_binaries "$app_path_x64" "x86_64-apple-darwin" "x86_64-apple-darwin" + sign_binaries "$app_path_aarch64" "aarch64-apple-darwin" "aarch64-apple-darwin" fi