From 7f5493532421a7c7a1d35dace83206fa9bbb90a0 Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Thu, 28 Mar 2024 18:32:11 +0100 Subject: [PATCH] Add `git blame` (#8889) This adds a new action to the editor: `editor: toggle git blame`. When used it turns on a sidebar containing `git blame` information for the currently open buffer. The git blame information is updated when the buffer changes. It handles additions, deletions, modifications, changes to the underlying git data (new commits, changed commits, ...), file saves. It also handles folding and wrapping lines correctly. When the user hovers over a commit, a tooltip displays information for the commit that introduced the line. If the repository has a remote with the name `origin` configured, then clicking on a blame entry opens the permalink to the commit on the code host. Users can right-click on a blame entry to get a context menu which allows them to copy the SHA of the commit. The feature also works on shared projects, e.g. when collaborating a peer can request `git blame` data. As of this PR, Zed now comes bundled with a `git` binary so that users don't have to have `git` installed locally to use this feature. ### Screenshots ![screenshot-2024-03-28-13 57 43@2x](https://github.com/zed-industries/zed/assets/1185253/ee8ec55d-3b5e-4d63-a85a-852da914f5ba) ![screenshot-2024-03-28-14 01 23@2x](https://github.com/zed-industries/zed/assets/1185253/2ba8efd7-e887-4076-a87a-587a732b9e9a) ![screenshot-2024-03-28-14 01 32@2x](https://github.com/zed-industries/zed/assets/1185253/496f4a06-b189-4881-b427-2289ae6e6075) ### TODOs - [x] Bundling `git` binary ### Release Notes Release Notes: - Added `editor: toggle git blame` command that toggles a sidebar with git blame information for the current buffer. --------- Co-authored-by: Antonio Co-authored-by: Piotr Co-authored-by: Bennet Co-authored-by: Mikayla --- Cargo.lock | 11 + Cargo.toml | 2 + crates/collab/src/rpc.rs | 1 + crates/collab/src/tests/editor_tests.rs | 190 +++++ crates/editor/Cargo.toml | 2 + crates/editor/src/actions.rs | 1 + crates/editor/src/editor.rs | 72 +- crates/editor/src/element.rs | 357 ++++++-- crates/editor/src/git.rs | 2 +- crates/editor/src/git/blame.rs | 706 ++++++++++++++++ crates/editor/src/mouse_context_menu.rs | 42 +- crates/extension/src/extension_store_test.rs | 2 +- crates/extension_cli/src/main.rs | 2 +- crates/fs/Cargo.toml | 1 + crates/fs/src/fs.rs | 29 +- crates/fs/src/repository.rs | 82 +- crates/git/Cargo.toml | 7 + crates/git/src/blame.rs | 358 ++++++++ crates/git/src/commit.rs | 35 + crates/git/src/git.rs | 96 +++ .../{editor/src/git => git/src}/permalink.rs | 90 +- .../git/test_data/blame_incremental_complex | 194 +++++ .../test_data/blame_incremental_not_committed | 64 ++ crates/git/test_data/blame_incremental_simple | 70 ++ .../golden/blame_incremental_complex.json | 781 ++++++++++++++++++ .../blame_incremental_not_committed.json | 135 +++ .../golden/blame_incremental_simple.json | 135 +++ crates/gpui/src/app.rs | 5 + crates/project/Cargo.toml | 1 + crates/project/src/project.rs | 227 ++++- crates/project/src/project_tests.rs | 4 +- crates/rpc/proto/zed.proto | 50 +- crates/rpc/src/proto.rs | 4 + crates/text/src/tests.rs | 5 + crates/text/src/text.rs | 43 + crates/worktree/src/worktree.rs | 4 +- crates/worktree/src/worktree_tests.rs | 18 +- crates/zed/src/main.rs | 11 +- script/bundle-mac | 78 +- 39 files changed, 3760 insertions(+), 157 deletions(-) create mode 100644 crates/editor/src/git/blame.rs create mode 100644 crates/git/src/blame.rs create mode 100644 crates/git/src/commit.rs rename crates/{editor/src/git => git/src}/permalink.rs (92%) create mode 100644 crates/git/test_data/blame_incremental_complex create mode 100644 crates/git/test_data/blame_incremental_not_committed create mode 100644 crates/git/test_data/blame_incremental_simple create mode 100644 crates/git/test_data/golden/blame_incremental_complex.json create mode 100644 crates/git/test_data/golden/blame_incremental_not_committed.json create mode 100644 crates/git/test_data/golden/blame_incremental_simple.json 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