diff --git a/Cargo.lock b/Cargo.lock index df2803c7044dd229d39de8087bfb57751de1fb1e..b02360781b6de0f0fbac0eb7e1c8496c0891a2e8 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", @@ -5367,6 +5371,8 @@ dependencies = [ "serde_json", "settings", "theme", + "time", + "time_format", "ui", "util", "windows 0.58.0", @@ -14410,6 +14416,7 @@ dependencies = [ "strum", "theme", "ui_macros", + "util", "windows 0.58.0", ] @@ -16845,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", @@ -17069,6 +17076,7 @@ dependencies = [ "postage", "project", "regex", + "release_channel", "reqwest_client", "rpc", "serde", @@ -17078,6 +17086,7 @@ dependencies = [ "telemetry", "telemetry_events", "theme", + "thiserror 1.0.69", "tree-sitter-go", "tree-sitter-rust", "ui", 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/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/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/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 2fa325b6a77576b515809ae3772ae2d3ee46ae22..7e4f72007da38e04c73a2a64a3ebdab4fecd099a 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -395,6 +395,9 @@ 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::) .add_message_handler(update_context) 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/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/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 3bb41fc27f0984f2f2740d2f7bc95ae18b91e901..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; @@ -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, }; @@ -160,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}; @@ -5656,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() } @@ -5807,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(), ); } @@ -5857,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) @@ -5885,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 { @@ -5895,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 }), @@ -12431,6 +12443,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..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, }, @@ -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); @@ -3806,39 +3808,44 @@ 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( h_flex() - .h(ACCEPT_INDICATOR_HEIGHT) - .mb(px(-1.)) - .px_1p5() - .gap_1() + .bg(cx.theme().colors().editor_background) + .border(BORDER_WIDTH) .shadow_sm() - .bg(Editor::edit_prediction_line_popover_bg_color(cx)) - .border_1() - .border_b_0() .border_color(cx.theme().colors().border) - .rounded_t_lg() - .children(editor.render_edit_prediction_accept_keybind(window, cx)), + .rounded_l_lg() + .when(line_count > 1, |el| el.rounded_br_lg()) + .pr_1() + .child(styled_text), ) .child( - div() - .bg(cx.theme().colors().editor_background) - .border_1() - .shadow_sm() + h_flex() + .h(line_height + BORDER_WIDTH * px(2.)) + .px_1p5() + .gap_1() + // 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_color(cx.theme().colors().border) - .rounded_lg() - .rounded_tr(Pixels::ZERO) - .child(styled_text), + .rounded_r_lg() + .children(editor.render_edit_prediction_accept_keybind(window, cx)), ) .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) { @@ -3869,55 +3876,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); @@ -5937,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") @@ -5987,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() @@ -6038,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, @@ -7186,7 +7195,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, @@ -7277,7 +7286,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, 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/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/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/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 e1bf864e503ef0a88091a4626d5de3f8341d90ac..ffc7450858a2a42e6f49419aaad8edcccea54571 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,13 +1,15 @@ 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; 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, @@ -18,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 { @@ -39,9 +92,10 @@ 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; /// Returns the SHA of the current HEAD. fn head_sha(&self) -> Option; @@ -56,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 @@ -128,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; @@ -161,19 +266,56 @@ 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()?; 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()) } @@ -213,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<()> { @@ -412,13 +583,22 @@ impl GitRepository for FakeGitRepository { state.head_contents.get(path.as_ref()).cloned() } - fn remote_url(&self, _name: &str) -> Option { - None + 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 branch_name(&self) -> Option { - let state = self.state.lock(); - state.current_branch_name.clone() + fn remote_url(&self, _name: &str) -> Option { + None } fn head_sha(&self) -> Option { @@ -429,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() @@ -471,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()) } @@ -641,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 c48934850e4f8337debc19c1e28553af8450614a..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<()>, @@ -69,6 +69,7 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { + workspace.open_panel::(window, cx); Self::deploy_at(workspace, None, window, cx) } @@ -136,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() = (); }, ); @@ -155,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, @@ -164,7 +165,7 @@ impl ProjectDiff { pending_scroll: None, update_needed: send, _task: worker, - _subscription: git_state_subscription, + _subscription: git_store_subscription, } } @@ -174,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); @@ -247,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/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..7654b6902978cce1597e1b36f3b72449f65b1bb7 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); @@ -2721,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(), @@ -2771,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 6385025ff5c72a1e099ffa6522a171cbfd2c47ba..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 { - project_id: Option, - client: Option, +pub struct GitStore { + pub(super) project_id: Option, + pub(super) client: Option, + buffer_store: Entity, repositories: Vec>, active_index: Option, - update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender>)>, + 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)] @@ -51,14 +54,20 @@ pub enum GitRepo { }, } -enum Message { +pub enum Message { Commit { git_repo: GitRepo, 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), } pub enum GitEvent { @@ -67,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>, @@ -79,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, @@ -89,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()) @@ -152,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, @@ -188,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 @@ -205,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 { @@ -232,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)?, @@ -291,16 +341,236 @@ 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, + }), + }, } } + + 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_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() } @@ -322,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); }); } @@ -374,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)); } @@ -422,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))? @@ -442,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(); @@ -454,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(); @@ -466,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() @@ -476,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() @@ -508,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(( @@ -522,4 +842,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..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::{ @@ -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}; @@ -158,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, @@ -607,14 +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_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); @@ -701,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(); @@ -714,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, @@ -821,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, @@ -842,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), @@ -892,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); @@ -905,6 +907,7 @@ impl Project { SettingsObserver::init(&ssh_proto); TaskStore::init(Some(&ssh_proto)); ToolchainStore::init(&ssh_proto); + GitStore::init(&ssh_proto); this }) @@ -1026,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, @@ -1085,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 { @@ -1483,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() } @@ -1656,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| { @@ -4019,121 +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_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, @@ -4325,16 +4232,37 @@ 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( + &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_store + .read(cx) + .all_repositories() + .into_iter() + .find_map(|repo| { + Some(( + repo.clone(), + repo.read(cx).repository_entry.relativize(&path.path).ok()?, + )) + }) } } 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 7d33dd1a3e842d100c4e8496f294ba6006a4a307..c86459f2cd9417f57b01123cfe74a64ffe66ffa2 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -315,7 +315,12 @@ 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; + GitShow git_show = 300; + GitReset git_reset = 301; + GitCommitDetails git_commit_details = 302; + + SetIndexText set_index_text = 299; // current max } reserved 87 to 88; @@ -1798,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. @@ -2087,6 +2094,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; } @@ -2605,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 { @@ -2629,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 d45cc0936cf399c86ff59eed9f7db728213ffa0a..0743e92da685a5c9d20eaea9281e838db8be611f 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -440,6 +440,10 @@ messages!( (SyncExtensionsResponse, Background), (InstallExtension, Background), (RegisterBufferWithLanguageServers, Background), + (GitReset, Background), + (GitShow, Background), + (GitCommitDetails, Background), + (SetIndexText, Background), ); request_messages!( @@ -573,6 +577,9 @@ request_messages!( (SyncExtensions, SyncExtensionsResponse), (InstallExtension, Ack), (RegisterBufferWithLanguageServers, Ack), + (GitShow, GitCommitDetails), + (GitReset, Ack), + (SetIndexText, Ack), ); entity_messages!( @@ -665,6 +672,9 @@ entity_messages!( GetPathMetadata, CancelLanguageServerWork, RegisterBufferWithLanguageServers, + GitShow, + GitReset, + SetIndexText, ); entity_messages!( diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index eb4122a321dac05d6fa38cf26074bc047c2ec346..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,11 +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_open_commit_message_buffer); - client.add_request_handler( extensions.clone().downgrade(), HeadlessExtensionStore::handle_sync_extensions, @@ -217,6 +212,7 @@ impl HeadlessProject { LspStore::init(&client); TaskStore::init(Some(&client)); ToolchainStore::init(&client); + GitStore::init(&client); HeadlessProject { session: client, @@ -229,7 +225,7 @@ impl HeadlessProject { next_entry_id: Default::default(), languages, extensions, - git_state, + git_store, } } @@ -615,137 +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_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/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, 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"), 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/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 4391c9061bae5dc2fb47d47618f60010ab187414..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(), }) } @@ -230,7 +243,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() @@ -387,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); @@ -395,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()); 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..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. @@ -1335,11 +1392,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) } @@ -2630,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 }); @@ -2652,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, }, @@ -3454,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(), }, @@ -4203,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) = @@ -4232,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 @@ -4241,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) + } }; } @@ -4290,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; @@ -4708,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, }); } @@ -5260,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( @@ -5278,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. @@ -5324,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(); }); @@ -5348,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(), @@ -5374,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 { @@ -5408,6 +5465,7 @@ impl BackgroundScanner { } repository.statuses_by_path = new_entries_by_path; + let mut state = self.state.lock(); state .snapshot @@ -5433,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( @@ -5642,7 +5707,7 @@ struct UpdateIgnoreStatusJob { scan_queue: Sender, } -struct UpdateGitStatusesJob { +struct UpdateGitRepoJob { local_repository: LocalRepositoryEntry, } 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/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 { 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()); 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