From ea8da43c6bb620d78bf0d96dec65d76f4492dd1b Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 12 Feb 2025 20:17:00 +0100 Subject: [PATCH 01/13] ui: Fix keybind sizing for non-default UI font sizes (#24708) Closes #24597 This fixes the regression from https://github.com/zed-industries/zed/commit/00971fbe415fdc4695307f192134093c7bcd138c which removed the `text_ui(cx)` - call from the keybinding render. The removal caused improperly scaled font rendering as shown in the images below. This PR reintroduces this behaviour for all cases where `size` is not set. | | `main` | With this patch | Parent of https://github.com/zed-industries/zed/commit/00971fbe415fdc4695307f192134093c7bcd138c | --- | ---- | ---- | --- | | Small font size (10px) | cur_small | pr_small | prev_small | | Large font size | cur_big | pr_big | prev_big | Release Notes: - Fixed keybind hints being improperly scaled for custom ui font sizes --- crates/ui/src/components/keybinding.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 4391c9061bae5dc2fb47d47618f60010ab187414..50083c251a5fb49ad05acd937fd9c737e80312f8 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -230,7 +230,9 @@ pub struct Key { impl RenderOnce for Key { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let single_char = self.key.len() == 1; - let size = self.size.unwrap_or(px(14.).into()); + let size = self + .size + .unwrap_or_else(|| TextSize::default().rems(cx).into()); div() .py_0() From eea6b526dcdd49cf70abdfaa9efaf99be531f99e Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 12 Feb 2025 14:46:42 -0500 Subject: [PATCH 02/13] Implement staging and unstaging hunks (#24606) - [x] Staging hunks - [x] Unstaging hunks - [x] Write a randomized test - [x] Get test passing - [x] Fix existing bug in diff_base_byte_range computation - [x] Remote project support - [ ] ~~Improve performance of buffer_range_to_unchanged_diff_base_range~~ - [ ] ~~Bug: project diff editor scrolls to top when staging/unstaging hunk~~ existing issue - [ ] ~~UI~~ deferred - [x] Tricky cases - [x] Correctly handle acting on multiple hunks for a single file - [x] Remove path from index when unstaging the last staged hunk, if it's absent from HEAD, or staging the only hunk, if it's deleted in the working copy Release Notes: - Add `ToggleStagedSelectedDiffHunks` action for staging and unstaging individual diff hunks --- Cargo.lock | 4 + crates/buffer_diff/Cargo.toml | 6 +- crates/buffer_diff/src/buffer_diff.rs | 437 +++++++++++++++--- crates/collab/src/rpc.rs | 1 + crates/editor/src/actions.rs | 1 + crates/editor/src/editor.rs | 118 ++++- crates/editor/src/editor_tests.rs | 53 +++ crates/editor/src/element.rs | 2 + crates/editor/src/test/editor_test_context.rs | 12 + crates/git/src/repository.rs | 62 +++ crates/multi_buffer/src/multi_buffer.rs | 13 +- crates/project/src/buffer_store.rs | 1 + crates/project/src/git.rs | 45 +- crates/project/src/project.rs | 43 ++ crates/proto/proto/zed.proto | 12 +- crates/proto/src/proto.rs | 3 + crates/remote_server/src/headless_project.rs | 21 + crates/sum_tree/src/cursor.rs | 2 +- 18 files changed, 767 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5dbfac4a2b2f04239a8ae1f4cc43863cb6a82552..a5648efab0dfe25d16e6ac84b5aa37abedc1a34c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2029,11 +2029,15 @@ name = "buffer_diff" version = "0.1.0" dependencies = [ "anyhow", + "ctor", + "env_logger 0.11.6", "futures 0.3.31", "git2", "gpui", "language", + "log", "pretty_assertions", + "rand 0.8.5", "rope", "serde_json", "sum_tree", diff --git a/crates/buffer_diff/Cargo.toml b/crates/buffer_diff/Cargo.toml index d4cac616d0e5eb5f372e1b5585e321f39e398b91..9d4691afd25644d09da8038e16fa21614e5e36dc 100644 --- a/crates/buffer_diff/Cargo.toml +++ b/crates/buffer_diff/Cargo.toml @@ -20,14 +20,18 @@ futures.workspace = true git2.workspace = true gpui.workspace = true language.workspace = true +log.workspace = true rope.workspace = true sum_tree.workspace = true text.workspace = true util.workspace = true [dev-dependencies] +ctor.workspace = true +env_logger.workspace = true +gpui = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true +rand.workspace = true serde_json.workspace = true text = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } unindent.workspace = true diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 772835f9a93f382934b91e016ba4c8c2cb91fef7..9d22576c3560270a0f39b8de4bcac2c22bf42a9c 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -5,6 +5,7 @@ use language::{Language, LanguageRegistry}; use rope::Rope; use std::{cmp, future::Future, iter, ops::Range, sync::Arc}; use sum_tree::SumTree; +use text::ToOffset as _; use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point}; use util::ResultExt; @@ -14,10 +15,11 @@ pub struct BufferDiff { secondary_diff: Option>, } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct BufferDiffSnapshot { inner: BufferDiffInner, secondary_diff: Option>, + pub is_single_insertion: bool, } #[derive(Clone)] @@ -40,21 +42,6 @@ pub enum DiffHunkSecondaryStatus { None, } -// to stage a hunk: -// - assume hunk starts out as not staged -// - hunk exists with the same buffer range in the unstaged diff and the uncommitted diff -// - we want to construct a "version" of the file that -// - starts from the index base text -// - has the single hunk applied to it -// - the hunk is the one from the UNSTAGED diff, so that the diff base offset range is correct to apply to that diff base -// - write that new version of the file into the index - -// to unstage a hunk -// - no hunk in the unstaged diff intersects this hunk from the uncommitted diff -// - we want to compute the hunk that -// - we can apply to the index text -// - at the end of applying it, - /// A diff hunk resolved to rows in the buffer. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DiffHunk { @@ -65,6 +52,7 @@ pub struct DiffHunk { /// The range in the buffer's diff base text to which this hunk corresponds. pub diff_base_byte_range: Range, pub secondary_status: DiffHunkSecondaryStatus, + pub secondary_diff_base_byte_range: Option>, } /// We store [`InternalDiffHunk`]s internally so we don't need to store the additional row range. @@ -166,6 +154,99 @@ impl BufferDiffSnapshot { } } } + + fn buffer_range_to_unchanged_diff_base_range( + &self, + buffer_range: Range, + buffer: &text::BufferSnapshot, + ) -> Option> { + let mut hunks = self.inner.hunks.iter(); + let mut start = 0; + let mut pos = buffer.anchor_before(0); + while let Some(hunk) = hunks.next() { + assert!(buffer_range.start.cmp(&pos, buffer).is_ge()); + assert!(hunk.buffer_range.start.cmp(&pos, buffer).is_ge()); + if hunk + .buffer_range + .start + .cmp(&buffer_range.end, buffer) + .is_ge() + { + // target buffer range is contained in the unchanged stretch leading up to this next hunk, + // so do a final adjustment based on that + break; + } + + // if the target buffer range intersects this hunk at all, no dice + if buffer_range + .start + .cmp(&hunk.buffer_range.end, buffer) + .is_lt() + { + return None; + } + + start += hunk.buffer_range.start.to_offset(buffer) - pos.to_offset(buffer); + start += hunk.diff_base_byte_range.end - hunk.diff_base_byte_range.start; + pos = hunk.buffer_range.end; + } + start += buffer_range.start.to_offset(buffer) - pos.to_offset(buffer); + let end = start + buffer_range.end.to_offset(buffer) - buffer_range.start.to_offset(buffer); + Some(start..end) + } + + pub fn secondary_edits_for_stage_or_unstage( + &self, + stage: bool, + hunks: impl Iterator, Option>, Range)>, + buffer: &text::BufferSnapshot, + ) -> Vec<(Range, String)> { + let Some(secondary_diff) = self.secondary_diff() else { + log::debug!("no secondary diff"); + return Vec::new(); + }; + let index_base = secondary_diff.base_text().map_or_else( + || Rope::from(""), + |snapshot| snapshot.text.as_rope().clone(), + ); + let head_base = self.base_text().map_or_else( + || Rope::from(""), + |snapshot| snapshot.text.as_rope().clone(), + ); + log::debug!("original: {:?}", index_base.to_string()); + let mut edits = Vec::new(); + for (diff_base_byte_range, secondary_diff_base_byte_range, buffer_range) in hunks { + let (index_byte_range, replacement_text) = if stage { + log::debug!("staging"); + let mut replacement_text = String::new(); + let Some(index_byte_range) = secondary_diff_base_byte_range.clone() else { + log::debug!("not a stageable hunk"); + continue; + }; + log::debug!("using {:?}", index_byte_range); + for chunk in buffer.text_for_range(buffer_range.clone()) { + replacement_text.push_str(chunk); + } + (index_byte_range, replacement_text) + } else { + log::debug!("unstaging"); + let mut replacement_text = String::new(); + let Some(index_byte_range) = secondary_diff + .buffer_range_to_unchanged_diff_base_range(buffer_range.clone(), &buffer) + else { + log::debug!("not an unstageable hunk"); + continue; + }; + for chunk in head_base.chunks_in_range(diff_base_byte_range.clone()) { + replacement_text.push_str(chunk); + } + (index_byte_range, replacement_text) + }; + edits.push((index_byte_range, replacement_text)); + } + log::debug!("edits: {edits:?}"); + edits + } } impl BufferDiffInner { @@ -225,6 +306,7 @@ impl BufferDiffInner { } let mut secondary_status = DiffHunkSecondaryStatus::None; + let mut secondary_diff_base_byte_range = None; if let Some(secondary_cursor) = secondary_cursor.as_mut() { if start_anchor .cmp(&secondary_cursor.start().buffer_range.start, buffer) @@ -234,9 +316,15 @@ impl BufferDiffInner { } if let Some(secondary_hunk) = secondary_cursor.item() { - let secondary_range = secondary_hunk.buffer_range.to_point(buffer); + let mut secondary_range = secondary_hunk.buffer_range.to_point(buffer); + if secondary_range.end.column > 0 { + secondary_range.end.row += 1; + secondary_range.end.column = 0; + } if secondary_range == (start_point..end_point) { secondary_status = DiffHunkSecondaryStatus::HasSecondaryHunk; + secondary_diff_base_byte_range = + Some(secondary_hunk.diff_base_byte_range.clone()); } else if secondary_range.start <= end_point { secondary_status = DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk; } @@ -248,6 +336,7 @@ impl BufferDiffInner { diff_base_byte_range: start_base..end_base, buffer_range: start_anchor..end_anchor, secondary_status, + secondary_diff_base_byte_range, }); }) } @@ -282,6 +371,7 @@ impl BufferDiffInner { buffer_range: hunk.buffer_range.clone(), // The secondary status is not used by callers of this method. secondary_status: DiffHunkSecondaryStatus::None, + secondary_diff_base_byte_range: None, }) }) } @@ -351,12 +441,12 @@ impl BufferDiffInner { } fn compute_hunks( - diff_base: Option>, + diff_base: Option<(Arc, Rope)>, buffer: text::BufferSnapshot, ) -> SumTree { let mut tree = SumTree::new(&buffer); - if let Some(diff_base) = diff_base { + if let Some((diff_base, diff_base_rope)) = diff_base { let buffer_text = buffer.as_rope().to_string(); let mut options = GitOptions::default(); @@ -387,7 +477,13 @@ fn compute_hunks( if let Some(patch) = patch { let mut divergence = 0; for hunk_index in 0..patch.num_hunks() { - let hunk = process_patch_hunk(&patch, hunk_index, &buffer, &mut divergence); + let hunk = process_patch_hunk( + &patch, + hunk_index, + &diff_base_rope, + &buffer, + &mut divergence, + ); tree.push(hunk, &buffer); } } @@ -399,6 +495,7 @@ fn compute_hunks( fn process_patch_hunk( patch: &GitPatch<'_>, hunk_index: usize, + diff_base: &Rope, buffer: &text::BufferSnapshot, buffer_row_divergence: &mut i64, ) -> InternalDiffHunk { @@ -408,50 +505,59 @@ fn process_patch_hunk( let mut first_deletion_buffer_row: Option = None; let mut buffer_row_range: Option> = None; let mut diff_base_byte_range: Option> = None; + let mut first_addition_old_row: Option = None; for line_index in 0..line_item_count { let line = patch.line_in_hunk(hunk_index, line_index).unwrap(); let kind = line.origin_value(); let content_offset = line.content_offset() as isize; let content_len = line.content().len() as isize; + match kind { + GitDiffLineType::Addition => { + if first_addition_old_row.is_none() { + first_addition_old_row = Some( + (line.new_lineno().unwrap() as i64 - *buffer_row_divergence - 1) as u32, + ); + } + *buffer_row_divergence += 1; + let row = line.new_lineno().unwrap().saturating_sub(1); - if kind == GitDiffLineType::Addition { - *buffer_row_divergence += 1; - let row = line.new_lineno().unwrap().saturating_sub(1); - - match &mut buffer_row_range { - Some(buffer_row_range) => buffer_row_range.end = row + 1, - None => buffer_row_range = Some(row..row + 1), + match &mut buffer_row_range { + Some(Range { end, .. }) => *end = row + 1, + None => buffer_row_range = Some(row..row + 1), + } } - } + GitDiffLineType::Deletion => { + let end = content_offset + content_len; - if kind == GitDiffLineType::Deletion { - let end = content_offset + content_len; + match &mut diff_base_byte_range { + Some(head_byte_range) => head_byte_range.end = end as usize, + None => diff_base_byte_range = Some(content_offset as usize..end as usize), + } - match &mut diff_base_byte_range { - Some(head_byte_range) => head_byte_range.end = end as usize, - None => diff_base_byte_range = Some(content_offset as usize..end as usize), - } + if first_deletion_buffer_row.is_none() { + let old_row = line.old_lineno().unwrap().saturating_sub(1); + let row = old_row as i64 + *buffer_row_divergence; + first_deletion_buffer_row = Some(row as u32); + } - if first_deletion_buffer_row.is_none() { - let old_row = line.old_lineno().unwrap().saturating_sub(1); - let row = old_row as i64 + *buffer_row_divergence; - first_deletion_buffer_row = Some(row as u32); + *buffer_row_divergence -= 1; } - - *buffer_row_divergence -= 1; + _ => {} } } - //unwrap_or deletion without addition let buffer_row_range = buffer_row_range.unwrap_or_else(|| { - //we cannot have an addition-less hunk without deletion(s) or else there would be no hunk + // Pure deletion hunk without addition. let row = first_deletion_buffer_row.unwrap(); row..row }); - - //unwrap_or addition without deletion - let diff_base_byte_range = diff_base_byte_range.unwrap_or(0..0); + let diff_base_byte_range = diff_base_byte_range.unwrap_or_else(|| { + // Pure addition hunk without deletion. + let row = first_addition_old_row.unwrap(); + let offset = diff_base.point_to_offset(Point::new(row, 0)); + offset..offset + }); let start = Point::new(buffer_row_range.start, 0); let end = Point::new(buffer_row_range.end, 0); @@ -499,9 +605,11 @@ impl BufferDiff { language_registry: Option>, cx: &mut App, ) -> impl Future { - let base_text_snapshot = diff_base.as_ref().map(|base_text| { + let diff_base = + diff_base.map(|diff_base| (diff_base.clone(), Rope::from(diff_base.as_str()))); + let base_text_snapshot = diff_base.as_ref().map(|(_, diff_base)| { language::Buffer::build_snapshot( - Rope::from(base_text.as_str()), + diff_base.clone(), language.clone(), language_registry.clone(), cx, @@ -528,6 +636,11 @@ impl BufferDiff { diff_base_buffer: Option, cx: &App, ) -> impl Future { + let diff_base = diff_base.clone().zip( + diff_base_buffer + .clone() + .map(|buffer| buffer.as_rope().clone()), + ); cx.background_executor().spawn(async move { BufferDiffInner { hunks: compute_hunks(diff_base, buffer), @@ -545,6 +658,7 @@ impl BufferDiff { pub fn build_with_single_insertion( insertion_present_in_secondary_diff: bool, + buffer: language::BufferSnapshot, cx: &mut App, ) -> BufferDiffSnapshot { let base_text = language::Buffer::build_empty_snapshot(cx); @@ -560,17 +674,23 @@ impl BufferDiff { hunks: hunks.clone(), base_text: Some(base_text.clone()), }, - secondary_diff: if insertion_present_in_secondary_diff { - Some(Box::new(BufferDiffSnapshot { - inner: BufferDiffInner { - hunks, - base_text: Some(base_text), + secondary_diff: Some(Box::new(BufferDiffSnapshot { + inner: BufferDiffInner { + hunks: if insertion_present_in_secondary_diff { + hunks + } else { + SumTree::new(&buffer.text) }, - secondary_diff: None, - })) - } else { - None - }, + base_text: Some(if insertion_present_in_secondary_diff { + base_text + } else { + buffer + }), + }, + secondary_diff: None, + is_single_insertion: true, + })), + is_single_insertion: true, } } @@ -675,6 +795,7 @@ impl BufferDiff { .secondary_diff .as_ref() .map(|diff| Box::new(diff.read(cx).snapshot(cx))), + is_single_insertion: false, } } @@ -875,13 +996,21 @@ pub fn assert_hunks( #[cfg(test)] mod tests { - use std::assert_eq; + use std::fmt::Write as _; use super::*; - use gpui::TestAppContext; - use text::{Buffer, BufferId}; + use gpui::{AppContext as _, TestAppContext}; + use rand::{rngs::StdRng, Rng as _}; + use text::{Buffer, BufferId, Rope}; use unindent::Unindent as _; + #[ctor::ctor] + fn init_logger() { + if std::env::var("RUST_LOG").is_ok() { + env_logger::init(); + } + } + #[gpui::test] async fn test_buffer_diff_simple(cx: &mut gpui::TestAppContext) { let diff_base = " @@ -1200,4 +1329,192 @@ mod tests { let range = diff_6.compare(&diff_5, &buffer).unwrap(); assert_eq!(range.to_point(&buffer), Point::new(7, 0)..Point::new(8, 0)); } + + #[gpui::test(iterations = 100)] + async fn test_secondary_edits_for_stage_unstage(cx: &mut TestAppContext, mut rng: StdRng) { + fn gen_line(rng: &mut StdRng) -> String { + if rng.gen_bool(0.2) { + "\n".to_owned() + } else { + let c = rng.gen_range('A'..='Z'); + format!("{c}{c}{c}\n") + } + } + + fn gen_working_copy(rng: &mut StdRng, head: &str) -> String { + let mut old_lines = { + let mut old_lines = Vec::new(); + let mut old_lines_iter = head.lines(); + while let Some(line) = old_lines_iter.next() { + assert!(!line.ends_with("\n")); + old_lines.push(line.to_owned()); + } + if old_lines.last().is_some_and(|line| line.is_empty()) { + old_lines.pop(); + } + old_lines.into_iter() + }; + let mut result = String::new(); + let unchanged_count = rng.gen_range(0..=old_lines.len()); + result += + &old_lines + .by_ref() + .take(unchanged_count) + .fold(String::new(), |mut s, line| { + writeln!(&mut s, "{line}").unwrap(); + s + }); + while old_lines.len() > 0 { + let deleted_count = rng.gen_range(0..=old_lines.len()); + let _advance = old_lines + .by_ref() + .take(deleted_count) + .map(|line| line.len() + 1) + .sum::(); + let minimum_added = if deleted_count == 0 { 1 } else { 0 }; + let added_count = rng.gen_range(minimum_added..=5); + let addition = (0..added_count).map(|_| gen_line(rng)).collect::(); + result += &addition; + + if old_lines.len() > 0 { + let blank_lines = old_lines.clone().take_while(|line| line.is_empty()).count(); + if blank_lines == old_lines.len() { + break; + }; + let unchanged_count = rng.gen_range((blank_lines + 1).max(1)..=old_lines.len()); + result += &old_lines.by_ref().take(unchanged_count).fold( + String::new(), + |mut s, line| { + writeln!(&mut s, "{line}").unwrap(); + s + }, + ); + } + } + result + } + + fn uncommitted_diff( + working_copy: &language::BufferSnapshot, + index_text: &Entity, + head_text: String, + cx: &mut TestAppContext, + ) -> BufferDiff { + let inner = BufferDiff::build_sync(working_copy.text.clone(), head_text, cx); + let secondary = BufferDiff { + buffer_id: working_copy.remote_id(), + inner: BufferDiff::build_sync( + working_copy.text.clone(), + index_text.read_with(cx, |index_text, _| index_text.text()), + cx, + ), + secondary_diff: None, + }; + let secondary = cx.new(|_| secondary); + BufferDiff { + buffer_id: working_copy.remote_id(), + inner, + secondary_diff: Some(secondary), + } + } + + let operations = std::env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(10); + + let rng = &mut rng; + let head_text = ('a'..='z').fold(String::new(), |mut s, c| { + writeln!(&mut s, "{c}{c}{c}").unwrap(); + s + }); + let working_copy = gen_working_copy(rng, &head_text); + let working_copy = cx.new(|cx| { + language::Buffer::local_normalized( + Rope::from(working_copy.as_str()), + text::LineEnding::default(), + cx, + ) + }); + let working_copy = working_copy.read_with(cx, |working_copy, _| working_copy.snapshot()); + let index_text = cx.new(|cx| { + language::Buffer::local_normalized( + if rng.gen() { + Rope::from(head_text.as_str()) + } else { + working_copy.as_rope().clone() + }, + text::LineEnding::default(), + cx, + ) + }); + + let mut diff = uncommitted_diff(&working_copy, &index_text, head_text.clone(), cx); + let mut hunks = cx.update(|cx| { + diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &working_copy, cx) + .collect::>() + }); + if hunks.len() == 0 { + return; + } + + for _ in 0..operations { + let i = rng.gen_range(0..hunks.len()); + let hunk = &mut hunks[i]; + let hunk_fields = ( + hunk.diff_base_byte_range.clone(), + hunk.secondary_diff_base_byte_range.clone(), + hunk.buffer_range.clone(), + ); + let stage = match ( + hunk.secondary_status, + hunk.secondary_diff_base_byte_range.clone(), + ) { + (DiffHunkSecondaryStatus::HasSecondaryHunk, Some(_)) => { + hunk.secondary_status = DiffHunkSecondaryStatus::None; + hunk.secondary_diff_base_byte_range = None; + true + } + (DiffHunkSecondaryStatus::None, None) => { + hunk.secondary_status = DiffHunkSecondaryStatus::HasSecondaryHunk; + // We don't look at this, just notice whether it's Some or not. + hunk.secondary_diff_base_byte_range = Some(17..17); + false + } + _ => unreachable!(), + }; + + let snapshot = cx.update(|cx| diff.snapshot(cx)); + let edits = snapshot.secondary_edits_for_stage_or_unstage( + stage, + [hunk_fields].into_iter(), + &working_copy, + ); + index_text.update(cx, |index_text, cx| { + index_text.edit(edits, None, cx); + }); + + diff = uncommitted_diff(&working_copy, &index_text, head_text.clone(), cx); + let found_hunks = cx.update(|cx| { + diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &working_copy, cx) + .collect::>() + }); + assert_eq!(hunks.len(), found_hunks.len()); + for (expected_hunk, found_hunk) in hunks.iter().zip(&found_hunks) { + assert_eq!( + expected_hunk.buffer_range.to_point(&working_copy), + found_hunk.buffer_range.to_point(&working_copy) + ); + assert_eq!( + expected_hunk.diff_base_byte_range, + found_hunk.diff_base_byte_range + ); + assert_eq!(expected_hunk.secondary_status, found_hunk.secondary_status); + assert_eq!( + expected_hunk.secondary_diff_base_byte_range.is_some(), + found_hunk.secondary_diff_base_byte_range.is_some() + ) + } + hunks = found_hunks; + } + } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 2fa325b6a77576b515809ae3772ae2d3ee46ae22..b511249f22c672c0f257953c6dcf46ba6e856447 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -395,6 +395,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_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(update_context) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 61481e3f9212becf6a80c109c0adee42faa8d8ad..afdc5ce2a03d27d44f8abbdb70669d469d909bc1 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -402,6 +402,7 @@ gpui::actions!( ToggleInlayHints, ToggleEditPrediction, ToggleLineNumbers, + ToggleStagedSelectedDiffHunks, SwapSelectionEnds, SetMark, ToggleRelativeLineNumbers, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3bb41fc27f0984f2f2740d2f7bc95ae18b91e901..9931dca5b0e664048ea3180efe74b38cf461f930 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -52,6 +52,7 @@ pub use actions::{AcceptEditPrediction, OpenExcerpts, OpenExcerptsSplit}; use aho_corasick::AhoCorasick; use anyhow::{anyhow, Context as _, Result}; use blink_manager::BlinkManager; +use buffer_diff::DiffHunkSecondaryStatus; use client::{Collaborator, ParticipantIndex}; use clock::ReplicaId; use collections::{BTreeMap, HashMap, HashSet, VecDeque}; @@ -95,7 +96,7 @@ use itertools::Itertools; use language::{ language_settings::{self, all_language_settings, language_settings, InlayHintSettings}, markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, Capability, CharKind, CodeLabel, - CompletionDocumentation, CursorShape, Diagnostic, EditPredictionsMode, EditPreview, + CompletionDocumentation, CursorShape, Diagnostic, DiskState, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, }; @@ -12431,6 +12432,121 @@ impl Editor { self.toggle_diff_hunks_in_ranges(ranges, cx); } + fn diff_hunks_in_ranges<'a>( + &'a self, + ranges: &'a [Range], + buffer: &'a MultiBufferSnapshot, + ) -> impl 'a + Iterator { + ranges.iter().flat_map(move |range| { + let end_excerpt_id = range.end.excerpt_id; + let range = range.to_point(buffer); + let mut peek_end = range.end; + if range.end.row < buffer.max_row().0 { + peek_end = Point::new(range.end.row + 1, 0); + } + buffer + .diff_hunks_in_range(range.start..peek_end) + .filter(move |hunk| hunk.excerpt_id.cmp(&end_excerpt_id, buffer).is_le()) + }) + } + + pub fn has_stageable_diff_hunks_in_ranges( + &self, + ranges: &[Range], + snapshot: &MultiBufferSnapshot, + ) -> bool { + let mut hunks = self.diff_hunks_in_ranges(ranges, &snapshot); + hunks.any(|hunk| { + log::debug!("considering {hunk:?}"); + hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk + }) + } + + pub fn toggle_staged_selected_diff_hunks( + &mut self, + _: &ToggleStagedSelectedDiffHunks, + _window: &mut Window, + cx: &mut Context, + ) { + let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); + self.stage_or_unstage_diff_hunks(&ranges, cx); + } + + pub fn stage_or_unstage_diff_hunks( + &mut self, + ranges: &[Range], + cx: &mut Context, + ) { + let Some(project) = &self.project else { + return; + }; + let snapshot = self.buffer.read(cx).snapshot(cx); + let stage = self.has_stageable_diff_hunks_in_ranges(ranges, &snapshot); + + let chunk_by = self + .diff_hunks_in_ranges(&ranges, &snapshot) + .chunk_by(|hunk| hunk.buffer_id); + for (buffer_id, hunks) in &chunk_by { + let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) else { + log::debug!("no buffer for id"); + continue; + }; + let buffer = buffer.read(cx).snapshot(); + let Some((repo, path)) = project + .read(cx) + .repository_and_path_for_buffer_id(buffer_id, cx) + else { + log::debug!("no git repo for buffer id"); + continue; + }; + let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else { + log::debug!("no diff for buffer id"); + continue; + }; + let Some(secondary_diff) = diff.secondary_diff() else { + log::debug!("no secondary diff for buffer id"); + continue; + }; + + let edits = diff.secondary_edits_for_stage_or_unstage( + stage, + hunks.map(|hunk| { + ( + hunk.diff_base_byte_range.clone(), + hunk.secondary_diff_base_byte_range.clone(), + hunk.buffer_range.clone(), + ) + }), + &buffer, + ); + + let index_base = secondary_diff.base_text().map_or_else( + || Rope::from(""), + |snapshot| snapshot.text.as_rope().clone(), + ); + let index_buffer = cx.new(|cx| { + Buffer::local_normalized(index_base.clone(), text::LineEnding::default(), cx) + }); + let new_index_text = index_buffer.update(cx, |index_buffer, cx| { + index_buffer.edit(edits, None, cx); + index_buffer.snapshot().as_rope().to_string() + }); + let new_index_text = if new_index_text.is_empty() + && (diff.is_single_insertion + || buffer + .file() + .map_or(false, |file| file.disk_state() == DiskState::New)) + { + log::debug!("removing from index"); + None + } else { + Some(new_index_text) + }; + + let _ = repo.read(cx).set_index_text(&path, new_index_text); + } + } + pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context) { let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); self.buffer diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 8b89a9f0faa60c7abafc929ead093c9517b717ff..3c4ad2f9a11997a3a73678f0444f423ab76b550c 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -14047,6 +14047,59 @@ async fn test_edit_after_expanded_modification_hunk( ); } +#[gpui::test] +async fn test_stage_and_unstage_added_file_hunk( + executor: BackgroundExecutor, + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_editor(|editor, _, cx| { + editor.set_expand_all_diff_hunks(cx); + }); + + let working_copy = r#" + ˇfn main() { + println!("hello, world!"); + } + "# + .unindent(); + + cx.set_state(&working_copy); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + + ˇfn main() { + + println!("hello, world!"); + + } + "# + .unindent(), + ); + cx.assert_index_text(None); + + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_diff_hunks(&ToggleStagedSelectedDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_index_text(Some(&working_copy.replace("ˇ", ""))); + cx.assert_state_with_diff( + r#" + + ˇfn main() { + + println!("hello, world!"); + + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_diff_hunks(&ToggleStagedSelectedDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_index_text(None); +} + async fn setup_indent_guides_editor( text: &str, cx: &mut gpui::TestAppContext, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 18b81d707a1b9283c491fe90649949c430d8b8f8..d789bbce3b5e6d36e6bf1c7f21961484c565b50f 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -417,7 +417,9 @@ impl EditorElement { register_action(editor, window, Editor::toggle_git_blame); register_action(editor, window, Editor::toggle_git_blame_inline); register_action(editor, window, Editor::toggle_selected_diff_hunks); + register_action(editor, window, Editor::toggle_staged_selected_diff_hunks); register_action(editor, window, Editor::expand_all_diff_hunks); + register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.format(action, window, cx) { task.detach_and_notify_err(window, cx); diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index a632d9fa1ec760a75eea71f36ceed9670956034a..f6cd523cc847d994dd066520f6aec61ed3ce4cb9 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -298,6 +298,18 @@ impl EditorTestContext { self.cx.run_until_parked(); } + pub fn assert_index_text(&mut self, expected: Option<&str>) { + let fs = self.update_editor(|editor, _, cx| { + editor.project.as_ref().unwrap().read(cx).fs().as_fake() + }); + let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone()); + let mut found = None; + fs.with_git_state(&Self::root_path().join(".git"), false, |git_state| { + found = git_state.index_contents.get(path.as_ref()).cloned(); + }); + assert_eq!(expected, found.as_deref()); + } + /// Change the editor's text and selections using a string containing /// embedded range markers that represent the ranges and directions of /// each selection. diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index e1bf864e503ef0a88091a4626d5de3f8341d90ac..24f8689d0445d12584ef3a6fc44a4eeda6408f90 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -8,6 +8,8 @@ use gpui::SharedString; use parking_lot::Mutex; use rope::Rope; use std::borrow::Borrow; +use std::io::Write as _; +use std::process::Stdio; use std::sync::LazyLock; use std::{ cmp::Ordering, @@ -39,6 +41,8 @@ pub trait GitRepository: Send + Sync { /// Note that for symlink entries, this will return the contents of the symlink, not the target. fn load_committed_text(&self, path: &RepoPath) -> Option; + fn set_index_text(&self, path: &RepoPath, content: Option) -> anyhow::Result<()>; + /// Returns the URL of the remote with the given name. fn remote_url(&self, name: &str) -> Option; fn branch_name(&self) -> Option; @@ -161,6 +165,50 @@ impl GitRepository for RealGitRepository { Some(content) } + fn set_index_text(&self, path: &RepoPath, content: Option) -> anyhow::Result<()> { + let working_directory = self + .repository + .lock() + .workdir() + .context("failed to read git work directory")? + .to_path_buf(); + if let Some(content) = content { + let mut child = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["hash-object", "-w", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + child.stdin.take().unwrap().write_all(content.as_bytes())?; + let output = child.wait_with_output()?.stdout; + let sha = String::from_utf8(output)?; + + log::debug!("indexing SHA: {sha}, path {path:?}"); + + let status = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["update-index", "--add", "--cacheinfo", "100644", &sha]) + .arg(path.as_ref()) + .status()?; + + if !status.success() { + return Err(anyhow!("Failed to add to index: {status:?}")); + } + } else { + let status = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["update-index", "--force-remove"]) + .arg(path.as_ref()) + .status()?; + + if !status.success() { + return Err(anyhow!("Failed to remove from index: {status:?}")); + } + } + + Ok(()) + } + fn remote_url(&self, name: &str) -> Option { let repo = self.repository.lock(); let remote = repo.find_remote(name).ok()?; @@ -412,6 +460,20 @@ impl GitRepository for FakeGitRepository { state.head_contents.get(path.as_ref()).cloned() } + fn set_index_text(&self, path: &RepoPath, content: Option) -> anyhow::Result<()> { + let mut state = self.state.lock(); + if let Some(content) = content { + state.index_contents.insert(path.clone(), content); + } else { + state.index_contents.remove(path); + } + state + .event_emitter + .try_send(state.path.clone()) + .expect("Dropped repo change event"); + Ok(()) + } + fn remote_url(&self, _name: &str) -> Option { None } diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 0306011b3ad9127672e87bcf41146a852d00ed0c..e75bc586decd1860b4f1533528497c8fa49b4d6e 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -133,6 +133,7 @@ pub struct MultiBufferDiffHunk { pub diff_base_byte_range: Range, /// Whether or not this hunk also appears in the 'secondary diff'. pub secondary_status: DiffHunkSecondaryStatus, + pub secondary_diff_base_byte_range: Option>, } impl MultiBufferDiffHunk { @@ -2191,7 +2192,11 @@ impl MultiBuffer { let secondary_diff_insertion = new_diff .secondary_diff() .map_or(true, |secondary_diff| secondary_diff.base_text().is_none()); - new_diff = BufferDiff::build_with_single_insertion(secondary_diff_insertion, cx); + new_diff = BufferDiff::build_with_single_insertion( + secondary_diff_insertion, + buffer.snapshot(), + cx, + ); } let mut snapshot = self.snapshot.borrow_mut(); @@ -3477,6 +3482,7 @@ impl MultiBufferSnapshot { buffer_range: hunk.buffer_range.clone(), diff_base_byte_range: hunk.diff_base_byte_range.clone(), secondary_status: hunk.secondary_status, + secondary_diff_base_byte_range: hunk.secondary_diff_base_byte_range, }) }) } @@ -3846,6 +3852,7 @@ impl MultiBufferSnapshot { buffer_range: hunk.buffer_range.clone(), diff_base_byte_range: hunk.diff_base_byte_range.clone(), secondary_status: hunk.secondary_status, + secondary_diff_base_byte_range: hunk.secondary_diff_base_byte_range, }); } } @@ -5937,6 +5944,10 @@ impl MultiBufferSnapshot { pub fn show_headers(&self) -> bool { self.show_headers } + + pub fn diff_for_buffer_id(&self, buffer_id: BufferId) -> Option<&BufferDiffSnapshot> { + self.diffs.get(&buffer_id) + } } #[cfg(any(test, feature = "test-support"))] diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 08d11de899ae8a068934873053819f53fc40496a..138d83f078b00ba04b4321332ed5a400bc9383aa 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -189,6 +189,7 @@ impl BufferDiffState { buffer: text::BufferSnapshot, cx: &mut Context, ) -> oneshot::Receiver<()> { + log::debug!("recalculate diffs"); let (tx, rx) = oneshot::channel(); self.diff_updated_futures.push(tx); diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index 6385025ff5c72a1e099ffa6522a171cbfd2c47ba..f420a2b9290e39e65764e7c1a7a428bfb02aeb0c 100644 --- a/crates/project/src/git.rs +++ b/crates/project/src/git.rs @@ -23,11 +23,11 @@ use util::{maybe, ResultExt}; use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry}; pub struct GitState { - project_id: Option, - client: Option, + pub(super) project_id: Option, + pub(super) client: Option, + pub update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender>)>, repositories: Vec>, active_index: Option, - update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender>)>, _subscription: Subscription, } @@ -51,7 +51,7 @@ pub enum GitRepo { }, } -enum Message { +pub enum Message { Commit { git_repo: GitRepo, message: SharedString, @@ -59,6 +59,7 @@ enum Message { }, Stage(GitRepo, Vec), Unstage(GitRepo, Vec), + SetIndexText(GitRepo, RepoPath, Option), } pub enum GitEvent { @@ -291,11 +292,32 @@ impl GitState { } Ok(()) } + Message::SetIndexText(git_repo, path, text) => match git_repo { + GitRepo::Local(repo) => repo.set_index_text(&path, text), + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => client.send(proto::SetIndexText { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + path: path.as_ref().to_proto(), + text, + }), + }, } } } +impl GitRepo {} + impl Repository { + pub fn git_state(&self) -> Option> { + self.git_state.upgrade() + } + fn id(&self) -> (WorktreeId, ProjectEntryId) { (self.worktree_id, self.repository_entry.work_directory_id()) } @@ -522,4 +544,19 @@ impl Repository { .ok(); result_rx } + + pub fn set_index_text( + &self, + path: &RepoPath, + content: Option, + ) -> oneshot::Receiver> { + let (result_tx, result_rx) = futures::channel::oneshot::channel(); + self.update_sender + .unbounded_send(( + Message::SetIndexText(self.git_repo.clone(), path.clone(), content), + result_tx, + )) + .ok(); + result_rx + } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 5446471b90f4d51b0db2b1000dc2bfbf94b09969..fac8d33347b032418ab26cc021d01b69d7efe433 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -610,6 +610,7 @@ impl Project { client.add_entity_request_handler(Self::handle_stage); client.add_entity_request_handler(Self::handle_unstage); client.add_entity_request_handler(Self::handle_commit); + client.add_entity_request_handler(Self::handle_set_index_text); client.add_entity_request_handler(Self::handle_open_commit_message_buffer); WorktreeStore::init(&client); @@ -4092,6 +4093,27 @@ impl Project { Ok(proto::Ack {}) } + async fn handle_set_index_text( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.set_index_text( + &RepoPath::from_str(&envelope.payload.path), + envelope.payload.text, + ) + })? + .await??; + Ok(proto::Ack {}) + } + async fn handle_open_commit_message_buffer( this: Entity, envelope: TypedEnvelope, @@ -4336,6 +4358,27 @@ impl Project { pub fn all_repositories(&self, cx: &App) -> Vec> { self.git_state.read(cx).all_repositories() } + + pub fn repository_and_path_for_buffer_id( + &self, + buffer_id: BufferId, + cx: &App, + ) -> Option<(Entity, RepoPath)> { + let path = self + .buffer_for_id(buffer_id, cx)? + .read(cx) + .project_path(cx)?; + self.git_state + .read(cx) + .all_repositories() + .into_iter() + .find_map(|repo| { + Some(( + repo.clone(), + repo.read(cx).repository_entry.relativize(&path.path).ok()?, + )) + }) + } } fn deserialize_code_actions(code_actions: &HashMap) -> Vec { diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 7d33dd1a3e842d100c4e8496f294ba6006a4a307..4fdfa0ae928a6441d1b1138766dd795add7202af 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -315,7 +315,9 @@ message Envelope { OpenCommitMessageBuffer open_commit_message_buffer = 296; OpenUncommittedDiff open_uncommitted_diff = 297; - OpenUncommittedDiffResponse open_uncommitted_diff_response = 298; // current max + OpenUncommittedDiffResponse open_uncommitted_diff_response = 298; + + SetIndexText set_index_text = 299; // current max } reserved 87 to 88; @@ -2087,6 +2089,14 @@ message OpenUncommittedDiffResponse { Mode mode = 3; } +message SetIndexText { + uint64 project_id = 1; + uint64 worktree_id = 2; + uint64 work_directory_id = 3; + string path = 4; + optional string text = 5; +} + message GetNotifications { optional uint64 before_id = 1; } diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index d45cc0936cf399c86ff59eed9f7db728213ffa0a..b51f34914b6a0869268a2bdbe6d58e5c372d6b8c 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -440,6 +440,7 @@ messages!( (SyncExtensionsResponse, Background), (InstallExtension, Background), (RegisterBufferWithLanguageServers, Background), + (SetIndexText, Background), ); request_messages!( @@ -573,6 +574,7 @@ request_messages!( (SyncExtensions, SyncExtensionsResponse), (InstallExtension, Ack), (RegisterBufferWithLanguageServers, Ack), + (SetIndexText, Ack), ); entity_messages!( @@ -665,6 +667,7 @@ entity_messages!( GetPathMetadata, CancelLanguageServerWork, RegisterBufferWithLanguageServers, + SetIndexText, ); entity_messages!( diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index eb4122a321dac05d6fa38cf26074bc047c2ec346..e274014c1e4a7a8ba36a464cf0f72e9dc763b6f8 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -200,6 +200,7 @@ impl HeadlessProject { client.add_entity_request_handler(Self::handle_stage); client.add_entity_request_handler(Self::handle_unstage); client.add_entity_request_handler(Self::handle_commit); + client.add_entity_request_handler(Self::handle_set_index_text); client.add_entity_request_handler(Self::handle_open_commit_message_buffer); client.add_request_handler( @@ -691,6 +692,26 @@ impl HeadlessProject { Ok(proto::Ack {}) } + async fn handle_set_index_text( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + repository + .update(&mut cx, |repository, _| { + repository.set_index_text( + &RepoPath::from(envelope.payload.path.as_str()), + envelope.payload.text, + ) + })? + .await??; + Ok(proto::Ack {}) + } + async fn handle_open_commit_message_buffer( this: Entity, envelope: TypedEnvelope, diff --git a/crates/sum_tree/src/cursor.rs b/crates/sum_tree/src/cursor.rs index 3e33d8b43e873ac2e54fe9aa6c693fa93839949b..b079365b8e6ac246df01479a4568fa37ff22ce25 100644 --- a/crates/sum_tree/src/cursor.rs +++ b/crates/sum_tree/src/cursor.rs @@ -447,7 +447,7 @@ where summary.0 } - /// Returns whether we found the item you where seeking for + /// Returns whether we found the item you were seeking for #[track_caller] fn seek_internal( &mut self, From 316b97d6e35f03dff14da8bdc4d6d7d1620284fd Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 12 Feb 2025 12:11:49 -0800 Subject: [PATCH 03/13] Position prediction popover adjacent to edit if possible (#24750) Also, place accept key binding indicator on right edge of popover to avoid obscuring content above. Release Notes: - N/A Co-authored-by: Danilo Leal Co-authored-by: rtfeldman Co-authored-by: Agus Zubiaga --- crates/editor/src/element.rs | 102 +++++++++++++++++------------------ 1 file changed, 48 insertions(+), 54 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index d789bbce3b5e6d36e6bf1c7f21961484c565b50f..78f854ea306f674ea7f1adf111233c63d67fde7a 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3808,39 +3808,38 @@ impl EditorElement { ); let styled_text = highlighted_edits.to_styled_text(&style.text); + let line_count = highlighted_edits.text.lines().count(); - const ACCEPT_INDICATOR_HEIGHT: Pixels = px(24.); + const BORDER_WIDTH: Pixels = px(1.); - let mut element = v_flex() - .items_end() + let mut element = h_flex() + .items_start() + .child( + div() + .bg(cx.theme().colors().editor_background) + .border(BORDER_WIDTH) + .shadow_sm() + .border_color(cx.theme().colors().border) + .rounded_l_lg() + .when(line_count > 1, |el| el.rounded_br_lg()) + .pr_1() + .child(styled_text), + ) .child( h_flex() - .h(ACCEPT_INDICATOR_HEIGHT) - .mb(px(-1.)) + .h(line_height + BORDER_WIDTH * px(2.)) .px_1p5() .gap_1() .shadow_sm() .bg(Editor::edit_prediction_line_popover_bg_color(cx)) - .border_1() - .border_b_0() + .border(BORDER_WIDTH) + .border_l_0() .border_color(cx.theme().colors().border) - .rounded_t_lg() + .rounded_r_lg() .children(editor.render_edit_prediction_accept_keybind(window, cx)), ) - .child( - div() - .bg(cx.theme().colors().editor_background) - .border_1() - .shadow_sm() - .border_color(cx.theme().colors().border) - .rounded_lg() - .rounded_tr(Pixels::ZERO) - .child(styled_text), - ) .into_any(); - let line_count = highlighted_edits.text.lines().count(); - let longest_row = editor_snapshot.longest_row_in_range(edit_start.row()..edit_end.row() + 1); let longest_line_width = if visible_row_range.contains(&longest_row) { @@ -3871,55 +3870,50 @@ impl EditorElement { // Fully visible if it can be displayed within the window (allow overlapping other // panes). However, this is only allowed if the popover starts within text_bounds. - let is_fully_visible = x_after_longest < text_bounds.right() + let can_position_to_the_right = x_after_longest < text_bounds.right() && x_after_longest + element_bounds.width < viewport_bounds.right(); - let mut origin = if is_fully_visible { + let mut origin = if can_position_to_the_right { point( x_after_longest, text_bounds.origin.y + edit_start.row().as_f32() * line_height - scroll_pixel_position.y, ) } else { - // Avoid overlapping both the edited rows and the user's cursor. - let target_above = DisplayRow( - edit_start - .row() - .0 - .min( - newest_selection_head - .map_or(u32::MAX, |cursor_row| cursor_row.row().0), - ) - .saturating_sub(line_count as u32), - ); - let mut row_target; - if visible_row_range.contains(&DisplayRow(target_above.0.saturating_sub(1))) { - row_target = target_above; - } else { - row_target = DisplayRow( - edit_end.row().0.max( - newest_selection_head.map_or(0, |cursor_row| cursor_row.row().0), - ) + 1, - ); - if !visible_row_range.contains(&row_target) { - // Not visible, so fallback on displaying immediately below the cursor. - if let Some(cursor) = newest_selection_head { - row_target = DisplayRow(cursor.row().0 + 1); - } else { - // Not visible and no cursor visible, so fallback on displaying at the top of the editor. - row_target = DisplayRow(0); - } - } - }; + let cursor_row = newest_selection_head.map(|head| head.row()); + let above_edit = edit_start + .row() + .0 + .checked_sub(line_count as u32) + .map(DisplayRow); + let below_edit = Some(edit_end.row() + 1); + let above_cursor = cursor_row + .and_then(|row| row.0.checked_sub(line_count as u32).map(DisplayRow)); + let below_cursor = cursor_row.map(|cursor_row| cursor_row + 1); + + // Place the edit popover adjacent to the edit if there is a location + // available that is onscreen and does not obscure the cursor. Otherwise, + // place it adjacent to the cursor. + let row_target = [above_edit, below_edit, above_cursor, below_cursor] + .into_iter() + .flatten() + .find(|&start_row| { + let end_row = start_row + line_count as u32; + visible_row_range.contains(&start_row) + && visible_row_range.contains(&end_row) + && cursor_row.map_or(true, |cursor_row| { + !((start_row..end_row).contains(&cursor_row)) + }) + })?; - text_bounds.origin + content_origin + point( -scroll_pixel_position.x, row_target.as_f32() * line_height - scroll_pixel_position.y, ) }; - origin.y -= ACCEPT_INDICATOR_HEIGHT; + origin.x -= BORDER_WIDTH; window.defer_draw(element, origin, 1); From ab4a6f1c7930b1c1391d86625a1c450e90c8a6aa Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 12 Feb 2025 15:37:17 -0500 Subject: [PATCH 04/13] Open git panel when deploying project diff via action (#24751) Release Notes: - N/A --- crates/git_ui/src/project_diff.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index c48934850e4f8337debc19c1e28553af8450614a..2f812de686e1d6f364be709a92536e1829c62226 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -69,6 +69,7 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { + workspace.open_panel::(window, cx); Self::deploy_at(workspace, None, window, cx) } From c771ca49e1cead870ef651016006c1d966a6db7d Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 12 Feb 2025 17:59:11 -0300 Subject: [PATCH 05/13] Fix <1px gap between diff popover and accept keybindingg (#24756) Release Notes: - N/A Co-authored-by: Max Brunsfeld Co-authored-by: Danilo Leal Co-authored-by: rtfeldman Co-authored-by: mgsloan --- crates/editor/src/element.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 78f854ea306f674ea7f1adf111233c63d67fde7a..a13278a40131c8ee17c9c5784743b8b6ffa78cc5 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3830,10 +3830,16 @@ impl EditorElement { .h(line_height + BORDER_WIDTH * px(2.)) .px_1p5() .gap_1() - .shadow_sm() + // Workaround: For some reason, there's a gap if we don't do this + .ml(-BORDER_WIDTH) + .shadow(smallvec![gpui::BoxShadow { + color: gpui::black().opacity(0.05), + offset: point(px(1.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + }]) .bg(Editor::edit_prediction_line_popover_bg_color(cx)) .border(BORDER_WIDTH) - .border_l_0() .border_color(cx.theme().colors().border) .rounded_r_lg() .children(editor.render_edit_prediction_accept_keybind(window, cx)), From 5dc3c237eb59f6134420d909039da804fad3f78e Mon Sep 17 00:00:00 2001 From: smit <0xtimsb@gmail.com> Date: Thu, 13 Feb 2025 03:37:39 +0530 Subject: [PATCH 06/13] workspace: Do not reuse window for sub directory (only for root directory and sub files) (#24560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #10232 Context: We have three ways to open files or dirs in Zed: `zed`, `zed --new`, and `zed --add`. `--new` forces the project to open in a new window, while `--add` forces it to open in an existing window (even if the dir isn’t a subdir of an existing project or the file isn’t part of it). Using just `zed` tries to open it in an existing window based on similar logic of `--add`, but if no related project is found the dir, opens in a new window. Problem: Right now, subdirs that are part of an existing project open in the existing window when using `zed`. By default, subdirs should open in a new window instead. If someone wants to open it in the existing window, they can explicitly use `--add`. After this PR, only root dir and files will focus on existing window, when `zed ` is used. Fix: For the `zed` case, we’ve filtered out subdirs in the logic that assigns them to an existing window. Release Notes: - Fixed an issue where subdirectories of an already opened project, when opened via the terminal, would open in the existing project instead of a new window. --- crates/project/src/project.rs | 34 ++++++++--- crates/workspace/src/workspace.rs | 96 ++++++++++++++++--------------- crates/worktree/src/worktree.rs | 5 -- 3 files changed, 77 insertions(+), 58 deletions(-) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index fac8d33347b032418ab26cc021d01b69d7efe433..d09ef9bd8f0af1ecaed26f6b88c04d5bd47760f9 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -95,7 +95,10 @@ use task_store::TaskStore; use terminals::Terminals; use text::{Anchor, BufferId}; use toolchain_store::EmptyToolchainStore; -use util::{paths::compare_paths, ResultExt as _}; +use util::{ + paths::{compare_paths, SanitizedPath}, + ResultExt as _, +}; use worktree::{CreatedEntry, Snapshot, Traversal}; use worktree_store::{WorktreeStore, WorktreeStoreEvent}; @@ -1484,22 +1487,37 @@ impl Project { .and_then(|worktree| worktree.read(cx).status_for_file(&project_path.path)) } - pub fn visibility_for_paths(&self, paths: &[PathBuf], cx: &App) -> Option { + pub fn visibility_for_paths( + &self, + paths: &[PathBuf], + metadatas: &[Metadata], + exclude_sub_dirs: bool, + cx: &App, + ) -> Option { paths .iter() - .map(|path| self.visibility_for_path(path, cx)) + .zip(metadatas) + .map(|(path, metadata)| self.visibility_for_path(path, metadata, exclude_sub_dirs, cx)) .max() .flatten() } - pub fn visibility_for_path(&self, path: &Path, cx: &App) -> Option { + pub fn visibility_for_path( + &self, + path: &Path, + metadata: &Metadata, + exclude_sub_dirs: bool, + cx: &App, + ) -> Option { + let sanitized_path = SanitizedPath::from(path); + let path = sanitized_path.as_path(); self.worktrees(cx) .filter_map(|worktree| { let worktree = worktree.read(cx); - worktree - .as_local()? - .contains_abs_path(path) - .then(|| worktree.is_visible()) + let abs_path = worktree.as_local()?.abs_path(); + let contains = path == abs_path + || (path.starts_with(abs_path) && (!exclude_sub_dirs || !metadata.is_dir)); + contains.then(|| worktree.is_visible()) }) .max() } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 2ad9cf1c4d8408161d627174d3e287132ce319d4..53bd58596c3bffd3070e2b28baa217a8ba5e4f6c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -5958,7 +5958,6 @@ pub struct OpenOptions { pub replace_window: Option>, pub env: Option>, } - #[allow(clippy::type_complexity)] pub fn open_paths( abs_paths: &[PathBuf], @@ -5976,58 +5975,65 @@ pub fn open_paths( let mut best_match = None; let mut open_visible = OpenVisible::All; - if open_options.open_new_workspace != Some(true) { - for window in local_workspace_windows(cx) { - if let Ok(workspace) = window.read(cx) { - let m = workspace - .project - .read(cx) - .visibility_for_paths(&abs_paths, cx); - if m > best_match { - existing = Some(window); - best_match = m; - } else if best_match.is_none() && open_options.open_new_workspace == Some(false) { - existing = Some(window) - } - } - } - } - cx.spawn(move |mut cx| async move { - if open_options.open_new_workspace.is_none() && existing.is_none() { - let all_files = abs_paths.iter().map(|path| app_state.fs.metadata(path)); - if futures::future::join_all(all_files) + if open_options.open_new_workspace != Some(true) { + let all_paths = abs_paths.iter().map(|path| app_state.fs.metadata(path)); + let all_metadatas = futures::future::join_all(all_paths) .await .into_iter() .filter_map(|result| result.ok().flatten()) - .all(|file| !file.is_dir) - { - cx.update(|cx| { - if let Some(window) = cx - .active_window() - .and_then(|window| window.downcast::()) - { - if let Ok(workspace) = window.read(cx) { - let project = workspace.project().read(cx); - if project.is_local() && !project.is_via_collab() { - existing = Some(window); - open_visible = OpenVisible::None; - return; - } + .collect::>(); + + cx.update(|cx| { + for window in local_workspace_windows(&cx) { + if let Ok(workspace) = window.read(&cx) { + let m = workspace.project.read(&cx).visibility_for_paths( + &abs_paths, + &all_metadatas, + open_options.open_new_workspace == None, + cx, + ); + if m > best_match { + existing = Some(window); + best_match = m; + } else if best_match.is_none() + && open_options.open_new_workspace == Some(false) + { + existing = Some(window) } } - for window in local_workspace_windows(cx) { - if let Ok(workspace) = window.read(cx) { - let project = workspace.project().read(cx); - if project.is_via_collab() { - continue; + } + })?; + + if open_options.open_new_workspace.is_none() && existing.is_none() { + if all_metadatas.iter().all(|file| !file.is_dir) { + cx.update(|cx| { + if let Some(window) = cx + .active_window() + .and_then(|window| window.downcast::()) + { + if let Ok(workspace) = window.read(cx) { + let project = workspace.project().read(cx); + if project.is_local() && !project.is_via_collab() { + existing = Some(window); + open_visible = OpenVisible::None; + return; + } } - existing = Some(window); - open_visible = OpenVisible::None; - break; } - } - })?; + for window in local_workspace_windows(cx) { + if let Ok(workspace) = window.read(cx) { + let project = workspace.project().read(cx); + if project.is_via_collab() { + continue; + } + existing = Some(window); + open_visible = OpenVisible::None; + break; + } + } + })?; + } } } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 07f77283db070d1a1bb43f7899888b9de97eaec7..88c61c9af6da1b963a7fcb3e2ff105ab02bace24 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -1335,11 +1335,6 @@ impl LocalWorktree { &self.fs } - pub fn contains_abs_path(&self, path: &Path) -> bool { - let path = SanitizedPath::from(path); - path.starts_with(&self.abs_path) - } - pub fn is_path_private(&self, path: &Path) -> bool { !self.share_private_files && self.settings.is_path_private(path) } From 522b8d662cc1adc02ac97b686e902d5f55930381 Mon Sep 17 00:00:00 2001 From: smit <0xtimsb@gmail.com> Date: Thu, 13 Feb 2025 03:42:22 +0530 Subject: [PATCH 07/13] editor: Fix autoscroll flickering regression (#24758) This PR fixes autoscroll flickering issue caused by recent [#24735](https://github.com/zed-industries/zed/pull/24735) which fixes soft wrap scroll issues. No release notes, as this was few hours ago. Adding vertical scrollbar width to viewport width, so that autoscroll function don't try to that much pixels extra. Release Notes: - N/A --- crates/editor/src/element.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index a13278a40131c8ee17c9c5784743b8b6ffa78cc5..9a5b7da6ffaa23e4d8fef5449ef8ffb435b45c1b 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -7188,7 +7188,7 @@ impl Element for EditorElement { let autoscrolled = if autoscroll_horizontally { editor.autoscroll_horizontally( start_row, - editor_width - (letter_size.width / 2.0), + editor_width - (letter_size.width / 2.0) + style.scrollbar_width, scroll_width, em_width, &line_layouts, @@ -7279,7 +7279,7 @@ impl Element for EditorElement { let autoscrolled = if autoscroll_horizontally { editor.autoscroll_horizontally( start_row, - editor_width - (letter_size.width / 2.0), + editor_width - (letter_size.width / 2.0) + style.scrollbar_width, scroll_width, em_width, &line_layouts, From df8adc8b11632f99639b69680ad24bffe1d5c5c0 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Wed, 12 Feb 2025 16:46:42 -0600 Subject: [PATCH 08/13] Fix linux zeta modifiers display (#24764) Improves rendering of Zeta keybind shortcuts on Linux Before: ![image](https://github.com/user-attachments/assets/9b6a61f7-dade-480f-a864-acdcede05957) After: (with muting modifier changes merged) ![image](https://github.com/user-attachments/assets/dd616d29-ac2e-4c8b-bf9b-5d74f8e4f1c4) Release Notes: - N/A --------- Co-authored-by: Michael Co-authored-by: Agus --- Cargo.lock | 1 + crates/editor/src/editor.rs | 57 +++++++++++++++----------- crates/editor/src/element.rs | 2 +- crates/ui/Cargo.toml | 5 ++- crates/ui/src/components/keybinding.rs | 51 ++++++++++++----------- crates/util/src/util.rs | 22 ++++++++++ 6 files changed, 89 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5648efab0dfe25d16e6ac84b5aa37abedc1a34c..038a102e1060df13243df931de81dba38fa228ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14414,6 +14414,7 @@ dependencies = [ "strum", "theme", "ui_macros", + "util", "windows 0.58.0", ] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 9931dca5b0e664048ea3180efe74b38cf461f930..66b76991a6b2d849d793ec0b9af9a0d0112b3783 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -161,7 +161,7 @@ use sum_tree::TreeMap; use text::{BufferId, OffsetUtf16, Rope}; use theme::{ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, ThemeColors, ThemeSettings}; use ui::{ - h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconName, IconSize, + h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconName, IconSize, Key, Tooltip, }; use util::{defer, maybe, post_inc, RangeExt, ResultExt, TakeUntilExt, TryFutureExt}; @@ -5657,29 +5657,39 @@ impl Editor { fn render_edit_prediction_accept_keybind(&self, window: &mut Window, cx: &App) -> Option
{ let accept_binding = self.accept_edit_prediction_keybind(window, cx); let accept_keystroke = accept_binding.keystroke()?; - let colors = cx.theme().colors(); - let accent_color = colors.text_accent; - let editor_bg_color = colors.editor_background; - let bg_color = editor_bg_color.blend(accent_color.opacity(0.1)); + + let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; + + let modifiers_color = if accept_keystroke.modifiers == window.modifiers() { + Color::Accent + } else { + Color::Muted + }; h_flex() .px_0p5() - .gap_1() - .bg(bg_color) + .when(is_platform_style_mac, |parent| parent.gap_0p5()) .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) .text_size(TextSize::XSmall.rems(cx)) - .children(ui::render_modifiers( + .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, PlatformStyle::platform(), - Some(if accept_keystroke.modifiers == window.modifiers() { - Color::Accent - } else { - Color::Muted - }), + Some(modifiers_color), Some(IconSize::XSmall.rems().into()), - false, - )) - .child(accept_keystroke.key.clone()) + true, + ))) + .when(is_platform_style_mac, |parent| { + parent.child(accept_keystroke.key.clone()) + }) + .when(!is_platform_style_mac, |parent| { + parent.child( + Key::new( + util::capitalize(&accept_keystroke.key), + Some(Color::Default), + ) + .size(Some(IconSize::XSmall.rems().into())), + ) + }) .into() } @@ -5808,13 +5818,13 @@ impl Editor { }, ) .child(Label::new("Hold").size(LabelSize::Small)) - .children(ui::render_modifiers( + .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, PlatformStyle::platform(), Some(Color::Default), Some(IconSize::Small.rems().into()), - true, - )) + false, + ))) .into_any(), ); } @@ -5858,6 +5868,7 @@ impl Editor { let has_completion = self.active_inline_completion.is_some(); + let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; Some( h_flex() .min_w(min_width) @@ -5886,8 +5897,8 @@ impl Editor { .child( h_flex() .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) - .gap_1() - .children(ui::render_modifiers( + .when(is_platform_style_mac, |parent| parent.gap_1()) + .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, PlatformStyle::platform(), Some(if !has_completion { @@ -5896,8 +5907,8 @@ impl Editor { Color::Default }), None, - true, - )), + false, + ))), ) .child(Label::new("Preview").into_any_element()) .opacity(if has_completion { 1.0 } else { 0.4 }), diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 9a5b7da6ffaa23e4d8fef5449ef8ffb435b45c1b..4cea82664b9f25ec6ec77dff6c437bf683447b13 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3815,7 +3815,7 @@ impl EditorElement { let mut element = h_flex() .items_start() .child( - div() + h_flex() .bg(cx.theme().colors().editor_background) .border(BORDER_WIDTH) .shadow_sm() diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index ba7c89a8a6450b12d7ed3ac0f40b786107863b3d..c15f4c2c1d000db1a54bfa80a7db48b44c249515 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -16,7 +16,7 @@ path = "src/ui.rs" chrono.workspace = true component.workspace = true gpui.workspace = true -itertools = { workspace = true, optional = true } +itertools.workspace = true linkme.workspace = true menu.workspace = true serde.workspace = true @@ -26,13 +26,14 @@ story = { workspace = true, optional = true } strum = { workspace = true, features = ["derive"] } theme.workspace = true ui_macros.workspace = true +util.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true [features] default = [] -stories = ["dep:itertools", "dep:story"] +stories = ["dep:story"] # cargo-machete doesn't understand that linkme is used in the component macro [package.metadata.cargo-machete] diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 50083c251a5fb49ad05acd937fd9c737e80312f8..8883dce89932883c1d7f1b2bce7b0954a8ffbcf9 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -92,7 +92,7 @@ impl RenderOnce for KeyBinding { self.platform_style, None, self.size, - false, + true, )) .map(|el| { el.child(render_key(&keystroke, self.platform_style, None, self.size)) @@ -110,7 +110,7 @@ pub fn render_key( let key_icon = icon_for_key(keystroke, platform_style); match key_icon { Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), - None => Key::new(capitalize(&keystroke.key), color) + None => Key::new(util::capitalize(&keystroke.key), color) .size(size) .into_any_element(), } @@ -145,10 +145,12 @@ pub fn render_modifiers( platform_style: PlatformStyle, color: Option, size: Option, - standalone: bool, + trailing_separator: bool, ) -> impl Iterator { + #[derive(Clone)] enum KeyOrIcon { Key(&'static str), + Plus, Icon(IconName), } @@ -200,23 +202,34 @@ pub fn render_modifiers( .into_iter() .filter(|modifier| modifier.enabled) .collect::>(); - let last_ix = filtered.len().saturating_sub(1); - filtered + let platform_keys = filtered .into_iter() - .enumerate() - .flat_map(move |(ix, modifier)| match platform_style { - PlatformStyle::Mac => vec![modifier.mac], - PlatformStyle::Linux if standalone && ix == last_ix => vec![modifier.linux], - PlatformStyle::Linux => vec![modifier.linux, KeyOrIcon::Key("+")], - PlatformStyle::Windows if standalone && ix == last_ix => { - vec![modifier.windows] - } - PlatformStyle::Windows => vec![modifier.windows, KeyOrIcon::Key("+")], + .map(move |modifier| match platform_style { + PlatformStyle::Mac => Some(modifier.mac), + PlatformStyle::Linux => Some(modifier.linux), + PlatformStyle::Windows => Some(modifier.windows), + }); + + let separator = match platform_style { + PlatformStyle::Mac => None, + PlatformStyle::Linux => Some(KeyOrIcon::Plus), + PlatformStyle::Windows => Some(KeyOrIcon::Plus), + }; + + let platform_keys = itertools::intersperse(platform_keys, separator.clone()); + + platform_keys + .chain(if modifiers.modified() && trailing_separator { + Some(separator) + } else { + None }) + .flatten() .map(move |key_or_icon| match key_or_icon { KeyOrIcon::Key(key) => Key::new(key, color).size(size).into_any_element(), KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), + KeyOrIcon::Plus => "+".into_any_element(), }) } @@ -389,7 +402,7 @@ pub fn text_for_keystroke(keystroke: &Keystroke, platform_style: PlatformStyle) let key = match keystroke.key.as_str() { "pageup" => "PageUp", "pagedown" => "PageDown", - key => &capitalize(key), + key => &util::capitalize(key), }; text.push_str(key); @@ -397,14 +410,6 @@ pub fn text_for_keystroke(keystroke: &Keystroke, platform_style: PlatformStyle) text } -fn capitalize(str: &str) -> String { - let mut chars = str.chars(); - match chars.next() { - None => String::new(), - Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 9fd802a09cf1658941d038ff9bdc3b754716bf51..79abd1065b5bc35529c28168df7b46b68e9fc69d 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -787,6 +787,28 @@ impl<'a> PartialOrd for NumericPrefixWithSuffix<'a> { } } +/// Capitalizes the first character of a string. +/// +/// This function takes a string slice as input and returns a new `String` with the first character +/// capitalized. +/// +/// # Examples +/// +/// ``` +/// use util::capitalize; +/// +/// assert_eq!(capitalize("hello"), "Hello"); +/// assert_eq!(capitalize("WORLD"), "WORLD"); +/// assert_eq!(capitalize(""), ""); +/// ``` +pub fn capitalize(str: &str) -> String { + let mut chars = str.chars(); + match chars.next() { + None => String::new(), + Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), + } +} + fn emoji_regex() -> &'static Regex { static EMOJI_REGEX: LazyLock = LazyLock::new(|| Regex::new("(\\p{Emoji}|\u{200D})").unwrap()); From b014afa938e5b7c1269ab010c69b952c25f05b33 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 12 Feb 2025 14:57:08 -0800 Subject: [PATCH 09/13] Add an undo button to the git panel (#24593) Also prep infrastructure for pushing a commit Release Notes: - N/A --------- Co-authored-by: Conrad Irwin Co-authored-by: Conrad Co-authored-by: Nate Butler --- Cargo.lock | 2 + assets/keymaps/vim.json | 2 +- .../20221109000000_test_schema.sql | 1 + .../20250210223746_add_branch_summary.sql | 2 + ...0212060936_add_worktree_branch_summary.sql | 1 + crates/collab/src/db/queries/projects.rs | 40 +- crates/collab/src/db/queries/rooms.rs | 8 + .../src/db/tables/worktree_repository.rs | 2 + crates/collab/src/rpc.rs | 2 + crates/collab/src/tests/integration_tests.rs | 9 +- .../remote_editing_collaboration_tests.rs | 4 +- ...ame_entry_tooltip.rs => commit_tooltip.rs} | 143 ++++--- crates/editor/src/editor.rs | 2 +- crates/editor/src/element.rs | 19 +- crates/editor/src/git/blame.rs | 31 +- crates/git/src/blame.rs | 16 +- crates/git/src/git.rs | 1 + crates/git/src/hosting_provider.rs | 6 +- crates/git/src/repository.rs | 320 +++++++++++++--- .../golden/blame_incremental_complex.json | 164 ++++---- .../blame_incremental_not_committed.json | 28 +- .../golden/blame_incremental_simple.json | 28 +- .../src/providers/codeberg.rs | 5 +- .../src/providers/github.rs | 5 +- crates/git_ui/Cargo.toml | 2 + crates/git_ui/src/branch_picker.rs | 23 +- crates/git_ui/src/git_panel.rs | 265 +++++++++++-- crates/git_ui/src/project_diff.rs | 20 +- crates/git_ui/src/quick_commit.rs | 4 +- crates/git_ui/src/repository_selector.rs | 12 +- crates/project/src/buffer_store.rs | 8 +- crates/project/src/git.rs | 358 ++++++++++++++++-- crates/project/src/project.rs | 181 ++------- crates/project/src/worktree_store.rs | 39 +- crates/proto/proto/zed.proto | 50 ++- crates/proto/src/proto.rs | 7 + crates/remote_server/src/headless_project.rs | 178 +-------- .../remote_server/src/remote_editing_tests.rs | 4 +- crates/time_format/src/time_format.rs | 24 +- crates/title_bar/src/title_bar.rs | 1 + crates/worktree/src/worktree.rs | 158 +++++--- 41 files changed, 1437 insertions(+), 738 deletions(-) create mode 100644 crates/collab/migrations/20250210223746_add_branch_summary.sql create mode 100644 crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql rename crates/editor/src/{blame_entry_tooltip.rs => commit_tooltip.rs} (73%) diff --git a/Cargo.lock b/Cargo.lock index 038a102e1060df13243df931de81dba38fa228ec..eb44454c909377eb1f555a5a9ff9747045fce9be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5371,6 +5371,8 @@ dependencies = [ "serde_json", "settings", "theme", + "time", + "time_format", "ui", "util", "windows 0.58.0", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 450e435bb37d3cd0e20ecee1077e2c5e436c2d31..6fa7475adcc810ed447c283c8c465af2128710db 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -631,7 +631,7 @@ } }, { - "context": "GitPanel || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || Welcome", + "context": "ChangesList || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || Welcome", "bindings": { ":": "command_palette::Toggle", "g /": "pane::DeploySearch" diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index ba6fdeb9290d38412665868d8b18e5fd719c61c2..30d36cfe8c1070cab7bfc7bf52f09118d79eef4e 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -101,6 +101,7 @@ CREATE TABLE "worktree_repositories" ( "scan_id" INTEGER NOT NULL, "is_deleted" BOOL NOT NULL, "current_merge_conflicts" VARCHAR, + "branch_summary" VARCHAR, PRIMARY KEY(project_id, worktree_id, work_directory_id), FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE, FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE diff --git a/crates/collab/migrations/20250210223746_add_branch_summary.sql b/crates/collab/migrations/20250210223746_add_branch_summary.sql new file mode 100644 index 0000000000000000000000000000000000000000..3294f38b94114a73713b6282d401b97fcdc383e5 --- /dev/null +++ b/crates/collab/migrations/20250210223746_add_branch_summary.sql @@ -0,0 +1,2 @@ +ALTER TABLE worktree_repositories +ADD COLUMN worktree_repositories VARCHAR NULL; diff --git a/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql b/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql new file mode 100644 index 0000000000000000000000000000000000000000..d7e3c04e2ff7844ed8d47907b0d21b64ae7d9a1c --- /dev/null +++ b/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql @@ -0,0 +1 @@ +ALTER TABLE worktree_repositories ADD COLUMN branch_summary TEXT NULL; diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 2755f1223037b1f5204e1ade3a0aa199780d5ac5..1cff5b53b09c1241202c9adf38884f7f18994117 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -326,16 +326,26 @@ impl Database { if !update.updated_repositories.is_empty() { worktree_repository::Entity::insert_many(update.updated_repositories.iter().map( - |repository| worktree_repository::ActiveModel { - project_id: ActiveValue::set(project_id), - worktree_id: ActiveValue::set(worktree_id), - work_directory_id: ActiveValue::set(repository.work_directory_id as i64), - scan_id: ActiveValue::set(update.scan_id as i64), - branch: ActiveValue::set(repository.branch.clone()), - is_deleted: ActiveValue::set(false), - current_merge_conflicts: ActiveValue::Set(Some( - serde_json::to_string(&repository.current_merge_conflicts).unwrap(), - )), + |repository| { + worktree_repository::ActiveModel { + project_id: ActiveValue::set(project_id), + worktree_id: ActiveValue::set(worktree_id), + work_directory_id: ActiveValue::set( + repository.work_directory_id as i64, + ), + scan_id: ActiveValue::set(update.scan_id as i64), + branch: ActiveValue::set(repository.branch.clone()), + is_deleted: ActiveValue::set(false), + branch_summary: ActiveValue::Set( + repository + .branch_summary + .as_ref() + .map(|summary| serde_json::to_string(summary).unwrap()), + ), + current_merge_conflicts: ActiveValue::Set(Some( + serde_json::to_string(&repository.current_merge_conflicts).unwrap(), + )), + } }, )) .on_conflict( @@ -347,6 +357,8 @@ impl Database { .update_columns([ worktree_repository::Column::ScanId, worktree_repository::Column::Branch, + worktree_repository::Column::BranchSummary, + worktree_repository::Column::CurrentMergeConflicts, ]) .to_owned(), ) @@ -779,6 +791,13 @@ impl Database { .transpose()? .unwrap_or_default(); + let branch_summary = db_repository_entry + .branch_summary + .as_ref() + .map(|branch_summary| serde_json::from_str(&branch_summary)) + .transpose()? + .unwrap_or_default(); + worktree.repository_entries.insert( db_repository_entry.work_directory_id as u64, proto::RepositoryEntry { @@ -787,6 +806,7 @@ impl Database { updated_statuses, removed_statuses: Vec::new(), current_merge_conflicts, + branch_summary, }, ); } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 8c9089dd756019a9c2d0f71616f63ea693cf559d..3f65cc4258e6c1891c844c4263b9f5262fed7b09 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -743,12 +743,20 @@ impl Database { .transpose()? .unwrap_or_default(); + let branch_summary = db_repository + .branch_summary + .as_ref() + .map(|branch_summary| serde_json::from_str(&branch_summary)) + .transpose()? + .unwrap_or_default(); + worktree.updated_repositories.push(proto::RepositoryEntry { work_directory_id: db_repository.work_directory_id as u64, branch: db_repository.branch, updated_statuses, removed_statuses, current_merge_conflicts, + branch_summary, }); } } diff --git a/crates/collab/src/db/tables/worktree_repository.rs b/crates/collab/src/db/tables/worktree_repository.rs index 66ff7b76430ef0cc0d4681c223ebdd83355f6a90..66247f9f17c45e215f833eeed73aed31c6a9b620 100644 --- a/crates/collab/src/db/tables/worktree_repository.rs +++ b/crates/collab/src/db/tables/worktree_repository.rs @@ -15,6 +15,8 @@ pub struct Model { pub is_deleted: bool, // JSON array typed string pub current_merge_conflicts: Option, + // A JSON object representing the current Branch values + pub branch_summary: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index b511249f22c672c0f257953c6dcf46ba6e856447..7e4f72007da38e04c73a2a64a3ebdab4fecd099a 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -395,6 +395,8 @@ 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_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index be9890dcb84275badc83404b5f17c24cbcfaf0e5..98d17f2b31490a5ecde47c419ea18e933effa694 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -2895,7 +2895,10 @@ async fn test_git_branch_name( assert_eq!(worktrees.len(), 1); let worktree = worktrees[0].clone(); let root_entry = worktree.read(cx).snapshot().root_git_entry().unwrap(); - assert_eq!(root_entry.branch(), branch_name.map(Into::into)); + assert_eq!( + root_entry.branch().map(|branch| branch.name.to_string()), + branch_name + ); } // Smoke test branch reading @@ -6783,7 +6786,7 @@ async fn test_remote_git_branches( }) }); - assert_eq!(host_branch.as_ref(), branches[2]); + assert_eq!(host_branch.name, branches[2]); // Also try creating a new branch cx_b.update(|cx| { @@ -6804,5 +6807,5 @@ async fn test_remote_git_branches( }) }); - assert_eq!(host_branch.as_ref(), "totally-new-branch"); + assert_eq!(host_branch.name, "totally-new-branch"); } diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index c251204459b0ee2119922593a1da39a82187dbbe..ec132a0c07c2fee047591af3995ef694942d88cb 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -314,7 +314,7 @@ async fn test_ssh_collaboration_git_branches( }) }); - assert_eq!(server_branch.as_ref(), branches[2]); + assert_eq!(server_branch.name, branches[2]); // Also try creating a new branch cx_b.update(|cx| { @@ -337,7 +337,7 @@ async fn test_ssh_collaboration_git_branches( }) }); - assert_eq!(server_branch.as_ref(), "totally-new-branch"); + assert_eq!(server_branch.name, "totally-new-branch"); } #[gpui::test] diff --git a/crates/editor/src/blame_entry_tooltip.rs b/crates/editor/src/commit_tooltip.rs similarity index 73% rename from crates/editor/src/blame_entry_tooltip.rs rename to crates/editor/src/commit_tooltip.rs index 755f63cc4078dabce557a5bb3d2f4ec3567031be..f45b8d2b62da7770020b31efe1cf7b7c6678af4b 100644 --- a/crates/editor/src/blame_entry_tooltip.rs +++ b/crates/editor/src/commit_tooltip.rs @@ -1,28 +1,48 @@ use futures::Future; use git::blame::BlameEntry; -use git::Oid; +use git::PullRequest; use gpui::{ App, Asset, ClipboardItem, Element, ParentElement, Render, ScrollHandle, StatefulInteractiveElement, WeakEntity, }; +use language::ParsedMarkdown; use settings::Settings; use std::hash::Hash; use theme::ThemeSettings; -use time::UtcOffset; +use time::{OffsetDateTime, UtcOffset}; +use time_format::format_local_timestamp; use ui::{prelude::*, tooltip_container, Avatar, Divider, IconButtonShape}; +use url::Url; use workspace::Workspace; -use crate::git::blame::{CommitDetails, GitRemote}; +use crate::git::blame::GitRemote; use crate::EditorStyle; +#[derive(Clone, Debug)] +pub struct CommitDetails { + pub sha: SharedString, + pub committer_name: SharedString, + pub committer_email: SharedString, + pub commit_time: OffsetDateTime, + pub message: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct ParsedCommitMessage { + pub message: SharedString, + pub parsed_message: ParsedMarkdown, + pub permalink: Option, + pub pull_request: Option, + pub remote: Option, +} + struct CommitAvatar<'a> { - details: Option<&'a CommitDetails>, - sha: Oid, + commit: &'a CommitDetails, } impl<'a> CommitAvatar<'a> { - fn new(details: Option<&'a CommitDetails>, sha: Oid) -> Self { - Self { details, sha } + fn new(details: &'a CommitDetails) -> Self { + Self { commit: details } } } @@ -30,14 +50,16 @@ impl<'a> CommitAvatar<'a> { fn render( &'a self, window: &mut Window, - cx: &mut Context, + cx: &mut Context, ) -> Option { let remote = self - .details + .commit + .message + .as_ref() .and_then(|details| details.remote.as_ref()) .filter(|remote| remote.host_supports_avatars())?; - let avatar_url = CommitAvatarAsset::new(remote.clone(), self.sha); + let avatar_url = CommitAvatarAsset::new(remote.clone(), self.commit.sha.clone()); let element = match window.use_asset::(&avatar_url, cx) { // Loading or no avatar found @@ -54,7 +76,7 @@ impl<'a> CommitAvatar<'a> { #[derive(Clone, Debug)] struct CommitAvatarAsset { - sha: Oid, + sha: SharedString, remote: GitRemote, } @@ -66,7 +88,7 @@ impl Hash for CommitAvatarAsset { } impl CommitAvatarAsset { - fn new(remote: GitRemote, sha: Oid) -> Self { + fn new(remote: GitRemote, sha: SharedString) -> Self { Self { remote, sha } } } @@ -91,50 +113,78 @@ impl Asset for CommitAvatarAsset { } } -pub(crate) struct BlameEntryTooltip { - blame_entry: BlameEntry, - details: Option, +pub struct CommitTooltip { + commit: CommitDetails, editor_style: EditorStyle, workspace: Option>, scroll_handle: ScrollHandle, } -impl BlameEntryTooltip { - pub(crate) fn new( - blame_entry: BlameEntry, - details: Option, - style: &EditorStyle, +impl CommitTooltip { + pub fn blame_entry( + blame: BlameEntry, + details: Option, + style: EditorStyle, + workspace: Option>, + ) -> Self { + let commit_time = blame + .committer_time + .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok()) + .unwrap_or(OffsetDateTime::now_utc()); + Self::new( + CommitDetails { + sha: blame.sha.to_string().into(), + commit_time, + committer_name: blame + .committer_name + .unwrap_or("".to_string()) + .into(), + committer_email: blame.committer_email.unwrap_or("".to_string()).into(), + message: details, + }, + style, + workspace, + ) + } + + pub fn new( + commit: CommitDetails, + editor_style: EditorStyle, workspace: Option>, ) -> Self { Self { - editor_style: style.clone(), - blame_entry, - details, + editor_style, + commit, workspace, scroll_handle: ScrollHandle::new(), } } } -impl Render for BlameEntryTooltip { +impl Render for CommitTooltip { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let avatar = - CommitAvatar::new(self.details.as_ref(), self.blame_entry.sha).render(window, cx); + let avatar = CommitAvatar::new(&self.commit).render(window, cx); - let author = self - .blame_entry - .author - .clone() - .unwrap_or("".to_string()); + let author = self.commit.committer_name.clone(); - let author_email = self.blame_entry.author_mail.clone(); + let author_email = self.commit.committer_email.clone(); - let short_commit_id = self.blame_entry.sha.display_short(); - let full_sha = self.blame_entry.sha.to_string().clone(); - let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry); + let short_commit_id = self + .commit + .sha + .get(0..8) + .map(|sha| sha.to_string().into()) + .unwrap_or_else(|| self.commit.sha.clone()); + let full_sha = self.commit.sha.to_string().clone(); + let absolute_timestamp = format_local_timestamp( + self.commit.commit_time, + OffsetDateTime::now_utc(), + time_format::TimestampFormat::MediumAbsolute, + ); let message = self - .details + .commit + .message .as_ref() .map(|details| { crate::render_parsed_markdown( @@ -149,7 +199,8 @@ impl Render for BlameEntryTooltip { .unwrap_or("".into_any()); let pull_request = self - .details + .commit + .message .as_ref() .and_then(|details| details.pull_request.clone()); @@ -171,7 +222,7 @@ impl Render for BlameEntryTooltip { .flex_wrap() .children(avatar) .child(author) - .when_some(author_email, |this, author_email| { + .when(!author_email.is_empty(), |this| { this.child( div() .text_color(cx.theme().colors().text_muted) @@ -231,12 +282,16 @@ impl Render for BlameEntryTooltip { .icon_color(Color::Muted) .icon_position(IconPosition::Start) .disabled( - self.details.as_ref().map_or(true, |details| { - details.permalink.is_none() - }), + self.commit + .message + .as_ref() + .map_or(true, |details| { + details.permalink.is_none() + }), ) .when_some( - self.details + self.commit + .message .as_ref() .and_then(|details| details.permalink.clone()), |this, url| { @@ -284,7 +339,3 @@ fn blame_entry_timestamp(blame_entry: &BlameEntry, format: time_format::Timestam pub fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String { blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative) } - -fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry) -> String { - blame_entry_timestamp(blame_entry, time_format::TimestampFormat::MediumAbsolute) -} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 66b76991a6b2d849d793ec0b9af9a0d0112b3783..f77a32a92bbcff29d25955a31d5ed234f2c04118 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -13,10 +13,10 @@ //! //! If you're looking to improve Vim mode, you should check out Vim crate that wraps Editor and overrides its behavior. pub mod actions; -mod blame_entry_tooltip; mod blink_manager; mod clangd_ext; mod code_context_menus; +pub mod commit_tooltip; pub mod display_map; mod editor_settings; mod editor_settings_controls; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4cea82664b9f25ec6ec77dff6c437bf683447b13..c6e55f483b352e5cd2872551ea154033011e6ea9 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1,6 +1,6 @@ use crate::{ - blame_entry_tooltip::{blame_entry_relative_timestamp, BlameEntryTooltip}, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, + commit_tooltip::{blame_entry_relative_timestamp, CommitTooltip, ParsedCommitMessage}, display_map::{ Block, BlockContext, BlockStyle, DisplaySnapshot, HighlightedChunk, ToDisplayPoint, }, @@ -8,7 +8,7 @@ use crate::{ CurrentLineHighlight, DoubleClickInMultibuffer, MultiCursorModifier, ScrollBeyondLastLine, ScrollbarDiagnostics, ShowScrollbar, }, - git::blame::{CommitDetails, GitBlame}, + git::blame::GitBlame, hover_popover::{ self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT, }, @@ -5939,7 +5939,8 @@ fn render_inline_blame_entry( let details = blame.read(cx).details_for_entry(&blame_entry); - let tooltip = cx.new(|_| BlameEntryTooltip::new(blame_entry, details, style, workspace)); + let tooltip = + cx.new(|_| CommitTooltip::blame_entry(blame_entry, details, style.clone(), workspace)); h_flex() .id("inline-blame") @@ -5989,8 +5990,14 @@ fn render_blame_entry( let workspace = editor.read(cx).workspace.as_ref().map(|(w, _)| w.clone()); - let tooltip = - cx.new(|_| BlameEntryTooltip::new(blame_entry.clone(), details.clone(), style, workspace)); + let tooltip = cx.new(|_| { + CommitTooltip::blame_entry( + blame_entry.clone(), + details.clone(), + style.clone(), + workspace, + ) + }); h_flex() .w_full() @@ -6040,7 +6047,7 @@ fn render_blame_entry( fn deploy_blame_entry_context_menu( blame_entry: &BlameEntry, - details: Option<&CommitDetails>, + details: Option<&ParsedCommitMessage>, editor: Entity, position: gpui::Point, window: &mut Window, diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 767c1eabb9e0e09756037d5b44ca7740cac4a017..d8ff8c359fc024c8dd30e8e96622d0a961d77d5a 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -2,7 +2,7 @@ use anyhow::Result; use collections::HashMap; use git::{ blame::{Blame, BlameEntry}, - parse_git_remote_url, GitHostingProvider, GitHostingProviderRegistry, Oid, PullRequest, + parse_git_remote_url, GitHostingProvider, GitHostingProviderRegistry, Oid, }; use gpui::{App, Context, Entity, Subscription, Task}; use http_client::HttpClient; @@ -12,8 +12,11 @@ use project::{Project, ProjectItem}; use smallvec::SmallVec; use std::{sync::Arc, time::Duration}; use sum_tree::SumTree; +use ui::SharedString; use url::Url; +use crate::commit_tooltip::ParsedCommitMessage; + #[derive(Clone, Debug, Default)] pub struct GitBlameEntry { pub rows: u32, @@ -77,7 +80,11 @@ impl GitRemote { self.host.supports_avatars() } - pub async fn avatar_url(&self, commit: Oid, client: Arc) -> Option { + pub async fn avatar_url( + &self, + commit: SharedString, + client: Arc, + ) -> Option { self.host .commit_author_avatar_url(&self.owner, &self.repo, commit, client) .await @@ -85,21 +92,11 @@ impl GitRemote { .flatten() } } - -#[derive(Clone, Debug)] -pub struct CommitDetails { - pub message: String, - pub parsed_message: ParsedMarkdown, - pub permalink: Option, - pub pull_request: Option, - pub remote: Option, -} - pub struct GitBlame { project: Entity, buffer: Entity, entries: SumTree, - commit_details: HashMap, + commit_details: HashMap, buffer_snapshot: BufferSnapshot, buffer_edits: text::Subscription, task: Task>, @@ -187,7 +184,7 @@ impl GitBlame { self.generated } - pub fn details_for_entry(&self, entry: &BlameEntry) -> Option { + pub fn details_for_entry(&self, entry: &BlameEntry) -> Option { self.commit_details.get(&entry.sha).cloned() } @@ -480,7 +477,7 @@ async fn parse_commit_messages( deprecated_permalinks: &HashMap, provider_registry: Arc, languages: &Arc, -) -> HashMap { +) -> HashMap { let mut commit_details = HashMap::default(); let parsed_remote_url = remote_url @@ -519,8 +516,8 @@ async fn parse_commit_messages( commit_details.insert( oid, - CommitDetails { - message, + ParsedCommitMessage { + message: message.into(), parsed_message, permalink, remote, diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index e4947e5bbd6dae0135dc3dfc730cff77606da49d..fd87fcb7aa1b63243c8947bfcc6314f6f288735a 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -132,8 +132,8 @@ pub struct BlameEntry { pub author_time: Option, pub author_tz: Option, - pub committer: Option, - pub committer_mail: Option, + pub committer_name: Option, + pub committer_email: Option, pub committer_time: Option, pub committer_tz: Option, @@ -255,10 +255,12 @@ fn parse_git_blame(output: &str) -> Result> { .clone_from(&existing_entry.author_mail); new_entry.author_time = existing_entry.author_time; new_entry.author_tz.clone_from(&existing_entry.author_tz); - new_entry.committer.clone_from(&existing_entry.committer); new_entry - .committer_mail - .clone_from(&existing_entry.committer_mail); + .committer_name + .clone_from(&existing_entry.committer_name); + new_entry + .committer_email + .clone_from(&existing_entry.committer_email); new_entry.committer_time = existing_entry.committer_time; new_entry .committer_tz @@ -288,8 +290,8 @@ fn parse_git_blame(output: &str) -> Result> { } "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" if is_committed => entry.committer_name = Some(value.into()), + "committer-mail" if is_committed => entry.committer_email = Some(value.into()), "committer-time" if is_committed => { entry.committer_time = Some(value.parse::()?) } diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 42da2e917083d1efef03363591b9fbc3cf51fc4e..b9b67d8415381140723bbb204da143922a0245f7 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -38,6 +38,7 @@ actions!( StageAll, UnstageAll, RevertAll, + Uncommit, Commit, ClearCommitMessage ] diff --git a/crates/git/src/hosting_provider.rs b/crates/git/src/hosting_provider.rs index 94069bd9e80d2df26290a34e6f685f267f2be5de..2b875418bf34bc7be34b1365a55edb687dbfcdbe 100644 --- a/crates/git/src/hosting_provider.rs +++ b/crates/git/src/hosting_provider.rs @@ -4,13 +4,11 @@ use anyhow::Result; use async_trait::async_trait; use collections::BTreeMap; use derive_more::{Deref, DerefMut}; -use gpui::{App, Global}; +use gpui::{App, Global, SharedString}; use http_client::HttpClient; use parking_lot::RwLock; use url::Url; -use crate::Oid; - #[derive(Debug, PartialEq, Eq, Clone)] pub struct PullRequest { pub number: u32, @@ -83,7 +81,7 @@ pub trait GitHostingProvider { &self, _repo_owner: &str, _repo: &str, - _commit: Oid, + _commit: SharedString, _http_client: Arc, ) -> Result> { Ok(None) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 24f8689d0445d12584ef3a6fc44a4eeda6408f90..ffc7450858a2a42e6f49419aaad8edcccea54571 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,7 +1,7 @@ use crate::status::FileStatus; use crate::GitHostingProviderRegistry; use crate::{blame::Blame, status::GitStatus}; -use anyhow::{anyhow, Context as _, Result}; +use anyhow::{anyhow, Context, Result}; use collections::{HashMap, HashSet}; use git2::BranchType; use gpui::SharedString; @@ -20,12 +20,63 @@ use sum_tree::MapSeekTarget; use util::command::new_std_command; use util::ResultExt; -#[derive(Clone, Debug, Hash, PartialEq)] +#[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Branch { pub is_head: bool, pub name: SharedString, - /// Timestamp of most recent commit, normalized to Unix Epoch format. - pub unix_timestamp: Option, + pub upstream: Option, + pub most_recent_commit: Option, +} + +impl Branch { + pub fn priority_key(&self) -> (bool, Option) { + ( + self.is_head, + self.most_recent_commit + .as_ref() + .map(|commit| commit.commit_timestamp), + ) + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct Upstream { + pub ref_name: SharedString, + pub tracking: Option, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct UpstreamTracking { + pub ahead: u32, + pub behind: u32, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct CommitSummary { + pub sha: SharedString, + pub subject: SharedString, + /// This is a unix timestamp + pub commit_timestamp: i64, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct CommitDetails { + pub sha: SharedString, + pub message: SharedString, + pub commit_timestamp: i64, + pub committer_email: SharedString, + pub committer_name: SharedString, +} + +pub enum ResetMode { + // reset the branch pointer, leave index and worktree unchanged + // (this will make it look like things that were committed are now + // staged) + Soft, + // reset the branch pointer and index, leave worktree unchanged + // (this makes it look as though things that were committed are now + // unstaged) + Mixed, } pub trait GitRepository: Send + Sync { @@ -45,7 +96,6 @@ pub trait GitRepository: Send + Sync { /// Returns the URL of the remote with the given name. fn remote_url(&self, name: &str) -> Option; - fn branch_name(&self) -> Option; /// Returns the SHA of the current HEAD. fn head_sha(&self) -> Option; @@ -60,6 +110,10 @@ pub trait GitRepository: Send + Sync { fn create_branch(&self, _: &str) -> Result<()>; fn branch_exits(&self, _: &str) -> Result; + fn reset(&self, commit: &str, mode: ResetMode) -> Result<()>; + + fn show(&self, commit: &str) -> Result; + fn blame(&self, path: &Path, content: Rope) -> Result; /// Returns the absolute path to the repository. For worktrees, this will be the path to the @@ -132,6 +186,53 @@ impl GitRepository for RealGitRepository { repo.commondir().into() } + fn show(&self, commit: &str) -> Result { + let repo = self.repository.lock(); + let Ok(commit) = repo.revparse_single(commit)?.into_commit() else { + anyhow::bail!("{} is not a commit", commit); + }; + let details = CommitDetails { + sha: commit.id().to_string().into(), + message: String::from_utf8_lossy(commit.message_raw_bytes()) + .to_string() + .into(), + commit_timestamp: commit.time().seconds(), + committer_email: String::from_utf8_lossy(commit.committer().email_bytes()) + .to_string() + .into(), + committer_name: String::from_utf8_lossy(commit.committer().name_bytes()) + .to_string() + .into(), + }; + Ok(details) + } + + fn reset(&self, commit: &str, mode: ResetMode) -> Result<()> { + let working_directory = self + .repository + .lock() + .workdir() + .context("failed to read git work directory")? + .to_path_buf(); + + let mode_flag = match mode { + ResetMode::Mixed => "--mixed", + ResetMode::Soft => "--soft", + }; + + let output = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["reset", mode_flag, commit]) + .output()?; + if !output.status.success() { + return Err(anyhow!( + "Failed to reset:\n{}", + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(()) + } + fn load_index_text(&self, path: &RepoPath) -> Option { fn logic(repo: &git2::Repository, path: &RepoPath) -> Result> { const STAGE_NORMAL: i32 = 0; @@ -215,13 +316,6 @@ impl GitRepository for RealGitRepository { remote.url().map(|url| url.to_string()) } - fn branch_name(&self) -> Option { - let repo = self.repository.lock(); - let head = repo.head().log_err()?; - let branch = String::from_utf8_lossy(head.shorthand_bytes()); - Some(branch.to_string()) - } - fn head_sha(&self) -> Option { Some(self.repository.lock().head().ok()?.target()?.to_string()) } @@ -261,33 +355,62 @@ impl GitRepository for RealGitRepository { } fn branches(&self) -> Result> { - let repo = self.repository.lock(); - let local_branches = repo.branches(Some(BranchType::Local))?; - let valid_branches = local_branches - .filter_map(|branch| { - branch.ok().and_then(|(branch, _)| { - let is_head = branch.is_head(); - let name = branch - .name() - .ok() - .flatten() - .map(|name| name.to_string().into())?; - let timestamp = branch.get().peel_to_commit().ok()?.time(); - let unix_timestamp = timestamp.seconds(); - let timezone_offset = timestamp.offset_minutes(); - let utc_offset = - time::UtcOffset::from_whole_seconds(timezone_offset * 60).ok()?; - let unix_timestamp = - time::OffsetDateTime::from_unix_timestamp(unix_timestamp).ok()?; - Some(Branch { - is_head, - name, - unix_timestamp: Some(unix_timestamp.to_offset(utc_offset).unix_timestamp()), - }) - }) - }) - .collect(); - Ok(valid_branches) + let working_directory = self + .repository + .lock() + .workdir() + .context("failed to read git work directory")? + .to_path_buf(); + let fields = [ + "%(HEAD)", + "%(objectname)", + "%(refname)", + "%(upstream)", + "%(upstream:track)", + "%(committerdate:unix)", + "%(contents:subject)", + ] + .join("%00"); + let args = vec!["for-each-ref", "refs/heads/*", "--format", &fields]; + + let output = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(args) + .output()?; + + if !output.status.success() { + return Err(anyhow!( + "Failed to git git branches:\n{}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let input = String::from_utf8_lossy(&output.stdout); + + let mut branches = parse_branch_input(&input)?; + if branches.is_empty() { + let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"]; + + let output = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(args) + .output()?; + + // git symbolic-ref returns a non-0 exit code if HEAD points + // to something other than a branch + if output.status.success() { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + branches.push(Branch { + name: name.into(), + is_head: true, + upstream: None, + most_recent_commit: None, + }); + } + } + + Ok(branches) } fn change_branch(&self, name: &str) -> Result<()> { @@ -478,11 +601,6 @@ impl GitRepository for FakeGitRepository { None } - fn branch_name(&self) -> Option { - let state = self.state.lock(); - state.current_branch_name.clone() - } - fn head_sha(&self) -> Option { None } @@ -491,6 +609,14 @@ impl GitRepository for FakeGitRepository { vec![] } + fn show(&self, _: &str) -> Result { + unimplemented!() + } + + fn reset(&self, _: &str, _: ResetMode) -> Result<()> { + unimplemented!() + } + fn path(&self) -> PathBuf { let state = self.state.lock(); state.path.clone() @@ -533,7 +659,8 @@ impl GitRepository for FakeGitRepository { .map(|branch_name| Branch { is_head: Some(branch_name) == current_branch.as_ref(), name: branch_name.into(), - unix_timestamp: None, + most_recent_commit: None, + upstream: None, }) .collect()) } @@ -703,3 +830,106 @@ impl<'a> MapSeekTarget for RepoPathDescendants<'a> { } } } + +fn parse_branch_input(input: &str) -> Result> { + let mut branches = Vec::new(); + for line in input.split('\n') { + if line.is_empty() { + continue; + } + let mut fields = line.split('\x00'); + let is_current_branch = fields.next().context("no HEAD")? == "*"; + let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into(); + let ref_name: SharedString = fields + .next() + .context("no refname")? + .strip_prefix("refs/heads/") + .context("unexpected format for refname")? + .to_string() + .into(); + let upstream_name = fields.next().context("no upstream")?.to_string(); + let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?; + let commiterdate = fields.next().context("no committerdate")?.parse::()?; + let subject: SharedString = fields + .next() + .context("no contents:subject")? + .to_string() + .into(); + + branches.push(Branch { + is_head: is_current_branch, + name: ref_name, + most_recent_commit: Some(CommitSummary { + sha: head_sha, + subject, + commit_timestamp: commiterdate, + }), + upstream: if upstream_name.is_empty() { + None + } else { + Some(Upstream { + ref_name: upstream_name.into(), + tracking: upstream_tracking, + }) + }, + }) + } + + Ok(branches) +} + +fn parse_upstream_track(upstream_track: &str) -> Result> { + if upstream_track == "" { + return Ok(Some(UpstreamTracking { + ahead: 0, + behind: 0, + })); + } + + let upstream_track = upstream_track + .strip_prefix("[") + .ok_or_else(|| anyhow!("missing ["))?; + let upstream_track = upstream_track + .strip_suffix("]") + .ok_or_else(|| anyhow!("missing ["))?; + let mut ahead: u32 = 0; + let mut behind: u32 = 0; + for component in upstream_track.split(", ") { + if component == "gone" { + return Ok(None); + } + if let Some(ahead_num) = component.strip_prefix("ahead ") { + ahead = ahead_num.parse::()?; + } + if let Some(behind_num) = component.strip_prefix("behind ") { + behind = behind_num.parse::()?; + } + } + Ok(Some(UpstreamTracking { ahead, behind })) +} + +#[test] +fn test_branches_parsing() { + // suppress "help: octal escapes are not supported, `\0` is always null" + #[allow(clippy::octal_escapes)] + let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n"; + assert_eq!( + parse_branch_input(&input).unwrap(), + vec![Branch { + is_head: true, + name: "zed-patches".into(), + upstream: Some(Upstream { + ref_name: "refs/remotes/origin/zed-patches".into(), + tracking: Some(UpstreamTracking { + ahead: 0, + behind: 0 + }) + }), + most_recent_commit: Some(CommitSummary { + sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(), + subject: "generated protobuf".into(), + commit_timestamp: 1733187470, + }) + }] + ) +} diff --git a/crates/git/test_data/golden/blame_incremental_complex.json b/crates/git/test_data/golden/blame_incremental_complex.json index 3eb6ec81e33a5ffbebe5a963c67d8f7b779c0378..1f05fea8f5ec7813dc9fd68c60197c7d6a68bc27 100644 --- a/crates/git/test_data/golden/blame_incremental_complex.json +++ b/crates/git/test_data/golden/blame_incremental_complex.json @@ -10,8 +10,8 @@ "author_mail": "<64036912+mmkaram@users.noreply.github.com>", "author_time": 1708621949, "author_tz": "-0800", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1708621949, "committer_tz": "-0700", "summary": "Add option to either use system clipboard or vim clipboard (#7936)", @@ -29,8 +29,8 @@ "author_mail": "<64036912+mmkaram@users.noreply.github.com>", "author_time": 1708621949, "author_tz": "-0800", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1708621949, "committer_tz": "-0700", "summary": "Add option to either use system clipboard or vim clipboard (#7936)", @@ -48,8 +48,8 @@ "author_mail": "<64036912+mmkaram@users.noreply.github.com>", "author_time": 1708621949, "author_tz": "-0800", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1708621949, "committer_tz": "-0700", "summary": "Add option to either use system clipboard or vim clipboard (#7936)", @@ -67,8 +67,8 @@ "author_mail": "<64036912+mmkaram@users.noreply.github.com>", "author_time": 1708621949, "author_tz": "-0800", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1708621949, "committer_tz": "-0700", "summary": "Add option to either use system clipboard or vim clipboard (#7936)", @@ -86,8 +86,8 @@ "author_mail": "<64036912+mmkaram@users.noreply.github.com>", "author_time": 1708621949, "author_tz": "-0800", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1708621949, "committer_tz": "-0700", "summary": "Add option to either use system clipboard or vim clipboard (#7936)", @@ -105,8 +105,8 @@ "author_mail": "<64036912+mmkaram@users.noreply.github.com>", "author_time": 1708621949, "author_tz": "-0800", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1708621949, "committer_tz": "-0700", "summary": "Add option to either use system clipboard or vim clipboard (#7936)", @@ -124,8 +124,8 @@ "author_mail": "<64036912+mmkaram@users.noreply.github.com>", "author_time": 1708621949, "author_tz": "-0800", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1708621949, "committer_tz": "-0700", "summary": "Add option to either use system clipboard or vim clipboard (#7936)", @@ -143,8 +143,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -162,8 +162,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -181,8 +181,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -200,8 +200,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -219,8 +219,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -238,8 +238,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -257,8 +257,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -276,8 +276,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -295,8 +295,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -314,8 +314,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -333,8 +333,8 @@ "author_mail": "", "author_time": 1707520689, "author_tz": "-0700", - "committer": "GitHub", - "committer_mail": "", + "committer_name": "GitHub", + "committer_email": "", "committer_time": 1707520689, "committer_tz": "-0700", "summary": "Highlight selections on vim yank (#7638)", @@ -352,8 +352,8 @@ "author_mail": "", "author_time": 1705619094, "author_tz": "-0800", - "committer": "Max Brunsfeld", - "committer_mail": "", + "committer_name": "Max Brunsfeld", + "committer_email": "", "committer_time": 1705619205, "committer_tz": "-0800", "summary": "Merge branch 'main' into language-api-docs", @@ -371,8 +371,8 @@ "author_mail": "", "author_time": 1705619094, "author_tz": "-0800", - "committer": "Max Brunsfeld", - "committer_mail": "", + "committer_name": "Max Brunsfeld", + "committer_email": "", "committer_time": 1705619205, "committer_tz": "-0800", "summary": "Merge branch 'main' into language-api-docs", @@ -390,8 +390,8 @@ "author_mail": "", "author_time": 1694798044, "author_tz": "-0600", - "committer": "Conrad Irwin", - "committer_mail": "", + "committer_name": "Conrad Irwin", + "committer_email": "", "committer_time": 1694798044, "committer_tz": "-0600", "summary": "Fix Y on last line with no trailing new line", @@ -409,8 +409,8 @@ "author_mail": "", "author_time": 1694798044, "author_tz": "-0600", - "committer": "Conrad Irwin", - "committer_mail": "", + "committer_name": "Conrad Irwin", + "committer_email": "", "committer_time": 1694798044, "committer_tz": "-0600", "summary": "Fix Y on last line with no trailing new line", @@ -428,8 +428,8 @@ "author_mail": "", "author_time": 1692855942, "author_tz": "-0600", - "committer": "Conrad Irwin", - "committer_mail": "", + "committer_name": "Conrad Irwin", + "committer_email": "", "committer_time": 1692856812, "committer_tz": "-0600", "summary": "vim: Fix linewise copy of last line with no trailing newline", @@ -447,8 +447,8 @@ "author_mail": "", "author_time": 1692855942, "author_tz": "-0600", - "committer": "Conrad Irwin", - "committer_mail": "", + "committer_name": "Conrad Irwin", + "committer_email": "", "committer_time": 1692856812, "committer_tz": "-0600", "summary": "vim: Fix linewise copy of last line with no trailing newline", @@ -466,8 +466,8 @@ "author_mail": "", "author_time": 1692855942, "author_tz": "-0600", - "committer": "Conrad Irwin", - "committer_mail": "", + "committer_name": "Conrad Irwin", + "committer_email": "", "committer_time": 1692856812, "committer_tz": "-0600", "summary": "vim: Fix linewise copy of last line with no trailing newline", @@ -485,8 +485,8 @@ "author_mail": "", "author_time": 1692855942, "author_tz": "-0600", - "committer": "Conrad Irwin", - "committer_mail": "", + "committer_name": "Conrad Irwin", + "committer_email": "", "committer_time": 1692856812, "committer_tz": "-0600", "summary": "vim: Fix linewise copy of last line with no trailing newline", @@ -504,8 +504,8 @@ "author_mail": "", "author_time": 1692855942, "author_tz": "-0600", - "committer": "Conrad Irwin", - "committer_mail": "", + "committer_name": "Conrad Irwin", + "committer_email": "", "committer_time": 1692856812, "committer_tz": "-0600", "summary": "vim: Fix linewise copy of last line with no trailing newline", @@ -523,8 +523,8 @@ "author_mail": "", "author_time": 1692644159, "author_tz": "-0600", - "committer": "Conrad Irwin", - "committer_mail": "", + "committer_name": "Conrad Irwin", + "committer_email": "", "committer_time": 1692732477, "committer_tz": "-0600", "summary": "Rewrite paste", @@ -542,8 +542,8 @@ "author_mail": "", "author_time": 1692644159, "author_tz": "-0600", - "committer": "Conrad Irwin", - "committer_mail": "", + "committer_name": "Conrad Irwin", + "committer_email": "", "committer_time": 1692732477, "committer_tz": "-0600", "summary": "Rewrite paste", @@ -561,8 +561,8 @@ "author_mail": "", "author_time": 1659072896, "author_tz": "-0700", - "committer": "Max Brunsfeld", - "committer_mail": "", + "committer_name": "Max Brunsfeld", + "committer_email": "", "committer_time": 1659073230, "committer_tz": "-0700", "summary": ":art: Rename and simplify some autoindent stuff", @@ -580,8 +580,8 @@ "author_mail": "", "author_time": 1653424557, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Unify visual line_mode and non line_mode operators", @@ -599,8 +599,8 @@ "author_mail": "", "author_time": 1653007350, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Enable copy and paste in vim mode", @@ -618,8 +618,8 @@ "author_mail": "", "author_time": 1653007350, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Enable copy and paste in vim mode", @@ -637,8 +637,8 @@ "author_mail": "", "author_time": 1653007350, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Enable copy and paste in vim mode", @@ -656,8 +656,8 @@ "author_mail": "", "author_time": 1653007350, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Enable copy and paste in vim mode", @@ -675,8 +675,8 @@ "author_mail": "", "author_time": 1653007350, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Enable copy and paste in vim mode", @@ -694,8 +694,8 @@ "author_mail": "", "author_time": 1653007350, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Enable copy and paste in vim mode", @@ -713,8 +713,8 @@ "author_mail": "", "author_time": 1653007350, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Enable copy and paste in vim mode", @@ -732,8 +732,8 @@ "author_mail": "", "author_time": 1653007350, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Enable copy and paste in vim mode", @@ -751,8 +751,8 @@ "author_mail": "", "author_time": 1653007350, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Enable copy and paste in vim mode", @@ -770,8 +770,8 @@ "author_mail": "", "author_time": 1653007350, "author_tz": "-0700", - "committer": "Kaylee Simmons", - "committer_mail": "", + "committer_name": "Kaylee Simmons", + "committer_email": "", "committer_time": 1653609725, "committer_tz": "-0700", "summary": "Enable copy and paste in vim mode", diff --git a/crates/git/test_data/golden/blame_incremental_not_committed.json b/crates/git/test_data/golden/blame_incremental_not_committed.json index 4e4834d45c5f19c3d4e0e87d57b72134f2fbdc7d..b50c793ad9394792af5adf9acaf811519d4e745c 100644 --- a/crates/git/test_data/golden/blame_incremental_not_committed.json +++ b/crates/git/test_data/golden/blame_incremental_not_committed.json @@ -10,8 +10,8 @@ "author_mail": "", "author_time": 1710764113, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1710764113, "committer_tz": "+0100", "summary": "Another commit", @@ -29,8 +29,8 @@ "author_mail": "", "author_time": 1710764113, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1710764113, "committer_tz": "+0100", "summary": "Another commit", @@ -48,8 +48,8 @@ "author_mail": "", "author_time": 1710764087, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1710764087, "committer_tz": "+0100", "summary": "Another commit", @@ -67,8 +67,8 @@ "author_mail": "", "author_time": 1710764087, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1710764087, "committer_tz": "+0100", "summary": "Another commit", @@ -86,8 +86,8 @@ "author_mail": "", "author_time": 1709299737, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1709299737, "committer_tz": "+0100", "summary": "Initial", @@ -105,8 +105,8 @@ "author_mail": "", "author_time": 1709299737, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1709299737, "committer_tz": "+0100", "summary": "Initial", @@ -124,8 +124,8 @@ "author_mail": "", "author_time": 1709299737, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1709299737, "committer_tz": "+0100", "summary": "Initial", diff --git a/crates/git/test_data/golden/blame_incremental_simple.json b/crates/git/test_data/golden/blame_incremental_simple.json index c8fba838972ac682a284cfcf93e340d979c2c99b..6e46ca3b692fe7f784dd93349738e128d0c76294 100644 --- a/crates/git/test_data/golden/blame_incremental_simple.json +++ b/crates/git/test_data/golden/blame_incremental_simple.json @@ -10,8 +10,8 @@ "author_mail": "", "author_time": 1709808710, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1709808710, "committer_tz": "+0100", "summary": "Make a commit", @@ -29,8 +29,8 @@ "author_mail": "", "author_time": 1709741400, "author_tz": "+0100", - "committer": "Joe Schmoe", - "committer_mail": "", + "committer_name": "Joe Schmoe", + "committer_email": "", "committer_time": 1709741400, "committer_tz": "+0100", "summary": "Joe's cool commit", @@ -48,8 +48,8 @@ "author_mail": "", "author_time": 1709741400, "author_tz": "+0100", - "committer": "Joe Schmoe", - "committer_mail": "", + "committer_name": "Joe Schmoe", + "committer_email": "", "committer_time": 1709741400, "committer_tz": "+0100", "summary": "Joe's cool commit", @@ -67,8 +67,8 @@ "author_mail": "", "author_time": 1709741400, "author_tz": "+0100", - "committer": "Joe Schmoe", - "committer_mail": "", + "committer_name": "Joe Schmoe", + "committer_email": "", "committer_time": 1709741400, "committer_tz": "+0100", "summary": "Joe's cool commit", @@ -86,8 +86,8 @@ "author_mail": "", "author_time": 1709129122, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1709129122, "committer_tz": "+0100", "summary": "Get to a state where eslint would change code and imports", @@ -105,8 +105,8 @@ "author_mail": "", "author_time": 1709128963, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1709128963, "committer_tz": "+0100", "summary": "Add some stuff", @@ -124,8 +124,8 @@ "author_mail": "", "author_time": 1709128963, "author_tz": "+0100", - "committer": "Thorsten Ball", - "committer_mail": "", + "committer_name": "Thorsten Ball", + "committer_email": "", "committer_time": 1709128963, "committer_tz": "+0100", "summary": "Add some stuff", diff --git a/crates/git_hosting_providers/src/providers/codeberg.rs b/crates/git_hosting_providers/src/providers/codeberg.rs index cb917823c5dbbf37a5b643b35ba32b29a4960e81..0e01331278a6f265df9b1041fe0bc6dc40f341ab 100644 --- a/crates/git_hosting_providers/src/providers/codeberg.rs +++ b/crates/git_hosting_providers/src/providers/codeberg.rs @@ -4,12 +4,13 @@ use std::sync::Arc; use anyhow::{bail, Context, Result}; use async_trait::async_trait; use futures::AsyncReadExt; +use gpui::SharedString; use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; use serde::Deserialize; use url::Url; use git::{ - BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote, + BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote, RemoteUrl, }; @@ -160,7 +161,7 @@ impl GitHostingProvider for Codeberg { &self, repo_owner: &str, repo: &str, - commit: Oid, + commit: SharedString, http_client: Arc, ) -> Result> { let commit = commit.to_string(); diff --git a/crates/git_hosting_providers/src/providers/github.rs b/crates/git_hosting_providers/src/providers/github.rs index 6026c6ed208f96209e1688bedf2ddcb898aba184..f86b586ea8c7016c7709d384cc4c029e68a039b1 100644 --- a/crates/git_hosting_providers/src/providers/github.rs +++ b/crates/git_hosting_providers/src/providers/github.rs @@ -4,13 +4,14 @@ use std::sync::{Arc, LazyLock}; use anyhow::{bail, Context, Result}; use async_trait::async_trait; use futures::AsyncReadExt; +use gpui::SharedString; use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; use regex::Regex; use serde::Deserialize; use url::Url; use git::{ - BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote, + BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote, PullRequest, RemoteUrl, }; @@ -178,7 +179,7 @@ impl GitHostingProvider for Github { &self, repo_owner: &str, repo: &str, - commit: Oid, + commit: SharedString, http_client: Arc, ) -> Result> { let commit = commit.to_string(); diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 4f10e067b8d986c99529d5a59cd74beafd812739..19e443766a14298ca933c6f9bcdba5a7356ca19d 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -36,6 +36,8 @@ serde_derive.workspace = true serde_json.workspace = true settings.workspace = true theme.workspace = true +time.workspace = true +time_format.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index bff1c8bf52431cab9746e2558b549de931aefb69..d6233dd8237119e164eeb18b8514d09d5df37564 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -190,9 +190,7 @@ impl PickerDelegate for BranchListDelegate { // Truncate list of recent branches // Do a partial sort to show recent-ish branches first. branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| { - rhs.is_head - .cmp(&lhs.is_head) - .then(rhs.unix_timestamp.cmp(&lhs.unix_timestamp)) + rhs.priority_key().cmp(&lhs.priority_key()) }); branches.truncate(RECENT_BRANCHES_COUNT); } @@ -255,6 +253,25 @@ impl PickerDelegate for BranchListDelegate { let Some(branch) = self.matches.get(self.selected_index()) else { return; }; + + let current_branch = self + .workspace + .update(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .active_repository(cx) + .and_then(|repo| repo.read(cx).branch()) + .map(|branch| branch.name.to_string()) + }) + .ok() + .flatten(); + + if current_branch == Some(branch.name().to_string()) { + cx.emit(DismissEvent); + return; + } + cx.spawn_in(window, { let branch = branch.clone(); |picker, mut cx| async move { diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index b3675f249438fe212aeeda99b3c8e8468522ac23..cf7d77754e5628ae413d99515326d9b6ab539ce1 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -6,13 +6,15 @@ use crate::{ }; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; +use editor::commit_tooltip::CommitTooltip; use editor::{ actions::MoveToEnd, scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar, }; +use git::repository::{CommitDetails, ResetMode}; use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged}; use gpui::*; -use language::{Buffer, File}; +use language::{markdown, Buffer, File, ParsedMarkdown}; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; use multi_buffer::ExcerptInfo; use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader}; @@ -23,6 +25,7 @@ use project::{ use serde::{Deserialize, Serialize}; use settings::Settings as _; use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize}; +use time::OffsetDateTime; use ui::{ prelude::*, ButtonLike, Checkbox, CheckboxWithLabel, Divider, DividerColor, ElevationIndex, IndentGuideColors, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, @@ -207,7 +210,7 @@ impl GitPanel { ) -> Entity { let fs = workspace.app_state().fs.clone(); let project = workspace.project().clone(); - let git_state = project.read(cx).git_state().clone(); + let git_store = project.read(cx).git_store().clone(); let active_repository = project.read(cx).active_repository(cx); let workspace = cx.entity().downgrade(); @@ -231,14 +234,14 @@ impl GitPanel { let scroll_handle = UniformListScrollHandle::new(); cx.subscribe_in( - &git_state, + &git_store, window, - move |this, git_state, event, window, cx| match event { + move |this, git_store, event, window, cx| match event { GitEvent::FileSystemUpdated => { this.schedule_update(false, window, cx); } GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => { - this.active_repository = git_state.read(cx).active_repository(); + this.active_repository = git_store.read(cx).active_repository(); this.schedule_update(true, window, cx); } }, @@ -744,6 +747,40 @@ impl GitPanel { self.pending_commit = Some(task); } + fn uncommit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repo) = self.active_repository.clone() else { + return; + }; + let prior_head = self.load_commit_details("HEAD", cx); + + let task = cx.spawn(|_, mut cx| async move { + let prior_head = prior_head.await?; + + repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))? + .await??; + + Ok(prior_head) + }); + + let task = cx.spawn_in(window, |this, mut cx| async move { + let result = task.await; + this.update_in(&mut cx, |this, window, cx| { + this.pending_commit.take(); + match result { + Ok(prior_commit) => { + this.commit_editor.update(cx, |editor, cx| { + editor.set_text(prior_commit.message, window, cx) + }); + } + Err(e) => this.show_err_toast(e, cx), + } + }) + .ok(); + }); + + self.pending_commit = Some(task); + } + fn fill_co_authors(&mut self, _: &FillCoAuthors, window: &mut Window, cx: &mut Context) { const CO_AUTHOR_PREFIX: &str = "Co-authored-by: "; @@ -1131,16 +1168,10 @@ impl GitPanel { let all_repositories = self .project .read(cx) - .git_state() + .git_store() .read(cx) .all_repositories(); - let branch = self - .active_repository - .as_ref() - .and_then(|repository| repository.read(cx).branch()) - .unwrap_or_else(|| "(no current branch)".into()); - let has_repo_above = all_repositories.iter().any(|repo| { repo.read(cx) .repository_entry @@ -1148,26 +1179,7 @@ impl GitPanel { .is_above_project() }); - let icon_button = Button::new("branch-selector", branch) - .color(Color::Muted) - .style(ButtonStyle::Subtle) - .icon(IconName::GitBranch) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .size(ButtonSize::Compact) - .icon_position(IconPosition::Start) - .tooltip(Tooltip::for_action_title( - "Switch Branch", - &zed_actions::git::Branch, - )) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); - })) - .style(ButtonStyle::Transparent); - self.panel_header_container(window, cx) - .child(h_flex().pl_1().child(icon_button)) - .child(div().flex_grow()) .when(all_repositories.len() > 1 || has_repo_above, |el| { el.child(self.render_repository_selector(cx)) }) @@ -1200,6 +1212,7 @@ impl GitPanel { && !editor.read(cx).is_empty(cx) && !self.has_unstaged_conflicts() && self.has_write_access(cx); + // let can_commit_all = // !self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx); let panel_editor_style = panel_editor_style(true, window, cx); @@ -1274,10 +1287,108 @@ impl GitPanel { ) } + fn render_previous_commit(&self, cx: &mut Context) -> Option { + let active_repository = self.active_repository.as_ref()?; + let branch = active_repository.read(cx).branch()?; + let commit = branch.most_recent_commit.as_ref()?.clone(); + + if branch.upstream.as_ref().is_some_and(|upstream| { + if let Some(tracking) = &upstream.tracking { + tracking.ahead == 0 + } else { + true + } + }) { + return None; + } + + let _branch_selector = Button::new("branch-selector", branch.name.clone()) + .color(Color::Muted) + .style(ButtonStyle::Subtle) + .icon(IconName::GitBranch) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .size(ButtonSize::Compact) + .icon_position(IconPosition::Start) + .tooltip(Tooltip::for_action_title( + "Switch Branch", + &zed_actions::git::Branch, + )) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); + })) + .style(ButtonStyle::Transparent); + + let _timestamp = Label::new(time_format::format_local_timestamp( + OffsetDateTime::from_unix_timestamp(commit.commit_timestamp).log_err()?, + OffsetDateTime::now_utc(), + time_format::TimestampFormat::Relative, + )) + .size(LabelSize::Small) + .color(Color::Muted); + + let tooltip = if self.has_staged_changes() { + "git reset HEAD^ --soft" + } else { + "git reset HEAD^" + }; + + let this = cx.entity(); + Some( + h_flex() + .items_center() + .py_1p5() + .px(px(8.)) + .bg(cx.theme().colors().background) + .border_t_1() + .border_color(cx.theme().colors().border) + .gap_1p5() + .child( + div() + .flex_grow() + .overflow_hidden() + .max_w(relative(0.6)) + .h_full() + .child( + Label::new(commit.subject.clone()) + .size(LabelSize::Small) + .text_ellipsis(), + ) + .id("commit-msg-hover") + .hoverable_tooltip(move |window, cx| { + GitPanelMessageTooltip::new( + this.clone(), + commit.sha.clone(), + window, + cx, + ) + .into() + }), + ) + .child(div().flex_1()) + .child( + panel_filled_button("Uncommit") + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .tooltip(Tooltip::for_action_title(tooltip, &git::Uncommit)) + .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))), + // .child( + // panel_filled_button("Push") + // .icon(IconName::ArrowUp) + // .icon_size(IconSize::Small) + // .icon_color(Color::Muted) + // .icon_position(IconPosition::Start), // .disabled(true), + // ), + ), + ) + } + fn render_empty_state(&self, cx: &mut Context) -> impl IntoElement { h_flex() .h_full() - .flex_1() + .flex_grow() .justify_center() .items_center() .child( @@ -1563,6 +1674,17 @@ impl GitPanel { .into_any_element() } + fn load_commit_details( + &self, + sha: &str, + cx: &mut Context, + ) -> Task> { + let Some(repo) = self.active_repository.clone() else { + return Task::ready(Err(anyhow::anyhow!("no active repo"))); + }; + repo.update(cx, |repo, cx| repo.show(sha, cx)) + } + fn render_entry( &self, ix: usize, @@ -1757,6 +1879,7 @@ impl Render for GitPanel { } else { self.render_empty_state(cx).into_any_element() }) + .children(self.render_previous_commit(cx)) .child(self.render_commit_editor(window, cx)) } } @@ -1843,3 +1966,81 @@ impl Panel for GitPanel { } impl PanelHeader for GitPanel {} + +struct GitPanelMessageTooltip { + commit_tooltip: Option>, +} + +impl GitPanelMessageTooltip { + fn new( + git_panel: Entity, + sha: SharedString, + window: &mut Window, + cx: &mut App, + ) -> Entity { + let workspace = git_panel.read(cx).workspace.clone(); + cx.new(|cx| { + cx.spawn_in(window, |this, mut cx| async move { + let language_registry = workspace.update(&mut cx, |workspace, _cx| { + workspace.app_state().languages.clone() + })?; + + let details = git_panel + .update(&mut cx, |git_panel, cx| { + git_panel.load_commit_details(&sha, cx) + })? + .await?; + + let mut parsed_message = ParsedMarkdown::default(); + markdown::parse_markdown_block( + &details.message, + Some(&language_registry), + None, + &mut parsed_message.text, + &mut parsed_message.highlights, + &mut parsed_message.region_ranges, + &mut parsed_message.regions, + ) + .await; + + let commit_details = editor::commit_tooltip::CommitDetails { + sha: details.sha.clone(), + committer_name: details.committer_name.clone(), + committer_email: details.committer_email.clone(), + commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?, + message: Some(editor::commit_tooltip::ParsedCommitMessage { + message: details.message.clone(), + parsed_message, + ..Default::default() + }), + }; + + this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| { + this.commit_tooltip = Some(cx.new(move |cx| { + CommitTooltip::new( + commit_details, + panel_editor_style(true, window, cx), + Some(workspace), + ) + })); + cx.notify(); + }) + }) + .detach(); + + Self { + commit_tooltip: None, + } + }) + } +} + +impl Render for GitPanelMessageTooltip { + fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement { + if let Some(commit_tooltip) = &self.commit_tooltip { + commit_tooltip.clone().into_any_element() + } else { + gpui::Empty.into_any_element() + } + } +} diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 2f812de686e1d6f364be709a92536e1829c62226..8b8907ecbe5e68e4ba74621b5e129d1a276f3c8e 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -12,7 +12,7 @@ use gpui::{ }; use language::{Anchor, Buffer, Capability, OffsetRangeExt, Point}; use multi_buffer::{MultiBuffer, PathKey}; -use project::{git::GitState, Project, ProjectPath}; +use project::{git::GitStore, Project, ProjectPath}; use theme::ActiveTheme; use ui::prelude::*; use util::ResultExt as _; @@ -31,7 +31,7 @@ pub(crate) struct ProjectDiff { editor: Entity, project: Entity, git_panel: Entity, - git_state: Entity, + git_store: Entity, workspace: WeakEntity, focus_handle: FocusHandle, update_needed: postage::watch::Sender<()>, @@ -137,11 +137,11 @@ impl ProjectDiff { cx.subscribe_in(&editor, window, Self::handle_editor_event) .detach(); - let git_state = project.read(cx).git_state().clone(); - let git_state_subscription = cx.subscribe_in( - &git_state, + let git_store = project.read(cx).git_store().clone(); + let git_store_subscription = cx.subscribe_in( + &git_store, window, - move |this, _git_state, _event, _window, _cx| { + move |this, _git_store, _event, _window, _cx| { *this.update_needed.borrow_mut() = (); }, ); @@ -156,7 +156,7 @@ impl ProjectDiff { Self { project, - git_state: git_state.clone(), + git_store: git_store.clone(), git_panel: git_panel.clone(), workspace: workspace.downgrade(), focus_handle, @@ -165,7 +165,7 @@ impl ProjectDiff { pending_scroll: None, update_needed: send, _task: worker, - _subscription: git_state_subscription, + _subscription: git_store_subscription, } } @@ -175,7 +175,7 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - let Some(git_repo) = self.git_state.read(cx).active_repository() else { + let Some(git_repo) = self.git_store.read(cx).active_repository() else { return; }; let repo = git_repo.read(cx); @@ -248,7 +248,7 @@ impl ProjectDiff { } fn load_buffers(&mut self, cx: &mut Context) -> Vec>> { - let Some(repo) = self.git_state.read(cx).active_repository() else { + let Some(repo) = self.git_store.read(cx).active_repository() else { self.multibuffer.update(cx, |multibuffer, cx| { multibuffer.clear(cx); }); diff --git a/crates/git_ui/src/quick_commit.rs b/crates/git_ui/src/quick_commit.rs index be7f3fa84db40465fdbdbe411c1e9d0d567d2de9..cd8a3154963f66b5db0f8e951e935e3436564a4f 100644 --- a/crates/git_ui/src/quick_commit.rs +++ b/crates/git_ui/src/quick_commit.rs @@ -98,7 +98,7 @@ impl QuickCommitModal { commit_message_buffer: Option>, cx: &mut Context, ) -> Self { - let git_state = project.read(cx).git_state().clone(); + let git_store = project.read(cx).git_store().clone(); let active_repository = project.read(cx).active_repository(cx); let focus_handle = cx.focus_handle(); @@ -130,7 +130,7 @@ impl QuickCommitModal { let all_repositories = self .project .read(cx) - .git_state() + .git_store() .read(cx) .all_repositories(); let entry_count = self diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index e5d9c1839a90bfe94e4c0440ac2d5bcfc001f496..8c27c605194ba988e104686a5496b9cd3ace5a23 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -4,7 +4,7 @@ use gpui::{ }; use picker::{Picker, PickerDelegate}; use project::{ - git::{GitState, Repository}, + git::{GitStore, Repository}, Project, }; use std::sync::Arc; @@ -20,8 +20,8 @@ pub struct RepositorySelector { impl RepositorySelector { pub fn new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { - let git_state = project.read(cx).git_state().clone(); - let all_repositories = git_state.read(cx).all_repositories(); + let git_store = project.read(cx).git_store().clone(); + let all_repositories = git_store.read(cx).all_repositories(); let filtered_repositories = all_repositories.clone(); let delegate = RepositorySelectorDelegate { project: project.downgrade(), @@ -38,7 +38,7 @@ impl RepositorySelector { }); let _subscriptions = - vec![cx.subscribe_in(&git_state, window, Self::handle_project_git_event)]; + vec![cx.subscribe_in(&git_store, window, Self::handle_project_git_event)]; RepositorySelector { picker, @@ -49,7 +49,7 @@ impl RepositorySelector { fn handle_project_git_event( &mut self, - git_state: &Entity, + git_store: &Entity, _event: &project::git::GitEvent, window: &mut Window, cx: &mut Context, @@ -57,7 +57,7 @@ impl RepositorySelector { // TODO handle events individually let task = self.picker.update(cx, |this, cx| { let query = this.query(cx); - this.delegate.repository_entries = git_state.read(cx).all_repositories(); + this.delegate.repository_entries = git_store.read(cx).all_repositories(); this.delegate.update_matches(query, window, cx) }); self.update_matches_task = Some(task); diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 138d83f078b00ba04b4321332ed5a400bc9383aa..7654b6902978cce1597e1b36f3b72449f65b1bb7 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -2722,8 +2722,8 @@ fn serialize_blame_buffer_response(blame: Option) -> proto::B 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: entry.committer_name.clone(), + committer_mail: entry.committer_email.clone(), committer_time: entry.committer_time, committer_tz: entry.committer_tz.clone(), summary: entry.summary.clone(), @@ -2772,10 +2772,10 @@ fn deserialize_blame_buffer_response( 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_name: entry.committer, committer_time: entry.committer_time, committer_tz: entry.committer_tz, - committer_mail: entry.committer_mail, + committer_email: entry.committer_mail, author: entry.author, author_mail: entry.author_mail, author_time: entry.author_time, diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index f420a2b9290e39e65764e7c1a7a428bfb02aeb0c..61b58fc133298b1af6890faee3727f414e56afd1 100644 --- a/crates/project/src/git.rs +++ b/crates/project/src/git.rs @@ -1,20 +1,22 @@ use crate::buffer_store::BufferStore; use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent}; use crate::{Project, ProjectPath}; -use anyhow::Context as _; +use anyhow::{Context as _, Result}; use client::ProjectId; use futures::channel::{mpsc, oneshot}; use futures::StreamExt as _; +use git::repository::{Branch, CommitDetails, ResetMode}; use git::{ repository::{GitRepository, RepoPath}, status::{GitSummary, TrackedSummary}, }; use gpui::{ - App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task, WeakEntity, + App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, + WeakEntity, }; use language::{Buffer, LanguageRegistry}; -use rpc::proto::ToProto; -use rpc::{proto, AnyProtoClient}; +use rpc::proto::{git_reset, ToProto}; +use rpc::{proto, AnyProtoClient, TypedEnvelope}; use settings::WorktreeId; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -22,22 +24,23 @@ use text::BufferId; use util::{maybe, ResultExt}; use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry}; -pub struct GitState { +pub struct GitStore { pub(super) project_id: Option, pub(super) client: Option, - pub update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender>)>, + buffer_store: Entity, repositories: Vec>, active_index: Option, + update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender>)>, _subscription: Subscription, } pub struct Repository { commit_message_buffer: Option>, - git_state: WeakEntity, + git_store: WeakEntity, pub worktree_id: WorktreeId, pub repository_entry: RepositoryEntry, pub git_repo: GitRepo, - update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender>)>, + update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender>)>, } #[derive(Clone)] @@ -57,6 +60,11 @@ pub enum Message { message: SharedString, name_and_email: Option<(SharedString, SharedString)>, }, + Reset { + repo: GitRepo, + commit: SharedString, + reset_mode: ResetMode, + }, Stage(GitRepo, Vec), Unstage(GitRepo, Vec), SetIndexText(GitRepo, RepoPath, Option), @@ -68,11 +76,12 @@ pub enum GitEvent { GitStateUpdated, } -impl EventEmitter for GitState {} +impl EventEmitter for GitStore {} -impl GitState { +impl GitStore { pub fn new( worktree_store: &Entity, + buffer_store: Entity, client: Option, project_id: Option, cx: &mut Context<'_, Self>, @@ -80,9 +89,10 @@ impl GitState { let update_sender = Self::spawn_git_worker(cx); let _subscription = cx.subscribe(worktree_store, Self::on_worktree_store_event); - GitState { + GitStore { project_id, client, + buffer_store, repositories: Vec::new(), active_index: None, update_sender, @@ -90,6 +100,16 @@ impl GitState { } } + pub fn init(client: &AnyProtoClient) { + client.add_entity_request_handler(Self::handle_stage); + client.add_entity_request_handler(Self::handle_unstage); + client.add_entity_request_handler(Self::handle_commit); + client.add_entity_request_handler(Self::handle_reset); + client.add_entity_request_handler(Self::handle_show); + client.add_entity_request_handler(Self::handle_open_commit_message_buffer); + client.add_entity_request_handler(Self::handle_set_index_text); + } + pub fn active_repository(&self) -> Option> { self.active_index .map(|index| self.repositories[index].clone()) @@ -153,7 +173,7 @@ impl GitState { existing_handle } else { cx.new(|_| Repository { - git_state: this.clone(), + git_store: this.clone(), worktree_id, repository_entry: repo.clone(), git_repo, @@ -189,10 +209,10 @@ impl GitState { } fn spawn_git_worker( - cx: &mut Context<'_, GitState>, - ) -> mpsc::UnboundedSender<(Message, oneshot::Sender>)> { + cx: &mut Context<'_, GitStore>, + ) -> mpsc::UnboundedSender<(Message, oneshot::Sender>)> { let (update_sender, mut update_receiver) = - mpsc::unbounded::<(Message, oneshot::Sender>)>(); + mpsc::unbounded::<(Message, oneshot::Sender>)>(); cx.spawn(|_, cx| async move { while let Some((msg, respond)) = update_receiver.next().await { let result = cx @@ -206,7 +226,7 @@ impl GitState { update_sender } - async fn process_git_msg(msg: Message) -> Result<(), anyhow::Error> { + async fn process_git_msg(msg: Message) -> Result<()> { match msg { Message::Stage(repo, paths) => { match repo { @@ -233,6 +253,35 @@ impl GitState { } Ok(()) } + Message::Reset { + repo, + commit, + reset_mode, + } => { + match repo { + GitRepo::Local(repo) => repo.reset(&commit, reset_mode)?, + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => { + client + .request(proto::GitReset { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + commit: commit.into(), + mode: match reset_mode { + ResetMode::Soft => git_reset::ResetMode::Soft.into(), + ResetMode::Mixed => git_reset::ResetMode::Mixed.into(), + }, + }) + .await?; + } + } + Ok(()) + } Message::Unstage(repo, paths) => { match repo { GitRepo::Local(repo) => repo.unstage_paths(&paths)?, @@ -309,20 +358,219 @@ impl GitState { }, } } + + async fn handle_stage( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + let entries = envelope + .payload + .paths + .into_iter() + .map(PathBuf::from) + .map(RepoPath::new) + .collect(); + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.stage_entries(entries) + })? + .await??; + Ok(proto::Ack {}) + } + + async fn handle_unstage( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + let entries = envelope + .payload + .paths + .into_iter() + .map(PathBuf::from) + .map(RepoPath::new) + .collect(); + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.unstage_entries(entries) + })? + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_set_index_text( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.set_index_text( + &RepoPath::from_str(&envelope.payload.path), + envelope.payload.text, + ) + })? + .await??; + Ok(proto::Ack {}) + } + + async fn handle_commit( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + let message = SharedString::from(envelope.payload.message); + let name = envelope.payload.name.map(SharedString::from); + let email = envelope.payload.email.map(SharedString::from); + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.commit(message, name.zip(email)) + })? + .await??; + Ok(proto::Ack {}) + } + + async fn handle_show( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + let commit = repository_handle + .update(&mut cx, |repository_handle, cx| { + repository_handle.show(&envelope.payload.commit, cx) + })? + .await?; + Ok(proto::GitCommitDetails { + sha: commit.sha.into(), + message: commit.message.into(), + commit_timestamp: commit.commit_timestamp, + committer_email: commit.committer_email.into(), + committer_name: commit.committer_name.into(), + }) + } + + async fn handle_reset( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository_handle = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + let mode = match envelope.payload.mode() { + git_reset::ResetMode::Soft => ResetMode::Soft, + git_reset::ResetMode::Mixed => ResetMode::Mixed, + }; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.reset(&envelope.payload.commit, mode) + })? + .await??; + Ok(proto::Ack {}) + } + + async fn handle_open_commit_message_buffer( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + let buffer = repository + .update(&mut cx, |repository, cx| { + repository.open_commit_buffer(None, this.read(cx).buffer_store.clone(), cx) + })? + .await?; + + let buffer_id = buffer.read_with(&cx, |buffer, _| buffer.remote_id())?; + this.update(&mut cx, |this, cx| { + this.buffer_store.update(cx, |buffer_store, cx| { + buffer_store + .create_buffer_for_peer( + &buffer, + envelope.original_sender_id.unwrap_or(envelope.sender_id), + cx, + ) + .detach_and_log_err(cx); + }) + })?; + + Ok(proto::OpenBufferResponse { + buffer_id: buffer_id.to_proto(), + }) + } + + fn repository_for_request( + this: &Entity, + worktree_id: WorktreeId, + work_directory_id: ProjectEntryId, + cx: &mut AsyncApp, + ) -> Result> { + this.update(cx, |this, cx| { + let repository_handle = this + .all_repositories() + .into_iter() + .find(|repository_handle| { + repository_handle.read(cx).worktree_id == worktree_id + && repository_handle + .read(cx) + .repository_entry + .work_directory_id() + == work_directory_id + }) + .context("missing repository handle")?; + anyhow::Ok(repository_handle) + })? + } } impl GitRepo {} impl Repository { - pub fn git_state(&self) -> Option> { - self.git_state.upgrade() + pub fn git_store(&self) -> Option> { + self.git_store.upgrade() } fn id(&self) -> (WorktreeId, ProjectEntryId) { (self.worktree_id, self.repository_entry.work_directory_id()) } - pub fn branch(&self) -> Option> { + pub fn branch(&self) -> Option<&Branch> { self.repository_entry.branch() } @@ -344,19 +592,19 @@ impl Repository { } pub fn activate(&self, cx: &mut Context) { - let Some(git_state) = self.git_state.upgrade() else { + let Some(git_store) = self.git_store.upgrade() else { return; }; let entity = cx.entity(); - git_state.update(cx, |git_state, cx| { - let Some(index) = git_state + git_store.update(cx, |git_store, cx| { + let Some(index) = git_store .repositories .iter() .position(|handle| *handle == entity) else { return; }; - git_state.active_index = Some(index); + git_store.active_index = Some(index); cx.emit(GitEvent::ActiveRepositoryChanged); }); } @@ -396,7 +644,7 @@ impl Repository { languages: Option>, buffer_store: Entity, cx: &mut Context, - ) -> Task>> { + ) -> Task>> { if let Some(buffer) = self.commit_message_buffer.clone() { return Task::ready(Ok(buffer)); } @@ -444,7 +692,7 @@ impl Repository { language_registry: Option>, buffer_store: Entity, cx: &mut Context, - ) -> Task>> { + ) -> Task>> { cx.spawn(|repository, mut cx| async move { let buffer = buffer_store .update(&mut cx, |buffer_store, cx| buffer_store.create_buffer(cx))? @@ -464,7 +712,57 @@ impl Repository { }) } - pub fn stage_entries(&self, entries: Vec) -> oneshot::Receiver> { + pub fn reset(&self, commit: &str, reset_mode: ResetMode) -> oneshot::Receiver> { + let (result_tx, result_rx) = futures::channel::oneshot::channel(); + let commit = commit.to_string().into(); + self.update_sender + .unbounded_send(( + Message::Reset { + repo: self.git_repo.clone(), + commit, + reset_mode, + }, + result_tx, + )) + .ok(); + result_rx + } + + pub fn show(&self, commit: &str, cx: &Context) -> Task> { + let commit = commit.to_string(); + match self.git_repo.clone() { + GitRepo::Local(git_repository) => { + let commit = commit.to_string(); + cx.background_executor() + .spawn(async move { git_repository.show(&commit) }) + } + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => cx.background_executor().spawn(async move { + let resp = client + .request(proto::GitShow { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + commit, + }) + .await?; + + Ok(CommitDetails { + sha: resp.sha.into(), + message: resp.message.into(), + commit_timestamp: resp.commit_timestamp, + committer_email: resp.committer_email.into(), + committer_name: resp.committer_name.into(), + }) + }), + } + } + + pub fn stage_entries(&self, entries: Vec) -> oneshot::Receiver> { let (result_tx, result_rx) = futures::channel::oneshot::channel(); if entries.is_empty() { result_tx.send(Ok(())).ok(); @@ -476,7 +774,7 @@ impl Repository { result_rx } - pub fn unstage_entries(&self, entries: Vec) -> oneshot::Receiver> { + pub fn unstage_entries(&self, entries: Vec) -> oneshot::Receiver> { let (result_tx, result_rx) = futures::channel::oneshot::channel(); if entries.is_empty() { result_tx.send(Ok(())).ok(); @@ -488,7 +786,7 @@ impl Repository { result_rx } - pub fn stage_all(&self) -> oneshot::Receiver> { + pub fn stage_all(&self) -> oneshot::Receiver> { let to_stage = self .repository_entry .status() @@ -498,7 +796,7 @@ impl Repository { self.stage_entries(to_stage) } - pub fn unstage_all(&self) -> oneshot::Receiver> { + pub fn unstage_all(&self) -> oneshot::Receiver> { let to_unstage = self .repository_entry .status() @@ -530,7 +828,7 @@ impl Repository { &self, message: SharedString, name_and_email: Option<(SharedString, SharedString)>, - ) -> oneshot::Receiver> { + ) -> oneshot::Receiver> { let (result_tx, result_rx) = futures::channel::oneshot::channel(); self.update_sender .unbounded_send(( diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index d09ef9bd8f0af1ecaed26f6b88c04d5bd47760f9..8cc0481a5a698113d6c88bec9c26098b4f227212 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -27,7 +27,7 @@ use git::Repository; pub mod search_history; mod yarn; -use crate::git::GitState; +use crate::git::GitStore; use anyhow::{anyhow, Context as _, Result}; use buffer_store::{BufferStore, BufferStoreEvent}; use client::{ @@ -161,7 +161,7 @@ pub struct Project { fs: Arc, ssh_client: Option>, client_state: ProjectClientState, - git_state: Entity, + git_store: Entity, collaborators: HashMap, client_subscriptions: Vec, worktree_store: Entity, @@ -610,15 +610,10 @@ impl Project { client.add_entity_request_handler(Self::handle_open_new_buffer); client.add_entity_message_handler(Self::handle_create_buffer_for_peer); - client.add_entity_request_handler(Self::handle_stage); - client.add_entity_request_handler(Self::handle_unstage); - client.add_entity_request_handler(Self::handle_commit); - client.add_entity_request_handler(Self::handle_set_index_text); - client.add_entity_request_handler(Self::handle_open_commit_message_buffer); - WorktreeStore::init(&client); BufferStore::init(&client); LspStore::init(&client); + GitStore::init(&client); SettingsObserver::init(&client); TaskStore::init(Some(&client)); ToolchainStore::init(&client); @@ -705,7 +700,8 @@ impl Project { ) }); - let git_state = cx.new(|cx| GitState::new(&worktree_store, None, None, cx)); + let git_store = + cx.new(|cx| GitStore::new(&worktree_store, buffer_store.clone(), None, None, cx)); cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); @@ -718,7 +714,7 @@ impl Project { lsp_store, join_project_response_message_id: 0, client_state: ProjectClientState::Local, - git_state, + git_store, client_subscriptions: Vec::new(), _subscriptions: vec![cx.on_release(Self::release)], active_entry: None, @@ -825,9 +821,10 @@ impl Project { }); cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); - let git_state = cx.new(|cx| { - GitState::new( + let git_store = cx.new(|cx| { + GitStore::new( &worktree_store, + buffer_store.clone(), Some(ssh_proto.clone()), Some(ProjectId(SSH_PROJECT_ID)), cx, @@ -846,7 +843,7 @@ impl Project { lsp_store, join_project_response_message_id: 0, client_state: ProjectClientState::Local, - git_state, + git_store, client_subscriptions: Vec::new(), _subscriptions: vec![ cx.on_release(Self::release), @@ -896,6 +893,7 @@ impl Project { ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.worktree_store); ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.lsp_store); ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.settings_observer); + ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.git_store); ssh_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer); ssh_proto.add_entity_message_handler(Self::handle_update_worktree); @@ -909,6 +907,7 @@ impl Project { SettingsObserver::init(&ssh_proto); TaskStore::init(Some(&ssh_proto)); ToolchainStore::init(&ssh_proto); + GitStore::init(&ssh_proto); this }) @@ -1030,9 +1029,10 @@ impl Project { SettingsObserver::new_remote(worktree_store.clone(), task_store.clone(), cx) })?; - let git_state = cx.new(|cx| { - GitState::new( + let git_store = cx.new(|cx| { + GitStore::new( &worktree_store, + buffer_store.clone(), Some(client.clone().into()), Some(ProjectId(remote_id)), cx, @@ -1089,7 +1089,7 @@ impl Project { remote_id, replica_id, }, - git_state, + git_store, buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), terminals: Terminals { @@ -1675,6 +1675,9 @@ impl Project { self.client .subscribe_to_entity(project_id)? .set_entity(&self.settings_observer, &mut cx.to_async()), + self.client + .subscribe_to_entity(project_id)? + .set_entity(&self.git_store, &mut cx.to_async()), ]); self.buffer_store.update(cx, |buffer_store, cx| { @@ -4038,142 +4041,6 @@ impl Project { Project::respond_to_open_buffer_request(this, buffer, peer_id, &mut cx) } - async fn handle_stage( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); - let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = - Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - - let entries = envelope - .payload - .paths - .into_iter() - .map(PathBuf::from) - .map(RepoPath::new) - .collect(); - - repository_handle - .update(&mut cx, |repository_handle, _| { - repository_handle.stage_entries(entries) - })? - .await??; - Ok(proto::Ack {}) - } - - async fn handle_unstage( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); - let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = - Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - - let entries = envelope - .payload - .paths - .into_iter() - .map(PathBuf::from) - .map(RepoPath::new) - .collect(); - - repository_handle - .update(&mut cx, |repository_handle, _| { - repository_handle.unstage_entries(entries) - })? - .await??; - Ok(proto::Ack {}) - } - - async fn handle_commit( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); - let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = - Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - - let message = SharedString::from(envelope.payload.message); - let name = envelope.payload.name.map(SharedString::from); - let email = envelope.payload.email.map(SharedString::from); - repository_handle - .update(&mut cx, |repository_handle, _| { - repository_handle.commit(message, name.zip(email)) - })? - .await??; - Ok(proto::Ack {}) - } - - async fn handle_set_index_text( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); - let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = - Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - - repository_handle - .update(&mut cx, |repository_handle, _| { - repository_handle.set_index_text( - &RepoPath::from_str(&envelope.payload.path), - envelope.payload.text, - ) - })? - .await??; - Ok(proto::Ack {}) - } - - async fn handle_open_commit_message_buffer( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); - let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = - Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - let buffer = repository_handle - .update(&mut cx, |repository_handle, cx| { - repository_handle.open_commit_buffer(None, this.read(cx).buffer_store.clone(), cx) - })? - .await?; - - let peer_id = envelope.original_sender_id()?; - Project::respond_to_open_buffer_request(this, buffer, peer_id, &mut cx) - } - - fn repository_for_request( - this: &Entity, - worktree_id: WorktreeId, - work_directory_id: ProjectEntryId, - cx: &mut AsyncApp, - ) -> Result> { - this.update(cx, |project, cx| { - let repository_handle = project - .git_state() - .read(cx) - .all_repositories() - .into_iter() - .find(|repository_handle| { - let repository_handle = repository_handle.read(cx); - repository_handle.worktree_id == worktree_id - && repository_handle.repository_entry.work_directory_id() - == work_directory_id - }) - .context("missing repository handle")?; - anyhow::Ok(repository_handle) - })? - } - fn respond_to_open_buffer_request( this: Entity, buffer: Entity, @@ -4365,16 +4232,16 @@ impl Project { &self.buffer_store } - pub fn git_state(&self) -> &Entity { - &self.git_state + pub fn git_store(&self) -> &Entity { + &self.git_store } pub fn active_repository(&self, cx: &App) -> Option> { - self.git_state.read(cx).active_repository() + self.git_store.read(cx).active_repository() } pub fn all_repositories(&self, cx: &App) -> Vec> { - self.git_state.read(cx).all_repositories() + self.git_store.read(cx).all_repositories() } pub fn repository_and_path_for_buffer_id( @@ -4386,7 +4253,7 @@ impl Project { .buffer_for_id(buffer_id, cx)? .read(cx) .project_path(cx)?; - self.git_state + self.git_store .read(cx) .all_repositories() .into_iter() diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 6461d97723b9f3fe1ba5d4aa5ebc8d758c6b9443..73b49775e64bd44667da25058db181fbaf310a46 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -12,6 +12,7 @@ use futures::{ future::{BoxFuture, Shared}, FutureExt, SinkExt, }; +use git::repository::Branch; use gpui::{App, AsyncApp, Context, Entity, EntityId, EventEmitter, Task, WeakEntity}; use postage::oneshot; use rpc::{ @@ -24,7 +25,10 @@ use smol::{ }; use text::ReplicaId; use util::{paths::SanitizedPath, ResultExt}; -use worktree::{Entry, ProjectEntryId, UpdatedEntriesSet, Worktree, WorktreeId, WorktreeSettings}; +use worktree::{ + branch_to_proto, Entry, ProjectEntryId, UpdatedEntriesSet, Worktree, WorktreeId, + WorktreeSettings, +}; use crate::{search::SearchQuery, ProjectPath}; @@ -133,11 +137,12 @@ impl WorktreeStore { .find(|worktree| worktree.read(cx).id() == id) } - pub fn current_branch(&self, repository: ProjectPath, cx: &App) -> Option> { + pub fn current_branch(&self, repository: ProjectPath, cx: &App) -> Option { self.worktree_for_id(repository.worktree_id, cx)? .read(cx) .git_entry(repository.path)? .branch() + .cloned() } pub fn worktree_for_entry( @@ -938,9 +943,24 @@ impl WorktreeStore { .map(|proto_branch| git::repository::Branch { is_head: proto_branch.is_head, name: proto_branch.name.into(), - unix_timestamp: proto_branch - .unix_timestamp - .map(|timestamp| timestamp as i64), + upstream: proto_branch.upstream.map(|upstream| { + git::repository::Upstream { + ref_name: upstream.ref_name.into(), + tracking: upstream.tracking.map(|tracking| { + git::repository::UpstreamTracking { + ahead: tracking.ahead as u32, + behind: tracking.behind as u32, + } + }), + } + }), + most_recent_commit: proto_branch.most_recent_commit.map(|commit| { + git::repository::CommitSummary { + sha: commit.sha.into(), + subject: commit.subject.into(), + commit_timestamp: commit.commit_timestamp, + } + }), }) .collect(); @@ -1126,14 +1146,7 @@ impl WorktreeStore { .await?; Ok(proto::GitBranchesResponse { - branches: branches - .into_iter() - .map(|branch| proto::Branch { - is_head: branch.is_head, - name: branch.name.to_string(), - unix_timestamp: branch.unix_timestamp.map(|timestamp| timestamp as u64), - }) - .collect(), + branches: branches.iter().map(branch_to_proto).collect(), }) } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 4fdfa0ae928a6441d1b1138766dd795add7202af..c86459f2cd9417f57b01123cfe74a64ffe66ffa2 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -316,6 +316,9 @@ message Envelope { OpenUncommittedDiff open_uncommitted_diff = 297; OpenUncommittedDiffResponse open_uncommitted_diff_response = 298; + GitShow git_show = 300; + GitReset git_reset = 301; + GitCommitDetails git_commit_details = 302; SetIndexText set_index_text = 299; // current max } @@ -1800,12 +1803,14 @@ message Entry { message RepositoryEntry { uint64 work_directory_id = 1; - optional string branch = 2; + optional string branch = 2; // deprecated + optional Branch branch_summary = 6; repeated StatusEntry updated_statuses = 3; repeated string removed_statuses = 4; repeated string current_merge_conflicts = 5; } + message StatusEntry { string repo_path = 1; // Can be removed once collab's min version is >=0.171.0. @@ -2615,10 +2620,26 @@ message ActiveToolchainResponse { optional Toolchain toolchain = 1; } +message CommitSummary { + string sha = 1; + string subject = 2; + int64 commit_timestamp = 3; +} + message Branch { bool is_head = 1; string name = 2; optional uint64 unix_timestamp = 3; + optional GitUpstream upstream = 4; + optional CommitSummary most_recent_commit = 5; +} +message GitUpstream { + string ref_name = 1; + optional UpstreamTracking tracking = 2; +} +message UpstreamTracking { + uint64 ahead = 1; + uint64 behind = 2; } message GitBranches { @@ -2639,6 +2660,33 @@ message UpdateGitBranch { message GetPanicFiles { } +message GitShow { + uint64 project_id = 1; + uint64 worktree_id = 2; + uint64 work_directory_id = 3; + string commit = 4; +} + +message GitCommitDetails { + string sha = 1; + string message = 2; + int64 commit_timestamp = 3; + string committer_email = 4; + string committer_name = 5; +} + +message GitReset { + uint64 project_id = 1; + uint64 worktree_id = 2; + uint64 work_directory_id = 3; + string commit = 4; + ResetMode mode = 5; + enum ResetMode { + SOFT = 0; + MIXED = 1; + } +} + message GetPanicFilesResponse { repeated string file_contents = 2; } diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index b51f34914b6a0869268a2bdbe6d58e5c372d6b8c..0743e92da685a5c9d20eaea9281e838db8be611f 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -440,6 +440,9 @@ messages!( (SyncExtensionsResponse, Background), (InstallExtension, Background), (RegisterBufferWithLanguageServers, Background), + (GitReset, Background), + (GitShow, Background), + (GitCommitDetails, Background), (SetIndexText, Background), ); @@ -574,6 +577,8 @@ request_messages!( (SyncExtensions, SyncExtensionsResponse), (InstallExtension, Ack), (RegisterBufferWithLanguageServers, Ack), + (GitShow, GitCommitDetails), + (GitReset, Ack), (SetIndexText, Ack), ); @@ -667,6 +672,8 @@ entity_messages!( GetPathMetadata, CancelLanguageServerWork, RegisterBufferWithLanguageServers, + GitShow, + GitReset, SetIndexText, ); diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index e274014c1e4a7a8ba36a464cf0f72e9dc763b6f8..62523443f5aea89b30c5aadfacd945cb61f7ea2c 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -1,22 +1,20 @@ use ::proto::{FromProto, ToProto}; -use anyhow::{anyhow, Context as _, Result}; +use anyhow::{anyhow, Result}; use extension::ExtensionHostProxy; use extension_host::headless_host::HeadlessExtensionStore; use fs::Fs; -use git::repository::RepoPath; -use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel, SharedString}; +use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel}; use http_client::HttpClient; use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry}; use node_runtime::NodeRuntime; use project::{ buffer_store::{BufferStore, BufferStoreEvent}, - git::{GitState, Repository}, + git::GitStore, project_settings::SettingsObserver, search::SearchQuery, task_store::TaskStore, worktree_store::WorktreeStore, - LspStore, LspStoreEvent, PrettierStore, ProjectEntryId, ProjectPath, ToolchainStore, - WorktreeId, + LspStore, LspStoreEvent, PrettierStore, ProjectPath, ToolchainStore, WorktreeId, }; use remote::ssh_session::ChannelClient; use rpc::{ @@ -44,7 +42,7 @@ pub struct HeadlessProject { pub next_entry_id: Arc, pub languages: Arc, pub extensions: Entity, - pub git_state: Entity, + pub git_store: Entity, } pub struct HeadlessAppState { @@ -83,13 +81,14 @@ impl HeadlessProject { store }); - let git_state = cx.new(|cx| GitState::new(&worktree_store, None, None, cx)); - let buffer_store = cx.new(|cx| { let mut buffer_store = BufferStore::local(worktree_store.clone(), cx); buffer_store.shared(SSH_PROJECT_ID, session.clone().into(), cx); buffer_store }); + + let git_store = + cx.new(|cx| GitStore::new(&worktree_store, buffer_store.clone(), None, None, cx)); let prettier_store = cx.new(|cx| { PrettierStore::new( node_runtime.clone(), @@ -180,6 +179,7 @@ impl HeadlessProject { session.subscribe_to_entity(SSH_PROJECT_ID, &task_store); session.subscribe_to_entity(SSH_PROJECT_ID, &toolchain_store); session.subscribe_to_entity(SSH_PROJECT_ID, &settings_observer); + session.subscribe_to_entity(SSH_PROJECT_ID, &git_store); client.add_request_handler(cx.weak_entity(), Self::handle_list_remote_directory); client.add_request_handler(cx.weak_entity(), Self::handle_get_path_metadata); @@ -197,12 +197,6 @@ impl HeadlessProject { client.add_entity_request_handler(BufferStore::handle_update_buffer); client.add_entity_message_handler(BufferStore::handle_close_buffer); - client.add_entity_request_handler(Self::handle_stage); - client.add_entity_request_handler(Self::handle_unstage); - client.add_entity_request_handler(Self::handle_commit); - client.add_entity_request_handler(Self::handle_set_index_text); - client.add_entity_request_handler(Self::handle_open_commit_message_buffer); - client.add_request_handler( extensions.clone().downgrade(), HeadlessExtensionStore::handle_sync_extensions, @@ -218,6 +212,7 @@ impl HeadlessProject { LspStore::init(&client); TaskStore::init(Some(&client)); ToolchainStore::init(&client); + GitStore::init(&client); HeadlessProject { session: client, @@ -230,7 +225,7 @@ impl HeadlessProject { next_entry_id: Default::default(), languages, extensions, - git_state, + git_store, } } @@ -616,157 +611,6 @@ impl HeadlessProject { log::debug!("Received ping from client"); Ok(proto::Ack {}) } - - async fn handle_stage( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); - let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = - Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - - let entries = envelope - .payload - .paths - .into_iter() - .map(PathBuf::from) - .map(RepoPath::new) - .collect(); - - repository_handle - .update(&mut cx, |repository_handle, _| { - repository_handle.stage_entries(entries) - })? - .await??; - Ok(proto::Ack {}) - } - - async fn handle_unstage( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); - let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = - Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - - let entries = envelope - .payload - .paths - .into_iter() - .map(PathBuf::from) - .map(RepoPath::new) - .collect(); - - repository_handle - .update(&mut cx, |repository_handle, _| { - repository_handle.unstage_entries(entries) - })? - .await??; - - Ok(proto::Ack {}) - } - - async fn handle_commit( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); - let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = - Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - - let message = SharedString::from(envelope.payload.message); - let name = envelope.payload.name.map(SharedString::from); - let email = envelope.payload.email.map(SharedString::from); - - repository_handle - .update(&mut cx, |repository_handle, _| { - repository_handle.commit(message, name.zip(email)) - })? - .await??; - Ok(proto::Ack {}) - } - - async fn handle_set_index_text( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); - let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository = - Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - repository - .update(&mut cx, |repository, _| { - repository.set_index_text( - &RepoPath::from(envelope.payload.path.as_str()), - envelope.payload.text, - ) - })? - .await??; - Ok(proto::Ack {}) - } - - async fn handle_open_commit_message_buffer( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); - let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository = - Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - let buffer = repository - .update(&mut cx, |repository, cx| { - repository.open_commit_buffer(None, this.read(cx).buffer_store.clone(), cx) - })? - .await?; - - let buffer_id = buffer.read_with(&cx, |buffer, _| buffer.remote_id())?; - this.update(&mut cx, |headless_project, cx| { - headless_project - .buffer_store - .update(cx, |buffer_store, cx| { - buffer_store - .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) - .detach_and_log_err(cx); - }) - })?; - - Ok(proto::OpenBufferResponse { - buffer_id: buffer_id.to_proto(), - }) - } - - fn repository_for_request( - this: &Entity, - worktree_id: WorktreeId, - work_directory_id: ProjectEntryId, - cx: &mut AsyncApp, - ) -> Result> { - this.update(cx, |project, cx| { - let repository_handle = project - .git_state - .read(cx) - .all_repositories() - .into_iter() - .find(|repository_handle| { - repository_handle.read(cx).worktree_id == worktree_id - && repository_handle - .read(cx) - .repository_entry - .work_directory_id() - == work_directory_id - }) - .context("missing repository handle")?; - anyhow::Ok(repository_handle) - })? - } } fn prompt_to_proto( diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index c1a22b2c8a8b29fe362796bf38c29d94956c67c4..7cc6cea1dffdfcc8cb923997f719a6e9c5f73650 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1364,7 +1364,7 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA }) }); - assert_eq!(server_branch.as_ref(), branches[2]); + assert_eq!(server_branch.name, branches[2]); // Also try creating a new branch cx.update(|cx| { @@ -1387,7 +1387,7 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA }) }); - assert_eq!(server_branch.as_ref(), "totally-new-branch"); + assert_eq!(server_branch.name, "totally-new-branch"); } pub async fn init_test( diff --git a/crates/time_format/src/time_format.rs b/crates/time_format/src/time_format.rs index bd5d96202649d4891d3269b2fae7e11f78da99c9..fe9a96b87b3a075e85a3b83bf912bb028bcbbcff 100644 --- a/crates/time_format/src/time_format.rs +++ b/crates/time_format/src/time_format.rs @@ -24,19 +24,21 @@ pub fn format_localized_timestamp( ) -> String { let timestamp_local = timestamp.to_offset(timezone); let reference_local = reference.to_offset(timezone); + format_local_timestamp(timestamp_local, reference_local, format) +} +/// Formats a timestamp, which respects the user's date and time preferences/custom format. +pub fn format_local_timestamp( + timestamp: OffsetDateTime, + reference: OffsetDateTime, + format: TimestampFormat, +) -> String { match format { - TimestampFormat::Absolute => { - format_absolute_timestamp(timestamp_local, reference_local, false) - } - TimestampFormat::EnhancedAbsolute => { - format_absolute_timestamp(timestamp_local, reference_local, true) - } - TimestampFormat::MediumAbsolute => { - format_absolute_timestamp_medium(timestamp_local, reference_local) - } - TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local) - .unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)), + TimestampFormat::Absolute => format_absolute_timestamp(timestamp, reference, false), + TimestampFormat::EnhancedAbsolute => format_absolute_timestamp(timestamp, reference, true), + TimestampFormat::MediumAbsolute => format_absolute_timestamp_medium(timestamp, reference), + TimestampFormat::Relative => format_relative_time(timestamp, reference) + .unwrap_or_else(|| format_relative_date(timestamp, reference)), } } diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 9f430585c41656afcb319b4dfd216b8f3b02fafb..15375cb18e73d29d50fe3bce3ff7306210c47845 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -521,6 +521,7 @@ impl TitleBar { let branch_name = entry .as_ref() .and_then(|entry| entry.branch()) + .map(|branch| branch.name.clone()) .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH))?; Some( Button::new("project_branch_trigger", branch_name) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 88c61c9af6da1b963a7fcb3e2ff105ab02bace24..c3e444caf1d19bb2103b6fcd3f19e16a937dac20 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -19,7 +19,7 @@ use futures::{ }; use fuzzy::CharBag; use git::{ - repository::{GitRepository, RepoPath}, + repository::{Branch, GitRepository, RepoPath}, status::{ FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode, }, @@ -201,7 +201,7 @@ pub struct RepositoryEntry { pub(crate) statuses_by_path: SumTree, work_directory_id: ProjectEntryId, pub work_directory: WorkDirectory, - pub(crate) branch: Option>, + pub(crate) branch: Option, pub current_merge_conflicts: TreeSet, } @@ -214,8 +214,8 @@ impl Deref for RepositoryEntry { } impl RepositoryEntry { - pub fn branch(&self) -> Option> { - self.branch.clone() + pub fn branch(&self) -> Option<&Branch> { + self.branch.as_ref() } pub fn work_directory_id(&self) -> ProjectEntryId { @@ -243,7 +243,8 @@ impl RepositoryEntry { pub fn initial_update(&self) -> proto::RepositoryEntry { proto::RepositoryEntry { work_directory_id: self.work_directory_id.to_proto(), - branch: self.branch.as_ref().map(|branch| branch.to_string()), + branch: self.branch.as_ref().map(|branch| branch.name.to_string()), + branch_summary: self.branch.as_ref().map(branch_to_proto), updated_statuses: self .statuses_by_path .iter() @@ -302,7 +303,8 @@ impl RepositoryEntry { proto::RepositoryEntry { work_directory_id: self.work_directory_id.to_proto(), - branch: self.branch.as_ref().map(|branch| branch.to_string()), + branch: self.branch.as_ref().map(|branch| branch.name.to_string()), + branch_summary: self.branch.as_ref().map(branch_to_proto), updated_statuses, removed_statuses, current_merge_conflicts: self @@ -314,6 +316,61 @@ impl RepositoryEntry { } } +pub fn branch_to_proto(branch: &git::repository::Branch) -> proto::Branch { + proto::Branch { + is_head: branch.is_head, + name: branch.name.to_string(), + unix_timestamp: branch + .most_recent_commit + .as_ref() + .map(|commit| commit.commit_timestamp as u64), + upstream: branch.upstream.as_ref().map(|upstream| proto::GitUpstream { + ref_name: upstream.ref_name.to_string(), + tracking: upstream + .tracking + .as_ref() + .map(|upstream| proto::UpstreamTracking { + ahead: upstream.ahead as u64, + behind: upstream.behind as u64, + }), + }), + most_recent_commit: branch + .most_recent_commit + .as_ref() + .map(|commit| proto::CommitSummary { + sha: commit.sha.to_string(), + subject: commit.subject.to_string(), + commit_timestamp: commit.commit_timestamp, + }), + } +} + +pub fn proto_to_branch(proto: &proto::Branch) -> git::repository::Branch { + git::repository::Branch { + is_head: proto.is_head, + name: proto.name.clone().into(), + upstream: proto + .upstream + .as_ref() + .map(|upstream| git::repository::Upstream { + ref_name: upstream.ref_name.to_string().into(), + tracking: upstream.tracking.as_ref().map(|tracking| { + git::repository::UpstreamTracking { + ahead: tracking.ahead as u32, + behind: tracking.behind as u32, + } + }), + }), + most_recent_commit: proto.most_recent_commit.as_ref().map(|commit| { + git::repository::CommitSummary { + sha: commit.sha.to_string().into(), + subject: commit.subject.to_string().into(), + commit_timestamp: commit.commit_timestamp, + } + }), + } +} + /// This path corresponds to the 'content path' of a repository in relation /// to Zed's project root. /// In the majority of the cases, this is the folder that contains the .git folder. @@ -2625,7 +2682,7 @@ impl Snapshot { self.repositories .update(&PathKey(work_dir_entry.path.clone()), &(), |repo| { - repo.branch = repository.branch.map(Into::into); + repo.branch = repository.branch_summary.as_ref().map(proto_to_branch); repo.statuses_by_path.edit(edits, &()); repo.current_merge_conflicts = conflicted_paths }); @@ -2647,7 +2704,7 @@ impl Snapshot { work_directory: WorkDirectory::InProject { relative_path: work_dir_entry.path.clone(), }, - branch: repository.branch.map(Into::into), + branch: repository.branch_summary.as_ref().map(proto_to_branch), statuses_by_path: statuses, current_merge_conflicts: conflicted_paths, }, @@ -3449,7 +3506,7 @@ impl BackgroundScannerState { RepositoryEntry { work_directory_id: work_dir_id, work_directory: work_directory.clone(), - branch: repository.branch_name().map(Into::into), + branch: None, statuses_by_path: Default::default(), current_merge_conflicts: Default::default(), }, @@ -4198,6 +4255,7 @@ impl BackgroundScanner { // the git repository in an ancestor directory. Find any gitignore files // in ancestor directories. let root_abs_path = self.state.lock().snapshot.abs_path.clone(); + let mut containing_git_repository = None; for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() { if index != 0 { if let Ok(ignore) = @@ -4227,7 +4285,7 @@ impl BackgroundScanner { { // We associate the external git repo with our root folder and // also mark where in the git repo the root folder is located. - self.state.lock().insert_git_repository_for_path( + let local_repository = self.state.lock().insert_git_repository_for_path( WorkDirectory::AboveProject { absolute_path: ancestor.into(), location_in_repo: root_abs_path @@ -4236,10 +4294,14 @@ impl BackgroundScanner { .unwrap() .into(), }, - ancestor_dot_git.into(), + ancestor_dot_git.clone().into(), self.fs.as_ref(), self.watcher.as_ref(), ); + + if local_repository.is_some() { + containing_git_repository = Some(ancestor_dot_git) + } }; } @@ -4285,6 +4347,9 @@ impl BackgroundScanner { self.process_events(paths.into_iter().map(Into::into).collect()) .await; } + if let Some(abs_path) = containing_git_repository { + self.process_events(vec![abs_path]).await; + } // Continue processing events until the worktree is dropped. self.phase = BackgroundScannerPhase::Events; @@ -4703,7 +4768,7 @@ impl BackgroundScanner { ); if let Some(local_repo) = repo { - self.update_git_statuses(UpdateGitStatusesJob { + self.update_git_repository(UpdateGitRepoJob { local_repository: local_repo, }); } @@ -5255,15 +5320,6 @@ impl BackgroundScanner { if local_repository.git_dir_scan_id == scan_id { continue; } - let Some(work_dir) = state - .snapshot - .entry_for_id(local_repository.work_directory_id) - .map(|entry| entry.path.clone()) - else { - continue; - }; - - let branch = local_repository.repo_ptr.branch_name(); local_repository.repo_ptr.reload_index(); state.snapshot.git_repositories.update( @@ -5273,17 +5329,12 @@ impl BackgroundScanner { entry.status_scan_id = scan_id; }, ); - state.snapshot.snapshot.repositories.update( - &PathKey(work_dir.clone()), - &(), - |entry| entry.branch = branch.map(Into::into), - ); local_repository } }; - repo_updates.push(UpdateGitStatusesJob { local_repository }); + repo_updates.push(UpdateGitRepoJob { local_repository }); } // Remove any git repositories whose .git entry no longer exists. @@ -5319,7 +5370,7 @@ impl BackgroundScanner { .scoped(|scope| { scope.spawn(async { for repo_update in repo_updates { - self.update_git_statuses(repo_update); + self.update_git_repository(repo_update); } updates_done_tx.blocking_send(()).ok(); }); @@ -5343,22 +5394,37 @@ impl BackgroundScanner { .await; } - /// Update the git statuses for a given batch of entries. - fn update_git_statuses(&self, job: UpdateGitStatusesJob) { + fn update_branches(&self, job: &UpdateGitRepoJob) -> Result<()> { + let branches = job.local_repository.repo().branches()?; + let snapshot = self.state.lock().snapshot.snapshot.clone(); + + let mut repository = snapshot + .repository(job.local_repository.work_directory.path_key()) + .context("Missing repository")?; + + repository.branch = branches.into_iter().find(|branch| branch.is_head); + + let mut state = self.state.lock(); + state + .snapshot + .repositories + .insert_or_replace(repository, &()); + + Ok(()) + } + + fn update_statuses(&self, job: &UpdateGitRepoJob) -> Result<()> { log::trace!( "updating git statuses for repo {:?}", job.local_repository.work_directory.display_name() ); let t0 = Instant::now(); - let Some(statuses) = job + let statuses = job .local_repository .repo() - .status(&[git::WORK_DIRECTORY_REPO_PATH.clone()]) - .log_err() - else { - return; - }; + .status(&[git::WORK_DIRECTORY_REPO_PATH.clone()])?; + log::trace!( "computed git statuses for repo {:?} in {:?}", job.local_repository.work_directory.display_name(), @@ -5369,13 +5435,9 @@ impl BackgroundScanner { let mut changed_paths = Vec::new(); let snapshot = self.state.lock().snapshot.snapshot.clone(); - let Some(mut repository) = - snapshot.repository(job.local_repository.work_directory.path_key()) - else { - // happens when a folder is deleted - log::debug!("Got an UpdateGitStatusesJob for a repository that isn't in the snapshot"); - return; - }; + let mut repository = snapshot + .repository(job.local_repository.work_directory.path_key()) + .context("Got an UpdateGitStatusesJob for a repository that isn't in the snapshot")?; let merge_head_shas = job.local_repository.repo().merge_head_shas(); if merge_head_shas != job.local_repository.current_merge_head_shas { @@ -5403,6 +5465,7 @@ impl BackgroundScanner { } repository.statuses_by_path = new_entries_by_path; + let mut state = self.state.lock(); state .snapshot @@ -5428,6 +5491,13 @@ impl BackgroundScanner { job.local_repository.work_directory.display_name(), t0.elapsed(), ); + Ok(()) + } + + /// Update the git statuses for a given batch of entries. + fn update_git_repository(&self, job: UpdateGitRepoJob) { + self.update_branches(&job).log_err(); + self.update_statuses(&job).log_err(); } fn build_change_set( @@ -5637,7 +5707,7 @@ struct UpdateIgnoreStatusJob { scan_queue: Sender, } -struct UpdateGitStatusesJob { +struct UpdateGitRepoJob { local_repository: LocalRepositoryEntry, } From 0e42a6949082fee2948035767dd7a3bf5e116b93 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 12 Feb 2025 23:26:07 +0000 Subject: [PATCH 10/13] Update dependency danger to v12.3.4 (#24770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [danger](https://redirect.github.com/danger/danger-js) | [`12.3.3` -> `12.3.4`](https://renovatebot.com/diffs/npm/danger/12.3.3/12.3.4) | [![age](https://developer.mend.io/api/mc/badges/age/npm/danger/12.3.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/danger/12.3.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/danger/12.3.3/12.3.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/danger/12.3.3/12.3.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
danger/danger-js (danger) ### [`v12.3.4`](https://redirect.github.com/danger/danger-js/blob/HEAD/CHANGELOG.md#1234) [Compare Source](https://redirect.github.com/danger/danger-js/compare/12.3.3...12.3.4) - Ensure that [babel ignores](https://babeljs.io/docs/options#ignore) do not cause the transpiler to fall over, by supporting the `null` return from `loadOptions` which occurs when a file is ignored. - Allow absolute paths for a Dangerfile
--- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- script/danger/package.json | 2 +- script/danger/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/script/danger/package.json b/script/danger/package.json index c6586b2514a47b77147b03d6b66a7c460475a3ba..2d180d786c79af5be0fa83e4f75c91306d5faa37 100644 --- a/script/danger/package.json +++ b/script/danger/package.json @@ -7,7 +7,7 @@ "danger": "danger" }, "devDependencies": { - "danger": "12.3.3", + "danger": "12.3.4", "danger-plugin-pr-hygiene": "0.5.0" } } diff --git a/script/danger/pnpm-lock.yaml b/script/danger/pnpm-lock.yaml index 8488b29c094af64a2363db4624589fdb37f0a2ff..e81d04f19e7a10ad8b28260ff5b2c39bfd82ffc1 100644 --- a/script/danger/pnpm-lock.yaml +++ b/script/danger/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: danger: - specifier: 12.3.3 - version: 12.3.3 + specifier: 12.3.4 + version: 12.3.4 danger-plugin-pr-hygiene: specifier: 0.5.0 version: 0.5.0 @@ -122,8 +122,8 @@ packages: danger-plugin-pr-hygiene@0.5.0: resolution: {integrity: sha512-5z8vImexNVLG0V3LpGMp4RbMoU5Unjn9Na0Dv79gozYqgKJgIlaVRfxGTWxdJP0/TXO8NwDAQYdlwy+vqvfTsg==} - danger@12.3.3: - resolution: {integrity: sha512-nZKzpgXN21rr4dwa6bFhM7G2JEa79dZRJiT3RVRSyi4yk1/hgZ2f8HDGoa7tMladTmu8WjJFyE3LpBIihh+aDw==} + danger@12.3.4: + resolution: {integrity: sha512-esr6iowAryWjWkMzOKyOmMRkamPkDRhC6OAj2tO48i0oobObdP0d8I/YE+qSj9m+/RRcrhaKnysvPL51eW1m3w==} engines: {node: '>=18'} hasBin: true @@ -601,7 +601,7 @@ snapshots: fp-ts: 2.12.2 io-ts: 2.2.17(fp-ts@2.12.2) - danger@12.3.3: + danger@12.3.4: dependencies: '@gitbeaker/rest': 38.12.1 '@octokit/rest': 18.12.0 From 277fb546321d6f4740ba7e668a5443ecbf177075 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 12 Feb 2025 18:58:38 -0500 Subject: [PATCH 11/13] zeta: Respect `x-zed-minimum-required-version` header (#24771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR makes it so Zeta respects the `x-zed-minimum-required-version` header sent back from the server. If the current Zed version is strictly less than the indicated minimum required version, we show an error indicating that an update is required in order to continue using Zeta: Screenshot 2025-02-12 at 6 15 44 PM Release Notes: - N/A --- Cargo.lock | 6 +- .../zed/src/zed/inline_completion_registry.rs | 8 +- crates/zeta/Cargo.toml | 2 + crates/zeta/src/zeta.rs | 135 +++++++++++++++--- 4 files changed, 132 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb44454c909377eb1f555a5a9ff9747045fce9be..9653529d7ecf80679b66b7b97f32026b9430559d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16852,9 +16852,9 @@ dependencies = [ [[package]] name = "zed_llm_client" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614669bead4741b2fc352ae1967318be16949cf46f59013e548c6dbfdfc01252" +checksum = "1bf21350eced858d129840589158a8f6895c4fa4327ae56dd8c7d6a98495bed4" dependencies = [ "serde", "serde_json", @@ -17076,6 +17076,7 @@ dependencies = [ "postage", "project", "regex", + "release_channel", "reqwest_client", "rpc", "serde", @@ -17085,6 +17086,7 @@ dependencies = [ "telemetry", "telemetry_events", "theme", + "thiserror 1.0.69", "tree-sitter-go", "tree-sitter-rust", "ui", diff --git a/crates/zed/src/zed/inline_completion_registry.rs b/crates/zed/src/zed/inline_completion_registry.rs index 8639ad51f9948ffbc8d77b9cbf774c3e29f6123a..21351265447c5dd58b9a4aded99f077a6c52eeee 100644 --- a/crates/zed/src/zed/inline_completion_registry.rs +++ b/crates/zed/src/zed/inline_completion_registry.rs @@ -264,7 +264,13 @@ fn assign_edit_prediction_provider( } } - let zeta = zeta::Zeta::register(worktree, client.clone(), user_store, cx); + let zeta = zeta::Zeta::register( + Some(cx.entity()), + worktree, + client.clone(), + user_store, + cx, + ); if let Some(buffer) = &singleton_buffer { if buffer.read(cx).file().is_some() { diff --git a/crates/zeta/Cargo.toml b/crates/zeta/Cargo.toml index 1904a4d2bac484394e07ce3f708358c78a79e81d..7e1f46c5fefa9ea6e1c21250a7971029be7b833b 100644 --- a/crates/zeta/Cargo.toml +++ b/crates/zeta/Cargo.toml @@ -39,6 +39,7 @@ menu.workspace = true postage.workspace = true project.workspace = true regex.workspace = true +release_channel.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true @@ -46,6 +47,7 @@ similar.workspace = true telemetry.workspace = true telemetry_events.workspace = true theme.workspace = true +thiserror.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index cc60ad46ec78f092c28e5944843cb909f5f5e106..7627b1e832e6f9beffd3675318dbd06462e5cad6 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -9,6 +9,7 @@ mod rate_completion_modal; pub(crate) use completion_diff_element::*; use db::kvp::KEY_VALUE_STORE; +use editor::Editor; pub use init::*; use inline_completion::DataCollectionState; pub use license_detection::is_license_eligible_for_data_collection; @@ -20,10 +21,10 @@ use anyhow::{anyhow, Context as _, Result}; use arrayvec::ArrayVec; use client::{Client, UserStore}; use collections::{HashMap, HashSet, VecDeque}; -use feature_flags::FeatureFlagAppExt as _; use futures::AsyncReadExt; use gpui::{ - actions, App, AppContext as _, AsyncApp, Context, Entity, EntityId, Global, Subscription, Task, + actions, App, AppContext as _, AsyncApp, Context, Entity, EntityId, Global, SemanticVersion, + Subscription, Task, }; use http_client::{HttpClient, Method}; use input_excerpt::excerpt_for_cursor_position; @@ -34,7 +35,9 @@ use language::{ use language_models::LlmApiToken; use postage::watch; use project::Project; +use release_channel::AppVersion; use settings::WorktreeId; +use std::str::FromStr; use std::{ borrow::Cow, cmp, @@ -48,10 +51,16 @@ use std::{ time::{Duration, Instant}, }; use telemetry_events::InlineCompletionRating; +use thiserror::Error; use util::ResultExt; use uuid::Uuid; +use workspace::notifications::{ErrorMessagePrompt, NotificationId}; +use workspace::Workspace; use worktree::Worktree; -use zed_llm_client::{PredictEditsBody, PredictEditsResponse, EXPIRED_LLM_TOKEN_HEADER_NAME}; +use zed_llm_client::{ + PredictEditsBody, PredictEditsResponse, EXPIRED_LLM_TOKEN_HEADER_NAME, + MINIMUM_REQUIRED_VERSION_HEADER_NAME, +}; const CURSOR_MARKER: &'static str = "<|user_cursor_is_here|>"; const START_OF_FILE_MARKER: &'static str = "<|start_of_file|>"; @@ -178,6 +187,7 @@ impl std::fmt::Debug for InlineCompletion { } pub struct Zeta { + editor: Option>, client: Arc, events: VecDeque, registered_buffers: HashMap, @@ -188,6 +198,8 @@ pub struct Zeta { _llm_token_subscription: Subscription, /// Whether the terms of service have been accepted. tos_accepted: bool, + /// Whether an update to a newer version of Zed is required to continue using Zeta. + update_required: bool, _user_store_subscription: Subscription, license_detection_watchers: HashMap>, } @@ -198,13 +210,14 @@ impl Zeta { } pub fn register( + editor: Option>, worktree: Option>, client: Arc, user_store: Entity, cx: &mut App, ) -> Entity { let this = Self::global(cx).unwrap_or_else(|| { - let entity = cx.new(|cx| Self::new(client, user_store, cx)); + let entity = cx.new(|cx| Self::new(editor, client, user_store, cx)); cx.set_global(ZetaGlobal(entity.clone())); entity }); @@ -226,13 +239,19 @@ impl Zeta { self.events.clear(); } - fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { + fn new( + editor: Option>, + client: Arc, + user_store: Entity, + cx: &mut Context, + ) -> Self { let refresh_llm_token_listener = language_models::RefreshLlmTokenListener::global(cx); let data_collection_choice = Self::load_data_collection_choices(); let data_collection_choice = cx.new(|_| data_collection_choice); Self { + editor, client, events: VecDeque::new(), shown_completions: VecDeque::new(), @@ -256,6 +275,7 @@ impl Zeta { .read(cx) .current_user_has_accepted_terms() .unwrap_or(false), + update_required: false, _user_store_subscription: cx.subscribe(&user_store, |this, user_store, event, cx| { match event { client::user::Event::PrivateUserInfoUpdated => { @@ -335,8 +355,10 @@ impl Zeta { } } - pub fn request_completion_impl( + #[allow(clippy::too_many_arguments)] + fn request_completion_impl( &mut self, + workspace: Option>, project: Option<&Entity>, buffer: &Entity, cursor: language::Anchor, @@ -345,7 +367,7 @@ impl Zeta { perform_predict_edits: F, ) -> Task>> where - F: FnOnce(Arc, LlmApiToken, bool, PredictEditsBody) -> R + 'static, + F: FnOnce(PerformPredictEditsParams) -> R + 'static, R: Future> + Send + 'static, { let snapshot = self.report_changes_for_buffer(&buffer, cx); @@ -358,9 +380,10 @@ impl Zeta { .map(|f| Arc::from(f.full_path(cx).as_path())) .unwrap_or_else(|| Arc::from(Path::new("untitled"))); + let zeta = cx.entity(); let client = self.client.clone(); let llm_token = self.llm_token.clone(); - let is_staff = cx.is_staff(); + let app_version = AppVersion::global(cx); let buffer = buffer.clone(); @@ -447,7 +470,46 @@ impl Zeta { }), }; - let response = perform_predict_edits(client, llm_token, is_staff, body).await?; + let response = perform_predict_edits(PerformPredictEditsParams { + client, + llm_token, + app_version, + body, + }) + .await; + let response = match response { + Ok(response) => response, + Err(err) => { + if err.is::() { + cx.update(|cx| { + zeta.update(cx, |zeta, _cx| { + zeta.update_required = true; + }); + + if let Some(workspace) = workspace { + workspace.update(cx, |workspace, cx| { + workspace.show_notification( + NotificationId::unique::(), + cx, + |cx| { + cx.new(|_| { + ErrorMessagePrompt::new(err.to_string()) + .with_link_button( + "Update Zed", + "https://zed.dev/releases", + ) + }) + }, + ); + }); + } + }) + .ok(); + } + + return Err(err); + } + }; log::debug!("completion response: {}", &response.output_excerpt); @@ -632,7 +694,7 @@ and then another ) -> Task>> { use std::future::ready; - self.request_completion_impl(project, buffer, position, false, cx, |_, _, _, _| { + self.request_completion_impl(None, project, buffer, position, false, cx, |_params| { ready(Ok(response)) }) } @@ -645,7 +707,12 @@ and then another can_collect_data: bool, cx: &mut Context, ) -> Task>> { + let workspace = self + .editor + .as_ref() + .and_then(|editor| editor.read(cx).workspace()); self.request_completion_impl( + workspace, project, buffer, position, @@ -656,12 +723,17 @@ and then another } fn perform_predict_edits( - client: Arc, - llm_token: LlmApiToken, - _is_staff: bool, - body: PredictEditsBody, + params: PerformPredictEditsParams, ) -> impl Future> { async move { + let PerformPredictEditsParams { + client, + llm_token, + app_version, + body, + .. + } = params; + let http_client = client.http_client(); let mut token = llm_token.acquire(&client).await?; let mut did_retry = false; @@ -685,6 +757,18 @@ and then another let mut response = http_client.send(request).await?; + if let Some(minimum_required_version) = response + .headers() + .get(MINIMUM_REQUIRED_VERSION_HEADER_NAME) + .and_then(|version| SemanticVersion::from_str(version.to_str().ok()?).ok()) + { + if app_version < minimum_required_version { + return Err(anyhow!(ZedUpdateRequiredError { + minimum_version: minimum_required_version + })); + } + } + if response.status().is_success() { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; @@ -1011,6 +1095,21 @@ and then another } } +struct PerformPredictEditsParams { + pub client: Arc, + pub llm_token: LlmApiToken, + pub app_version: SemanticVersion, + pub body: PredictEditsBody, +} + +#[derive(Error, Debug)] +#[error( + "You must update to Zed version {minimum_version} or higher to continue using edit predictions." +)] +pub struct ZedUpdateRequiredError { + minimum_version: SemanticVersion, +} + struct LicenseDetectionWatcher { is_open_source_rx: watch::Receiver, _is_open_source_task: Task<()>, @@ -1406,6 +1505,10 @@ impl inline_completion::EditPredictionProvider for ZetaInlineCompletionProvider return; } + if self.zeta.read(cx).update_required { + return; + } + if let Some(current_completion) = self.current_completion.as_ref() { let snapshot = buffer.read(cx).snapshot(); if current_completion @@ -1837,7 +1940,7 @@ mod tests { }); let server = FakeServer::for_client(42, &client, cx).await; let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let zeta = cx.new(|cx| Zeta::new(client, user_store, cx)); + let zeta = cx.new(|cx| Zeta::new(None, client, user_store, cx)); let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0))); @@ -1890,7 +1993,7 @@ mod tests { }); let server = FakeServer::for_client(42, &client, cx).await; let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let zeta = cx.new(|cx| Zeta::new(client, user_store, cx)); + let zeta = cx.new(|cx| Zeta::new(None, client, user_store, cx)); let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); From 0a681225b6921397bcb628e2e1a91d9f27f3fa6c Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 12 Feb 2025 20:59:06 -0300 Subject: [PATCH 12/13] edit predictions: Enable blog post link behind a feature flag (#24720) This PR updates the blog post link in the onboarding modal to be behind the `predict-edits-launch` feature flag instead of a staff flag. This will allow us to enable the blog post link once we're live. Release Notes: - N/A --------- Co-authored-by: Marshall Bowers --- crates/feature_flags/src/feature_flags.rs | 6 ++++++ crates/zeta/src/onboarding_modal.rs | 15 ++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index a5dbef424f2be34fabd1961ac4a62d3a05bd78aa..a2864e07360f106c7312193e48c5e2491b8b1a0c 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -64,6 +64,12 @@ impl FeatureFlag for PredictEditsFeatureFlag { const NAME: &'static str = "predict-edits"; } +/// A feature flag that controls things that shouldn't go live until the predictive edits launch. +pub struct PredictEditsLaunchFeatureFlag; +impl FeatureFlag for PredictEditsLaunchFeatureFlag { + const NAME: &'static str = "predict-edits-launch"; +} + pub struct PredictEditsRateCompletionsFeatureFlag; impl FeatureFlag for PredictEditsRateCompletionsFeatureFlag { const NAME: &'static str = "predict-edits-rate-completions"; diff --git a/crates/zeta/src/onboarding_modal.rs b/crates/zeta/src/onboarding_modal.rs index 435b8be5fc95aaa9e371877d4d30ec6834dd1cbb..9d7ad41a05aa0e29769cddf9a42734cad73e80cd 100644 --- a/crates/zeta/src/onboarding_modal.rs +++ b/crates/zeta/src/onboarding_modal.rs @@ -66,7 +66,7 @@ impl ZedPredictModal { } fn view_blog(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - cx.open_url("https://zed.dev/blog/"); // TODO Add the link when live + cx.open_url("https://zed.dev/blog/edit-predictions"); cx.notify(); onboarding_event!("Blog Link clicked"); @@ -272,19 +272,16 @@ impl Render for ZedPredictModal { )), )); - let blog_post_button = if cx.is_staff() { - Some( + let blog_post_button = cx + .has_flag::() + .then(|| { Button::new("view-blog", "Read the Blog Post") .full_width() .icon(IconName::ArrowUpRight) .icon_size(IconSize::Indicator) .icon_color(Color::Muted) - .on_click(cx.listener(Self::view_blog)), - ) - } else { - // TODO: put back when blog post is published - None - }; + .on_click(cx.listener(Self::view_blog)) + }); if self.user_store.read(cx).current_user().is_some() { let copy = match self.sign_in_status { From 7ba1492f0af820eb8d558ba4c6b3c4f780be0de0 Mon Sep 17 00:00:00 2001 From: Michal Vrbata Date: Thu, 13 Feb 2025 01:36:27 +0100 Subject: [PATCH 13/13] file_icons: Add separate icon key for Bicep files (#24757) This PR adds support for bicep file icon: Release Notes: - Icon themes: Added the ability to change the file icon for Bicep (`.bicep`) files. --- assets/icons/file_icons/file_types.json | 1 + crates/theme/src/icon_theme.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/assets/icons/file_icons/file_types.json b/assets/icons/file_icons/file_types.json index 81c40e9b2fbe97c19cc71f526cdb59e9f1173be5..4dca98ba58464b5df3c60d6a1144c28fe1a3d192 100644 --- a/assets/icons/file_icons/file_types.json +++ b/assets/icons/file_icons/file_types.json @@ -18,6 +18,7 @@ "bash_logout": "terminal", "bash_profile": "terminal", "bashrc": "terminal", + "bicep": "bicep", "bmp": "image", "c": "c", "c++": "cpp", diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index b4690512c5f2a2d3fc9a78fc61fe4f7b5a35cf4d..891bf75e3525cd38c1a85e18dda37714bce6dde5 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -61,6 +61,7 @@ pub struct IconDefinition { const FILE_ICONS: &[(&str, &str)] = &[ ("astro", "icons/file_icons/astro.svg"), ("audio", "icons/file_icons/audio.svg"), + ("bicep", "icons/file_icons/file.svg"), ("bun", "icons/file_icons/bun.svg"), ("c", "icons/file_icons/c.svg"), ("code", "icons/file_icons/code.svg"),