From 765ed65e8864d6d47b0b0a8774c694af03075a3c Mon Sep 17 00:00:00 2001 From: Anthony Date: Thu, 5 Jun 2025 17:34:17 -0400 Subject: [PATCH 01/24] Start work on optomizing tab map We currently iterate over each character when looking for tab bytes even though chunks keeps a bitmask that represents each tab position. This commit is the first step in using the bitmask Co-authored-by: Remco Smits Co-authored-by: Cole Miller --- crates/editor/src/display_map/fold_map.rs | 12 + crates/editor/src/display_map/tab_map.rs | 256 ++++++++++++++++++---- crates/editor/src/element.rs | 1 + crates/language/src/buffer.rs | 8 +- crates/language/src/buffer_tests.rs | 22 ++ crates/rope/src/chunk.rs | 2 +- crates/rope/src/rope.rs | 25 +++ 7 files changed, 284 insertions(+), 42 deletions(-) diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index 0011f07fea..2abc07464c 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -863,6 +863,14 @@ impl FoldSnapshot { .flat_map(|chunk| chunk.text.chars()) } + pub fn chunks_at(&self, start: FoldPoint) -> FoldChunks<'_> { + self.chunks( + start.to_offset(self)..self.len(), + false, + Highlights::default(), + ) + } + #[cfg(test)] pub fn clip_offset(&self, offset: FoldOffset, bias: Bias) -> FoldOffset { if offset > self.len() { @@ -1263,6 +1271,8 @@ pub struct Chunk<'a> { pub is_inlay: bool, /// An optional recipe for how the chunk should be presented. pub renderer: Option, + /// The location of tab characters in the chunk. + pub tabs: u128, } /// A recipe for how the chunk should be presented. @@ -1410,6 +1420,7 @@ impl<'a> Iterator for FoldChunks<'a> { chunk.text = &chunk.text [(self.inlay_offset - buffer_chunk_start).0..(chunk_end - buffer_chunk_start).0]; + chunk.tabs = chunk.tabs >> (self.inlay_offset - buffer_chunk_start).0; if chunk_end == transform_end { self.transform_cursor.next(&()); @@ -1421,6 +1432,7 @@ impl<'a> Iterator for FoldChunks<'a> { self.output_offset.0 += chunk.text.len(); return Some(Chunk { text: chunk.text, + tabs: chunk.tabs, syntax_highlight_id: chunk.syntax_highlight_id, highlight_style: chunk.highlight_style, diagnostic_severity: chunk.diagnostic_severity, diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index eb5d57d484..d344db371e 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -305,10 +305,13 @@ impl TabSnapshot { } pub fn to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) { - let chars = self.fold_snapshot.chars_at(FoldPoint::new(output.row(), 0)); + let chunks = self + .fold_snapshot + .chunks_at(FoldPoint::new(output.row(), 0)); + let tab_cursor = TabStopCursor::new(chunks); let expanded = output.column(); let (collapsed, expanded_char_column, to_next_stop) = - self.collapse_tabs(chars, expanded, bias); + self.collapse_tabs(tab_cursor, expanded, bias); ( FoldPoint::new(output.row(), collapsed), expanded_char_column, @@ -354,53 +357,89 @@ impl TabSnapshot { expanded_bytes + column.saturating_sub(collapsed_bytes) } - fn collapse_tabs( - &self, - chars: impl Iterator, - column: u32, - bias: Bias, - ) -> (u32, u32, u32) { + fn collapse_tabs(&self, mut cursor: TabStopCursor, column: u32, bias: Bias) -> (u32, u32, u32) { let tab_size = self.tab_size.get(); + let mut collapsed_column = column; + let mut tab_count = 0; + let mut expanded_tab_len = 0; + while let Some(tab_stop) = cursor.next(collapsed_column) { + // Calculate how much we want to expand this tab stop (into spaces) + let mut expanded_chars = tab_stop.char_offset - tab_count + expanded_tab_len; + let tab_len = tab_size - (expanded_chars % tab_size); + // Increment tab count + tab_count += 1; + // The count of how many spaces we've added to this line in place of tab bytes + expanded_tab_len += tab_len; - let mut expanded_bytes = 0; - let mut expanded_chars = 0; - let mut collapsed_bytes = 0; - for c in chars { - if expanded_bytes >= column { - break; - } - if collapsed_bytes >= self.max_expansion_column { - break; - } + // The count of bytes at this point in the iteration while considering tab_count and previous expansions + let expanded_bytes = tab_stop.byte_offset - tab_count + expanded_tab_len; - if c == '\t' { - let tab_len = tab_size - (expanded_chars % tab_size); - expanded_chars += tab_len; - expanded_bytes += tab_len; - if expanded_bytes > column { - expanded_chars -= expanded_bytes - column; - return match bias { - Bias::Left => (collapsed_bytes, expanded_chars, expanded_bytes - column), - Bias::Right => (collapsed_bytes + 1, expanded_chars, 0), - }; - } + // Did we expand past the search target? + if expanded_bytes > column { + // We expanded past the search target, so need to calculate the offshoot + expanded_chars -= expanded_bytes - column; + return match bias { + Bias::Left => ( + cursor.byte_offset(), + expanded_chars, + expanded_bytes - column, + ), + Bias::Right => (cursor.byte_offset() + 1, expanded_chars, 0), + }; } else { - expanded_chars += 1; - expanded_bytes += c.len_utf8() as u32; + // otherwise we only want to move the cursor collapse column forward + collapsed_column = collapsed_column - tab_len + 1; } - - if expanded_bytes > column && matches!(bias, Bias::Left) { - expanded_chars -= 1; - break; - } - - collapsed_bytes += c.len_utf8() as u32; } + + let collapsed_bytes = cursor.byte_offset(); + let expanded_bytes = cursor.byte_offset() - tab_count + expanded_tab_len; + // let expanded_chars = cursor.char_offset() - tab_count + expanded_tab_len; ( collapsed_bytes + column.saturating_sub(expanded_bytes), - expanded_chars, + expanded_bytes, 0, ) + + // let mut expanded_bytes = 0; + // let mut expanded_chars = 0; + // let mut collapsed_bytes = 0; + // for c in chars { + // if expanded_bytes >= column { + // break; + // } + // if collapsed_bytes >= self.max_expansion_column { + // break; + // } + + // if c == '\t' { + // let tab_len = tab_size - (expanded_chars % tab_size); + // expanded_chars += tab_len; + // expanded_bytes += tab_len; + // if expanded_bytes > column { + // expanded_chars -= expanded_bytes - column; + // return match bias { + // Bias::Left => (collapsed_bytes, expanded_chars, expanded_bytes - column), + // Bias::Right => (collapsed_bytes + 1, expanded_chars, 0), + // }; + // } + // } else { + // expanded_chars += 1; + // expanded_bytes += c.len_utf8() as u32; + // } + + // if expanded_bytes > column && matches!(bias, Bias::Left) { + // expanded_chars -= 1; + // break; + // } + + // collapsed_bytes += c.len_utf8() as u32; + // } + // ( + // collapsed_bytes + column.saturating_sub(expanded_bytes), + // expanded_chars, + // 0, + // ) } } @@ -603,7 +642,10 @@ mod tests { use super::*; use crate::{ MultiBuffer, - display_map::{fold_map::FoldMap, inlay_map::InlayMap}, + display_map::{ + fold_map::{FoldMap, FoldOffset}, + inlay_map::InlayMap, + }, }; use rand::{Rng, prelude::StdRng}; @@ -811,4 +853,138 @@ mod tests { ); } } + + #[gpui::test] + fn test_tab_stop_cursor(cx: &mut gpui::App) { + let text = "\tfoo\tbarbarbar\t\tbaz\n"; + let buffer = MultiBuffer::build_simple(text, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let chunks = fold_snapshot.chunks( + FoldOffset(0)..fold_snapshot.len(), + false, + Default::default(), + ); + let mut cursor = TabStopCursor::new(chunks); + let mut tab_stops = Vec::new(); + while let Some(tab_stop) = cursor.next(u32::MAX) { + tab_stops.push(tab_stop); + } + assert_eq!( + &[ + TabStop { + byte_offset: 1, + char_offset: 1 + }, + TabStop { + byte_offset: 5, + char_offset: 5 + }, + TabStop { + byte_offset: 15, + char_offset: 15, + }, + TabStop { + byte_offset: 16, + char_offset: 16, + }, + ], + tab_stops.as_slice(), + ); + + assert_eq!(cursor.byte_offset(), 16); + } +} + +struct TabStopCursor<'a> { + chunks: FoldChunks<'a>, + distance_traveled: u32, + bytes_offset: u32, + /// Chunk + /// last tab position iterated through + current_chunk: Option<(Chunk<'a>, u32)>, +} + +impl<'a> TabStopCursor<'a> { + fn new(chunks: FoldChunks<'a>) -> Self { + Self { + chunks, + distance_traveled: 0, + bytes_offset: 0, + current_chunk: None, + } + } + + /// distance: length to move forward while searching for the next tab stop + fn next(&mut self, distance: u32) -> Option { + if let Some((mut chunk, past_tab_position)) = self.current_chunk.take() { + let tab_position = chunk.tabs.trailing_zeros() + 1; + + if self.distance_traveled + tab_position > distance { + self.bytes_offset += distance; + return None; + } + self.bytes_offset += tab_position - past_tab_position; + + let tabstop = TabStop { + char_offset: self.bytes_offset, + byte_offset: self.bytes_offset, + }; + + self.distance_traveled += tab_position; + + chunk.tabs = (chunk.tabs - 1) & chunk.tabs; + if chunk.tabs > 0 { + self.current_chunk = Some((chunk, tab_position)); + } + + return Some(tabstop); + } + + while let Some(mut chunk) = self.chunks.next() { + if chunk.tabs == 0 { + self.distance_traveled += chunk.text.len() as u32; + if self.distance_traveled > distance { + self.bytes_offset += distance; + return None; + } + continue; + } + + let tab_position = chunk.tabs.trailing_zeros() + 1; + + if self.distance_traveled + tab_position > distance { + self.bytes_offset += distance; + return None; + } + self.bytes_offset += tab_position; + + let tabstop = TabStop { + char_offset: self.bytes_offset, + byte_offset: self.bytes_offset, + }; + + self.distance_traveled += tab_position; + + chunk.tabs = (chunk.tabs - 1) & chunk.tabs; + if chunk.tabs > 0 { + self.current_chunk = Some((chunk, tab_position)); + } + + return Some(tabstop); + } + + None + } + + fn byte_offset(&self) -> u32 { + self.bytes_offset + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TabStop { + char_offset: u32, + byte_offset: u32, } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index abe0f6aa7b..0af1b1e9f2 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -278,6 +278,7 @@ impl EditorElement { if text.is_empty() { return; } + dbg!("Handle input text:", text); editor.handle_input(text, window, cx); }, ); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 8c02eb5b44..4173d13fbc 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -28,6 +28,7 @@ use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, HighlightStyle, SharedString, StyledText, Task, TaskLabel, TextStyle, }; + use lsp::{LanguageServerId, NumberOrString}; use parking_lot::Mutex; use schemars::JsonSchema; @@ -485,6 +486,8 @@ pub struct Chunk<'a> { pub is_unnecessary: bool, /// Whether this chunk of text was originally a tab character. pub is_tab: bool, + /// A bitset of which characters are tabs in this string. + pub tabs: u128, /// Whether this chunk of text was originally a tab character. pub is_inlay: bool, /// Whether to underline the corresponding text range in the editor. @@ -4579,7 +4582,7 @@ impl<'a> Iterator for BufferChunks<'a> { } self.diagnostic_endpoints = diagnostic_endpoints; - if let Some(chunk) = self.chunks.peek() { + if let Some((chunk, tabs)) = self.chunks.peek_tabs() { let chunk_start = self.range.start; let mut chunk_end = (self.chunks.offset() + chunk.len()) .min(next_capture_start) @@ -4594,6 +4597,8 @@ impl<'a> Iterator for BufferChunks<'a> { let slice = &chunk[chunk_start - self.chunks.offset()..chunk_end - self.chunks.offset()]; + let tabs = tabs >> (chunk_start - self.chunks.offset()); + self.range.start = chunk_end; if self.range.start == self.chunks.offset() + chunk.len() { self.chunks.next().unwrap(); @@ -4605,6 +4610,7 @@ impl<'a> Iterator for BufferChunks<'a> { underline: self.underline, diagnostic_severity: self.current_diagnostic_severity(), is_unnecessary: self.current_code_is_unnecessary(), + tabs, ..Chunk::default() }) } else { diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index ebf7558abb..2bfae50012 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -3279,6 +3279,28 @@ fn test_contiguous_ranges() { ); } +#[test] +fn test_buffer_chunks_tabs() { + let buffer = text::Buffer::new(0, BufferId::new(1).unwrap(), "\ta\tbc"); + let mut iter = buffer.as_rope().chunks(); + + while let Some((str, tabs)) = iter.peek_tabs() { + dbg!(str, format!("{:b}", tabs)); + iter.next(); + } + dbg!("---"); + + let buffer = text::Buffer::new(0, BufferId::new(1).unwrap(), "\ta\tbc"); + let mut iter = buffer.as_rope().chunks(); + iter.seek(3); + + while let Some((str, tabs)) = iter.peek_tabs() { + dbg!(str, format!("{:b}", tabs)); + iter.next(); + } + assert!(false) +} + #[gpui::test(iterations = 500)] fn test_trailing_whitespace_ranges(mut rng: StdRng) { // Generate a random multi-line string containing diff --git a/crates/rope/src/chunk.rs b/crates/rope/src/chunk.rs index 5b0a671d86..5f7b1a7ea0 100644 --- a/crates/rope/src/chunk.rs +++ b/crates/rope/src/chunk.rs @@ -13,7 +13,7 @@ pub struct Chunk { chars: u128, chars_utf16: u128, newlines: u128, - tabs: u128, + pub tabs: u128, pub text: ArrayString, } diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index b049498ccb..c82f6beaf9 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -772,6 +772,31 @@ impl<'a> Chunks<'a> { Some(&chunk.text[slice_range]) } + pub fn peek_tabs(&self) -> Option<(&'a str, u128)> { + if !self.offset_is_valid() { + return None; + } + + let chunk = self.chunks.item()?; + let chunk_start = *self.chunks.start(); + let slice_range = if self.reversed { + let slice_start = cmp::max(chunk_start, self.range.start) - chunk_start; + let slice_end = self.offset - chunk_start; + slice_start..slice_end + } else { + let slice_start = self.offset - chunk_start; + let slice_end = cmp::min(self.chunks.end(&()), self.range.end) - chunk_start; + slice_start..slice_end + }; + let chunk_start_offset = slice_range.start; + let slice_text = &chunk.text[slice_range]; + + // Shift the tabs to align with our slice window + let shifted_tabs = chunk.tabs >> chunk_start_offset; + + Some((slice_text, shifted_tabs)) + } + pub fn lines(self) -> Lines<'a> { let reversed = self.reversed; Lines { From ae713d831eb237244af9edbe69161751356ad58b Mon Sep 17 00:00:00 2001 From: Anthony Date: Fri, 6 Jun 2025 09:11:58 -0400 Subject: [PATCH 02/24] more wip --- crates/editor/src/display_map/tab_map.rs | 218 +++++++++++++++++++++-- 1 file changed, 201 insertions(+), 17 deletions(-) diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index d344db371e..ab16f3b049 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -362,14 +362,15 @@ impl TabSnapshot { let mut collapsed_column = column; let mut tab_count = 0; let mut expanded_tab_len = 0; + dbg!(collapsed_column); while let Some(tab_stop) = cursor.next(collapsed_column) { // Calculate how much we want to expand this tab stop (into spaces) let mut expanded_chars = tab_stop.char_offset - tab_count + expanded_tab_len; - let tab_len = tab_size - (expanded_chars % tab_size); + let tab_len = tab_size - ((expanded_chars - 1) % tab_size); // Increment tab count tab_count += 1; // The count of how many spaces we've added to this line in place of tab bytes - expanded_tab_len += tab_len; + expanded_tab_len += dbg!(tab_len); // The count of bytes at this point in the iteration while considering tab_count and previous expansions let expanded_bytes = tab_stop.byte_offset - tab_count + expanded_tab_len; @@ -378,6 +379,7 @@ impl TabSnapshot { if expanded_bytes > column { // We expanded past the search target, so need to calculate the offshoot expanded_chars -= expanded_bytes - column; + dbg!(expanded_bytes); return match bias { Bias::Left => ( cursor.byte_offset(), @@ -392,8 +394,10 @@ impl TabSnapshot { } } + dbg!(tab_count, expanded_tab_len); let collapsed_bytes = cursor.byte_offset(); let expanded_bytes = cursor.byte_offset() - tab_count + expanded_tab_len; + dbg!(collapsed_bytes, expanded_bytes, column); // let expanded_chars = cursor.char_offset() - tab_count + expanded_tab_len; ( collapsed_bytes + column.saturating_sub(expanded_bytes), @@ -649,6 +653,84 @@ mod tests { }; use rand::{Rng, prelude::StdRng}; + impl TabSnapshot { + fn test_collapse_tabs( + &self, + chars: impl Iterator, + column: u32, + bias: Bias, + ) -> (u32, u32, u32) { + let tab_size = self.tab_size.get(); + + let mut expanded_bytes = 0; + let mut expanded_chars = 0; + let mut collapsed_bytes = 0; + let mut expanded_tab_len = 0; + for (i, c) in chars.enumerate() { + if expanded_bytes >= column { + break; + } + if collapsed_bytes >= self.max_expansion_column { + break; + } + + if c == '\t' { + let tab_len = tab_size - (expanded_chars % tab_size); + dbg!(tab_len); + expanded_tab_len += tab_len; + expanded_chars += tab_len; + expanded_bytes += tab_len; + if expanded_bytes > column { + expanded_chars -= expanded_bytes - column; + dbg!(expanded_bytes); + return match bias { + Bias::Left => { + (collapsed_bytes, expanded_chars, expanded_bytes - column) + } + Bias::Right => (collapsed_bytes + 1, expanded_chars, 0), + }; + } + } else { + expanded_chars += 1; + expanded_bytes += c.len_utf8() as u32; + } + + if expanded_bytes > column && matches!(bias, Bias::Left) { + expanded_chars -= 1; + break; + } + + collapsed_bytes += c.len_utf8() as u32; + } + dbg!( + collapsed_bytes, + column, + expanded_bytes, + expanded_chars, + expanded_tab_len + ); + dbg!("expected\n---------------\n"); + + ( + collapsed_bytes + column.saturating_sub(expanded_bytes), + expanded_chars, + 0, + ) + } + + fn test_to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) { + let chars = self.fold_snapshot.chars_at(FoldPoint::new(output.row(), 0)); + let expanded = output.column(); + let (collapsed, expanded_char_column, to_next_stop) = + self.test_collapse_tabs(chars, expanded, bias); + ( + FoldPoint::new(output.row(), collapsed), + expanded_char_column, + to_next_stop, + ) + } + } + #[gpui::test] fn test_expand_tabs(cx: &mut gpui::App) { let buffer = MultiBuffer::build_simple("", cx); @@ -662,6 +744,37 @@ mod tests { assert_eq!(tab_snapshot.expand_tabs("\ta".chars(), 2), 5); } + #[gpui::test] + fn test_collapse_tabs(cx: &mut gpui::App) { + let input = "A\tBC\tDEF\tG\tHI\tJ\tK\tL\tM"; + + let buffer = MultiBuffer::build_simple(input, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, mut tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + + let range = TabPoint::zero()..tab_snapshot.max_point(); + + assert_eq!( + tab_snapshot.test_to_fold_point(range.start, Bias::Left), + tab_snapshot.to_fold_point(range.start, Bias::Left) + ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.start, Bias::Right), + tab_snapshot.to_fold_point(range.start, Bias::Right) + ); + + assert_eq!( + tab_snapshot.test_to_fold_point(range.end, Bias::Left), + tab_snapshot.to_fold_point(range.end, Bias::Left) + ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.end, Bias::Right), + tab_snapshot.to_fold_point(range.end, Bias::Right) + ); + } + #[gpui::test] fn test_long_lines(cx: &mut gpui::App) { let max_expansion_column = 12; @@ -895,6 +1008,62 @@ mod tests { assert_eq!(cursor.byte_offset(), 16); } + + #[gpui::test] + fn test_tab_stop_with_end_range(cx: &mut gpui::App) { + let input = "A\tBC\tDEF\tG\tHI\tJ\tK\tL\tM"; + + let buffer = MultiBuffer::build_simple(input, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + + let chunks = fold_snapshot.chunks_at(FoldPoint::new(0, 0)); + let mut cursor = TabStopCursor::new(chunks); + let mut tab_stops = Vec::new(); + while let Some(tab_stop) = cursor.next(33) { + tab_stops.push(tab_stop); + } + pretty_assertions::assert_eq!( + &[ + TabStop { + byte_offset: 2, + char_offset: 2 + }, + TabStop { + byte_offset: 5, + char_offset: 5 + }, + TabStop { + byte_offset: 9, + char_offset: 9, + }, + TabStop { + byte_offset: 11, + char_offset: 11, + }, + TabStop { + byte_offset: 14, + char_offset: 14 + }, + TabStop { + byte_offset: 16, + char_offset: 16, + }, + TabStop { + byte_offset: 18, + char_offset: 18 + }, + TabStop { + byte_offset: 20, + char_offset: 20, + }, + ], + tab_stops.as_slice(), + ); + + assert_eq!(cursor.byte_offset(), 21); + } } struct TabStopCursor<'a> { @@ -904,6 +1073,7 @@ struct TabStopCursor<'a> { /// Chunk /// last tab position iterated through current_chunk: Option<(Chunk<'a>, u32)>, + end_of_chunk: Option, } impl<'a> TabStopCursor<'a> { @@ -912,6 +1082,7 @@ impl<'a> TabStopCursor<'a> { chunks, distance_traveled: 0, bytes_offset: 0, + end_of_chunk: None, current_chunk: None, } } @@ -921,25 +1092,38 @@ impl<'a> TabStopCursor<'a> { if let Some((mut chunk, past_tab_position)) = self.current_chunk.take() { let tab_position = chunk.tabs.trailing_zeros() + 1; - if self.distance_traveled + tab_position > distance { + if self.distance_traveled + tab_position - past_tab_position > distance { self.bytes_offset += distance; - return None; + } else { + self.bytes_offset += tab_position - past_tab_position; + + let tabstop = TabStop { + char_offset: self.bytes_offset, + byte_offset: self.bytes_offset, + }; + + self.distance_traveled += tab_position - past_tab_position; + + chunk.tabs = (chunk.tabs - 1) & chunk.tabs; + if chunk.tabs > 0 { + self.current_chunk = Some((chunk, tab_position)); + } else { + self.end_of_chunk = Some(chunk.text.len() as u32 - tab_position); + } + + return Some(tabstop); } - self.bytes_offset += tab_position - past_tab_position; + } - let tabstop = TabStop { - char_offset: self.bytes_offset, - byte_offset: self.bytes_offset, - }; + let past_chunk = self.end_of_chunk.take().unwrap_or_default(); - self.distance_traveled += tab_position; - - chunk.tabs = (chunk.tabs - 1) & chunk.tabs; - if chunk.tabs > 0 { - self.current_chunk = Some((chunk, tab_position)); - } - - return Some(tabstop); + if self.distance_traveled + past_chunk > distance { + let overshoot = self.distance_traveled + past_chunk - distance; + self.bytes_offset += past_chunk - overshoot; + self.distance_traveled += past_chunk - overshoot; + } else { + self.bytes_offset += past_chunk; + self.distance_traveled += past_chunk; } while let Some(mut chunk) = self.chunks.next() { From 516212a072e0323f1a33bccbb023362ccd2df0a0 Mon Sep 17 00:00:00 2001 From: Anthony Date: Fri, 6 Jun 2025 13:01:24 -0400 Subject: [PATCH 03/24] add tests --- crates/editor/src/display_map/tab_map.rs | 254 +++++++++++++++++------ 1 file changed, 185 insertions(+), 69 deletions(-) diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index ab16f3b049..e515c89af1 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -6,6 +6,7 @@ use language::Point; use multi_buffer::MultiBufferSnapshot; use std::{cmp, mem, num::NonZeroU32, ops::Range}; use sum_tree::Bias; +use util::debug_panic; const MAX_EXPANSION_COLUMN: u32 = 256; @@ -72,6 +73,7 @@ impl TabMap { false, Highlights::default(), ) { + // todo!(performance use tabs bitmask) for (ix, _) in chunk.text.match_indices('\t') { let offset_from_edit = offset_from_edit + (ix as u32); if first_tab_offset.is_none() { @@ -333,6 +335,7 @@ impl TabSnapshot { .to_buffer_point(inlay_point) } + /// todo!(performance use tabs bitmask) fn expand_tabs(&self, chars: impl Iterator, column: u32) -> u32 { let tab_size = self.tab_size.get(); @@ -363,7 +366,7 @@ impl TabSnapshot { let mut tab_count = 0; let mut expanded_tab_len = 0; dbg!(collapsed_column); - while let Some(tab_stop) = cursor.next(collapsed_column) { + while let Some(tab_stop) = cursor.seek(collapsed_column) { // Calculate how much we want to expand this tab stop (into spaces) let mut expanded_chars = tab_stop.char_offset - tab_count + expanded_tab_len; let tab_len = tab_size - ((expanded_chars - 1) % tab_size); @@ -377,7 +380,7 @@ impl TabSnapshot { // Did we expand past the search target? if expanded_bytes > column { - // We expanded past the search target, so need to calculate the offshoot + // We expanded past the search target, so need to account for the offshoot expanded_chars -= expanded_bytes - column; dbg!(expanded_bytes); return match bias { @@ -404,46 +407,6 @@ impl TabSnapshot { expanded_bytes, 0, ) - - // let mut expanded_bytes = 0; - // let mut expanded_chars = 0; - // let mut collapsed_bytes = 0; - // for c in chars { - // if expanded_bytes >= column { - // break; - // } - // if collapsed_bytes >= self.max_expansion_column { - // break; - // } - - // if c == '\t' { - // let tab_len = tab_size - (expanded_chars % tab_size); - // expanded_chars += tab_len; - // expanded_bytes += tab_len; - // if expanded_bytes > column { - // expanded_chars -= expanded_bytes - column; - // return match bias { - // Bias::Left => (collapsed_bytes, expanded_chars, expanded_bytes - column), - // Bias::Right => (collapsed_bytes + 1, expanded_chars, 0), - // }; - // } - // } else { - // expanded_chars += 1; - // expanded_bytes += c.len_utf8() as u32; - // } - - // if expanded_bytes > column && matches!(bias, Bias::Left) { - // expanded_chars -= 1; - // break; - // } - - // collapsed_bytes += c.len_utf8() as u32; - // } - // ( - // collapsed_bytes + column.saturating_sub(expanded_bytes), - // expanded_chars, - // 0, - // ) } } @@ -652,6 +615,7 @@ mod tests { }, }; use rand::{Rng, prelude::StdRng}; + use util; impl TabSnapshot { fn test_collapse_tabs( @@ -752,7 +716,7 @@ mod tests { let buffer_snapshot = buffer.read(cx).snapshot(cx); let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); - let (_, mut tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); let range = TabPoint::zero()..tab_snapshot.max_point(); @@ -775,6 +739,65 @@ mod tests { ); } + #[gpui::test(iterations = 100)] + fn test_collapse_tabs_random(cx: &mut gpui::App, mut rng: StdRng) { + // Generate random input string with up to 200 characters including tabs + // to stay within the MAX_EXPANSION_COLUMN limit of 256 + let len = rng.gen_range(0..=200); + let mut input = String::with_capacity(len); + + for _ in 0..len { + if rng.gen_bool(0.1) { + // 10% chance of inserting a tab + input.push('\t'); + } else { + // 90% chance of inserting a random ASCII character (excluding tab, newline, carriage return) + let ch = loop { + let ascii_code = rng.gen_range(32..=126); // printable ASCII range + let ch = ascii_code as u8 as char; + if ch != '\t' && ch != '\n' && ch != '\r' { + break ch; + } + }; + input.push(ch); + } + } + + let buffer = MultiBuffer::build_simple(&input, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + + let range = TabPoint::zero()..tab_snapshot.max_point(); + + assert_eq!( + tab_snapshot.test_to_fold_point(range.start, Bias::Left), + tab_snapshot.to_fold_point(range.start, Bias::Left), + "Failed with input: {}", + input + ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.start, Bias::Right), + tab_snapshot.to_fold_point(range.start, Bias::Right), + "Failed with input: {}", + input + ); + + assert_eq!( + tab_snapshot.test_to_fold_point(range.end, Bias::Left), + tab_snapshot.to_fold_point(range.end, Bias::Left), + "Failed with input: {}", + input + ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.end, Bias::Right), + tab_snapshot.to_fold_point(range.end, Bias::Right), + "Failed with input: {}", + input + ); + } + #[gpui::test] fn test_long_lines(cx: &mut gpui::App) { let max_expansion_column = 12; @@ -981,10 +1004,11 @@ mod tests { ); let mut cursor = TabStopCursor::new(chunks); let mut tab_stops = Vec::new(); - while let Some(tab_stop) = cursor.next(u32::MAX) { + while let Some(tab_stop) = cursor.seek(u32::MAX) { tab_stops.push(tab_stop); } - assert_eq!( + pretty_assertions::assert_eq!( + tab_stops.as_slice(), &[ TabStop { byte_offset: 1, @@ -1003,10 +1027,9 @@ mod tests { char_offset: 16, }, ], - tab_stops.as_slice(), ); - assert_eq!(cursor.byte_offset(), 16); + assert_eq!(cursor.byte_offset(), 20); } #[gpui::test] @@ -1021,7 +1044,7 @@ mod tests { let chunks = fold_snapshot.chunks_at(FoldPoint::new(0, 0)); let mut cursor = TabStopCursor::new(chunks); let mut tab_stops = Vec::new(); - while let Some(tab_stop) = cursor.next(33) { + while let Some(tab_stop) = cursor.seek(33) { tab_stops.push(tab_stop); } pretty_assertions::assert_eq!( @@ -1064,11 +1087,97 @@ mod tests { assert_eq!(cursor.byte_offset(), 21); } + + #[gpui::test(iterations = 100)] + fn test_tab_stop_cursor_random(cx: &mut gpui::App, mut rng: StdRng) { + // Generate random input string with up to 512 characters including tabs + let len = rng.gen_range(0..=2048); + let mut input = String::with_capacity(len); + + for _ in 0..len { + if rng.gen_bool(0.15) { + // 15% chance of inserting a tab + input.push('\t'); + } else { + // 85% chance of inserting a random ASCII character (excluding tab, newline, carriage return) + let ch = loop { + let ascii_code = rng.gen_range(32..=126); // printable ASCII range + let ch = ascii_code as u8 as char; + if ch != '\t' && ch != '\n' && ch != '\r' { + break ch; + } + }; + input.push(ch); + } + } + + // Build the buffer and create cursor + let buffer = MultiBuffer::build_simple(&input, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + + // First, collect all expected tab positions + let mut all_tab_stops = Vec::new(); + let mut byte_offset = 1; + let mut char_offset = 1; + for ch in input.chars() { + if ch == '\t' { + all_tab_stops.push(TabStop { + byte_offset, + char_offset, + }); + } + // byte_offset += ch.len_utf8(); + byte_offset += 1; + char_offset += 1; + } + + // Test with various distances + let distances = vec![1, 5, 10, 50, 100, u32::MAX]; + + for distance in distances { + let chunks = fold_snapshot.chunks_at(FoldPoint::new(0, 0)); + let mut cursor = TabStopCursor::new(chunks); + + let mut found_tab_stops = Vec::new(); + let mut position = distance; + while let Some(tab_stop) = cursor.seek(position) { + found_tab_stops.push(tab_stop); + position = distance - tab_stop.byte_offset; + } + + let expected_found_tab_stops: Vec<_> = all_tab_stops + .iter() + .take_while(|tab_stop| tab_stop.byte_offset <= distance) + .cloned() + .collect(); + + pretty_assertions::assert_eq!( + found_tab_stops, + expected_found_tab_stops, + "TabStopCursor output mismatch for distance {}. Input: {:?}", + distance, + input + ); + + let final_position = cursor.byte_offset(); + if !found_tab_stops.is_empty() { + let last_tab_stop = found_tab_stops.last().unwrap(); + assert!( + final_position >= last_tab_stop.byte_offset, + "Cursor final position {} is before last tab stop {}. Input: {:?}", + final_position, + last_tab_stop.byte_offset, + input + ); + } + } + } } struct TabStopCursor<'a> { chunks: FoldChunks<'a>, - distance_traveled: u32, bytes_offset: u32, /// Chunk /// last tab position iterated through @@ -1080,7 +1189,6 @@ impl<'a> TabStopCursor<'a> { fn new(chunks: FoldChunks<'a>) -> Self { Self { chunks, - distance_traveled: 0, bytes_offset: 0, end_of_chunk: None, current_chunk: None, @@ -1088,49 +1196,55 @@ impl<'a> TabStopCursor<'a> { } /// distance: length to move forward while searching for the next tab stop - fn next(&mut self, distance: u32) -> Option { - if let Some((mut chunk, past_tab_position)) = self.current_chunk.take() { + fn seek(&mut self, distance: u32) -> Option { + if distance <= 0 { + debug_assert!(distance == 0, "Can't seek backwards: {distance}"); + return None; + } + if let Some((mut chunk, chunk_position)) = self.current_chunk.take() { let tab_position = chunk.tabs.trailing_zeros() + 1; - if self.distance_traveled + tab_position - past_tab_position > distance { + if tab_position - chunk_position > distance { self.bytes_offset += distance; + self.current_chunk = Some((chunk, distance)); + return None; } else { - self.bytes_offset += tab_position - past_tab_position; + self.bytes_offset += tab_position - chunk_position; let tabstop = TabStop { char_offset: self.bytes_offset, byte_offset: self.bytes_offset, }; - self.distance_traveled += tab_position - past_tab_position; - chunk.tabs = (chunk.tabs - 1) & chunk.tabs; if chunk.tabs > 0 { self.current_chunk = Some((chunk, tab_position)); } else { self.end_of_chunk = Some(chunk.text.len() as u32 - tab_position); } - return Some(tabstop); } } let past_chunk = self.end_of_chunk.take().unwrap_or_default(); - if self.distance_traveled + past_chunk > distance { - let overshoot = self.distance_traveled + past_chunk - distance; - self.bytes_offset += past_chunk - overshoot; - self.distance_traveled += past_chunk - overshoot; + let mut distance_traversed = 0; + if past_chunk > distance { + self.bytes_offset += distance; + self.end_of_chunk = Some(past_chunk - distance); + return None; } else { self.bytes_offset += past_chunk; - self.distance_traveled += past_chunk; + distance_traversed += past_chunk; } while let Some(mut chunk) = self.chunks.next() { if chunk.tabs == 0 { - self.distance_traveled += chunk.text.len() as u32; - if self.distance_traveled > distance { - self.bytes_offset += distance; + let chunk_distance = chunk.text.len() as u32; + if chunk_distance + distance_traversed > distance { + let overshoot = chunk_distance + distance_traversed - distance; + self.bytes_offset += distance_traversed.abs_diff(distance); + self.end_of_chunk = Some(overshoot); return None; } continue; @@ -1138,8 +1252,10 @@ impl<'a> TabStopCursor<'a> { let tab_position = chunk.tabs.trailing_zeros() + 1; - if self.distance_traveled + tab_position > distance { - self.bytes_offset += distance; + if distance_traversed + tab_position > distance { + let cursor_position = distance_traversed.abs_diff(distance); + self.current_chunk = Some((chunk, cursor_position)); + self.bytes_offset += cursor_position; return None; } self.bytes_offset += tab_position; @@ -1149,11 +1265,11 @@ impl<'a> TabStopCursor<'a> { byte_offset: self.bytes_offset, }; - self.distance_traveled += tab_position; - chunk.tabs = (chunk.tabs - 1) & chunk.tabs; if chunk.tabs > 0 { self.current_chunk = Some((chunk, tab_position)); + } else { + self.end_of_chunk = Some(chunk.text.len() as u32 - tab_position); } return Some(tabstop); From 118585d13352869b2e0d751d9716b1968cfd2a55 Mon Sep 17 00:00:00 2001 From: Anthony Date: Sat, 7 Jun 2025 01:38:04 -0400 Subject: [PATCH 04/24] Get every test passing except random tabs --- crates/editor/src/display_map/tab_map.rs | 141 ++++++++++++----------- 1 file changed, 72 insertions(+), 69 deletions(-) diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index e515c89af1..e4b6d85f57 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -2,11 +2,11 @@ use super::{ Highlights, fold_map::{self, Chunk, FoldChunks, FoldEdit, FoldPoint, FoldSnapshot}, }; + use language::Point; use multi_buffer::MultiBufferSnapshot; use std::{cmp, mem, num::NonZeroU32, ops::Range}; use sum_tree::Bias; -use util::debug_panic; const MAX_EXPANSION_COLUMN: u32 = 256; @@ -310,6 +310,7 @@ impl TabSnapshot { let chunks = self .fold_snapshot .chunks_at(FoldPoint::new(output.row(), 0)); + let tab_cursor = TabStopCursor::new(chunks); let expanded = output.column(); let (collapsed, expanded_char_column, to_next_stop) = @@ -363,44 +364,44 @@ impl TabSnapshot { fn collapse_tabs(&self, mut cursor: TabStopCursor, column: u32, bias: Bias) -> (u32, u32, u32) { let tab_size = self.tab_size.get(); let mut collapsed_column = column; + let mut seek_target = column; let mut tab_count = 0; let mut expanded_tab_len = 0; - dbg!(collapsed_column); - while let Some(tab_stop) = cursor.seek(collapsed_column) { + while let Some(tab_stop) = cursor.seek(seek_target) { // Calculate how much we want to expand this tab stop (into spaces) - let mut expanded_chars = tab_stop.char_offset - tab_count + expanded_tab_len; - let tab_len = tab_size - ((expanded_chars - 1) % tab_size); + let expanded_chars_old = tab_stop.char_offset - tab_count + expanded_tab_len; + let tab_len = tab_size - ((expanded_chars_old - 1) % tab_size); // Increment tab count tab_count += 1; // The count of how many spaces we've added to this line in place of tab bytes - expanded_tab_len += dbg!(tab_len); + expanded_tab_len += tab_len; // The count of bytes at this point in the iteration while considering tab_count and previous expansions let expanded_bytes = tab_stop.byte_offset - tab_count + expanded_tab_len; // Did we expand past the search target? if expanded_bytes > column { + let mut expanded_chars = tab_stop.char_offset - tab_count + expanded_tab_len; // We expanded past the search target, so need to account for the offshoot expanded_chars -= expanded_bytes - column; - dbg!(expanded_bytes); return match bias { Bias::Left => ( - cursor.byte_offset(), + cursor.byte_offset() - 1, expanded_chars, expanded_bytes - column, ), - Bias::Right => (cursor.byte_offset() + 1, expanded_chars, 0), + Bias::Right => (cursor.byte_offset(), expanded_chars, 0), }; } else { // otherwise we only want to move the cursor collapse column forward collapsed_column = collapsed_column - tab_len + 1; + seek_target = (collapsed_column - cursor.bytes_offset) + .min(self.max_expansion_column - cursor.bytes_offset); } } - dbg!(tab_count, expanded_tab_len); let collapsed_bytes = cursor.byte_offset(); let expanded_bytes = cursor.byte_offset() - tab_count + expanded_tab_len; - dbg!(collapsed_bytes, expanded_bytes, column); // let expanded_chars = cursor.char_offset() - tab_count + expanded_tab_len; ( collapsed_bytes + column.saturating_sub(expanded_bytes), @@ -629,8 +630,7 @@ mod tests { let mut expanded_bytes = 0; let mut expanded_chars = 0; let mut collapsed_bytes = 0; - let mut expanded_tab_len = 0; - for (i, c) in chars.enumerate() { + for c in chars { if expanded_bytes >= column { break; } @@ -640,13 +640,10 @@ mod tests { if c == '\t' { let tab_len = tab_size - (expanded_chars % tab_size); - dbg!(tab_len); - expanded_tab_len += tab_len; expanded_chars += tab_len; expanded_bytes += tab_len; if expanded_bytes > column { expanded_chars -= expanded_bytes - column; - dbg!(expanded_bytes); return match bias { Bias::Left => { (collapsed_bytes, expanded_chars, expanded_bytes - column) @@ -666,14 +663,6 @@ mod tests { collapsed_bytes += c.len_utf8() as u32; } - dbg!( - collapsed_bytes, - column, - expanded_bytes, - expanded_chars, - expanded_tab_len - ); - dbg!("expected\n---------------\n"); ( collapsed_bytes + column.saturating_sub(expanded_bytes), @@ -718,32 +707,38 @@ mod tests { let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); - let range = TabPoint::zero()..tab_snapshot.max_point(); + for (ix, _) in input.char_indices() { + let range = TabPoint::new(0, ix as u32)..tab_snapshot.max_point(); - assert_eq!( - tab_snapshot.test_to_fold_point(range.start, Bias::Left), - tab_snapshot.to_fold_point(range.start, Bias::Left) - ); - assert_eq!( - tab_snapshot.test_to_fold_point(range.start, Bias::Right), - tab_snapshot.to_fold_point(range.start, Bias::Right) - ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.start, Bias::Left), + tab_snapshot.to_fold_point(range.start, Bias::Left), + "Failed with tab_point at column {ix}" + ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.start, Bias::Right), + tab_snapshot.to_fold_point(range.start, Bias::Right), + "Failed with tab_point at column {ix}" + ); - assert_eq!( - tab_snapshot.test_to_fold_point(range.end, Bias::Left), - tab_snapshot.to_fold_point(range.end, Bias::Left) - ); - assert_eq!( - tab_snapshot.test_to_fold_point(range.end, Bias::Right), - tab_snapshot.to_fold_point(range.end, Bias::Right) - ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.end, Bias::Left), + tab_snapshot.to_fold_point(range.end, Bias::Left), + "Failed with tab_point at column {ix}" + ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.end, Bias::Right), + tab_snapshot.to_fold_point(range.end, Bias::Right), + "Failed with tab_point at column {ix}" + ); + } } #[gpui::test(iterations = 100)] fn test_collapse_tabs_random(cx: &mut gpui::App, mut rng: StdRng) { // Generate random input string with up to 200 characters including tabs // to stay within the MAX_EXPANSION_COLUMN limit of 256 - let len = rng.gen_range(0..=200); + let len = rng.gen_range(0..=2048); let mut input = String::with_capacity(len); for _ in 0..len { @@ -769,33 +764,35 @@ mod tests { let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); - let range = TabPoint::zero()..tab_snapshot.max_point(); + for (ix, _) in input.char_indices() { + let range = TabPoint::new(0, ix as u32)..tab_snapshot.max_point(); - assert_eq!( - tab_snapshot.test_to_fold_point(range.start, Bias::Left), - tab_snapshot.to_fold_point(range.start, Bias::Left), - "Failed with input: {}", - input - ); - assert_eq!( - tab_snapshot.test_to_fold_point(range.start, Bias::Right), - tab_snapshot.to_fold_point(range.start, Bias::Right), - "Failed with input: {}", - input - ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.start, Bias::Left), + tab_snapshot.to_fold_point(range.start, Bias::Left), + "Failed with input: {}, with idx: {ix}", + input + ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.start, Bias::Right), + tab_snapshot.to_fold_point(range.start, Bias::Right), + "Failed with input: {}, with idx: {ix}", + input + ); - assert_eq!( - tab_snapshot.test_to_fold_point(range.end, Bias::Left), - tab_snapshot.to_fold_point(range.end, Bias::Left), - "Failed with input: {}", - input - ); - assert_eq!( - tab_snapshot.test_to_fold_point(range.end, Bias::Right), - tab_snapshot.to_fold_point(range.end, Bias::Right), - "Failed with input: {}", - input - ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.end, Bias::Left), + tab_snapshot.to_fold_point(range.end, Bias::Left), + "Failed with input: {}, with idx: {ix}", + input + ); + assert_eq!( + tab_snapshot.test_to_fold_point(range.end, Bias::Right), + tab_snapshot.to_fold_point(range.end, Bias::Right), + "Failed with input: {}, with idx: {ix}", + input + ); + } } #[gpui::test] @@ -1094,8 +1091,13 @@ mod tests { let len = rng.gen_range(0..=2048); let mut input = String::with_capacity(len); - for _ in 0..len { - if rng.gen_bool(0.15) { + let mut skip_tabs = rng.gen_bool(0.10); + for idx in 0..len { + if idx % 128 == 0 { + skip_tabs = rng.gen_bool(0.10); + } + + if rng.gen_bool(0.15) && !skip_tabs { // 15% chance of inserting a tab input.push('\t'); } else { @@ -1247,6 +1249,7 @@ impl<'a> TabStopCursor<'a> { self.end_of_chunk = Some(overshoot); return None; } + self.bytes_offset += chunk.text.len() as u32; continue; } From 5463fc44bc8a42fefc15a12b3524030093b41b4a Mon Sep 17 00:00:00 2001 From: Anthony Date: Sat, 7 Jun 2025 03:00:20 -0400 Subject: [PATCH 05/24] fix repro panic --- crates/editor/src/display_map/tab_map.rs | 54 +++++++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index e4b6d85f57..69ca240f04 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -315,11 +315,28 @@ impl TabSnapshot { let expanded = output.column(); let (collapsed, expanded_char_column, to_next_stop) = self.collapse_tabs(tab_cursor, expanded, bias); - ( + + let result = ( FoldPoint::new(output.row(), collapsed), expanded_char_column, to_next_stop, - ) + ); + + // let expected = self.test_to_fold_point(output, bias); + + // if result != expected { + // let text = self.buffer_snapshot().text(); + // let bias = if bias == Bias::Left { "left" } else { "right" }; + // panic!( + // "text: {text}, output: {}, bias: {bias}, result: {:?},{},{}, expected: {expected:?}", + // output.row(), + // result.0, + // result.1, + // result.2 + // ); + // } + + result } pub fn make_tab_point(&self, point: Point, bias: Bias) -> TabPoint { @@ -364,9 +381,10 @@ impl TabSnapshot { fn collapse_tabs(&self, mut cursor: TabStopCursor, column: u32, bias: Bias) -> (u32, u32, u32) { let tab_size = self.tab_size.get(); let mut collapsed_column = column; - let mut seek_target = column; + let mut seek_target = column.min(self.max_expansion_column); let mut tab_count = 0; let mut expanded_tab_len = 0; + while let Some(tab_stop) = cursor.seek(seek_target) { // Calculate how much we want to expand this tab stop (into spaces) let expanded_chars_old = tab_stop.char_offset - tab_count + expanded_tab_len; @@ -378,6 +396,7 @@ impl TabSnapshot { // The count of bytes at this point in the iteration while considering tab_count and previous expansions let expanded_bytes = tab_stop.byte_offset - tab_count + expanded_tab_len; + dbg!(expanded_bytes, column); // Did we expand past the search target? if expanded_bytes > column { @@ -734,6 +753,25 @@ mod tests { } } + #[gpui::test] + fn test_to_fold_point_panic_reproduction(cx: &mut gpui::App) { + // This test reproduces a specific panic where to_fold_point returns incorrect results + let text = "use macro_rules_attribute::apply;\nuse serde_json::Value;\nuse smol::{\n io::AsyncReadExt,\n process::{Command, Stdio},\n};\nuse smol_macros::main;\nuse std::io;\n\nfn test_random() {\n // Generate a random value\n let random_value = std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_secs()\n % 100;\n\n // Create some complex nested data structures\n let mut vector = Vec::new();\n for i in 0..random_value {\n vector.push(i);\n }\n "; + + let buffer = MultiBuffer::build_simple(text, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + + // This should panic with the expected vs actual mismatch + let tab_point = TabPoint::new(6, 6); + let result = tab_snapshot.to_fold_point(tab_point, Bias::Right); + let expected = tab_snapshot.test_to_fold_point(tab_point, Bias::Right); + + assert_eq!(result, expected); + } + #[gpui::test(iterations = 100)] fn test_collapse_tabs_random(cx: &mut gpui::App, mut rng: StdRng) { // Generate random input string with up to 200 characters including tabs @@ -750,7 +788,7 @@ mod tests { let ch = loop { let ascii_code = rng.gen_range(32..=126); // printable ASCII range let ch = ascii_code as u8 as char; - if ch != '\t' && ch != '\n' && ch != '\r' { + if ch != '\t' { break ch; } }; @@ -762,7 +800,8 @@ mod tests { let buffer_snapshot = buffer.read(cx).snapshot(cx); let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); - let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + let (_, mut tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + tab_snapshot.max_expansion_column = rng.gen_range(0..323); for (ix, _) in input.char_indices() { let range = TabPoint::new(0, ix as u32)..tab_snapshot.max_point(); @@ -1000,7 +1039,9 @@ mod tests { Default::default(), ); let mut cursor = TabStopCursor::new(chunks); + assert!(cursor.seek(0).is_none()); let mut tab_stops = Vec::new(); + while let Some(tab_stop) = cursor.seek(u32::MAX) { tab_stops.push(tab_stop); } @@ -1243,13 +1284,14 @@ impl<'a> TabStopCursor<'a> { while let Some(mut chunk) = self.chunks.next() { if chunk.tabs == 0 { let chunk_distance = chunk.text.len() as u32; - if chunk_distance + distance_traversed > distance { + if chunk_distance + distance_traversed >= distance { let overshoot = chunk_distance + distance_traversed - distance; self.bytes_offset += distance_traversed.abs_diff(distance); self.end_of_chunk = Some(overshoot); return None; } self.bytes_offset += chunk.text.len() as u32; + distance_traversed += chunk.text.len() as u32; continue; } From 3f3125d2061cddac73a6c8347d3d73ea13db1f35 Mon Sep 17 00:00:00 2001 From: Anthony Date: Mon, 9 Jun 2025 17:06:04 -0400 Subject: [PATCH 06/24] Simplify tab cursor code --- crates/editor/src/display_map/tab_map.rs | 41 ++++++------------------ 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index 69ca240f04..4dbf93bfe0 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -396,7 +396,6 @@ impl TabSnapshot { // The count of bytes at this point in the iteration while considering tab_count and previous expansions let expanded_bytes = tab_stop.byte_offset - tab_count + expanded_tab_len; - dbg!(expanded_bytes, column); // Did we expand past the search target? if expanded_bytes > column { @@ -1244,31 +1243,6 @@ impl<'a> TabStopCursor<'a> { debug_assert!(distance == 0, "Can't seek backwards: {distance}"); return None; } - if let Some((mut chunk, chunk_position)) = self.current_chunk.take() { - let tab_position = chunk.tabs.trailing_zeros() + 1; - - if tab_position - chunk_position > distance { - self.bytes_offset += distance; - self.current_chunk = Some((chunk, distance)); - return None; - } else { - self.bytes_offset += tab_position - chunk_position; - - let tabstop = TabStop { - char_offset: self.bytes_offset, - byte_offset: self.bytes_offset, - }; - - chunk.tabs = (chunk.tabs - 1) & chunk.tabs; - if chunk.tabs > 0 { - self.current_chunk = Some((chunk, tab_position)); - } else { - self.end_of_chunk = Some(chunk.text.len() as u32 - tab_position); - } - return Some(tabstop); - } - } - let past_chunk = self.end_of_chunk.take().unwrap_or_default(); let mut distance_traversed = 0; @@ -1281,29 +1255,32 @@ impl<'a> TabStopCursor<'a> { distance_traversed += past_chunk; } - while let Some(mut chunk) = self.chunks.next() { + while let Some((mut chunk, chunk_position)) = self + .current_chunk + .take() + .or_else(|| self.chunks.next().zip(Some(0))) + { if chunk.tabs == 0 { let chunk_distance = chunk.text.len() as u32; - if chunk_distance + distance_traversed >= distance { + if chunk_distance + distance_traversed - chunk_position >= distance { let overshoot = chunk_distance + distance_traversed - distance; self.bytes_offset += distance_traversed.abs_diff(distance); - self.end_of_chunk = Some(overshoot); + self.end_of_chunk = Some(overshoot); // todo! this should be a chunk position return None; } self.bytes_offset += chunk.text.len() as u32; distance_traversed += chunk.text.len() as u32; continue; } - let tab_position = chunk.tabs.trailing_zeros() + 1; - if distance_traversed + tab_position > distance { + if distance_traversed + tab_position - chunk_position > distance { let cursor_position = distance_traversed.abs_diff(distance); self.current_chunk = Some((chunk, cursor_position)); self.bytes_offset += cursor_position; return None; } - self.bytes_offset += tab_position; + self.bytes_offset += tab_position - chunk_position; let tabstop = TabStop { char_offset: self.bytes_offset, From 94b034ffc172e5b066499e173c091e86377e63a6 Mon Sep 17 00:00:00 2001 From: Anthony Date: Mon, 9 Jun 2025 23:54:56 -0400 Subject: [PATCH 07/24] Get tab cursor working with correct character offset with utf16 chars --- crates/editor/src/display_map/fold_map.rs | 6 +- crates/editor/src/display_map/tab_map.rs | 343 +++++++++++++++------- crates/editor/src/element.rs | 1 - crates/language/src/buffer.rs | 6 +- crates/language/src/buffer_tests.rs | 9 +- crates/rope/src/chunk.rs | 5 + crates/rope/src/rope.rs | 5 +- 7 files changed, 255 insertions(+), 120 deletions(-) diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index 2abc07464c..c390ba5eb1 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -1271,8 +1271,10 @@ pub struct Chunk<'a> { pub is_inlay: bool, /// An optional recipe for how the chunk should be presented. pub renderer: Option, - /// The location of tab characters in the chunk. + /// Bitmap of tab character locations in chunk pub tabs: u128, + /// Bitmap of character locations in chunk + pub chars: u128, } /// A recipe for how the chunk should be presented. @@ -1421,6 +1423,7 @@ impl<'a> Iterator for FoldChunks<'a> { chunk.text = &chunk.text [(self.inlay_offset - buffer_chunk_start).0..(chunk_end - buffer_chunk_start).0]; chunk.tabs = chunk.tabs >> (self.inlay_offset - buffer_chunk_start).0; + chunk.chars = chunk.chars >> (self.inlay_offset - buffer_chunk_start).0; if chunk_end == transform_end { self.transform_cursor.next(&()); @@ -1433,6 +1436,7 @@ impl<'a> Iterator for FoldChunks<'a> { return Some(Chunk { text: chunk.text, tabs: chunk.tabs, + chars: chunk.chars, syntax_highlight_id: chunk.syntax_highlight_id, highlight_style: chunk.highlight_style, diagnostic_severity: chunk.diagnostic_severity, diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index 4dbf93bfe0..2dc118704b 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -387,7 +387,7 @@ impl TabSnapshot { while let Some(tab_stop) = cursor.seek(seek_target) { // Calculate how much we want to expand this tab stop (into spaces) - let expanded_chars_old = tab_stop.char_offset - tab_count + expanded_tab_len; + let expanded_chars_old = tab_stop.char_offset + expanded_tab_len - tab_count; let tab_len = tab_size - ((expanded_chars_old - 1) % tab_size); // Increment tab count tab_count += 1; @@ -395,11 +395,11 @@ impl TabSnapshot { expanded_tab_len += tab_len; // The count of bytes at this point in the iteration while considering tab_count and previous expansions - let expanded_bytes = tab_stop.byte_offset - tab_count + expanded_tab_len; + let expanded_bytes = tab_stop.byte_offset + expanded_tab_len - tab_count; // Did we expand past the search target? if expanded_bytes > column { - let mut expanded_chars = tab_stop.char_offset - tab_count + expanded_tab_len; + let mut expanded_chars = tab_stop.char_offset + expanded_tab_len - tab_count; // We expanded past the search target, so need to account for the offshoot expanded_chars -= expanded_bytes - column; return match bias { @@ -413,17 +413,17 @@ impl TabSnapshot { } else { // otherwise we only want to move the cursor collapse column forward collapsed_column = collapsed_column - tab_len + 1; - seek_target = (collapsed_column - cursor.bytes_offset) - .min(self.max_expansion_column - cursor.bytes_offset); + seek_target = (collapsed_column - cursor.byte_offset) + .min(self.max_expansion_column - cursor.byte_offset); } } let collapsed_bytes = cursor.byte_offset(); - let expanded_bytes = cursor.byte_offset() - tab_count + expanded_tab_len; - // let expanded_chars = cursor.char_offset() - tab_count + expanded_tab_len; + let expanded_bytes = cursor.byte_offset() + expanded_tab_len - tab_count; + let expanded_chars = cursor.char_offset() + expanded_tab_len - tab_count; ( collapsed_bytes + column.saturating_sub(expanded_bytes), - expanded_bytes, + expanded_chars, 0, ) } @@ -776,6 +776,7 @@ mod tests { // Generate random input string with up to 200 characters including tabs // to stay within the MAX_EXPANSION_COLUMN limit of 256 let len = rng.gen_range(0..=2048); + let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap(); let mut input = String::with_capacity(len); for _ in 0..len { @@ -801,6 +802,7 @@ mod tests { let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, mut tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); tab_snapshot.max_expansion_column = rng.gen_range(0..323); + tab_snapshot.tab_size = tab_size; for (ix, _) in input.char_indices() { let range = TabPoint::new(0, ix as u32)..tab_snapshot.max_point(); @@ -1026,8 +1028,9 @@ mod tests { } #[gpui::test] - fn test_tab_stop_cursor(cx: &mut gpui::App) { + fn test_tab_stop_cursor_utf8(cx: &mut gpui::App) { let text = "\tfoo\tbarbarbar\t\tbaz\n"; + let text = "rikR~${H25ao'\\@r/<`&bjrzg(uQG})kl#!^r>Z\\27X$mmh\"tz;fq@F>= = all_tab_stops + .iter() + .take_while(|tab_stop| tab_stop.byte_offset <= distance) + .cloned() + .collect(); + + pretty_assertions::assert_eq!( + found_tab_stops, + expected_found_tab_stops, + "TabStopCursor output mismatch for distance {}. Input: {:?}", + distance, + input + ); + + let final_position = cursor.byte_offset(); + if !found_tab_stops.is_empty() { + let last_tab_stop = found_tab_stops.last().unwrap(); + assert!( + final_position >= last_tab_stop.byte_offset, + "Cursor final position {} is before last tab stop {}. Input: {:?}", + final_position, + last_tab_stop.byte_offset, + input + ); + } + } + } + + #[gpui::test] + fn test_tab_stop_cursor_utf16(cx: &mut gpui::App) { + let text = "\r\t😁foo\tb😀arbar🤯bar\t\tbaz\n"; + let buffer = MultiBuffer::build_simple(text, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let chunks = fold_snapshot.chunks( + FoldOffset(0)..fold_snapshot.len(), + false, + Default::default(), + ); + let mut cursor = TabStopCursor::new(chunks); + assert!(cursor.seek(0).is_none()); + let mut tab_stops = Vec::new(); + + let mut all_tab_stops = Vec::new(); + let mut byte_offset = 0; + let mut char_offset = 0; + for ch in buffer.read(cx).snapshot(cx).text().chars() { + // byte_offset += ch.len_utf8(); + byte_offset += ch.len_utf8() as u32; + char_offset += 1; + + if ch == '\t' { + all_tab_stops.push(TabStop { + byte_offset, + char_offset, + }); + } + } + + while let Some(tab_stop) = cursor.seek(u32::MAX) { + tab_stops.push(tab_stop); + } + pretty_assertions::assert_eq!(tab_stops.as_slice(), all_tab_stops.as_slice(),); + + assert_eq!(cursor.byte_offset(), byte_offset); + } + + #[gpui::test(iterations = 100)] + fn test_tab_stop_cursor_random_utf16(cx: &mut gpui::App, mut rng: StdRng) { + // Generate random input string with up to 512 characters including tabs + let len = rng.gen_range(0..=2048); + let input = util::RandomCharIter::new(&mut rng) + .take(len) + .collect::(); + + // Build the buffer and create cursor + let buffer = MultiBuffer::build_simple(&input, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + + // First, collect all expected tab positions + let mut all_tab_stops = Vec::new(); + let mut byte_offset = 0; + let mut char_offset = 0; + for ch in buffer_snapshot.text().chars() { + byte_offset += ch.len_utf8() as u32; + char_offset += 1; if ch == '\t' { all_tab_stops.push(TabStop { byte_offset, @@ -1171,12 +1264,11 @@ mod tests { }); } // byte_offset += ch.len_utf8(); - byte_offset += 1; - char_offset += 1; } // Test with various distances - let distances = vec![1, 5, 10, 50, 100, u32::MAX]; + // let distances = vec![1, 5, 10, 50, 100, u32::MAX]; + let distances = vec![150]; for distance in distances { let chunks = fold_snapshot.chunks_at(FoldPoint::new(0, 0)); @@ -1220,19 +1312,19 @@ mod tests { struct TabStopCursor<'a> { chunks: FoldChunks<'a>, - bytes_offset: u32, + byte_offset: u32, + char_offset: u32, /// Chunk /// last tab position iterated through current_chunk: Option<(Chunk<'a>, u32)>, - end_of_chunk: Option, } impl<'a> TabStopCursor<'a> { fn new(chunks: FoldChunks<'a>) -> Self { Self { chunks, - bytes_offset: 0, - end_of_chunk: None, + byte_offset: 0, + char_offset: 0, current_chunk: None, } } @@ -1243,17 +1335,8 @@ impl<'a> TabStopCursor<'a> { debug_assert!(distance == 0, "Can't seek backwards: {distance}"); return None; } - let past_chunk = self.end_of_chunk.take().unwrap_or_default(); let mut distance_traversed = 0; - if past_chunk > distance { - self.bytes_offset += distance; - self.end_of_chunk = Some(past_chunk - distance); - return None; - } else { - self.bytes_offset += past_chunk; - distance_traversed += past_chunk; - } while let Some((mut chunk, chunk_position)) = self .current_chunk @@ -1261,37 +1344,52 @@ impl<'a> TabStopCursor<'a> { .or_else(|| self.chunks.next().zip(Some(0))) { if chunk.tabs == 0 { - let chunk_distance = chunk.text.len() as u32; - if chunk_distance + distance_traversed - chunk_position >= distance { - let overshoot = chunk_distance + distance_traversed - distance; - self.bytes_offset += distance_traversed.abs_diff(distance); - self.end_of_chunk = Some(overshoot); // todo! this should be a chunk position + let chunk_distance = chunk.text.len() as u32 - chunk_position; + if chunk_distance + distance_traversed >= distance { + let overshoot = distance_traversed.abs_diff(distance); + self.byte_offset += overshoot; + + self.char_offset += get_char_offset( + chunk_position..(chunk_position + overshoot).saturating_sub(1).min(127), + chunk.chars, + ); + self.current_chunk = Some((chunk, chunk_position + overshoot)); + return None; } - self.bytes_offset += chunk.text.len() as u32; - distance_traversed += chunk.text.len() as u32; + + self.byte_offset += chunk_distance; + // todo! calculate char offset + self.char_offset += get_char_offset( + chunk_position..(chunk_position + chunk_distance).saturating_sub(1).min(127), + chunk.chars, + ); + distance_traversed += chunk_distance; continue; } let tab_position = chunk.tabs.trailing_zeros() + 1; if distance_traversed + tab_position - chunk_position > distance { let cursor_position = distance_traversed.abs_diff(distance); + self.char_offset += get_char_offset(0..(cursor_position - 1), chunk.chars); self.current_chunk = Some((chunk, cursor_position)); - self.bytes_offset += cursor_position; + self.byte_offset += cursor_position; + return None; } - self.bytes_offset += tab_position - chunk_position; + + self.byte_offset += tab_position - chunk_position; + self.char_offset += get_char_offset(chunk_position..(tab_position - 1), chunk.chars); let tabstop = TabStop { - char_offset: self.bytes_offset, - byte_offset: self.bytes_offset, + char_offset: self.char_offset, + byte_offset: self.byte_offset, }; chunk.tabs = (chunk.tabs - 1) & chunk.tabs; - if chunk.tabs > 0 { + + if tab_position as usize != chunk.text.len() { self.current_chunk = Some((chunk, tab_position)); - } else { - self.end_of_chunk = Some(chunk.text.len() as u32 - tab_position); } return Some(tabstop); @@ -1301,8 +1399,31 @@ impl<'a> TabStopCursor<'a> { } fn byte_offset(&self) -> u32 { - self.bytes_offset + self.byte_offset } + + fn char_offset(&self) -> u32 { + self.char_offset + } +} + +#[inline(always)] +fn get_char_offset(range: Range, bit_map: u128) -> u32 { + // This edge case can happen when we're at chunk position 128 + + if range.start == range.end { + return if (1u128 << range.start) & bit_map == 0 { + 0 + } else { + 1 + }; + } + let end_shift: u128 = 127u128 - range.end.min(127) as u128; + let mut bit_mask = (u128::MAX >> range.start) << range.start; + bit_mask = (bit_mask << end_shift) >> end_shift; + let bit_map = bit_map & bit_mask; + + bit_map.count_ones() } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 0af1b1e9f2..abe0f6aa7b 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -278,7 +278,6 @@ impl EditorElement { if text.is_empty() { return; } - dbg!("Handle input text:", text); editor.handle_input(text, window, cx); }, ); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 4173d13fbc..09de7db03b 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -488,6 +488,8 @@ pub struct Chunk<'a> { pub is_tab: bool, /// A bitset of which characters are tabs in this string. pub tabs: u128, + /// Bitmap of character indices in this chunk + pub chars: u128, /// Whether this chunk of text was originally a tab character. pub is_inlay: bool, /// Whether to underline the corresponding text range in the editor. @@ -4582,7 +4584,7 @@ impl<'a> Iterator for BufferChunks<'a> { } self.diagnostic_endpoints = diagnostic_endpoints; - if let Some((chunk, tabs)) = self.chunks.peek_tabs() { + if let Some((chunk, tabs, chars_map)) = self.chunks.peek_tabs() { let chunk_start = self.range.start; let mut chunk_end = (self.chunks.offset() + chunk.len()) .min(next_capture_start) @@ -4598,6 +4600,7 @@ impl<'a> Iterator for BufferChunks<'a> { let slice = &chunk[chunk_start - self.chunks.offset()..chunk_end - self.chunks.offset()]; let tabs = tabs >> (chunk_start - self.chunks.offset()); + let chars_map = chars_map >> (chunk_start - self.chunks.offset()); self.range.start = chunk_end; if self.range.start == self.chunks.offset() + chunk.len() { @@ -4611,6 +4614,7 @@ impl<'a> Iterator for BufferChunks<'a> { diagnostic_severity: self.current_diagnostic_severity(), is_unnecessary: self.current_code_is_unnecessary(), tabs, + chars: chars_map, ..Chunk::default() }) } else { diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 2bfae50012..d057cca9ba 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -3281,11 +3281,12 @@ fn test_contiguous_ranges() { #[test] fn test_buffer_chunks_tabs() { - let buffer = text::Buffer::new(0, BufferId::new(1).unwrap(), "\ta\tbc"); + let buffer = text::Buffer::new(0, BufferId::new(1).unwrap(), "\ta\tbc😁"); let mut iter = buffer.as_rope().chunks(); - while let Some((str, tabs)) = iter.peek_tabs() { - dbg!(str, format!("{:b}", tabs)); + while let Some((str, _, chars)) = iter.peek_tabs() { + dbg!(str.len(), str.bytes().count()); + dbg!(str, format!("{:b}", chars)); iter.next(); } dbg!("---"); @@ -3294,7 +3295,7 @@ fn test_buffer_chunks_tabs() { let mut iter = buffer.as_rope().chunks(); iter.seek(3); - while let Some((str, tabs)) = iter.peek_tabs() { + while let Some((str, tabs, _)) = iter.peek_tabs() { dbg!(str, format!("{:b}", tabs)); iter.next(); } diff --git a/crates/rope/src/chunk.rs b/crates/rope/src/chunk.rs index 5f7b1a7ea0..93d394b001 100644 --- a/crates/rope/src/chunk.rs +++ b/crates/rope/src/chunk.rs @@ -67,6 +67,11 @@ impl Chunk { pub fn slice(&self, range: Range) -> ChunkSlice { self.as_slice().slice(range) } + + #[inline(always)] + pub fn chars(&self) -> u128 { + self.chars + } } #[derive(Clone, Copy, Debug)] diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index c82f6beaf9..f3067cbd34 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -772,7 +772,7 @@ impl<'a> Chunks<'a> { Some(&chunk.text[slice_range]) } - pub fn peek_tabs(&self) -> Option<(&'a str, u128)> { + pub fn peek_tabs(&self) -> Option<(&'a str, u128, u128)> { if !self.offset_is_valid() { return None; } @@ -793,8 +793,9 @@ impl<'a> Chunks<'a> { // Shift the tabs to align with our slice window let shifted_tabs = chunk.tabs >> chunk_start_offset; + let shifted_chars_utf16 = chunk.chars(); - Some((slice_text, shifted_tabs)) + Some((slice_text, shifted_tabs, shifted_chars_utf16)) } pub fn lines(self) -> Lines<'a> { From c56c3dad42408f7d64f6c71211370b07dbed6bdb Mon Sep 17 00:00:00 2001 From: Anthony Date: Wed, 11 Jun 2025 02:36:41 -0400 Subject: [PATCH 08/24] Get all test passing --- crates/editor/src/display_map/block_map.rs | 14 +++++++-- crates/editor/src/display_map/fold_map.rs | 15 ++++++++-- crates/editor/src/display_map/inlay_map.rs | 2 ++ crates/editor/src/display_map/tab_map.rs | 33 ++++++---------------- crates/language/src/buffer.rs | 12 ++++++-- crates/rope/src/rope.rs | 4 +-- 6 files changed, 48 insertions(+), 32 deletions(-) diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 8214ab7a8c..77b5039e64 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -1713,6 +1713,7 @@ impl<'a> Iterator for BlockChunks<'a> { return Some(Chunk { text: unsafe { std::str::from_utf8_unchecked(&NEWLINES[..line_count as usize]) }, + chars: (1 << line_count) - 1, ..Default::default() }); } @@ -1742,17 +1743,26 @@ impl<'a> Iterator for BlockChunks<'a> { let (mut prefix, suffix) = self.input_chunk.text.split_at(prefix_bytes); self.input_chunk.text = suffix; + self.input_chunk.tabs >>= prefix_bytes; + self.input_chunk.chars >>= prefix_bytes; + + let mut tabs = self.input_chunk.tabs; + let mut chars = self.input_chunk.chars; if self.masked { // Not great for multibyte text because to keep cursor math correct we // need to have the same number of bytes in the input as output. - let chars = prefix.chars().count(); - let bullet_len = chars; + let chars_count = prefix.chars().count(); + let bullet_len = chars_count; prefix = &BULLETS[..bullet_len]; + chars = (1 << bullet_len) - 1; + tabs = 0; } let chunk = Chunk { text: prefix, + tabs, + chars, ..self.input_chunk.clone() }; diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index c390ba5eb1..1eb3c07812 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -526,6 +526,7 @@ impl FoldMap { }, placeholder: Some(TransformPlaceholder { text: ELLIPSIS, + chars: 1, renderer: ChunkRenderer { id: fold.id, render: Arc::new(move |cx| { @@ -1031,6 +1032,7 @@ struct Transform { #[derive(Clone, Debug)] struct TransformPlaceholder { text: &'static str, + chars: u128, renderer: ChunkRenderer, } @@ -1386,6 +1388,7 @@ impl<'a> Iterator for FoldChunks<'a> { self.output_offset.0 += placeholder.text.len(); return Some(Chunk { text: placeholder.text, + chars: placeholder.chars, renderer: Some(placeholder.renderer.clone()), ..Default::default() }); @@ -1422,8 +1425,16 @@ impl<'a> Iterator for FoldChunks<'a> { chunk.text = &chunk.text [(self.inlay_offset - buffer_chunk_start).0..(chunk_end - buffer_chunk_start).0]; - chunk.tabs = chunk.tabs >> (self.inlay_offset - buffer_chunk_start).0; - chunk.chars = chunk.chars >> (self.inlay_offset - buffer_chunk_start).0; + + let bit_end = (chunk_end - buffer_chunk_start).0; + let mask = if bit_end >= 128 { + u128::MAX + } else { + (1u128 << bit_end) - 1 + }; + + chunk.tabs = (chunk.tabs >> (self.inlay_offset - buffer_chunk_start).0) & mask; + chunk.chars = (chunk.chars >> (self.inlay_offset - buffer_chunk_start).0) & mask; if chunk_end == transform_end { self.transform_cursor.next(&()); diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 3ec0084775..39891acf6c 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -258,6 +258,7 @@ impl<'a> Iterator for InlayChunks<'a> { *chunk = self.buffer_chunks.next().unwrap(); } + // todo! create a tabs/chars bitmask here and pass it in chunk let (prefix, suffix) = chunk.text.split_at( chunk .text @@ -333,6 +334,7 @@ impl<'a> Iterator for InlayChunks<'a> { self.output_offset.0 += chunk.len(); + // todo! figure out how to get tabs here Chunk { text: chunk, highlight_style, diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index 2dc118704b..c0a6209c09 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -322,20 +322,6 @@ impl TabSnapshot { to_next_stop, ); - // let expected = self.test_to_fold_point(output, bias); - - // if result != expected { - // let text = self.buffer_snapshot().text(); - // let bias = if bias == Bias::Left { "left" } else { "right" }; - // panic!( - // "text: {text}, output: {}, bias: {bias}, result: {:?},{},{}, expected: {expected:?}", - // output.row(), - // result.0, - // result.1, - // result.2 - // ); - // } - result } @@ -1030,7 +1016,6 @@ mod tests { #[gpui::test] fn test_tab_stop_cursor_utf8(cx: &mut gpui::App) { let text = "\tfoo\tbarbarbar\t\tbaz\n"; - let text = "rikR~${H25ao'\\@r/<`&bjrzg(uQG})kl#!^r>Z\\27X$mmh\"tz;fq@F>= TabStopCursor<'a> { let chunk_distance = chunk.text.len() as u32 - chunk_position; if chunk_distance + distance_traversed >= distance { let overshoot = distance_traversed.abs_diff(distance); - self.byte_offset += overshoot; + self.byte_offset += overshoot; self.char_offset += get_char_offset( chunk_position..(chunk_position + overshoot).saturating_sub(1).min(127), chunk.chars, ); + self.current_chunk = Some((chunk, chunk_position + overshoot)); return None; } self.byte_offset += chunk_distance; - // todo! calculate char offset self.char_offset += get_char_offset( chunk_position..(chunk_position + chunk_distance).saturating_sub(1).min(127), chunk.chars, diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 09de7db03b..0fcf98c876 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -4597,10 +4597,18 @@ impl<'a> Iterator for BufferChunks<'a> { } } + // todo! write a test for this let slice = &chunk[chunk_start - self.chunks.offset()..chunk_end - self.chunks.offset()]; - let tabs = tabs >> (chunk_start - self.chunks.offset()); - let chars_map = chars_map >> (chunk_start - self.chunks.offset()); + let bit_end = chunk_end - self.chunks.offset(); + + let mask = if bit_end >= 128 { + u128::MAX + } else { + (1u128 << bit_end) - 1 + }; + let tabs = (tabs >> (chunk_start - self.chunks.offset())) & mask; + let chars_map = (chars_map >> (chunk_start - self.chunks.offset())) & mask; self.range.start = chunk_end; if self.range.start == self.chunks.offset() + chunk.len() { diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index f3067cbd34..a513ba87b9 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -793,9 +793,9 @@ impl<'a> Chunks<'a> { // Shift the tabs to align with our slice window let shifted_tabs = chunk.tabs >> chunk_start_offset; - let shifted_chars_utf16 = chunk.chars(); + let shifted_chars = chunk.chars() >> chunk_start_offset; - Some((slice_text, shifted_tabs, shifted_chars_utf16)) + Some((slice_text, shifted_tabs, shifted_chars)) } pub fn lines(self) -> Lines<'a> { From 7cf02246ef2a6041a2b0e53dfec76af101723299 Mon Sep 17 00:00:00 2001 From: Anthony Date: Wed, 11 Jun 2025 03:08:58 -0400 Subject: [PATCH 09/24] Implement expand tabs (will follow up with tests) --- crates/editor/src/display_map/tab_map.rs | 99 ++++++++++++++++-------- 1 file changed, 65 insertions(+), 34 deletions(-) diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index c0a6209c09..f7532bfb80 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -301,8 +301,9 @@ impl TabSnapshot { } pub fn to_tab_point(&self, input: FoldPoint) -> TabPoint { - let chars = self.fold_snapshot.chars_at(FoldPoint::new(input.row(), 0)); - let expanded = self.expand_tabs(chars, input.column()); + let chunks = self.fold_snapshot.chunks_at(FoldPoint::new(input.row(), 0)); + let tab_cursor = TabStopCursor::new(chunks); + let expanded = self.expand_tabs(tab_cursor, input.column()); TabPoint::new(input.row(), expanded) } @@ -340,27 +341,25 @@ impl TabSnapshot { } /// todo!(performance use tabs bitmask) - fn expand_tabs(&self, chars: impl Iterator, column: u32) -> u32 { + fn expand_tabs(&self, mut cursor: TabStopCursor, column: u32) -> u32 { let tab_size = self.tab_size.get(); - let mut expanded_chars = 0; - let mut expanded_bytes = 0; - let mut collapsed_bytes = 0; let end_column = column.min(self.max_expansion_column); - for c in chars { - if collapsed_bytes >= end_column { - break; - } - if c == '\t' { - let tab_len = tab_size - expanded_chars % tab_size; - expanded_bytes += tab_len; - expanded_chars += tab_len; - } else { - expanded_bytes += c.len_utf8() as u32; - expanded_chars += 1; - } - collapsed_bytes += c.len_utf8() as u32; + let mut seek_target = end_column; + let mut tab_count = 0; + let mut expanded_tab_len = 0; + + while let Some(tab_stop) = cursor.seek(seek_target) { + let expanded_chars_old = tab_stop.char_offset + expanded_tab_len - tab_count; + let tab_len = tab_size - ((expanded_chars_old - 1) % tab_size); + tab_count += 1; + expanded_tab_len += tab_len; + + seek_target = end_column - cursor.byte_offset; } + + let collapsed_bytes = cursor.byte_offset(); + let expanded_bytes = cursor.byte_offset() + expanded_tab_len - tab_count; expanded_bytes + column.saturating_sub(collapsed_bytes) } @@ -557,6 +556,7 @@ impl<'a> Iterator for TabChunks<'a> { } } + //todo!(improve performance by using tab cursor) for (ix, c) in self.chunk.text.char_indices() { match c { '\t' => { @@ -623,7 +623,7 @@ mod tests { use util; impl TabSnapshot { - fn test_collapse_tabs( + fn expected_collapse_tabs( &self, chars: impl Iterator, column: u32, @@ -675,11 +675,41 @@ mod tests { ) } - fn test_to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) { + pub fn expected_to_tab_point(&self, input: FoldPoint) -> TabPoint { + let chars = self.fold_snapshot.chars_at(FoldPoint::new(input.row(), 0)); + let expanded = self.expected_expand_tabs(chars, input.column()); + TabPoint::new(input.row(), expanded) + } + + fn expected_expand_tabs(&self, chars: impl Iterator, column: u32) -> u32 { + let tab_size = self.tab_size.get(); + + let mut expanded_chars = 0; + let mut expanded_bytes = 0; + let mut collapsed_bytes = 0; + let end_column = column.min(self.max_expansion_column); + for c in chars { + if collapsed_bytes >= end_column { + break; + } + if c == '\t' { + let tab_len = tab_size - expanded_chars % tab_size; + expanded_bytes += tab_len; + expanded_chars += tab_len; + } else { + expanded_bytes += c.len_utf8() as u32; + expanded_chars += 1; + } + collapsed_bytes += c.len_utf8() as u32; + } + expanded_bytes + column.saturating_sub(collapsed_bytes) + } + + fn expected_to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) { let chars = self.fold_snapshot.chars_at(FoldPoint::new(output.row(), 0)); let expanded = output.column(); let (collapsed, expanded_char_column, to_next_stop) = - self.test_collapse_tabs(chars, expanded, bias); + self.expected_collapse_tabs(chars, expanded, bias); ( FoldPoint::new(output.row(), collapsed), expanded_char_column, @@ -696,9 +726,10 @@ mod tests { let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); - assert_eq!(tab_snapshot.expand_tabs("\t".chars(), 0), 0); - assert_eq!(tab_snapshot.expand_tabs("\t".chars(), 1), 4); - assert_eq!(tab_snapshot.expand_tabs("\ta".chars(), 2), 5); + // assert_eq!(tab_snapshot.expand_tabs("\t".chars(), 0), 0); + // assert_eq!(tab_snapshot.expand_tabs("\t".chars(), 1), 4); + // assert_eq!(tab_snapshot.expand_tabs("\ta".chars(), 2), 5); + panic!("Fix this test") } #[gpui::test] @@ -715,23 +746,23 @@ mod tests { let range = TabPoint::new(0, ix as u32)..tab_snapshot.max_point(); assert_eq!( - tab_snapshot.test_to_fold_point(range.start, Bias::Left), + tab_snapshot.expected_to_fold_point(range.start, Bias::Left), tab_snapshot.to_fold_point(range.start, Bias::Left), "Failed with tab_point at column {ix}" ); assert_eq!( - tab_snapshot.test_to_fold_point(range.start, Bias::Right), + tab_snapshot.expected_to_fold_point(range.start, Bias::Right), tab_snapshot.to_fold_point(range.start, Bias::Right), "Failed with tab_point at column {ix}" ); assert_eq!( - tab_snapshot.test_to_fold_point(range.end, Bias::Left), + tab_snapshot.expected_to_fold_point(range.end, Bias::Left), tab_snapshot.to_fold_point(range.end, Bias::Left), "Failed with tab_point at column {ix}" ); assert_eq!( - tab_snapshot.test_to_fold_point(range.end, Bias::Right), + tab_snapshot.expected_to_fold_point(range.end, Bias::Right), tab_snapshot.to_fold_point(range.end, Bias::Right), "Failed with tab_point at column {ix}" ); @@ -752,7 +783,7 @@ mod tests { // This should panic with the expected vs actual mismatch let tab_point = TabPoint::new(6, 6); let result = tab_snapshot.to_fold_point(tab_point, Bias::Right); - let expected = tab_snapshot.test_to_fold_point(tab_point, Bias::Right); + let expected = tab_snapshot.expected_to_fold_point(tab_point, Bias::Right); assert_eq!(result, expected); } @@ -794,26 +825,26 @@ mod tests { let range = TabPoint::new(0, ix as u32)..tab_snapshot.max_point(); assert_eq!( - tab_snapshot.test_to_fold_point(range.start, Bias::Left), + tab_snapshot.expected_to_fold_point(range.start, Bias::Left), tab_snapshot.to_fold_point(range.start, Bias::Left), "Failed with input: {}, with idx: {ix}", input ); assert_eq!( - tab_snapshot.test_to_fold_point(range.start, Bias::Right), + tab_snapshot.expected_to_fold_point(range.start, Bias::Right), tab_snapshot.to_fold_point(range.start, Bias::Right), "Failed with input: {}, with idx: {ix}", input ); assert_eq!( - tab_snapshot.test_to_fold_point(range.end, Bias::Left), + tab_snapshot.expected_to_fold_point(range.end, Bias::Left), tab_snapshot.to_fold_point(range.end, Bias::Left), "Failed with input: {}, with idx: {ix}", input ); assert_eq!( - tab_snapshot.test_to_fold_point(range.end, Bias::Right), + tab_snapshot.expected_to_fold_point(range.end, Bias::Right), tab_snapshot.to_fold_point(range.end, Bias::Right), "Failed with input: {}, with idx: {ix}", input From 81bb5c2d27e7153617fa6d82f011564576187cb6 Mon Sep 17 00:00:00 2001 From: Anthony Date: Wed, 11 Jun 2025 14:54:53 -0400 Subject: [PATCH 10/24] Bug fix of incorrect tabs/chars bitmap shift --- crates/editor/src/display_map/block_map.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 755d4ff789..1f2d04a55a 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -1743,8 +1743,8 @@ impl<'a> Iterator for BlockChunks<'a> { let (mut prefix, suffix) = self.input_chunk.text.split_at(prefix_bytes); self.input_chunk.text = suffix; - self.input_chunk.tabs >>= prefix_bytes; - self.input_chunk.chars >>= prefix_bytes; + self.input_chunk.tabs >>= prefix_bytes.saturating_sub(1); + self.input_chunk.chars >>= prefix_bytes.saturating_sub(1); let mut tabs = self.input_chunk.tabs; let mut chars = self.input_chunk.chars; From aa39f979b9a9de3dce8359df67bd6e58b2a38cab Mon Sep 17 00:00:00 2001 From: Anthony Date: Sat, 14 Jun 2025 04:18:39 -0400 Subject: [PATCH 11/24] Add testing suite for chunk bitmaps --- .../src/display_map/custom_highlights.rs | 122 ++++++++++++++++++ crates/editor/src/display_map/fold_map.rs | 91 +++++++++++++ crates/editor/src/display_map/inlay_map.rs | 100 +++++++++++++- crates/editor/src/display_map/tab_map.rs | 1 + crates/language/src/buffer_tests.rs | 77 +++++++++++ crates/multi_buffer/src/multi_buffer.rs | 2 + crates/multi_buffer/src/multi_buffer_tests.rs | 81 ++++++++++++ 7 files changed, 473 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/display_map/custom_highlights.rs b/crates/editor/src/display_map/custom_highlights.rs index 11356586eb..2b42e8e05a 100644 --- a/crates/editor/src/display_map/custom_highlights.rs +++ b/crates/editor/src/display_map/custom_highlights.rs @@ -144,6 +144,7 @@ impl<'a> Iterator for CustomHighlightsChunks<'a> { chunk.text = suffix; self.offset += prefix.len(); + // FIXME: chunk cloning is wrong because the bitmaps might be off let mut prefix = Chunk { text: prefix, ..chunk.clone() @@ -172,3 +173,124 @@ impl Ord for HighlightEndpoint { .then_with(|| other.is_start.cmp(&self.is_start)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::MultiBuffer; + use gpui::App; + use rand::prelude::*; + use util::RandomCharIter; + + #[gpui::test(iterations = 100)] + fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { + // Generate random buffer using existing test infrastructure + let len = rng.gen_range(0..10000); + let buffer = if rng.r#gen() { + let text = RandomCharIter::new(&mut rng).take(len).collect::(); + MultiBuffer::build_simple(&text, cx) + } else { + MultiBuffer::build_random(&mut rng, cx) + }; + + let buffer_snapshot = buffer.read(cx).snapshot(cx); + + // Create random highlights + let mut highlights = TreeMap::default(); + let highlight_count = rng.gen_range(1..10); + + for _i in 0..highlight_count { + let style = HighlightStyle { + color: Some(gpui::Hsla { + h: rng.r#gen::(), + s: rng.r#gen::(), + l: rng.r#gen::(), + a: 1.0, + }), + ..Default::default() + }; + + let mut ranges = Vec::new(); + let range_count = rng.gen_range(1..10); + for _ in 0..range_count { + let start = rng.gen_range(0..buffer_snapshot.len()); + let end = rng.gen_range(start..buffer_snapshot.len().min(start + 100)); + let start_anchor = buffer_snapshot.anchor_after(start); + let end_anchor = buffer_snapshot.anchor_after(end); + ranges.push(start_anchor..end_anchor); + } + + let type_id = TypeId::of::<()>(); // Simple type ID for testing + highlights.insert(type_id, Arc::new((style, ranges))); + } + + // Get all chunks and verify their bitmaps + let chunks = CustomHighlightsChunks::new( + 0..buffer_snapshot.len(), + false, + Some(&highlights), + &buffer_snapshot, + ); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + // Check empty chunks have empty bitmaps + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + // Verify that chunk text doesn't exceed 128 bytes + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + // Verify chars bitmap + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + } + + // Verify tabs bitmap + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + if has_bit != is_tab { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Tabs bitmap: {:#b}", tabs_bitmap); + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } + } +} diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index 2bca5c90a6..a832fda042 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -2075,6 +2075,97 @@ mod tests { ); } + #[gpui::test(iterations = 100)] + fn test_random_chunk_bitmaps(cx: &mut gpui::App, mut rng: StdRng) { + init_test(cx); + + // Generate random buffer using existing test infrastructure + let text_len = rng.gen_range(0..10000); + let buffer = if rng.r#gen() { + let text = RandomCharIter::new(&mut rng) + .take(text_len) + .collect::(); + MultiBuffer::build_simple(&text, cx) + } else { + MultiBuffer::build_random(&mut rng, cx) + }; + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (mut fold_map, _) = FoldMap::new(inlay_snapshot.clone()); + + // Perform random mutations + let mutation_count = rng.gen_range(1..10); + for _ in 0..mutation_count { + fold_map.randomly_mutate(&mut rng); + } + + let (snapshot, _) = fold_map.read(inlay_snapshot, vec![]); + + // Get all chunks and verify their bitmaps + let chunks = snapshot.chunks( + FoldOffset(0)..FoldOffset(snapshot.len().0), + false, + Highlights::default(), + ); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + // Check empty chunks have empty bitmaps + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + // Verify that chunk text doesn't exceed 128 bytes + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + // Verify chars bitmap + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + } + + // Verify tabs bitmap + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } + fn init_test(cx: &mut gpui::App) { let store = SettingsStore::test(cx); cx.set_global(store); diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 39891acf6c..a1de14810b 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -268,6 +268,7 @@ impl<'a> Iterator for InlayChunks<'a> { chunk.text = suffix; self.output_offset.0 += prefix.len(); + // FIXME: chunk cloning is wrong because the bitmaps might be off Chunk { text: prefix, ..chunk.clone() @@ -1080,7 +1081,7 @@ mod tests { use super::*; use crate::{ InlayId, MultiBuffer, - display_map::{InlayHighlights, TextHighlights}, + display_map::{Highlights, InlayHighlights, TextHighlights}, hover_links::InlayHighlight, }; use gpui::{App, HighlightStyle}; @@ -1090,6 +1091,7 @@ mod tests { use std::{any::TypeId, cmp::Reverse, env, sync::Arc}; use sum_tree::TreeMap; use text::Patch; + use util::RandomCharIter; use util::post_inc; #[test] @@ -1809,6 +1811,102 @@ mod tests { } } + #[gpui::test(iterations = 100)] + fn test_random_chunk_bitmaps(cx: &mut gpui::App, mut rng: StdRng) { + init_test(cx); + + // Generate random buffer using existing test infrastructure + let text_len = rng.gen_range(0..10000); + let buffer = if rng.r#gen() { + let text = RandomCharIter::new(&mut rng) + .take(text_len) + .collect::(); + MultiBuffer::build_simple(&text, cx) + } else { + MultiBuffer::build_random(&mut rng, cx) + }; + + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (mut inlay_map, _) = InlayMap::new(buffer_snapshot.clone()); + + // Perform random mutations to add inlays + let mut next_inlay_id = 0; + let mutation_count = rng.gen_range(1..10); + for _ in 0..mutation_count { + inlay_map.randomly_mutate(&mut next_inlay_id, &mut rng); + } + + let (snapshot, _) = inlay_map.sync(buffer_snapshot, vec![]); + + // Get all chunks and verify their bitmaps + let chunks = snapshot.chunks( + InlayOffset(0)..InlayOffset(snapshot.len().0), + false, + Highlights::default(), + ); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + // Check empty chunks have empty bitmaps + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + // Verify that chunk text doesn't exceed 128 bytes + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + // Verify chars bitmap + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + } + + // Verify tabs bitmap + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + if has_bit != is_tab { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Tabs bitmap: {:#b}", tabs_bitmap); + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } + } + fn init_test(cx: &mut App) { let store = SettingsStore::test(cx); cx.set_global(store); diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index f7532bfb80..c5ed44a904 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -729,6 +729,7 @@ mod tests { // assert_eq!(tab_snapshot.expand_tabs("\t".chars(), 0), 0); // assert_eq!(tab_snapshot.expand_tabs("\t".chars(), 1), 4); // assert_eq!(tab_snapshot.expand_tabs("\ta".chars(), 2), 5); + // FIXME: the test panic!("Fix this test") } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index d057cca9ba..0b6f89db6b 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -3764,3 +3764,80 @@ fn init_settings(cx: &mut App, f: fn(&mut AllLanguageSettingsContent)) { settings.update_user_settings::(cx, f); }); } + +#[gpui::test(iterations = 100)] +fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { + use util::RandomCharIter; + + // Generate random text + let len = rng.gen_range(0..10000); + let text = RandomCharIter::new(&mut rng).take(len).collect::(); + + let buffer = cx.new(|cx| Buffer::local(text, cx)); + let snapshot = buffer.read(cx).snapshot(); + + // Get all chunks and verify their bitmaps + let chunks = snapshot.chunks(0..snapshot.len(), false); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + // Check empty chunks have empty bitmaps + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + // Verify that chunk text doesn't exceed 128 bytes + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + // Verify chars bitmap + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + } + + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + + // Verify tabs bitmap + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + if has_bit != is_tab { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Tabs bitmap: {:#b}", tabs_bitmap); + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } +} diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 6d544222d4..8ba6a2929a 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -7726,6 +7726,8 @@ impl<'a> Iterator for MultiBufferChunks<'a> { chunk.text.split_at(diff_transform_end - self.range.start); self.range.start = diff_transform_end; chunk.text = after; + // FIXME: We should be handling bitmap for tabs and chars here + // Because we do a split at operation the bitmaps will be off Some(Chunk { text: before, ..chunk.clone() diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 824efa559f..75c9d0cfc8 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -7,6 +7,7 @@ use parking_lot::RwLock; use rand::prelude::*; use settings::SettingsStore; use std::env; +use util::RandomCharIter; use util::test::sample_text; #[ctor::ctor] @@ -3717,3 +3718,83 @@ fn test_new_empty_buffers_title_can_be_set(cx: &mut App) { }); assert_eq!(multibuffer.read(cx).title(cx), "Hey"); } + +#[gpui::test(iterations = 100)] +fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { + // Generate random multibuffer using existing test infrastructure + let multibuffer = if rng.r#gen() { + let len = rng.gen_range(0..10000); + let text = RandomCharIter::new(&mut rng).take(len).collect::(); + let buffer = cx.new(|cx| Buffer::local(text, cx)); + cx.new(|cx| MultiBuffer::singleton(buffer, cx)) + } else { + MultiBuffer::build_random(&mut rng, cx) + }; + + let snapshot = multibuffer.read(cx).snapshot(cx); + + // Get all chunks and verify their bitmaps + let chunks = snapshot.chunks(0..snapshot.len(), false); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + // Check empty chunks have empty bitmaps + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + // Verify that chunk text doesn't exceed 128 bytes + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + // Verify chars bitmap + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + } + + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + + // Verify tabs bitmap + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + if has_bit != is_tab { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Tabs bitmap: {:#b}", tabs_bitmap); + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } +} From 42ce2238c0a6d7da013c6794d386abcf4371f163 Mon Sep 17 00:00:00 2001 From: Anthony Date: Mon, 16 Jun 2025 17:08:40 -0400 Subject: [PATCH 12/24] Add multi buffer test for tab map work --- crates/multi_buffer/src/multi_buffer.rs | 9 +- crates/multi_buffer/src/multi_buffer_tests.rs | 146 +++++++++++++++++- 2 files changed, 147 insertions(+), 8 deletions(-) diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 8ba6a2929a..0291471c6f 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -7729,12 +7729,12 @@ impl<'a> Iterator for MultiBufferChunks<'a> { // FIXME: We should be handling bitmap for tabs and chars here // Because we do a split at operation the bitmaps will be off Some(Chunk { - text: before, + text: dbg!(before), ..chunk.clone() }) } else { self.range.start = chunk_end; - self.buffer_chunk.take() + dbg!(self.buffer_chunk.take()) } } DiffTransform::DeletedHunk { @@ -7767,12 +7767,13 @@ impl<'a> Iterator for MultiBufferChunks<'a> { let chunk = if let Some(chunk) = chunks.next() { self.range.start += chunk.text.len(); self.diff_base_chunks = Some((*buffer_id, chunks)); - chunk + dbg!(chunk) } else { debug_assert!(has_trailing_newline); self.range.start += "\n".len(); Chunk { text: "\n", + chars: 1u128, ..Default::default() } }; @@ -7868,9 +7869,11 @@ impl<'a> Iterator for ExcerptChunks<'a> { if self.footer_height > 0 { let text = unsafe { str::from_utf8_unchecked(&NEWLINES[..self.footer_height]) }; + let chars = (1 << self.footer_height) - 1; self.footer_height = 0; return Some(Chunk { text, + chars, ..Default::default() }); } diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 75c9d0cfc8..22395b97d5 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -3721,7 +3721,6 @@ fn test_new_empty_buffers_title_can_be_set(cx: &mut App) { #[gpui::test(iterations = 100)] fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { - // Generate random multibuffer using existing test infrastructure let multibuffer = if rng.r#gen() { let len = rng.gen_range(0..10000); let text = RandomCharIter::new(&mut rng).take(len).collect::(); @@ -3733,7 +3732,6 @@ fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { let snapshot = multibuffer.read(cx).snapshot(cx); - // Get all chunks and verify their bitmaps let chunks = snapshot.chunks(0..snapshot.len(), false); for chunk in chunks { @@ -3741,7 +3739,6 @@ fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { let chars_bitmap = chunk.chars; let tabs_bitmap = chunk.tabs; - // Check empty chunks have empty bitmaps if chunk_text.is_empty() { assert_eq!( chars_bitmap, 0, @@ -3751,7 +3748,6 @@ fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { continue; } - // Verify that chunk text doesn't exceed 128 bytes assert!( chunk_text.len() <= 128, "Chunk text length {} exceeds 128 bytes", @@ -3781,7 +3777,147 @@ fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { ); } - // Verify tabs bitmap + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + if has_bit != is_tab { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Tabs bitmap: {:#b}", tabs_bitmap); + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } +} + +#[gpui::test(iterations = 100)] +fn test_random_chunk_bitmaps_with_diffs(cx: &mut App, mut rng: StdRng) { + use buffer_diff::BufferDiff; + use util::RandomCharIter; + + let multibuffer = if rng.r#gen() { + let len = rng.gen_range(100..10000); + let text = RandomCharIter::new(&mut rng).take(len).collect::(); + let buffer = cx.new(|cx| Buffer::local(text, cx)); + cx.new(|cx| MultiBuffer::singleton(buffer, cx)) + } else { + MultiBuffer::build_random(&mut rng, cx) + }; + + let _diff_count = rng.gen_range(1..5); + let mut diffs = Vec::new(); + + multibuffer.update(cx, |multibuffer, cx| { + for buffer_id in multibuffer.excerpt_buffer_ids() { + if rng.gen_bool(0.7) { + if let Some(buffer_handle) = multibuffer.buffer(buffer_id) { + let buffer_text = buffer_handle.read(cx).text(); + let mut base_text = String::new(); + + for line in buffer_text.lines() { + if rng.gen_bool(0.3) { + continue; + } else if rng.gen_bool(0.3) { + let line_len = rng.gen_range(0..50); + let modified_line = RandomCharIter::new(&mut rng) + .take(line_len) + .collect::(); + base_text.push_str(&modified_line); + base_text.push('\n'); + } else { + base_text.push_str(line); + base_text.push('\n'); + } + } + + if rng.gen_bool(0.5) { + let extra_lines = rng.gen_range(1..5); + for _ in 0..extra_lines { + let line_len = rng.gen_range(0..50); + let extra_line = RandomCharIter::new(&mut rng) + .take(line_len) + .collect::(); + base_text.push_str(&extra_line); + base_text.push('\n'); + } + } + + let diff = + cx.new(|cx| BufferDiff::new_with_base_text(&base_text, &buffer_handle, cx)); + diffs.push(diff.clone()); + multibuffer.add_diff(diff, cx); + } + } + } + }); + + multibuffer.update(cx, |multibuffer, cx| { + if rng.gen_bool(0.5) { + multibuffer.set_all_diff_hunks_expanded(cx); + } else { + let snapshot = multibuffer.snapshot(cx); + let mut ranges = Vec::new(); + for _ in 0..rng.gen_range(1..5) { + let start = rng.gen_range(0..snapshot.len()); + let end = rng.gen_range(start..snapshot.len().min(start + 1000)); + let start_anchor = snapshot.anchor_after(start); + let end_anchor = snapshot.anchor_before(end); + ranges.push(start_anchor..end_anchor); + } + multibuffer.expand_diff_hunks(ranges, cx); + } + }); + + let snapshot = multibuffer.read(cx).snapshot(cx); + + let chunks = snapshot.chunks(0..snapshot.len(), false); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + } + + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + for (byte_idx, byte) in chunk_text.bytes().enumerate() { let is_tab = byte == b'\t'; let has_bit = tabs_bitmap & (1 << byte_idx) != 0; From 665a2ccfbfc01b306da4c57ff18062682932243c Mon Sep 17 00:00:00 2001 From: Anthony Date: Wed, 25 Jun 2025 16:08:16 -0400 Subject: [PATCH 13/24] Start work on bench marking Co-authored-by: Cole Miller --- Cargo.lock | 1 + crates/editor/Cargo.toml | 6 ++ crates/editor/benches/editor_render.rs | 89 +++++++++++++++++++++++++ crates/multi_buffer/src/multi_buffer.rs | 2 +- 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 crates/editor/benches/editor_render.rs diff --git a/Cargo.lock b/Cargo.lock index 9663b5c6c0..06c3d1e99a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4697,6 +4697,7 @@ dependencies = [ "clock", "collections", "convert_case 0.8.0", + "criterion", "ctor", "dap", "db", diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index bc901fece3..ff8026c3c0 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -92,6 +92,7 @@ zed_actions.workspace = true workspace-hack.workspace = true [dev-dependencies] +criterion.workspace = true ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } @@ -114,3 +115,8 @@ util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } zlog.workspace = true + + +[[bench]] +name = "editor_render" +harness = false diff --git a/crates/editor/benches/editor_render.rs b/crates/editor/benches/editor_render.rs new file mode 100644 index 0000000000..d208889950 --- /dev/null +++ b/crates/editor/benches/editor_render.rs @@ -0,0 +1,89 @@ +use criterion::{ + BatchSize, Bencher, BenchmarkId, Criterion, Throughput, black_box, criterion_group, + criterion_main, +}; +use editor::{Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer}; +use gpui::{AppContext, Focusable as _, Render, TestAppContext, TestDispatcher}; +use project::Project; +use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; +use settings::{Settings, SettingsStore}; +use ui::{Element, IntoElement}; +use util::RandomCharIter; + +fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { + let mut cx = cx.clone(); + let buffer = cx.update(|cx| { + let mut rng = StdRng::seed_from_u64(1); + let text_len = rng.gen_range(10000..100000); + if rng.r#gen() { + let text = RandomCharIter::new(&mut rng) + .take(text_len) + .collect::(); + MultiBuffer::build_simple(&text, cx) + } else { + MultiBuffer::build_random(&mut rng, cx) + } + }); + + let cx = cx.add_empty_window(); + let mut editor = cx.update(|window, cx| { + let editor = cx.new(|cx| Editor::new(EditorMode::full(), buffer, None, window, cx)); + window.focus(&editor.focus_handle(cx)); + editor + }); + + bencher.iter(|| { + cx.update(|window, cx| { + let (_, mut layout_state) = editor.request_layout(None, None, window, cx); + let mut prepaint = + editor.prepaint(None, None, window.bounds(), &mut layout_state, window, cx); + editor.paint( + None, + None, + window.bounds(), + &mut layout_state, + &mut prepaint, + window, + cx, + ); + + window.refresh(); + }); + }) +} + +pub fn benches() { + let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(1)); + let cx = gpui::TestAppContext::build(dispatcher, None); + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + assets::Assets.load_test_fonts(cx); + theme::init(theme::LoadThemes::JustBase, cx); + // release_channel::init(SemanticVersion::default(), cx); + client::init_settings(cx); + language::init(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + editor::init(cx); + }); + + let mut criterion: criterion::Criterion<_> = + (criterion::Criterion::default()).configure_from_args(); + + cx.dispatch_keystroke(window, keystroke); + + // setup app context + criterion.bench_with_input( + BenchmarkId::new("editor_render", "TestAppContext"), + &cx, + |bencher, cx| editor_render(bencher, cx), + ); +} + +fn main() { + benches(); + criterion::Criterion::default() + .configure_from_args() + .final_summary(); +} diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 0291471c6f..c39f0ba49e 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -7734,7 +7734,7 @@ impl<'a> Iterator for MultiBufferChunks<'a> { }) } else { self.range.start = chunk_end; - dbg!(self.buffer_chunk.take()) + self.buffer_chunk.take() } } DiffTransform::DeletedHunk { From 97f4406ef6d87ea13f2dc4a39e4b8ef3133994e6 Mon Sep 17 00:00:00 2001 From: Anthony Date: Wed, 25 Jun 2025 17:49:24 -0400 Subject: [PATCH 14/24] Switch to view --- crates/editor/benches/editor_render.rs | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/crates/editor/benches/editor_render.rs b/crates/editor/benches/editor_render.rs index d208889950..03ff02a215 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/editor/benches/editor_render.rs @@ -14,7 +14,7 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { let mut cx = cx.clone(); let buffer = cx.update(|cx| { let mut rng = StdRng::seed_from_u64(1); - let text_len = rng.gen_range(10000..100000); + let text_len = rng.gen_range(0..100); if rng.r#gen() { let text = RandomCharIter::new(&mut rng) .take(text_len) @@ -34,20 +34,10 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { bencher.iter(|| { cx.update(|window, cx| { - let (_, mut layout_state) = editor.request_layout(None, None, window, cx); - let mut prepaint = - editor.prepaint(None, None, window.bounds(), &mut layout_state, window, cx); - editor.paint( - None, - None, - window.bounds(), - &mut layout_state, - &mut prepaint, - window, - cx, - ); - - window.refresh(); + let mut view = editor.clone().into_any_element(); + let _ = view.request_layout(window, cx); + let prepaint = view.prepaint(window, cx); + view.paint(window, cx); }); }) } @@ -71,8 +61,6 @@ pub fn benches() { let mut criterion: criterion::Criterion<_> = (criterion::Criterion::default()).configure_from_args(); - cx.dispatch_keystroke(window, keystroke); - // setup app context criterion.bench_with_input( BenchmarkId::new("editor_render", "TestAppContext"), From 1bdde8b2e40ceadd9ca35bee4b7dab70b00ff9ff Mon Sep 17 00:00:00 2001 From: Anthony Date: Fri, 27 Jun 2025 18:38:25 -0400 Subject: [PATCH 15/24] WIP and merge --- .github/actions/run_tests_windows/action.yml | 8 +- .github/workflows/ci.yml | 124 +- .../workflows/community_delete_comments.yml | 34 - .gitignore | 1 + .rules | 4 +- .zed/debug.json | 18 +- .zed/settings.json | 3 +- Cargo.lock | 312 +- Cargo.toml | 23 +- assets/icons/ai_v_zero.svg | 16 + assets/icons/arrow_up_alt.svg | 3 + assets/icons/blocks.svg | 2 +- assets/icons/file_icons/cairo.svg | 3 + assets/icons/zed_mcp_custom.svg | 4 + assets/icons/zed_mcp_extension.svg | 4 + assets/images/debugger_grid.svg | 890 ++++++ assets/keymaps/default-linux.json | 19 +- assets/keymaps/default-macos.json | 18 +- assets/keymaps/linux/atom.json | 18 +- assets/keymaps/linux/cursor.json | 2 - assets/keymaps/linux/emacs.json | 7 + assets/keymaps/macos/atom.json | 20 +- assets/keymaps/macos/cursor.json | 2 - assets/keymaps/macos/emacs.json | 7 + assets/keymaps/vim.json | 21 +- assets/prompts/assistant_system_prompt.hbs | 4 +- assets/settings/default.json | 86 +- assets/themes/one/one.json | 2 +- crates/activity_indicator/Cargo.toml | 1 + .../src/activity_indicator.rs | 337 ++- crates/agent/Cargo.toml | 42 +- crates/agent/src/agent.rs | 304 +- .../add_context_server_modal.rs | 197 -- .../configure_context_server_modal.rs | 553 ---- crates/agent/src/agent_profile.rs | 15 +- crates/agent/src/context.rs | 86 +- .../agent/src/context_server_configuration.rs | 144 - crates/agent/src/context_server_tool.rs | 2 +- crates/agent/src/context_store.rs | 75 +- crates/agent/src/history_store.rs | 22 +- .../src/prompts/stale_files_prompt_header.txt | 1 - crates/agent/src/thread.rs | 719 +++-- crates/agent/src/thread_store.rs | 76 +- crates/agent/src/tool_use.rs | 21 +- crates/agent_settings/Cargo.toml | 7 - crates/agent_settings/src/agent_settings.rs | 746 +---- crates/agent_ui/Cargo.toml | 110 + .../LICENSE-GPL | 0 .../{agent => agent_ui}/src/active_thread.rs | 359 ++- .../src/agent_configuration.rs | 464 ++- .../configure_context_server_modal.rs | 763 +++++ .../manage_profiles_modal.rs | 2 +- .../profile_modal_header.rs | 0 .../src/agent_configuration/tool_picker.rs | 63 +- crates/{agent => agent_ui}/src/agent_diff.rs | 14 +- .../src/agent_model_selector.rs | 13 +- crates/{agent => agent_ui}/src/agent_panel.rs | 427 ++- crates/agent_ui/src/agent_ui.rs | 291 ++ .../{agent => agent_ui}/src/buffer_codegen.rs | 9 +- .../{agent => agent_ui}/src/context_picker.rs | 10 +- .../src/context_picker/completion_provider.rs | 32 +- .../context_picker/fetch_context_picker.rs | 2 +- .../src/context_picker/file_context_picker.rs | 2 +- .../context_picker/rules_context_picker.rs | 4 +- .../context_picker/symbol_context_picker.rs | 6 +- .../context_picker/thread_context_picker.rs | 9 +- .../src/context_server_configuration.rs | 116 + .../{agent => agent_ui}/src/context_strip.rs | 72 +- crates/{agent => agent_ui}/src/debug.rs | 10 +- .../src/inline_assistant.rs | 85 +- .../src/inline_prompt_editor.rs | 24 +- .../src/language_model_selector.rs | 15 +- .../src/max_mode_tooltip.rs | 0 .../{agent => agent_ui}/src/message_editor.rs | 509 ++-- .../src/profile_selector.rs | 13 +- .../src/slash_command.rs | 7 +- .../src/slash_command_picker.rs | 12 +- .../src/slash_command_settings.rs | 0 .../src/terminal_codegen.rs | 0 .../src/terminal_inline_assistant.rs | 11 +- .../src/text_thread_editor.rs} | 178 +- .../{agent => agent_ui}/src/thread_history.rs | 11 +- .../src/tool_compatibility.rs | 6 +- crates/{agent => agent_ui}/src/ui.rs | 0 .../src/ui/agent_notification.rs | 0 .../src/ui/animated_label.rs | 0 .../src/ui/context_pill.rs | 2 +- .../src/ui/max_mode_tooltip.rs | 0 .../src/ui/onboarding_modal.rs | 0 crates/{agent => agent_ui}/src/ui/preview.rs | 0 .../src/ui/preview/agent_preview.rs | 0 .../src/ui/preview/usage_callouts.rs | 60 +- crates/{agent => agent_ui}/src/ui/upsell.rs | 0 crates/anthropic/src/anthropic.rs | 307 +- crates/askpass/Cargo.toml | 1 - crates/askpass/src/askpass.rs | 38 +- .../Cargo.toml | 15 +- crates/assistant_context/LICENSE-GPL | 1 + .../src/assistant_context.rs} | 24 +- .../src/assistant_context_tests.rs} | 0 .../src/context_store.rs | 1 + .../src/assistant_context_editor.rs | 36 - .../src/context_history.rs | 271 -- .../src/diagnostics_command.rs | 1 + .../src/file_command.rs | 41 +- .../src/tab_command.rs | 1 + crates/assistant_tool/src/action_log.rs | 6 +- crates/assistant_tools/src/edit_agent.rs | 102 +- .../src/edit_agent/edit_parser.rs | 681 ++++- .../assistant_tools/src/edit_agent/evals.rs | 115 +- .../disable_cursor_blinking/before.rs | 208 +- .../src/edit_agent/streaming_fuzzy_matcher.rs | 121 +- crates/assistant_tools/src/edit_file_tool.rs | 69 +- crates/assistant_tools/src/read_file_tool.rs | 4 +- .../edit_file_prompt_diff_fenced.hbs | 77 + ...le_prompt.hbs => edit_file_prompt_xml.hbs} | 11 +- crates/auto_update/src/auto_update.rs | 25 +- crates/auto_update_ui/src/auto_update_ui.rs | 10 +- crates/bedrock/src/bedrock.rs | 2 +- crates/bedrock/src/models.rs | 87 +- crates/buffer_diff/src/buffer_diff.rs | 6 +- crates/channel/Cargo.toml | 1 + crates/channel/src/channel_store.rs | 146 +- crates/channel/src/channel_store_tests.rs | 3 - crates/cli/src/cli.rs | 1 + crates/cli/src/main.rs | 25 +- crates/client/Cargo.toml | 8 +- crates/client/src/client.rs | 12 +- crates/client/src/telemetry.rs | 280 +- crates/client/src/user.rs | 148 +- crates/collab/Cargo.toml | 2 +- .../20221109000000_test_schema.sql | 5 +- ...12153105_add_collaborator_commit_email.sql | 4 + ...g_adapter_provides_field_to_extensions.sql | 2 + crates/collab/src/api.rs | 6 +- crates/collab/src/api/billing.rs | 14 +- crates/collab/src/db.rs | 4 + crates/collab/src/db/queries/buffers.rs | 8 + crates/collab/src/db/queries/channels.rs | 1 - crates/collab/src/db/queries/contributors.rs | 2 +- crates/collab/src/db/queries/extensions.rs | 7 + crates/collab/src/db/queries/projects.rs | 36 +- crates/collab/src/db/queries/rooms.rs | 4 + crates/collab/src/db/queries/users.rs | 6 +- .../collab/src/db/tables/extension_version.rs | 5 + .../src/db/tables/project_collaborator.rs | 2 + crates/collab/src/db/tests/buffer_tests.rs | 4 + crates/collab/src/db/tests/db_tests.rs | 19 +- crates/collab/src/rpc.rs | 34 +- crates/collab/src/seed.rs | 2 +- crates/collab/src/stripe_billing.rs | 27 +- crates/collab/src/stripe_client.rs | 44 + .../src/stripe_client/fake_stripe_client.rs | 31 +- .../src/stripe_client/real_stripe_client.rs | 101 +- .../collab/src/tests/channel_guest_tests.rs | 2 +- crates/collab/src/tests/editor_tests.rs | 891 +++++- crates/collab/src/tests/following_tests.rs | 2 + crates/collab/src/tests/integration_tests.rs | 108 +- .../remote_editing_collaboration_tests.rs | 6 +- .../collab/src/tests/stripe_billing_tests.rs | 48 +- crates/collab/src/tests/test_server.rs | 2 +- crates/collab/src/user_backfiller.rs | 2 +- crates/collab_ui/src/chat_panel.rs | 5 +- .../src/chat_panel/message_editor.rs | 1 + crates/collab_ui/src/collab_panel.rs | 18 +- .../src/collab_panel/channel_modal.rs | 1 + crates/command_palette/src/command_palette.rs | 1 + crates/context_server/src/context_server.rs | 19 +- crates/copilot/Cargo.toml | 1 + crates/copilot/src/copilot.rs | 143 +- crates/copilot/src/copilot_chat.rs | 179 +- crates/dap/src/adapters.rs | 26 +- crates/dap/src/client.rs | 144 +- crates/dap/src/dap.rs | 34 +- crates/dap/src/inline_value.rs | 640 ---- crates/dap/src/registry.rs | 54 +- crates/dap/src/transport.rs | 762 +++-- crates/dap_adapters/src/codelldb.rs | 38 +- crates/dap_adapters/src/dap_adapters.rs | 6 - crates/dap_adapters/src/gdb.rs | 24 +- crates/dap_adapters/src/go.rs | 87 +- crates/dap_adapters/src/javascript.rs | 114 +- crates/dap_adapters/src/php.rs | 65 +- crates/dap_adapters/src/python.rs | 111 +- crates/dap_adapters/src/ruby.rs | 23 +- crates/db/src/db.rs | 4 +- crates/debug_adapter_extension/Cargo.toml | 1 + .../src/debug_adapter_extension.rs | 32 +- .../src/extension_dap_adapter.rs | 51 +- .../src/extension_locator_adapter.rs | 50 + crates/debugger_tools/src/dap_log.rs | 475 ++- crates/debugger_ui/Cargo.toml | 8 +- crates/debugger_ui/src/attach_modal.rs | 41 +- crates/debugger_ui/src/debugger_panel.rs | 410 ++- crates/debugger_ui/src/debugger_ui.rs | 313 +- crates/debugger_ui/src/dropdown_menus.rs | 26 +- crates/debugger_ui/src/new_process_modal.rs | 656 +++-- crates/debugger_ui/src/onboarding_modal.rs | 166 ++ crates/debugger_ui/src/persistence.rs | 33 +- crates/debugger_ui/src/session.rs | 7 +- crates/debugger_ui/src/session/running.rs | 194 +- .../src/session/running/breakpoint_list.rs | 217 +- .../src/session/running/console.rs | 432 ++- .../src/session/running/module_list.rs | 21 +- .../src/session/running/stack_frame_list.rs | 150 +- .../src/session/running/variable_list.rs | 643 +++- crates/debugger_ui/src/stack_trace_view.rs | 10 +- crates/debugger_ui/src/tests/console.rs | 203 +- .../debugger_ui/src/tests/debugger_panel.rs | 224 +- crates/debugger_ui/src/tests/inline_values.rs | 468 ++- .../src/tests/new_process_modal.rs | 2 + .../debugger_ui/src/tests/stack_frame_list.rs | 4 +- crates/debugger_ui/src/tests/variable_list.rs | 544 +++- crates/deepseek/src/deepseek.rs | 21 +- crates/diagnostics/src/diagnostics.rs | 6 +- crates/diagnostics/src/diagnostics_tests.rs | 12 +- crates/docs_preprocessor/src/main.rs | 4 +- crates/editor/Cargo.toml | 1 - crates/editor/benches/editor_render.rs | 19 +- crates/editor/src/actions.rs | 135 +- crates/editor/src/code_completion_tests.rs | 2623 ++--------------- crates/editor/src/code_context_menus.rs | 153 +- crates/editor/src/display_map.rs | 46 +- .../src/display_map/custom_highlights.rs | 23 +- crates/editor/src/display_map/inlay_map.rs | 131 +- crates/editor/src/editor.rs | 1896 +++++++----- crates/editor/src/editor_settings.rs | 64 + crates/editor/src/editor_tests.rs | 779 ++++- crates/editor/src/element.rs | 792 +++-- .../editor/src/highlight_matching_bracket.rs | 7 +- crates/editor/src/hover_popover.rs | 2 +- crates/editor/src/items.rs | 86 +- crates/editor/src/jsx_tag_auto_close.rs | 13 +- crates/editor/src/lsp_colors.rs | 356 +++ crates/editor/src/movement.rs | 104 +- crates/editor/src/proposed_changes_editor.rs | 14 +- crates/editor/src/scroll.rs | 17 +- crates/editor/src/scroll/autoscroll.rs | 16 +- crates/editor/src/scroll/scroll_amount.rs | 21 + crates/editor/src/selections_collection.rs | 62 +- .../src/test/editor_lsp_test_context.rs | 8 +- crates/editor/src/test/editor_test_context.rs | 4 +- crates/eval/Cargo.toml | 1 + crates/eval/src/eval.rs | 4 +- crates/eval/src/example.rs | 2 +- .../src/examples/file_change_notification.rs | 74 + crates/eval/src/examples/mod.rs | 2 + crates/eval/src/instance.rs | 10 +- crates/extension/src/extension.rs | 22 +- crates/extension/src/extension_builder.rs | 17 + crates/extension/src/extension_events.rs | 1 + crates/extension/src/extension_host_proxy.rs | 43 +- crates/extension/src/extension_manifest.rs | 18 +- crates/extension/src/types.rs | 19 +- crates/extension/src/types/dap.rs | 5 +- crates/extension_api/Cargo.toml | 3 +- crates/extension_api/README.md | 1 + crates/extension_api/src/extension_api.rs | 96 +- crates/extension_api/wit/since_v0.6.0/dap.wit | 67 +- .../wit/since_v0.6.0/extension.wit | 9 +- crates/extension_cli/src/main.rs | 4 + .../extension_compilation_benchmark.rs | 1 + crates/extension_host/src/extension_host.rs | 50 +- .../src/extension_store_test.rs | 32 +- crates/extension_host/src/wasm_host.rs | 133 +- crates/extension_host/src/wasm_host/wit.rs | 116 +- .../src/wasm_host/wit/since_v0_0_1.rs | 5 +- .../src/wasm_host/wit/since_v0_0_4.rs | 5 +- .../src/wasm_host/wit/since_v0_0_6.rs | 5 +- .../src/wasm_host/wit/since_v0_1_0.rs | 25 +- .../src/wasm_host/wit/since_v0_2_0.rs | 5 +- .../src/wasm_host/wit/since_v0_3_0.rs | 5 +- .../src/wasm_host/wit/since_v0_4_0.rs | 5 +- .../src/wasm_host/wit/since_v0_5_0.rs | 6 +- .../src/wasm_host/wit/since_v0_6_0.rs | 225 +- .../src/extension_version_selector.rs | 1 + crates/extensions_ui/src/extensions_ui.rs | 9 +- crates/feature_flags/src/feature_flags.rs | 5 - crates/feedback/src/system_specs.rs | 29 +- crates/file_finder/src/file_finder.rs | 41 +- crates/file_finder/src/file_finder_tests.rs | 146 +- crates/file_finder/src/open_path_prompt.rs | 1 + crates/fs/src/fs.rs | 51 +- crates/fuzzy/src/matcher.rs | 13 +- crates/fuzzy/src/paths.rs | 4 +- crates/fuzzy/src/strings.rs | 10 +- crates/git/src/git.rs | 16 +- crates/git/src/repository.rs | 46 +- crates/git_ui/Cargo.toml | 2 + crates/git_ui/src/branch_picker.rs | 5 +- crates/git_ui/src/commit_modal.rs | 1 + crates/git_ui/src/diff_view.rs | 581 ++++ crates/git_ui/src/git_panel.rs | 78 +- crates/git_ui/src/git_panel_settings.rs | 6 + crates/git_ui/src/git_ui.rs | 34 +- crates/git_ui/src/picker_prompt.rs | 1 + crates/git_ui/src/project_diff.rs | 35 +- crates/git_ui/src/repository_selector.rs | 13 +- crates/google_ai/src/google_ai.rs | 193 +- crates/gpui/examples/data_table.rs | 13 +- crates/gpui/src/action.rs | 446 +-- crates/gpui/src/app.rs | 21 +- crates/gpui/src/app/test_context.rs | 17 +- crates/gpui/src/arena.rs | 143 +- crates/gpui/src/geometry.rs | 10 +- crates/gpui/src/gpui.rs | 2 +- crates/gpui/src/interactive.rs | 8 +- crates/gpui/src/key_dispatch.rs | 4 +- crates/gpui/src/keymap.rs | 4 +- crates/gpui/src/keymap/binding.rs | 25 + crates/gpui/src/keymap/context.rs | 4 +- crates/gpui/src/platform.rs | 16 +- .../gpui/src/platform/blade/blade_renderer.rs | 7 +- crates/gpui/src/platform/keystroke.rs | 8 + .../src/platform/linux/headless/client.rs | 2 +- crates/gpui/src/platform/linux/keyboard.rs | 13 +- crates/gpui/src/platform/linux/platform.rs | 77 +- .../gpui/src/platform/linux/wayland/client.rs | 123 +- .../gpui/src/platform/linux/wayland/cursor.rs | 183 +- .../gpui/src/platform/linux/wayland/window.rs | 17 +- crates/gpui/src/platform/linux/x11/client.rs | 982 +++--- crates/gpui/src/platform/linux/x11/window.rs | 172 +- crates/gpui/src/platform/mac/events.rs | 7 +- crates/gpui/src/platform/mac/window.rs | 30 +- crates/gpui/src/platform/test/window.rs | 4 + .../gpui/src/platform/windows/direct_write.rs | 194 +- crates/gpui/src/platform/windows/events.rs | 139 +- crates/gpui/src/platform/windows/platform.rs | 53 +- crates/gpui/src/platform/windows/util.rs | 13 +- crates/gpui/src/platform/windows/window.rs | 50 +- crates/gpui/src/taffy.rs | 4 +- crates/gpui/src/text_system.rs | 14 + crates/gpui/src/window.rs | 162 +- crates/gpui/tests/action_macros.rs | 22 +- crates/gpui_macros/src/derive_action.rs | 176 ++ crates/gpui_macros/src/gpui_macros.rs | 15 +- crates/gpui_macros/src/register_action.rs | 15 +- crates/icons/src/icons.rs | 4 + crates/indexed_docs/src/store.rs | 1 + crates/inline_completion/Cargo.toml | 3 +- .../src/inline_completion.rs | 40 +- crates/jj_ui/src/bookmark_picker.rs | 1 + crates/language/Cargo.toml | 1 + crates/language/src/buffer.rs | 109 +- crates/language/src/language.rs | 74 +- crates/language/src/language_registry.rs | 39 +- crates/language/src/language_settings.rs | 23 +- crates/language/src/outline.rs | 1 + crates/language/src/proto.rs | 32 + crates/language/src/syntax_map.rs | 10 + .../src/extension_lsp_adapter.rs | 15 +- crates/language_model/Cargo.toml | 1 + crates/language_model/src/fake_provider.rs | 4 +- crates/language_model/src/language_model.rs | 168 +- crates/language_model/src/registry.rs | 59 + crates/language_model/src/request.rs | 2 +- crates/language_model/src/telemetry.rs | 51 +- crates/language_models/Cargo.toml | 4 +- crates/language_models/src/language_models.rs | 10 +- crates/language_models/src/provider.rs | 1 + .../language_models/src/provider/anthropic.rs | 60 +- .../language_models/src/provider/bedrock.rs | 174 +- crates/language_models/src/provider/cloud.rs | 31 +- .../src/provider/copilot_chat.rs | 252 +- .../language_models/src/provider/deepseek.rs | 25 +- crates/language_models/src/provider/google.rs | 50 +- .../language_models/src/provider/lmstudio.rs | 133 +- .../language_models/src/provider/mistral.rs | 28 +- crates/language_models/src/provider/ollama.rs | 16 +- .../language_models/src/provider/open_ai.rs | 283 +- .../src/provider/open_router.rs | 196 +- crates/language_models/src/provider/vercel.rs | 577 ++++ crates/language_models/src/settings.rs | 239 +- .../src/ui/instruction_list_item.rs | 41 +- .../src/language_selector.rs | 1 + crates/language_tools/Cargo.toml | 4 +- crates/language_tools/src/language_tools.rs | 39 +- crates/language_tools/src/lsp_log.rs | 240 +- crates/language_tools/src/lsp_tool.rs | 917 ++++++ crates/language_tools/src/syntax_tree_view.rs | 2 +- crates/languages/src/c.rs | 52 +- crates/languages/src/go.rs | 60 +- crates/languages/src/go/debugger.scm | 26 + crates/languages/src/json.rs | 113 +- crates/languages/src/json/config.toml | 2 +- crates/languages/src/lib.rs | 8 +- crates/languages/src/package_json.rs | 106 + crates/languages/src/python.rs | 14 +- crates/languages/src/python/debugger.scm | 43 + crates/languages/src/rust.rs | 98 +- crates/languages/src/rust/debugger.scm | 50 + crates/languages/src/tsx/outline.scm | 25 +- crates/languages/src/tsx/runnables.scm | 27 +- crates/languages/src/typescript.rs | 410 ++- crates/languages/src/vtsls.rs | 8 +- crates/livekit_client/Cargo.toml | 4 +- crates/livekit_client/src/lib.rs | 10 +- crates/lmstudio/src/lmstudio.rs | 122 +- crates/lsp/src/lsp.rs | 11 +- .../markdown_preview/src/markdown_preview.rs | 5 +- .../src/markdown_preview_view.rs | 76 +- crates/migrator/src/migrations.rs | 12 + .../src/migrations/m_2025_06_16/settings.rs | 90 + .../src/migrations/m_2025_06_25/settings.rs | 133 + crates/migrator/src/migrator.rs | 277 ++ crates/mistral/src/mistral.rs | 19 +- crates/multi_buffer/src/multi_buffer.rs | 41 +- crates/multi_buffer/src/multi_buffer_tests.rs | 20 +- crates/notifications/src/status_toast.rs | 40 +- crates/ollama/src/ollama.rs | 16 +- crates/open_ai/src/open_ai.rs | 292 +- crates/open_router/src/open_router.rs | 187 +- crates/outline_panel/src/outline_panel.rs | 12 +- crates/paths/src/paths.rs | 90 +- crates/picker/src/picker.rs | 13 +- crates/prettier/src/prettier.rs | 5 +- crates/prettier/src/prettier_server.js | 69 +- crates/project/src/buffer_store.rs | 15 +- crates/project/src/context_server_store.rs | 587 +++- .../project/src/debugger/breakpoint_store.rs | 4 +- crates/project/src/debugger/dap_command.rs | 2 +- crates/project/src/debugger/dap_store.rs | 110 +- crates/project/src/debugger/locators/cargo.rs | 20 +- crates/project/src/debugger/locators/go.rs | 62 +- crates/project/src/debugger/locators/node.rs | 35 +- .../project/src/debugger/locators/python.rs | 6 +- crates/project/src/debugger/session.rs | 458 ++- crates/project/src/direnv.rs | 30 +- crates/project/src/environment.rs | 8 +- crates/project/src/git_store.rs | 31 +- crates/project/src/lsp_command.rs | 237 +- crates/project/src/lsp_store.rs | 1743 ++++++++--- crates/project/src/lsp_store/clangd_ext.rs | 2 +- .../src/lsp_store/rust_analyzer_ext.rs | 90 +- .../project/src/manifest_tree/server_tree.rs | 6 + crates/project/src/project.rs | 189 +- crates/project/src/project_settings.rs | 94 +- crates/project/src/project_tests.rs | 242 +- crates/project/src/task_inventory.rs | 198 +- crates/project/src/toolchain_store.rs | 2 +- crates/project_panel/src/project_panel.rs | 21 +- .../project_panel/src/project_panel_tests.rs | 276 +- crates/project_symbols/src/project_symbols.rs | 3 + crates/prompt_store/src/prompt_store.rs | 1 + crates/prompt_store/src/prompts.rs | 3 + crates/proto/proto/call.proto | 2 + crates/proto/proto/core.proto | 4 +- crates/proto/proto/debugger.proto | 3 +- crates/proto/proto/lsp.proto | 91 + crates/proto/proto/zed.proto | 7 +- crates/proto/src/proto.rs | 8 + crates/recent_projects/src/recent_projects.rs | 1 + crates/recent_projects/src/ssh_connections.rs | 3 + crates/remote/Cargo.toml | 1 + crates/remote/src/ssh_session.rs | 86 +- crates/remote_server/Cargo.toml | 2 + crates/remote_server/src/headless_project.rs | 2 + crates/remote_server/src/main.rs | 8 + .../remote_server/src/remote_editing_tests.rs | 146 +- crates/repl/src/notebook/cell.rs | 5 +- crates/repl/src/notebook/notebook_ui.rs | 4 +- crates/repl/src/outputs.rs | 2 + crates/rpc/src/extension.rs | 1 + crates/rules_library/Cargo.toml | 1 + crates/rules_library/src/rules_library.rs | 175 +- crates/search/src/buffer_search.rs | 7 +- crates/search/src/project_search.rs | 30 +- crates/semantic_index/src/semantic_index.rs | 4 +- crates/settings/src/keymap_file.rs | 104 +- crates/settings/src/settings.rs | 3 +- crates/settings/src/settings_store.rs | 142 +- crates/settings/src/vscode_import.rs | 49 +- crates/settings_ui/Cargo.toml | 8 +- crates/settings_ui/src/settings_ui.rs | 59 +- crates/snippets_ui/src/snippets_ui.rs | 1 + .../src/stories/auto_height_editor.rs | 2 +- crates/storybook/src/stories/picker.rs | 1 + crates/storybook/src/storybook.rs | 2 +- crates/supermaven_api/Cargo.toml | 1 + crates/supermaven_api/src/supermaven_api.rs | 18 +- crates/tab_switcher/src/tab_switcher.rs | 8 +- crates/task/src/vscode_debug_format.rs | 73 +- crates/task/src/vscode_format.rs | 25 +- crates/tasks_ui/src/modal.rs | 42 +- crates/tasks_ui/src/tasks_ui.rs | 16 +- crates/terminal/Cargo.toml | 1 + crates/terminal/src/terminal.rs | 18 +- crates/terminal/src/terminal_hyperlinks.rs | 10 +- crates/terminal_view/src/terminal_element.rs | 2 +- crates/terminal_view/src/terminal_view.rs | 16 +- crates/theme/src/icon_theme.rs | 2 + .../theme_selector/src/icon_theme_selector.rs | 1 + crates/theme_selector/src/theme_selector.rs | 1 + crates/title_bar/src/application_menu.rs | 8 +- crates/title_bar/src/collab.rs | 5 +- crates/title_bar/src/platform_title_bar.rs | 169 ++ .../src/platforms/platform_windows.rs | 39 +- crates/title_bar/src/title_bar.rs | 283 +- .../src/toolchain_selector.rs | 1 + .../ui/src/components/button/button_like.rs | 4 + crates/ui/src/components/callout.rs | 258 +- crates/ui/src/components/image.rs | 1 + .../ui/src/components/stories/context_menu.rs | 2 +- crates/ui_input/src/ui_input.rs | 6 +- crates/util/Cargo.toml | 2 + crates/util/src/fs.rs | 19 + crates/util/src/redact.rs | 8 + crates/util/src/shell_env.rs | 373 +-- crates/util/src/util.rs | 136 +- crates/util_macros/src/util_macros.rs | 25 +- crates/vercel/Cargo.toml | 23 + crates/vercel/LICENSE-GPL | 1 + crates/vercel/src/vercel.rs | 74 + crates/vim/src/command.rs | 52 +- crates/vim/src/digraph.rs | 6 +- crates/vim/src/helix.rs | 33 + crates/vim/src/insert.rs | 23 +- crates/vim/src/motion.rs | 89 +- crates/vim/src/normal.rs | 84 - crates/vim/src/normal/increment.rs | 10 +- crates/vim/src/normal/paste.rs | 15 +- crates/vim/src/normal/scroll.rs | 157 +- crates/vim/src/normal/search.rs | 19 +- crates/vim/src/normal/yank.rs | 2 +- crates/vim/src/object.rs | 13 +- crates/vim/src/replace.rs | 2 +- crates/vim/src/state.rs | 10 +- crates/vim/src/test/vim_test_context.rs | 8 +- crates/vim/src/vim.rs | 99 +- crates/vim/test_data/test_scroll_jumps.json | 12 + .../vim_mode_setting/src/vim_mode_setting.rs | 31 +- crates/welcome/src/base_keymap_picker.rs | 1 + crates/workspace/src/dock.rs | 2 +- crates/workspace/src/item.rs | 27 +- crates/workspace/src/pane.rs | 195 +- crates/workspace/src/workspace.rs | 238 +- crates/zed/Cargo.toml | 10 +- crates/zed/resources/snap/snapcraft.yaml.in | 2 + crates/zed/src/main.rs | 73 +- crates/zed/src/zed.rs | 291 +- crates/zed/src/zed/app_menus.rs | 2 +- crates/zed/src/zed/component_preview.rs | 95 +- .../preview_support/active_thread.rs | 3 +- crates/zed/src/zed/migrate.rs | 41 +- crates/zed/src/zed/open_listener.rs | 81 +- crates/zed/src/zed/quick_action_bar.rs | 20 +- crates/zed/src/zed/windows_only_instance.rs | 25 +- crates/zed_actions/src/lib.rs | 166 +- crates/zeta/src/zeta.rs | 40 +- docs/src/SUMMARY.md | 5 +- docs/src/accounts.md | 6 +- docs/src/ai/ai-improvement.md | 2 + docs/src/ai/configuration.md | 145 +- docs/src/ai/mcp.md | 4 +- docs/src/ai/models.md | 2 + docs/src/ai/rules.md | 1 + docs/src/ai/text-threads.md | 2 +- docs/src/debugger.md | 137 +- docs/src/development/debuggers.md | 35 +- docs/src/diagnostics.md | 70 + docs/src/extensions.md | 1 + docs/src/extensions/debugger-extensions.md | 117 + docs/src/extensions/developing-extensions.md | 21 +- docs/src/extensions/languages.md | 21 +- docs/src/getting-started.md | 19 + docs/src/git.md | 14 + docs/src/key-bindings.md | 2 +- docs/src/languages/helm.md | 5 +- docs/src/languages/rust.md | 4 +- docs/src/languages/sql.md | 2 +- docs/src/vim.md | 3 +- docs/src/visual-customization.md | 544 ++++ docs/src/workspace-persistence.md | 17 + script/bootstrap | 27 + script/bundle-linux | 35 +- script/clear-target-dir-if-larger-than.ps1 | 22 + script/clippy.ps1 | 25 +- script/danger/dangerfile.ts | 3 +- script/debug-cli | 3 + script/flatpak/bundle-flatpak | 2 +- script/install-rustup.ps1 | 39 + script/run-local-minio | 6 +- script/update_top_ranking_issues/main.py | 99 +- tooling/workspace-hack/Cargo.toml | 4 + 584 files changed, 33536 insertions(+), 17400 deletions(-) delete mode 100644 .github/workflows/community_delete_comments.yml create mode 100644 assets/icons/ai_v_zero.svg create mode 100644 assets/icons/arrow_up_alt.svg create mode 100644 assets/icons/file_icons/cairo.svg create mode 100644 assets/icons/zed_mcp_custom.svg create mode 100644 assets/icons/zed_mcp_extension.svg create mode 100644 assets/images/debugger_grid.svg delete mode 100644 crates/agent/src/agent_configuration/add_context_server_modal.rs delete mode 100644 crates/agent/src/agent_configuration/configure_context_server_modal.rs delete mode 100644 crates/agent/src/context_server_configuration.rs delete mode 100644 crates/agent/src/prompts/stale_files_prompt_header.txt create mode 100644 crates/agent_ui/Cargo.toml rename crates/{assistant_context_editor => agent_ui}/LICENSE-GPL (100%) rename crates/{agent => agent_ui}/src/active_thread.rs (93%) rename crates/{agent => agent_ui}/src/agent_configuration.rs (56%) create mode 100644 crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs rename crates/{agent => agent_ui}/src/agent_configuration/manage_profiles_modal.rs (99%) rename crates/{agent => agent_ui}/src/agent_configuration/manage_profiles_modal/profile_modal_header.rs (100%) rename crates/{agent => agent_ui}/src/agent_configuration/tool_picker.rs (85%) rename crates/{agent => agent_ui}/src/agent_diff.rs (99%) rename crates/{agent => agent_ui}/src/agent_model_selector.rs (96%) rename crates/{agent => agent_ui}/src/agent_panel.rs (91%) create mode 100644 crates/agent_ui/src/agent_ui.rs rename crates/{agent => agent_ui}/src/buffer_codegen.rs (99%) rename crates/{agent => agent_ui}/src/context_picker.rs (99%) rename crates/{agent => agent_ui}/src/context_picker/completion_provider.rs (98%) rename crates/{agent => agent_ui}/src/context_picker/fetch_context_picker.rs (99%) rename crates/{agent => agent_ui}/src/context_picker/file_context_picker.rs (99%) rename crates/{agent => agent_ui}/src/context_picker/rules_context_picker.rs (98%) rename crates/{agent => agent_ui}/src/context_picker/symbol_context_picker.rs (99%) rename crates/{agent => agent_ui}/src/context_picker/thread_context_picker.rs (98%) create mode 100644 crates/agent_ui/src/context_server_configuration.rs rename crates/{agent => agent_ui}/src/context_strip.rs (93%) rename crates/{agent => agent_ui}/src/debug.rs (93%) rename crates/{agent => agent_ui}/src/inline_assistant.rs (97%) rename crates/{agent => agent_ui}/src/inline_prompt_editor.rs (98%) rename crates/{assistant_context_editor => agent_ui}/src/language_model_selector.rs (98%) rename crates/{assistant_context_editor => agent_ui}/src/max_mode_tooltip.rs (100%) rename crates/{agent => agent_ui}/src/message_editor.rs (80%) rename crates/{agent => agent_ui}/src/profile_selector.rs (99%) rename crates/{assistant_context_editor => agent_ui}/src/slash_command.rs (98%) rename crates/{assistant_context_editor => agent_ui}/src/slash_command_picker.rs (98%) rename crates/{agent => agent_ui}/src/slash_command_settings.rs (100%) rename crates/{agent => agent_ui}/src/terminal_codegen.rs (100%) rename crates/{agent => agent_ui}/src/terminal_inline_assistant.rs (98%) rename crates/{assistant_context_editor/src/context_editor.rs => agent_ui/src/text_thread_editor.rs} (96%) rename crates/{agent => agent_ui}/src/thread_history.rs (99%) rename crates/{agent => agent_ui}/src/tool_compatibility.rs (98%) rename crates/{agent => agent_ui}/src/ui.rs (100%) rename crates/{agent => agent_ui}/src/ui/agent_notification.rs (100%) rename crates/{agent => agent_ui}/src/ui/animated_label.rs (100%) rename crates/{agent => agent_ui}/src/ui/context_pill.rs (99%) rename crates/{agent => agent_ui}/src/ui/max_mode_tooltip.rs (100%) rename crates/{agent => agent_ui}/src/ui/onboarding_modal.rs (100%) rename crates/{agent => agent_ui}/src/ui/preview.rs (100%) rename crates/{agent => agent_ui}/src/ui/preview/agent_preview.rs (100%) rename crates/{agent => agent_ui}/src/ui/preview/usage_callouts.rs (82%) rename crates/{agent => agent_ui}/src/ui/upsell.rs (100%) rename crates/{assistant_context_editor => assistant_context}/Cargo.toml (77%) create mode 120000 crates/assistant_context/LICENSE-GPL rename crates/{assistant_context_editor/src/context.rs => assistant_context/src/assistant_context.rs} (99%) rename crates/{assistant_context_editor/src/context/context_tests.rs => assistant_context/src/assistant_context_tests.rs} (100%) rename crates/{assistant_context_editor => assistant_context}/src/context_store.rs (99%) delete mode 100644 crates/assistant_context_editor/src/assistant_context_editor.rs delete mode 100644 crates/assistant_context_editor/src/context_history.rs create mode 100644 crates/assistant_tools/src/templates/edit_file_prompt_diff_fenced.hbs rename crates/assistant_tools/src/templates/{edit_file_prompt.hbs => edit_file_prompt_xml.hbs} (91%) create mode 100644 crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql create mode 100644 crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql create mode 100644 crates/debug_adapter_extension/src/extension_locator_adapter.rs create mode 100644 crates/debugger_ui/src/onboarding_modal.rs create mode 100644 crates/editor/src/lsp_colors.rs create mode 100644 crates/eval/src/examples/file_change_notification.rs create mode 100644 crates/git_ui/src/diff_view.rs create mode 100644 crates/gpui_macros/src/derive_action.rs create mode 100644 crates/language_models/src/provider/vercel.rs create mode 100644 crates/language_tools/src/lsp_tool.rs create mode 100644 crates/languages/src/go/debugger.scm create mode 100644 crates/languages/src/package_json.rs create mode 100644 crates/languages/src/python/debugger.scm create mode 100644 crates/languages/src/rust/debugger.scm create mode 100644 crates/migrator/src/migrations/m_2025_06_16/settings.rs create mode 100644 crates/migrator/src/migrations/m_2025_06_25/settings.rs create mode 100644 crates/title_bar/src/platform_title_bar.rs create mode 100644 crates/util/src/redact.rs create mode 100644 crates/vercel/Cargo.toml create mode 120000 crates/vercel/LICENSE-GPL create mode 100644 crates/vercel/src/vercel.rs create mode 100644 crates/vim/test_data/test_scroll_jumps.json create mode 100644 docs/src/diagnostics.md create mode 100644 docs/src/extensions/debugger-extensions.md create mode 100644 docs/src/visual-customization.md create mode 100644 script/clear-target-dir-if-larger-than.ps1 create mode 100755 script/debug-cli create mode 100644 script/install-rustup.ps1 diff --git a/.github/actions/run_tests_windows/action.yml b/.github/actions/run_tests_windows/action.yml index 5115ef02ac..cbe95e82c1 100644 --- a/.github/actions/run_tests_windows/action.yml +++ b/.github/actions/run_tests_windows/action.yml @@ -10,8 +10,8 @@ inputs: runs: using: "composite" steps: - - name: Install Rust - shell: pwsh + - name: Install test runner + shell: powershell working-directory: ${{ inputs.working-directory }} run: cargo install cargo-nextest --locked @@ -21,6 +21,6 @@ runs: node-version: "18" - name: Run tests - shell: pwsh + shell: powershell working-directory: ${{ inputs.working-directory }} - run: cargo nextest run --workspace --no-fail-fast --config='profile.dev.debug="limited"' + run: cargo nextest run --workspace --no-fail-fast diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb7a36db5b..600956c379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: outputs: run_tests: ${{ steps.filter.outputs.run_tests }} run_license: ${{ steps.filter.outputs.run_license }} + run_docs: ${{ steps.filter.outputs.run_docs }} runs-on: - ubuntu-latest steps: @@ -58,6 +59,11 @@ jobs: else echo "run_tests=false" >> $GITHUB_OUTPUT fi + if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^docs/') ]]; then + echo "run_docs=true" >> $GITHUB_OUTPUT + else + echo "run_docs=false" >> $GITHUB_OUTPUT + fi if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^Cargo.lock') ]]; then echo "run_license=true" >> $GITHUB_OUTPUT else @@ -198,7 +204,9 @@ jobs: timeout-minutes: 60 name: Check docs needs: [job_spec] - if: github.repository_owner == 'zed-industries' + if: | + github.repository_owner == 'zed-industries' && + (needs.job_spec.outputs.run_tests == 'true' || needs.job_spec.outputs.run_docs == 'true') runs-on: - buildjet-8vcpu-ubuntu-2204 steps: @@ -373,64 +381,6 @@ jobs: if: always() run: rm -rf ./../.cargo - windows_clippy: - timeout-minutes: 60 - name: (Windows) Run Clippy - needs: [job_spec] - if: | - github.repository_owner == 'zed-industries' && - needs.job_spec.outputs.run_tests == 'true' - runs-on: windows-2025-16 - steps: - # more info here:- https://github.com/rust-lang/cargo/issues/13020 - - name: Enable longer pathnames for git - run: git config --system core.longpaths true - - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Create Dev Drive using ReFS - run: ./script/setup-dev-driver.ps1 - - # actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone... - - name: Copy Git Repo to Dev Drive - run: | - Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.ZED_WORKSPACE }}" -Recurse - - - name: Cache dependencies - uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - workspaces: ${{ env.ZED_WORKSPACE }} - cache-provider: "github" - - - name: Configure CI - run: | - mkdir -p ${{ env.CARGO_HOME }} -ErrorAction Ignore - cp ./.cargo/ci-config.toml ${{ env.CARGO_HOME }}/config.toml - - - name: cargo clippy - working-directory: ${{ env.ZED_WORKSPACE }} - run: ./script/clippy.ps1 - - - name: Check dev drive space - working-directory: ${{ env.ZED_WORKSPACE }} - # `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive. - run: ./script/exit-ci-if-dev-drive-is-full.ps1 95 - - # Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug. - - name: Clean CI config file - if: always() - run: | - if (Test-Path "${{ env.CARGO_HOME }}/config.toml") { - Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml" -Force - } - - # Windows CI takes twice as long as our other platforms and fast github hosted runners are expensive. - # But we still want to do CI, so let's only run tests on main and come back to this when we're - # ready to self host our Windows CI (e.g. during the push for full Windows support) windows_tests: timeout-minutes: 60 name: (Windows) Run Tests @@ -438,51 +388,45 @@ jobs: if: | github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_tests == 'true' - # Use bigger runners for PRs (speed); smaller for async (cost) - runs-on: ${{ github.event_name == 'pull_request' && 'windows-2025-32' || 'windows-2025-16' }} + runs-on: [self-hosted, Windows, X64] steps: - # more info here:- https://github.com/rust-lang/cargo/issues/13020 - - name: Enable longer pathnames for git - run: git config --system core.longpaths true + - name: Environment Setup + run: | + $RunnerDir = Split-Path -Parent $env:RUNNER_WORKSPACE + Write-Output ` + "RUSTUP_HOME=$RunnerDir\.rustup" ` + "CARGO_HOME=$RunnerDir\.cargo" ` + "PATH=$RunnerDir\.cargo\bin;$env:PATH" ` + >> $env:GITHUB_ENV - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: clean: false - - name: Create Dev Drive using ReFS - run: ./script/setup-dev-driver.ps1 - - # actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone... - - name: Copy Git Repo to Dev Drive - run: | - Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.ZED_WORKSPACE }}" -Recurse - - - name: Cache dependencies - uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - workspaces: ${{ env.ZED_WORKSPACE }} - cache-provider: "github" - - - name: Configure CI + - name: Setup Cargo and Rustup run: | mkdir -p ${{ env.CARGO_HOME }} -ErrorAction Ignore cp ./.cargo/ci-config.toml ${{ env.CARGO_HOME }}/config.toml + .\script\install-rustup.ps1 + + - name: cargo clippy + run: | + .\script\clippy.ps1 - name: Run tests uses: ./.github/actions/run_tests_windows - with: - working-directory: ${{ env.ZED_WORKSPACE }} - name: Build Zed - working-directory: ${{ env.ZED_WORKSPACE }} run: cargo build - - name: Check dev drive space - working-directory: ${{ env.ZED_WORKSPACE }} - # `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive. - run: ./script/exit-ci-if-dev-drive-is-full.ps1 95 + - name: Limit target directory size + run: ./script/clear-target-dir-if-larger-than.ps1 250 + + # - name: Check dev drive space + # working-directory: ${{ env.ZED_WORKSPACE }} + # # `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive. + # run: ./script/exit-ci-if-dev-drive-is-full.ps1 95 # Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug. - name: Clean CI config file @@ -498,13 +442,13 @@ jobs: needs: - job_spec - style + - check_docs - migration_checks # run_tests: If adding required tests, add them here and to script below. - workspace_hack - linux_tests - build_remote_server - macos_tests - - windows_clippy - windows_tests if: | github.repository_owner == 'zed-industries' && @@ -515,7 +459,8 @@ jobs: # Check dependent jobs... RET_CODE=0 # Always check style - [[ "${{ needs.style.result }}" != 'success' ]] && { RET_CODE=1; echo "style tests failed"; } + [[ "${{ needs.style.result }}" != 'success' ]] && { RET_CODE=1; echo "style tests failed"; } + [[ "${{ needs.check_docs.result }}" != 'success' ]] && { RET_CODE=1; echo "docs checks failed"; } # Only check test jobs if they were supposed to run if [[ "${{ needs.job_spec.outputs.run_tests }}" == "true" ]]; then @@ -523,7 +468,6 @@ jobs: [[ "${{ needs.macos_tests.result }}" != 'success' ]] && { RET_CODE=1; echo "macOS tests failed"; } [[ "${{ needs.linux_tests.result }}" != 'success' ]] && { RET_CODE=1; echo "Linux tests failed"; } [[ "${{ needs.windows_tests.result }}" != 'success' ]] && { RET_CODE=1; echo "Windows tests failed"; } - [[ "${{ needs.windows_clippy.result }}" != 'success' ]] && { RET_CODE=1; echo "Windows clippy failed"; } [[ "${{ needs.build_remote_server.result }}" != 'success' ]] && { RET_CODE=1; echo "Remote server build failed"; } # This check is intentionally disabled. See: https://github.com/zed-industries/zed/pull/28431 # [[ "${{ needs.migration_checks.result }}" != 'success' ]] && { RET_CODE=1; echo "Migration Checks failed"; } diff --git a/.github/workflows/community_delete_comments.yml b/.github/workflows/community_delete_comments.yml deleted file mode 100644 index 0ebe1ac3ac..0000000000 --- a/.github/workflows/community_delete_comments.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Delete Mediafire Comments - -on: - issue_comment: - types: [created] - -permissions: - issues: write - -jobs: - delete_comment: - if: github.repository_owner == 'zed-industries' - runs-on: ubuntu-latest - steps: - - name: Check for specific strings in comment - id: check_comment - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 - with: - script: | - const comment = context.payload.comment.body; - const triggerStrings = ['www.mediafire.com']; - return triggerStrings.some(triggerString => comment.includes(triggerString)); - - - name: Delete comment if it contains any of the specific strings - if: steps.check_comment.outputs.result == 'true' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 - with: - script: | - const commentId = context.payload.comment.id; - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: commentId - }); diff --git a/.gitignore b/.gitignore index db2a8139cd..7b40c45adf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ .flatpak-builder .idea .netrc +*.pyc .pytest_cache .swiftpm .swiftpm/config/registries.json diff --git a/.rules b/.rules index b9eea27b67..da009f1877 100644 --- a/.rules +++ b/.rules @@ -100,9 +100,7 @@ Often event handlers will want to update the entity that's in the current `Conte Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`. -Actions which have no data inside are created and registered with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. - -Actions that do have data must implement `Clone, Default, PartialEq, Deserialize, JsonSchema` and can be registered with an `impl_actions!(some_namespace, [SomeActionWithData])` macro call. +Actions with no data defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user. Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`. diff --git a/.zed/debug.json b/.zed/debug.json index 2dde32b870..49b8f1a7a6 100644 --- a/.zed/debug.json +++ b/.zed/debug.json @@ -2,11 +2,23 @@ { "label": "Debug Zed (CodeLLDB)", "adapter": "CodeLLDB", - "build": { "label": "Build Zed", "command": "cargo", "args": ["build"] } + "build": { + "label": "Build Zed", + "command": "cargo", + "args": [ + "build" + ] + } }, { "label": "Debug Zed (GDB)", "adapter": "GDB", - "build": { "label": "Build Zed", "command": "cargo", "args": ["build"] } - } + "build": { + "label": "Build Zed", + "command": "cargo", + "args": [ + "build" + ] + } + }, ] diff --git a/.zed/settings.json b/.zed/settings.json index 67677d8d91..1ef6bc28f7 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -40,6 +40,7 @@ }, "file_types": { "Dockerfile": ["Dockerfile*[!dockerignore]"], + "JSONC": ["assets/**/*.json", "renovate.json"], "Git Ignore": ["dockerignore"] }, "hard_tabs": false, @@ -47,7 +48,7 @@ "remove_trailing_whitespace_on_save": true, "ensure_final_newline_on_save": true, "file_scan_exclusions": [ - "crates/assistant_tools/src/evals/fixtures", + "crates/assistant_tools/src/edit_agent/evals/fixtures", "crates/eval/worktrees/", "crates/eval/repos/", "**/.git", diff --git a/Cargo.lock b/Cargo.lock index 06c3d1e99a..122d1ef7b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,7 @@ dependencies = [ "gpui", "language", "project", + "proto", "release_channel", "smallvec", "ui", @@ -55,7 +56,83 @@ version = "0.1.0" dependencies = [ "agent_settings", "anyhow", - "assistant_context_editor", + "assistant_context", + "assistant_tool", + "assistant_tools", + "chrono", + "client", + "collections", + "component", + "context_server", + "convert_case 0.8.0", + "feature_flags", + "fs", + "futures 0.3.31", + "git", + "gpui", + "heed", + "http_client", + "icons", + "indoc", + "itertools 0.14.0", + "language", + "language_model", + "log", + "paths", + "postage", + "pretty_assertions", + "project", + "prompt_store", + "proto", + "rand 0.8.5", + "ref-cast", + "rope", + "schemars", + "serde", + "serde_json", + "settings", + "smol", + "sqlez", + "telemetry", + "text", + "theme", + "thiserror 2.0.12", + "time", + "util", + "uuid", + "workspace", + "workspace-hack", + "zed_llm_client", + "zstd", +] + +[[package]] +name = "agent_settings" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "fs", + "gpui", + "language_model", + "paths", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "settings", + "workspace-hack", + "zed_llm_client", +] + +[[package]] +name = "agent_ui" +version = "0.1.0" +dependencies = [ + "agent", + "agent_settings", + "anyhow", + "assistant_context", "assistant_slash_command", "assistant_slash_commands", "assistant_tool", @@ -67,18 +144,16 @@ dependencies = [ "collections", "component", "context_server", - "convert_case 0.8.0", "db", "editor", "extension", + "extension_host", "feature_flags", "file_icons", "fs", "futures 0.3.31", "fuzzy", - "git", "gpui", - "heed", "html_to_markdown", "http_client", "indexed_docs", @@ -88,6 +163,7 @@ dependencies = [ "jsonschema", "language", "language_model", + "languages", "log", "lsp", "markdown", @@ -98,13 +174,11 @@ dependencies = [ "parking_lot", "paths", "picker", - "postage", "pretty_assertions", "project", "prompt_store", "proto", "rand 0.8.5", - "ref-cast", "release_channel", "rope", "rules_library", @@ -115,7 +189,6 @@ dependencies = [ "serde_json_lenient", "settings", "smol", - "sqlez", "streaming_diff", "telemetry", "telemetry_events", @@ -123,11 +196,11 @@ dependencies = [ "terminal_view", "text", "theme", - "thiserror 2.0.12", "time", "time_format", + "tree-sitter-md", "ui", - "ui_input", + "unindent", "urlencoding", "util", "uuid", @@ -136,33 +209,6 @@ dependencies = [ "workspace-hack", "zed_actions", "zed_llm_client", - "zstd", -] - -[[package]] -name = "agent_settings" -version = "0.1.0" -dependencies = [ - "anthropic", - "anyhow", - "collections", - "deepseek", - "fs", - "gpui", - "language_model", - "lmstudio", - "log", - "mistral", - "ollama", - "open_ai", - "paths", - "schemars", - "serde", - "serde_json", - "serde_json_lenient", - "settings", - "workspace-hack", - "zed_llm_client", ] [[package]] @@ -491,7 +537,6 @@ dependencies = [ "anyhow", "futures 0.3.31", "gpui", - "shlex", "smol", "tempfile", "util", @@ -509,7 +554,7 @@ dependencies = [ ] [[package]] -name = "assistant_context_editor" +name = "assistant_context" version = "0.1.0" dependencies = [ "agent_settings", @@ -521,31 +566,23 @@ dependencies = [ "clock", "collections", "context_server", - "editor", - "feature_flags", "fs", "futures 0.3.31", "fuzzy", "gpui", - "indexed_docs", "indoc", "language", "language_model", - "languages", "log", - "multi_buffer", "open_ai", - "ordered-float 2.10.1", "parking_lot", "paths", - "picker", "pretty_assertions", "project", "prompt_store", "proto", "rand 0.8.5", "regex", - "rope", "rpc", "serde", "serde_json", @@ -554,15 +591,12 @@ dependencies = [ "smol", "telemetry_events", "text", - "theme", - "tree-sitter-md", "ui", "unindent", "util", "uuid", "workspace", "workspace-hack", - "zed_actions", "zed_llm_client", ] @@ -2042,7 +2076,7 @@ dependencies = [ [[package]] name = "blade-graphics" version = "0.6.0" -source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad" +source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5" dependencies = [ "ash", "ash-window", @@ -2075,7 +2109,7 @@ dependencies = [ [[package]] name = "blade-macros" version = "0.3.0" -source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad" +source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5" dependencies = [ "proc-macro2", "quote", @@ -2085,7 +2119,7 @@ dependencies = [ [[package]] name = "blade-util" version = "0.2.0" -source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad" +source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5" dependencies = [ "blade-graphics", "bytemuck", @@ -2649,6 +2683,7 @@ dependencies = [ "http_client", "language", "log", + "postage", "rand 0.8.5", "release_channel", "rpc", @@ -2822,7 +2857,9 @@ dependencies = [ "cocoa 0.26.0", "collections", "credentials_provider", + "derive_more", "feature_flags", + "fs", "futures 0.3.31", "gpui", "gpui_tokio", @@ -2834,6 +2871,7 @@ dependencies = [ "paths", "postage", "rand 0.8.5", + "regex", "release_channel", "rpc", "rustls-pki-types", @@ -2858,6 +2896,7 @@ dependencies = [ "windows 0.61.1", "workspace-hack", "worktree", + "zed_llm_client", ] [[package]] @@ -2978,7 +3017,7 @@ version = "0.44.0" dependencies = [ "agent_settings", "anyhow", - "assistant_context_editor", + "assistant_context", "assistant_slash_command", "async-stripe", "async-trait", @@ -3345,6 +3384,7 @@ dependencies = [ "collections", "command_palette_hooks", "ctor", + "dirs 4.0.0", "editor", "fs", "futures 0.3.31", @@ -3540,6 +3580,20 @@ dependencies = [ "coreaudio-sys", ] +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + [[package]] name = "coreaudio-sys" version = "0.2.16" @@ -3575,7 +3629,8 @@ dependencies = [ [[package]] name = "cpal" version = "0.15.3" -source = "git+https://github.com/zed-industries/cpal?rev=fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50#fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ "alsa", "core-foundation-sys", @@ -3585,7 +3640,7 @@ dependencies = [ "js-sys", "libc", "mach2", - "ndk", + "ndk 0.8.0", "ndk-context", "oboe", "wasm-bindgen", @@ -3594,6 +3649,32 @@ dependencies = [ "windows 0.54.0", ] +[[package]] +name = "cpal" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +dependencies = [ + "alsa", + "coreaudio-rs 0.13.0", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.9.0", + "ndk-context", + "num-derive", + "num-traits", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpp_demangle" version = "0.4.4" @@ -4199,6 +4280,7 @@ dependencies = [ "gpui", "serde_json", "task", + "util", "workspace-hack", ] @@ -4224,6 +4306,7 @@ dependencies = [ name = "debugger_ui" version = "0.1.0" dependencies = [ + "alacritty_terminal", "anyhow", "client", "collections", @@ -4233,7 +4316,6 @@ dependencies = [ "db", "debugger_tools", "editor", - "feature_flags", "file_icons", "futures 0.3.31", "fuzzy", @@ -4250,13 +4332,18 @@ dependencies = [ "rpc", "serde", "serde_json", + "serde_json_lenient", "settings", "shlex", "sysinfo", "task", "tasks_ui", + "telemetry", "terminal_view", "theme", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-json", "ui", "unindent", "util", @@ -4702,7 +4789,6 @@ dependencies = [ "dap", "db", "emojis", - "feature_flags", "file_icons", "fs", "futures 0.3.31", @@ -5014,6 +5100,7 @@ version = "0.1.0" dependencies = [ "agent", "agent_settings", + "agent_ui", "anyhow", "assistant_tool", "assistant_tools", @@ -6122,6 +6209,7 @@ dependencies = [ "anyhow", "askpass", "buffer_diff", + "call", "chrono", "collections", "command_palette_hooks", @@ -6160,6 +6248,7 @@ dependencies = [ "ui", "unindent", "util", + "watch", "windows 0.61.1", "workspace", "workspace-hack", @@ -8110,12 +8199,11 @@ dependencies = [ name = "inline_completion" version = "0.1.0" dependencies = [ - "anyhow", + "client", "gpui", "language", "project", "workspace-hack", - "zed_llm_client", ] [[package]] @@ -8831,6 +8919,7 @@ dependencies = [ "http_client", "icons", "image", + "log", "parking_lot", "proto", "schemars", @@ -8866,6 +8955,7 @@ dependencies = [ "gpui", "gpui_tokio", "http_client", + "language", "language_model", "lmstudio", "log", @@ -8889,7 +8979,9 @@ dependencies = [ "tiktoken-rs", "tokio", "ui", + "ui_input", "util", + "vercel", "workspace-hack", "zed_llm_client", ] @@ -8928,6 +9020,7 @@ dependencies = [ "itertools 0.14.0", "language", "lsp", + "picker", "project", "release_channel", "serde_json", @@ -9321,7 +9414,7 @@ dependencies = [ "core-foundation 0.10.0", "core-video", "coreaudio-rs 0.12.1", - "cpal", + "cpal 0.16.0", "futures 0.3.31", "gpui", "gpui_tokio", @@ -10091,7 +10184,21 @@ dependencies = [ "bitflags 2.9.0", "jni-sys", "log", - "ndk-sys", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.0", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", "num_enum", "thiserror 1.0.69", ] @@ -10111,6 +10218,15 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -10524,6 +10640,43 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07" +dependencies = [ + "bitflags 2.9.0", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" +dependencies = [ + "bitflags 2.9.0", + "objc2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.1" @@ -10629,7 +10782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ "jni", - "ndk", + "ndk 0.8.0", "ndk-context", "num-derive", "num-traits", @@ -11079,9 +11232,9 @@ dependencies = [ [[package]] name = "pathfinder_simd" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf07ef4804cfa9aea3b04a7bbdd5a40031dbb6b4f2cbaf2b011666c80c5b4f2" +checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" dependencies = [ "rustc_version", ] @@ -13011,6 +13164,7 @@ dependencies = [ "thiserror 2.0.12", "urlencoding", "util", + "which 6.0.3", "workspace-hack", ] @@ -13031,6 +13185,7 @@ dependencies = [ "dap", "dap_adapters", "debug_adapter_extension", + "editor", "env_logger 0.11.8", "extension", "extension_host", @@ -13069,6 +13224,7 @@ dependencies = [ "unindent", "util", "watch", + "workspace", "worktree", "zlog", ] @@ -13401,7 +13557,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" dependencies = [ - "cpal", + "cpal 0.15.3", "hound", ] @@ -13495,6 +13651,7 @@ dependencies = [ "serde", "settings", "theme", + "title_bar", "ui", "util", "workspace", @@ -14413,12 +14570,12 @@ dependencies = [ "fs", "gpui", "log", - "paths", "schemars", "serde", "settings", "theme", "ui", + "util", "workspace", "workspace-hack", ] @@ -15279,6 +15436,7 @@ dependencies = [ "serde", "serde_json", "smol", + "util", "workspace-hack", ] @@ -15749,6 +15907,7 @@ dependencies = [ "theme", "thiserror 2.0.12", "url", + "urlencoding", "util", "windows 0.61.1", "workspace-hack", @@ -17156,12 +17315,14 @@ dependencies = [ "itertools 0.14.0", "libc", "log", + "nix 0.29.0", "rand 0.8.5", "regex", "rust-embed", "serde", "serde_json", "serde_json_lenient", + "shlex", "smol", "take-until", "tempfile", @@ -17262,6 +17423,17 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vercel" +version = "0.1.0" +dependencies = [ + "anyhow", + "schemars", + "serde", + "strum 0.27.1", + "workspace-hack", +] + [[package]] name = "version-compare" version = "0.2.0" @@ -19323,6 +19495,7 @@ dependencies = [ "num-rational", "num-traits", "objc2", + "objc2-core-foundation", "objc2-foundation", "objc2-metal", "object", @@ -19743,16 +19916,16 @@ dependencies = [ [[package]] name = "zed" -version = "0.191.0" +version = "0.194.0" dependencies = [ "activity_indicator", "agent", "agent_settings", + "agent_ui", "anyhow", "ashpd", "askpass", "assets", - "assistant_context_editor", "assistant_tool", "assistant_tools", "audio", @@ -19783,7 +19956,6 @@ dependencies = [ "extension", "extension_host", "extensions_ui", - "feature_flags", "feedback", "file_finder", "fs", @@ -19800,6 +19972,7 @@ dependencies = [ "inline_completion_button", "inspector_ui", "install_cli", + "itertools 0.14.0", "jj_ui", "journal", "language", @@ -19824,6 +19997,7 @@ dependencies = [ "parking_lot", "paths", "picker", + "pretty_assertions", "profiling", "project", "project_panel", diff --git a/Cargo.toml b/Cargo.toml index fac347056f..da2ed94ac4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,12 +2,13 @@ resolver = "2" members = [ "crates/activity_indicator", + "crates/agent_ui", "crates/agent", "crates/agent_settings", "crates/anthropic", "crates/askpass", "crates/assets", - "crates/assistant_context_editor", + "crates/assistant_context", "crates/assistant_slash_command", "crates/assistant_slash_commands", "crates/assistant_tool", @@ -65,6 +66,7 @@ members = [ "crates/gpui", "crates/gpui_macros", "crates/gpui_tokio", + "crates/html_to_markdown", "crates/http_client", "crates/http_client_tls", @@ -163,6 +165,7 @@ members = [ "crates/ui_prompt", "crates/util", "crates/util_macros", + "crates/vercel", "crates/vim", "crates/vim_mode_setting", "crates/watch", @@ -213,12 +216,13 @@ edition = "2024" activity_indicator = { path = "crates/activity_indicator" } agent = { path = "crates/agent" } +agent_ui = { path = "crates/agent_ui" } agent_settings = { path = "crates/agent_settings" } ai = { path = "crates/ai" } anthropic = { path = "crates/anthropic" } askpass = { path = "crates/askpass" } assets = { path = "crates/assets" } -assistant_context_editor = { path = "crates/assistant_context_editor" } +assistant_context = { path = "crates/assistant_context" } assistant_slash_command = { path = "crates/assistant_slash_command" } assistant_slash_commands = { path = "crates/assistant_slash_commands" } assistant_tool = { path = "crates/assistant_tool" } @@ -372,8 +376,10 @@ ui_macros = { path = "crates/ui_macros" } ui_prompt = { path = "crates/ui_prompt" } util = { path = "crates/util" } util_macros = { path = "crates/util_macros" } +vercel = { path = "crates/vercel" } vim = { path = "crates/vim" } vim_mode_setting = { path = "crates/vim_mode_setting" } + watch = { path = "crates/watch" } web_search = { path = "crates/web_search" } web_search_providers = { path = "crates/web_search_providers" } @@ -417,9 +423,9 @@ aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] } aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] } base64 = "0.22" bitflags = "2.6.0" -blade-graphics = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" } -blade-macros = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" } -blade-util = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" } +blade-graphics = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" } +blade-macros = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" } +blade-util = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" } blake3 = "1.5.3" bytes = "1.0" cargo_metadata = "0.19" @@ -433,6 +439,7 @@ convert_case = "0.8.0" core-foundation = "0.10.0" core-foundation-sys = "0.8.6" core-video = { version = "0.4.3", features = ["metal"] } +cpal = "0.16" criterion = { version = "0.5", features = ["html_reports"] } ctor = "0.4.0" dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "b40956a7f4d1939da67429d941389ee306a3a308" } @@ -452,7 +459,6 @@ futures-batch = "0.6.1" futures-lite = "1.13" git2 = { version = "0.20.1", default-features = false } globset = "0.4" -hashbrown = "0.15.3" handlebars = "4.3" heck = "0.5" heed = { version = "0.21.0", features = ["read-txn-no-tls"] } @@ -480,7 +486,6 @@ log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "c9c189f1c5dd53c624a419ce35bc77ad6a908d18" } markup5ever_rcdom = "0.3.0" metal = "0.29" -mlua = { version = "0.10", features = ["lua54", "vendored", "async", "send"] } moka = { version = "0.12.10", features = ["sync"] } naga = { version = "25.0", features = ["wgsl-in"] } nanoid = "0.4" @@ -515,7 +520,6 @@ rand = "0.8.5" rayon = "1.8" ref-cast = "1.0.24" regex = "1.5" -repair_json = "0.1.0" reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "951c770a32f1998d6e999cef3e59e0013e6c4415", default-features = false, features = [ "charset", "http2", @@ -547,7 +551,6 @@ serde_repr = "0.1" sha2 = "0.10" shellexpand = "2.1.0" shlex = "1.3.0" -signal-hook = "0.3.17" simplelog = "0.12.2" smallvec = { version = "1.6", features = ["union"] } smol = "2.0" @@ -682,9 +685,7 @@ features = [ "Win32_UI_WindowsAndMessaging", ] -# TODO livekit https://github.com/RustAudio/cpal/pull/891 [patch.crates-io] -cpal = { git = "https://github.com/zed-industries/cpal", rev = "fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50" } notify = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" } notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" } diff --git a/assets/icons/ai_v_zero.svg b/assets/icons/ai_v_zero.svg new file mode 100644 index 0000000000..26d09ea26a --- /dev/null +++ b/assets/icons/ai_v_zero.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/icons/arrow_up_alt.svg b/assets/icons/arrow_up_alt.svg new file mode 100644 index 0000000000..c8cf286a8c --- /dev/null +++ b/assets/icons/arrow_up_alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/blocks.svg b/assets/icons/blocks.svg index 42d44c3f95..588d49abbc 100644 --- a/assets/icons/blocks.svg +++ b/assets/icons/blocks.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/assets/icons/file_icons/cairo.svg b/assets/icons/file_icons/cairo.svg new file mode 100644 index 0000000000..dcf77c6fbf --- /dev/null +++ b/assets/icons/file_icons/cairo.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/zed_mcp_custom.svg b/assets/icons/zed_mcp_custom.svg new file mode 100644 index 0000000000..6410a26fca --- /dev/null +++ b/assets/icons/zed_mcp_custom.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/zed_mcp_extension.svg b/assets/icons/zed_mcp_extension.svg new file mode 100644 index 0000000000..996e0c1920 --- /dev/null +++ b/assets/icons/zed_mcp_extension.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/debugger_grid.svg b/assets/images/debugger_grid.svg new file mode 100644 index 0000000000..8b40dbd707 --- /dev/null +++ b/assets/images/debugger_grid.svg @@ -0,0 +1,890 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index b85b0626b3..0c4de0e053 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -10,8 +10,10 @@ "pagedown": "menu::SelectLast", "ctrl-n": "menu::SelectNext", "tab": "menu::SelectNext", + "down": "menu::SelectNext", "ctrl-p": "menu::SelectPrevious", "shift-tab": "menu::SelectPrevious", + "up": "menu::SelectPrevious", "enter": "menu::Confirm", "ctrl-enter": "menu::SecondaryConfirm", "ctrl-escape": "menu::Cancel", @@ -39,7 +41,8 @@ "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-alt-z": "edit_prediction::RateCompletions", - "ctrl-shift-i": "edit_prediction::ToggleMenu" + "ctrl-shift-i": "edit_prediction::ToggleMenu", + "ctrl-alt-l": "lsp_tool::ToggleMenu" } }, { @@ -115,6 +118,7 @@ "ctrl-\"": "editor::ExpandAllDiffHunks", "ctrl-i": "editor::ShowSignatureHelp", "alt-g b": "git::Blame", + "alt-g m": "git::OpenModifiedFiles", "menu": "editor::OpenContextMenu", "shift-f10": "editor::OpenContextMenu", "ctrl-shift-e": "editor::ToggleEditPrediction", @@ -240,8 +244,7 @@ "ctrl-alt-e": "agent::RemoveAllContext", "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-enter": "agent::ContinueThread", - "alt-enter": "agent::ContinueWithBurnMode", - "ctrl-alt-b": "agent::ToggleBurnMode" + "alt-enter": "agent::ContinueWithBurnMode" } }, { @@ -891,7 +894,10 @@ "right": "variable_list::ExpandSelectedEntry", "enter": "variable_list::EditVariable", "ctrl-c": "variable_list::CopyVariableValue", - "ctrl-alt-c": "variable_list::CopyVariableName" + "ctrl-alt-c": "variable_list::CopyVariableName", + "delete": "variable_list::RemoveWatch", + "backspace": "variable_list::RemoveWatch", + "alt-enter": "variable_list::AddWatch" } }, { @@ -939,7 +945,7 @@ } }, { - "context": "FileFinder", + "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { "ctrl-shift-a": "file_finder::ToggleSplitMenu", "ctrl-shift-i": "file_finder::ToggleFilterMenu" @@ -1034,7 +1040,8 @@ "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { - "enter": "menu::Confirm" + "enter": "menu::Confirm", + "alt-enter": "console::WatchExpression" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 225cddf590..5bd99963bd 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -47,7 +47,8 @@ "fn-f": "zed::ToggleFullScreen", "ctrl-cmd-f": "zed::ToggleFullScreen", "ctrl-cmd-z": "edit_prediction::RateCompletions", - "ctrl-cmd-i": "edit_prediction::ToggleMenu" + "ctrl-cmd-i": "edit_prediction::ToggleMenu", + "ctrl-cmd-l": "lsp_tool::ToggleMenu" } }, { @@ -139,6 +140,7 @@ "cmd-'": "editor::ToggleSelectedDiffHunks", "cmd-\"": "editor::ExpandAllDiffHunks", "cmd-alt-g b": "git::Blame", + "cmd-alt-g m": "git::OpenModifiedFiles", "cmd-i": "editor::ShowSignatureHelp", "f9": "editor::ToggleBreakpoint", "shift-f9": "editor::EditLogBreakpoint", @@ -282,8 +284,7 @@ "cmd-alt-e": "agent::RemoveAllContext", "cmd-shift-e": "project_panel::ToggleFocus", "cmd-shift-enter": "agent::ContinueThread", - "alt-enter": "agent::ContinueWithBurnMode", - "cmd-alt-b": "agent::ToggleBurnMode" + "alt-enter": "agent::ContinueWithBurnMode" } }, { @@ -586,7 +587,6 @@ "alt-cmd-o": ["projects::OpenRecent", { "create_new_window": false }], "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }], - "alt-cmd-b": "branches::OpenRecent", "ctrl-~": "workspace::NewTerminal", "cmd-s": "workspace::Save", "cmd-k s": "workspace::SaveWithoutFormat", @@ -863,7 +863,10 @@ "right": "variable_list::ExpandSelectedEntry", "enter": "variable_list::EditVariable", "cmd-c": "variable_list::CopyVariableValue", - "cmd-alt-c": "variable_list::CopyVariableName" + "cmd-alt-c": "variable_list::CopyVariableName", + "delete": "variable_list::RemoveWatch", + "backspace": "variable_list::RemoveWatch", + "alt-enter": "variable_list::AddWatch" } }, { @@ -1007,7 +1010,7 @@ } }, { - "context": "FileFinder", + "context": "FileFinder || (FileFinder > Picker > Editor)", "use_key_equivalents": true, "bindings": { "cmd-shift-a": "file_finder::ToggleSplitMenu", @@ -1134,7 +1137,8 @@ "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { - "enter": "menu::Confirm" + "enter": "menu::Confirm", + "alt-enter": "console::WatchExpression" } }, { diff --git a/assets/keymaps/linux/atom.json b/assets/keymaps/linux/atom.json index d471a54ea5..86ee068b06 100644 --- a/assets/keymaps/linux/atom.json +++ b/assets/keymaps/linux/atom.json @@ -9,6 +9,13 @@ }, { "context": "Editor", + "bindings": { + "ctrl-k ctrl-u": "editor::ConvertToUpperCase", // editor:upper-case + "ctrl-k ctrl-l": "editor::ConvertToLowerCase" // editor:lower-case + } + }, + { + "context": "Editor && mode == full", "bindings": { "ctrl-shift-l": "language_selector::Toggle", // grammar-selector:show "ctrl-|": "pane::RevealInProjectPanel", // tree-view:reveal-active-file @@ -19,25 +26,20 @@ "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous "alt-shift-down": "editor::AddSelectionBelow", // editor:add-selection-below "alt-shift-up": "editor::AddSelectionAbove", // editor:add-selection-above - "ctrl-k ctrl-u": "editor::ConvertToUpperCase", // editor:upper-case - "ctrl-k ctrl-l": "editor::ConvertToLowerCase", // editor:lower-case "ctrl-j": "editor::JoinLines", // editor:join-lines "ctrl-shift-d": "editor::DuplicateLineDown", // editor:duplicate-lines "ctrl-up": "editor::MoveLineUp", // editor:move-line-up "ctrl-down": "editor::MoveLineDown", // editor:move-line-down "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle - "ctrl-shift-m": "markdown::OpenPreviewToTheSide" // markdown-preview:toggle - } - }, - { - "context": "Editor && mode == full", - "bindings": { + "ctrl-shift-m": "markdown::OpenPreviewToTheSide", // markdown-preview:toggle "ctrl-r": "outline::Toggle" // symbols-view:toggle-project-symbols } }, { "context": "BufferSearchBar", "bindings": { + "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next + "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous "ctrl-f3": "search::SelectNextMatch", // find-and-replace:find-next-selected "ctrl-shift-f3": "search::SelectPreviousMatch" // find-and-replace:find-previous-selected } diff --git a/assets/keymaps/linux/cursor.json b/assets/keymaps/linux/cursor.json index 14cfcc43ec..347b7885fc 100644 --- a/assets/keymaps/linux/cursor.json +++ b/assets/keymaps/linux/cursor.json @@ -8,7 +8,6 @@ "ctrl-shift-i": "agent::ToggleFocus", "ctrl-l": "agent::ToggleFocus", "ctrl-shift-l": "agent::ToggleFocus", - "ctrl-alt-b": "agent::ToggleFocus", "ctrl-shift-j": "agent::OpenConfiguration" } }, @@ -42,7 +41,6 @@ "ctrl-shift-i": "workspace::ToggleRightDock", "ctrl-l": "workspace::ToggleRightDock", "ctrl-shift-l": "workspace::ToggleRightDock", - "ctrl-alt-b": "workspace::ToggleRightDock", "ctrl-w": "workspace::ToggleRightDock", // technically should close chat "ctrl-.": "agent::ToggleProfileSelector", "ctrl-/": "agent::ToggleModelSelector", diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 5a5cb6d90c..d1453da485 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -90,6 +90,13 @@ "ctrl-g": "editor::Cancel" } }, + { + "context": "Editor && (showing_code_actions || showing_completions)", + "bindings": { + "ctrl-p": "editor::ContextMenuPrevious", + "ctrl-n": "editor::ContextMenuNext" + } + }, { "context": "Workspace", "bindings": { diff --git a/assets/keymaps/macos/atom.json b/assets/keymaps/macos/atom.json index 9ddf353810..df48e51767 100644 --- a/assets/keymaps/macos/atom.json +++ b/assets/keymaps/macos/atom.json @@ -9,6 +9,14 @@ }, { "context": "Editor", + "bindings": { + "cmd-shift-backspace": "editor::DeleteToBeginningOfLine", + "cmd-k cmd-u": "editor::ConvertToUpperCase", + "cmd-k cmd-l": "editor::ConvertToLowerCase" + } + }, + { + "context": "Editor && mode == full", "bindings": { "ctrl-shift-l": "language_selector::Toggle", "cmd-|": "pane::RevealInProjectPanel", @@ -19,26 +27,20 @@ "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }], "ctrl-shift-down": "editor::AddSelectionBelow", "ctrl-shift-up": "editor::AddSelectionAbove", - "cmd-shift-backspace": "editor::DeleteToBeginningOfLine", - "cmd-k cmd-u": "editor::ConvertToUpperCase", - "cmd-k cmd-l": "editor::ConvertToLowerCase", "alt-enter": "editor::Newline", "cmd-shift-d": "editor::DuplicateLineDown", "ctrl-cmd-up": "editor::MoveLineUp", "ctrl-cmd-down": "editor::MoveLineDown", "cmd-\\": "workspace::ToggleLeftDock", - "ctrl-shift-m": "markdown::OpenPreviewToTheSide" - } - }, - { - "context": "Editor && mode == full", - "bindings": { + "ctrl-shift-m": "markdown::OpenPreviewToTheSide", "cmd-r": "outline::Toggle" } }, { "context": "BufferSearchBar", "bindings": { + "cmd-g": ["editor::SelectNext", { "replace_newest": true }], + "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }], "cmd-f3": "search::SelectNextMatch", "cmd-shift-f3": "search::SelectPreviousMatch" } diff --git a/assets/keymaps/macos/cursor.json b/assets/keymaps/macos/cursor.json index 5d26974f05..b1d39bef9e 100644 --- a/assets/keymaps/macos/cursor.json +++ b/assets/keymaps/macos/cursor.json @@ -8,7 +8,6 @@ "cmd-shift-i": "agent::ToggleFocus", "cmd-l": "agent::ToggleFocus", "cmd-shift-l": "agent::ToggleFocus", - "cmd-alt-b": "agent::ToggleFocus", "cmd-shift-j": "agent::OpenConfiguration" } }, @@ -43,7 +42,6 @@ "cmd-shift-i": "workspace::ToggleRightDock", "cmd-l": "workspace::ToggleRightDock", "cmd-shift-l": "workspace::ToggleRightDock", - "cmd-alt-b": "workspace::ToggleRightDock", "cmd-w": "workspace::ToggleRightDock", // technically should close chat "cmd-.": "agent::ToggleProfileSelector", "cmd-/": "agent::ToggleModelSelector", diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index 5a5cb6d90c..d1453da485 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -90,6 +90,13 @@ "ctrl-g": "editor::Cancel" } }, + { + "context": "Editor && (showing_code_actions || showing_completions)", + "bindings": { + "ctrl-p": "editor::ContextMenuPrevious", + "ctrl-n": "editor::ContextMenuNext" + } + }, { "context": "Workspace", "bindings": { diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index f2d50bd13f..6b95839e2a 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -56,6 +56,9 @@ "[ shift-b": ["pane::ActivateItem", 0], "] space": "vim::InsertEmptyLineBelow", "[ space": "vim::InsertEmptyLineAbove", + "[ e": "editor::MoveLineUp", + "] e": "editor::MoveLineDown", + // Word motions "w": "vim::NextWordStart", "e": "vim::NextWordEnd", @@ -82,10 +85,10 @@ "[ {": ["vim::UnmatchedBackward", { "char": "{" }], "] )": ["vim::UnmatchedForward", { "char": ")" }], "[ (": ["vim::UnmatchedBackward", { "char": "(" }], - "f": ["vim::PushFindForward", { "before": false }], - "t": ["vim::PushFindForward", { "before": true }], - "shift-f": ["vim::PushFindBackward", { "after": false }], - "shift-t": ["vim::PushFindBackward", { "after": true }], + "f": ["vim::PushFindForward", { "before": false, "multiline": false }], + "t": ["vim::PushFindForward", { "before": true, "multiline": false }], + "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": false }], + "shift-t": ["vim::PushFindBackward", { "after": true, "multiline": false }], "m": "vim::PushMark", "'": ["vim::PushJump", { "line": true }], "`": ["vim::PushJump", { "line": false }], @@ -184,6 +187,8 @@ "z f": "editor::FoldSelectedRanges", "z shift-m": "editor::FoldAll", "z shift-r": "editor::UnfoldAll", + "z l": "vim::ColumnRight", + "z h": "vim::ColumnLeft", "shift-z shift-q": ["pane::CloseActiveItem", { "save_intent": "skip" }], "shift-z shift-z": ["pane::CloseActiveItem", { "save_intent": "save_all" }], // Count support @@ -363,6 +368,10 @@ "escape": "editor::Cancel", "ctrl-[": "editor::Cancel", ":": "command_palette::Toggle", + "left": "vim::WrappingLeft", + "right": "vim::WrappingRight", + "h": "vim::WrappingLeft", + "l": "vim::WrappingRight", "shift-d": "vim::DeleteToEndOfLine", "shift-j": "vim::JoinLines", "y": "editor::Copy", @@ -380,6 +389,10 @@ "shift-p": ["vim::Paste", { "before": true }], "u": "vim::Undo", "ctrl-r": "vim::Redo", + "f": ["vim::PushFindForward", { "before": false, "multiline": true }], + "t": ["vim::PushFindForward", { "before": true, "multiline": true }], + "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": true }], + "shift-t": ["vim::PushFindBackward", { "after": true, "multiline": true }], "r": "vim::PushReplace", "s": "vim::Substitute", "shift-s": "vim::SubstituteLine", diff --git a/assets/prompts/assistant_system_prompt.hbs b/assets/prompts/assistant_system_prompt.hbs index a155dea19d..b4545f5a74 100644 --- a/assets/prompts/assistant_system_prompt.hbs +++ b/assets/prompts/assistant_system_prompt.hbs @@ -27,11 +27,11 @@ If you are unsure how to fulfill the user's request, gather more information wit If appropriate, use tool calls to explore the current project, which contains the following root directories: {{#each worktrees}} -- `{{root_name}}` +- `{{abs_path}}` {{/each}} - Bias towards not asking the user for help if you can find the answer yourself. -- When providing paths to tools, the path should always begin with a path that starts with a project root directory listed above. +- When providing paths to tools, the path should always start with the name of a project root directory listed above. - Before you read or edit a file, you must first find the full path. DO NOT ever guess a file path! {{# if (has_tool 'grep') }} - When looking for symbols in the project, prefer the `grep` tool. diff --git a/assets/settings/default.json b/assets/settings/default.json index 939f79e281..1b9a19615d 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -80,6 +80,7 @@ "inactive_opacity": 1.0 }, // Layout mode of the bottom dock. Defaults to "contained" + // choices: contained, full, left_aligned, right_aligned "bottom_dock_layout": "contained", // The direction that you want to split panes horizontally. Defaults to "up" "pane_split_direction_horizontal": "up", @@ -94,11 +95,9 @@ // workspace when the centered layout is used. "right_padding": 0.2 }, - // All settings related to the image viewer. + // Image viewer settings "image_viewer": { - // The unit for image file sizes. - // By default we're setting it to binary. - // The second option is decimal. + // The unit for image file sizes: "binary" (KiB, MiB) or decimal (KB, MB) "unit": "binary" }, // Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier. @@ -110,6 +109,8 @@ "multi_cursor_modifier": "alt", // Whether to enable vim modes and key bindings. "vim_mode": false, + // Whether to enable helix mode and key bindings. + "helix_mode": false, // Whether to show the informational hover box when moving the mouse // over symbols in the editor. "hover_popover_enabled": true, @@ -117,7 +118,14 @@ "hover_popover_delay": 300, // Whether to confirm before quitting Zed. "confirm_quit": false, - // Whether to restore last closed project when fresh Zed instance is opened. + // Whether to restore last closed project when fresh Zed instance is opened + // May take 3 values: + // 1. All workspaces open during last session + // "restore_on_startup": "last_session" + // 2. The workspace opened + // "restore_on_startup": "last_workspace", + // 3. Do not restore previous workspaces + // "restore_on_startup": "none", "restore_on_startup": "last_session", // Whether to attempt to restore previous file's state when opening it again. // The state is stored per pane. @@ -130,7 +138,9 @@ "restore_on_file_reopen": true, // Whether to automatically close files that have been deleted on disk. "close_on_file_delete": false, - // Size of the drop target in the editor. + // Relative size of the drop target in the editor that will open dropped file as a split pane (0-0.5) + // E.g. 0.25 == If you drop onto the top/bottom quarter of the pane a new vertical split will be used + // If you drop onto the left/right quarter of the pane a new horizontal split will be used "drop_target_size": 0.2, // Whether the window should be closed when using 'close active item' on a window with no tabs. // May take 3 values: @@ -307,6 +317,8 @@ // "all" // 4. Draw whitespaces at boundaries only: // "boundary" + // 5. Draw whitespaces only after non-whitespace characters: + // "trailing" // For a whitespace to be on a boundary, any of the following conditions need to be met: // - It is a tab // - It is adjacent to an edge (start or end) @@ -398,6 +410,13 @@ // 3. Never show the minimap: // "never" (default) "show": "never", + // Where to show the minimap in the editor. + // This setting can take two values: + // 1. Show the minimap on the focused editor only: + // "active_editor" (default) + // 2. Show the minimap on all open editors: + // "all_editors" + "display_in": "active_editor", // When to show the minimap thumb. // This setting can take two values: // 1. Show the minimap thumb if the mouse is over the minimap: @@ -424,7 +443,9 @@ // 1. `null` to inherit the editor `current_line_highlight` setting (default) // 2. "line" or "all" to highlight the current line in the minimap. // 3. "gutter" or "none" to not highlight the current line in the minimap. - "current_line_highlight": null + "current_line_highlight": null, + // Maximum number of columns to display in the minimap. + "max_width_columns": 80 }, // Enable middle-click paste on Linux. "middle_click_paste": true, @@ -445,7 +466,9 @@ // Whether to show breakpoints in the gutter. "breakpoints": true, // Whether to show fold buttons in the gutter. - "folds": true + "folds": true, + // Minimum number of characters to reserve space for in the gutter. + "min_line_number_digits": 4 }, "indent_guides": { // Whether to show indent guides in the editor. @@ -470,7 +493,7 @@ }, // Whether the editor will scroll beyond the last line. "scroll_beyond_last_line": "one_page", - // The number of lines to keep above/below the cursor when scrolling. + // The number of lines to keep above/below the cursor when scrolling with the keyboard "vertical_scroll_margin": 3, // Whether to scroll when clicking near the edge of the visible text area. "autoscroll_on_clicks": false, @@ -686,23 +709,27 @@ "default_width": 360, // Style of the git status indicator in the panel. // + // Choices: label_color, icon // Default: icon "status_style": "icon", - // What branch name to use if init.defaultBranch - // is not set + // What branch name to use if `init.defaultBranch` is not set // // Default: main "fallback_branch_name": "main", - // Whether to sort entries in the panel by path - // or by status (the default). + // Whether to sort entries in the panel by path or by status (the default). // // Default: false "sort_by_path": false, + // Whether to collapse untracked files in the diff panel. + // + // Default: false + "collapse_untracked_diff": false, "scrollbar": { // When to show the scrollbar in the git panel. // + // Choices: always, auto, never, system // Default: inherits editor scrollbar settings - "show": null + // "show": null } }, "message_editor": { @@ -980,8 +1007,7 @@ // Removes any lines containing only whitespace at the end of the file and // ensures just one newline at the end. "ensure_final_newline_on_save": true, - // Whether or not to perform a buffer format before saving - // + // Whether or not to perform a buffer format before saving: [on, off, prettier, language_server] // Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored "format_on_save": "on", // How to perform a buffer format. This setting can take 4 values: @@ -1034,6 +1060,19 @@ // Automatically update Zed. This setting may be ignored on Linux if // installed through a package manager. "auto_update": true, + // How to render LSP `textDocument/documentColor` colors in the editor. + // + // Possible values: + // + // 1. Do not query and render document colors. + // "lsp_document_colors": "none", + // 2. Render document colors as inlay hints near the color text (default). + // "lsp_document_colors": "inlay", + // 3. Draw a border around the color text. + // "lsp_document_colors": "border", + // 4. Draw a background behind the color text.. + // "lsp_document_colors": "background", + "lsp_document_colors": "inlay", // Diagnostics configuration. "diagnostics": { // Whether to show the project diagnostics button in the status bar. @@ -1149,6 +1188,12 @@ // 2. Display predictions inline only when holding a modifier key (alt by default). // "mode": "subtle" "mode": "eager", + // Copilot-specific settings + // "copilot": { + // "enterprise_uri": "", + // "proxy": "", + // "proxy_no_verify": false + // }, // Whether edit predictions are enabled when editing text threads. // This setting has no effect if globally disabled. "enabled_in_text_threads": true @@ -1314,6 +1359,8 @@ // the terminal will default to matching the buffer's font fallbacks. // This will be merged with the platform's default font fallbacks // "font_fallbacks": ["FiraCode Nerd Fonts"], + // The weight of the editor font in standard CSS units from 100 to 900. + // "font_weight": 400 // Sets the maximum number of lines in the terminal's scrollback buffer. // Default: 10_000, maximum: 100_000 (all bigger values set will be treated as 100_000), 0 disables the scrolling. // Existing terminals will not pick up this change until they are recreated. @@ -1673,6 +1720,11 @@ // } // } }, + // Common language server settings. + "global_lsp_settings": { + // Whether to show the LSP servers button in the status bar. + "button": true + }, // Jupyter settings "jupyter": { "enabled": true @@ -1687,7 +1739,6 @@ "default_mode": "normal", "toggle_relative_line_numbers": false, "use_system_clipboard": "always", - "use_multiline_find": false, "use_smartcase_find": false, "highlight_on_yank_duration": 200, "custom_digraphs": {}, @@ -1770,6 +1821,7 @@ "debugger": { "stepping_granularity": "line", "save_breakpoints": true, + "dock": "bottom", "button": true } } diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index bf38d9dccb..384ad28272 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -601,7 +601,7 @@ "font_weight": null }, "constant": { - "color": "#669f59ff", + "color": "#c18401ff", "font_style": null, "font_weight": null }, diff --git a/crates/activity_indicator/Cargo.toml b/crates/activity_indicator/Cargo.toml index 778cf472df..3a80f012f9 100644 --- a/crates/activity_indicator/Cargo.toml +++ b/crates/activity_indicator/Cargo.toml @@ -21,6 +21,7 @@ futures.workspace = true gpui.workspace = true language.workspace = true project.workspace = true +proto.workspace = true smallvec.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 86d60d2640..b3287e8222 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -7,7 +7,10 @@ use gpui::{ InteractiveElement as _, ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled, Transformation, Window, actions, percentage, }; -use language::{BinaryStatus, LanguageRegistry, LanguageServerId}; +use language::{ + BinaryStatus, LanguageRegistry, LanguageServerId, LanguageServerName, + LanguageServerStatusUpdate, ServerHealth, +}; use project::{ EnvironmentErrorMessage, LanguageServerProgress, LspStoreEvent, Project, ProjectEnvironmentEvent, @@ -16,6 +19,7 @@ use project::{ use smallvec::SmallVec; use std::{ cmp::Reverse, + collections::HashSet, fmt::Write, path::Path, sync::Arc, @@ -30,9 +34,9 @@ const GIT_OPERATION_DELAY: Duration = Duration::from_millis(0); actions!(activity_indicator, [ShowErrorMessage]); pub enum Event { - ShowError { - server_name: SharedString, - error: String, + ShowStatus { + server_name: LanguageServerName, + status: SharedString, }, } @@ -45,8 +49,8 @@ pub struct ActivityIndicator { #[derive(Debug)] struct ServerStatus { - name: SharedString, - status: BinaryStatus, + name: LanguageServerName, + status: LanguageServerStatusUpdate, } struct PendingWork<'a> { @@ -76,10 +80,13 @@ impl ActivityIndicator { let this = cx.new(|cx| { let mut status_events = languages.language_server_binary_statuses(); cx.spawn(async move |this, cx| { - while let Some((name, status)) = status_events.next().await { + while let Some((name, binary_status)) = status_events.next().await { this.update(cx, |this: &mut ActivityIndicator, cx| { this.statuses.retain(|s| s.name != name); - this.statuses.push(ServerStatus { name, status }); + this.statuses.push(ServerStatus { + name, + status: LanguageServerStatusUpdate::Binary(binary_status), + }); cx.notify(); })?; } @@ -108,8 +115,76 @@ impl ActivityIndicator { cx.subscribe( &project.read(cx).lsp_store(), - |_, _, event, cx| match event { - LspStoreEvent::LanguageServerUpdate { .. } => cx.notify(), + |activity_indicator, _, event, cx| match event { + LspStoreEvent::LanguageServerUpdate { name, message, .. } => { + if let proto::update_language_server::Variant::StatusUpdate(status_update) = + message + { + let Some(name) = name.clone() else { + return; + }; + let status = match &status_update.status { + Some(proto::status_update::Status::Binary(binary_status)) => { + if let Some(binary_status) = + proto::ServerBinaryStatus::from_i32(*binary_status) + { + let binary_status = match binary_status { + proto::ServerBinaryStatus::None => BinaryStatus::None, + proto::ServerBinaryStatus::CheckingForUpdate => { + BinaryStatus::CheckingForUpdate + } + proto::ServerBinaryStatus::Downloading => { + BinaryStatus::Downloading + } + proto::ServerBinaryStatus::Starting => { + BinaryStatus::Starting + } + proto::ServerBinaryStatus::Stopping => { + BinaryStatus::Stopping + } + proto::ServerBinaryStatus::Stopped => { + BinaryStatus::Stopped + } + proto::ServerBinaryStatus::Failed => { + let Some(error) = status_update.message.clone() + else { + return; + }; + BinaryStatus::Failed { error } + } + }; + LanguageServerStatusUpdate::Binary(binary_status) + } else { + return; + } + } + Some(proto::status_update::Status::Health(health_status)) => { + if let Some(health) = + proto::ServerHealth::from_i32(*health_status) + { + let health = match health { + proto::ServerHealth::Ok => ServerHealth::Ok, + proto::ServerHealth::Warning => ServerHealth::Warning, + proto::ServerHealth::Error => ServerHealth::Error, + }; + LanguageServerStatusUpdate::Health( + health, + status_update.message.clone().map(SharedString::from), + ) + } else { + return; + } + } + None => return, + }; + + activity_indicator.statuses.retain(|s| s.name != name); + activity_indicator + .statuses + .push(ServerStatus { name, status }); + } + cx.notify() + } _ => {} }, ) @@ -145,19 +220,19 @@ impl ActivityIndicator { }); cx.subscribe_in(&this, window, move |_, _, event, window, cx| match event { - Event::ShowError { server_name, error } => { + Event::ShowStatus { + server_name, + status, + } => { let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx)); let project = project.clone(); - let error = error.clone(); + let status = status.clone(); let server_name = server_name.clone(); cx.spawn_in(window, async move |workspace, cx| { let buffer = create_buffer.await?; buffer.update(cx, |buffer, cx| { buffer.edit( - [( - 0..0, - format!("Language server error: {}\n\n{}", server_name, error), - )], + [(0..0, format!("Language server {server_name}:\n\n{status}"))], None, cx, ); @@ -166,7 +241,10 @@ impl ActivityIndicator { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane( Box::new(cx.new(|cx| { - Editor::for_buffer(buffer, Some(project.clone()), window, cx) + let mut editor = + Editor::for_buffer(buffer, Some(project.clone()), window, cx); + editor.set_read_only(true); + editor })), None, true, @@ -185,19 +263,34 @@ impl ActivityIndicator { } fn show_error_message(&mut self, _: &ShowErrorMessage, _: &mut Window, cx: &mut Context) { - self.statuses.retain(|status| { - if let BinaryStatus::Failed { error } = &status.status { - cx.emit(Event::ShowError { + let mut status_message_shown = false; + self.statuses.retain(|status| match &status.status { + LanguageServerStatusUpdate::Binary(BinaryStatus::Failed { error }) + if !status_message_shown => + { + cx.emit(Event::ShowStatus { server_name: status.name.clone(), - error: error.clone(), + status: SharedString::from(error), }); + status_message_shown = true; false - } else { - true } + LanguageServerStatusUpdate::Health( + ServerHealth::Error | ServerHealth::Warning, + status_string, + ) if !status_message_shown => match status_string { + Some(error) => { + cx.emit(Event::ShowStatus { + server_name: status.name.clone(), + status: error.clone(), + }); + status_message_shown = true; + false + } + None => false, + }, + _ => true, }); - - cx.notify(); } fn dismiss_error_message( @@ -206,9 +299,23 @@ impl ActivityIndicator { _: &mut Window, cx: &mut Context, ) { - if let Some(updater) = &self.auto_updater { - updater.update(cx, |updater, cx| updater.dismiss_error(cx)); + let error_dismissed = if let Some(updater) = &self.auto_updater { + updater.update(cx, |updater, cx| updater.dismiss_error(cx)) + } else { + false + }; + if error_dismissed { + return; } + + self.project.update(cx, |project, cx| { + if project.last_formatting_failure(cx).is_some() { + project.reset_last_formatting_failure(cx); + true + } else { + false + } + }); } fn pending_language_server_work<'a>( @@ -267,48 +374,52 @@ impl ActivityIndicator { }); } // Show any language server has pending activity. - let mut pending_work = self.pending_language_server_work(cx); - if let Some(PendingWork { - progress_token, - progress, - .. - }) = pending_work.next() { - let mut message = progress - .title - .as_deref() - .unwrap_or(progress_token) - .to_string(); + let mut pending_work = self.pending_language_server_work(cx); + if let Some(PendingWork { + progress_token, + progress, + .. + }) = pending_work.next() + { + let mut message = progress + .title + .as_deref() + .unwrap_or(progress_token) + .to_string(); - if let Some(percentage) = progress.percentage { - write!(&mut message, " ({}%)", percentage).unwrap(); + if let Some(percentage) = progress.percentage { + write!(&mut message, " ({}%)", percentage).unwrap(); + } + + if let Some(progress_message) = progress.message.as_ref() { + message.push_str(": "); + message.push_str(progress_message); + } + + let additional_work_count = pending_work.count(); + if additional_work_count > 0 { + write!(&mut message, " + {} more", additional_work_count).unwrap(); + } + + return Some(Content { + icon: Some( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate(percentage(delta))) + }, + ) + .into_any_element(), + ), + message, + on_click: Some(Arc::new(Self::toggle_language_server_work_context_menu)), + tooltip_message: None, + }); } - - if let Some(progress_message) = progress.message.as_ref() { - message.push_str(": "); - message.push_str(progress_message); - } - - let additional_work_count = pending_work.count(); - if additional_work_count > 0 { - write!(&mut message, " + {} more", additional_work_count).unwrap(); - } - - return Some(Content { - icon: Some( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ) - .into_any_element(), - ), - message, - on_click: Some(Arc::new(Self::toggle_language_server_work_context_menu)), - tooltip_message: None, - }); } if let Some(session) = self @@ -369,14 +480,44 @@ impl ActivityIndicator { let mut downloading = SmallVec::<[_; 3]>::new(); let mut checking_for_update = SmallVec::<[_; 3]>::new(); let mut failed = SmallVec::<[_; 3]>::new(); + let mut health_messages = SmallVec::<[_; 3]>::new(); + let mut servers_to_clear_statuses = HashSet::::default(); for status in &self.statuses { - match status.status { - BinaryStatus::CheckingForUpdate => checking_for_update.push(status.name.clone()), - BinaryStatus::Downloading => downloading.push(status.name.clone()), - BinaryStatus::Failed { .. } => failed.push(status.name.clone()), - BinaryStatus::None => {} + match &status.status { + LanguageServerStatusUpdate::Binary( + BinaryStatus::Starting | BinaryStatus::Stopping, + ) => {} + LanguageServerStatusUpdate::Binary(BinaryStatus::Stopped) => { + servers_to_clear_statuses.insert(status.name.clone()); + } + LanguageServerStatusUpdate::Binary(BinaryStatus::CheckingForUpdate) => { + checking_for_update.push(status.name.clone()); + } + LanguageServerStatusUpdate::Binary(BinaryStatus::Downloading) => { + downloading.push(status.name.clone()); + } + LanguageServerStatusUpdate::Binary(BinaryStatus::Failed { .. }) => { + failed.push(status.name.clone()); + } + LanguageServerStatusUpdate::Binary(BinaryStatus::None) => {} + LanguageServerStatusUpdate::Health(health, server_status) => match server_status { + Some(server_status) => { + health_messages.push((status.name.clone(), *health, server_status.clone())); + } + None => { + servers_to_clear_statuses.insert(status.name.clone()); + } + }, } } + self.statuses + .retain(|status| !servers_to_clear_statuses.contains(&status.name)); + + health_messages.sort_by_key(|(_, health, _)| match health { + ServerHealth::Error => 2, + ServerHealth::Warning => 1, + ServerHealth::Ok => 0, + }); if !downloading.is_empty() { return Some(Content { @@ -457,7 +598,7 @@ impl ActivityIndicator { }), ), on_click: Some(Arc::new(|this, window, cx| { - this.show_error_message(&Default::default(), window, cx) + this.show_error_message(&ShowErrorMessage, window, cx) })), tooltip_message: None, }); @@ -471,7 +612,7 @@ impl ActivityIndicator { .size(IconSize::Small) .into_any_element(), ), - message: format!("Formatting failed: {}. Click to see logs.", failure), + message: format!("Formatting failed: {failure}. Click to see logs."), on_click: Some(Arc::new(|indicator, window, cx| { indicator.project.update(cx, |project, cx| { project.reset_last_formatting_failure(cx); @@ -482,6 +623,56 @@ impl ActivityIndicator { }); } + // Show any health messages for the language servers + if let Some((server_name, health, message)) = health_messages.pop() { + let health_str = match health { + ServerHealth::Ok => format!("({server_name}) "), + ServerHealth::Warning => format!("({server_name}) Warning: "), + ServerHealth::Error => format!("({server_name}) Error: "), + }; + let single_line_message = message + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { None } else { Some(line) } + }) + .collect::>() + .join(" "); + let mut altered_message = single_line_message != message; + let truncated_message = truncate_and_trailoff( + &single_line_message, + MAX_MESSAGE_LEN.saturating_sub(health_str.len()), + ); + altered_message |= truncated_message != single_line_message; + let final_message = format!("{health_str}{truncated_message}"); + + let tooltip_message = if altered_message { + Some(format!("{health_str}{message}")) + } else { + None + }; + + return Some(Content { + icon: Some( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .into_any_element(), + ), + message: final_message, + tooltip_message, + on_click: Some(Arc::new(move |activity_indicator, window, cx| { + if altered_message { + activity_indicator.show_error_message(&ShowErrorMessage, window, cx) + } else { + activity_indicator + .statuses + .retain(|status| status.name != server_name); + cx.notify(); + } + })), + }); + } + // Show any application auto-update info. if let Some(updater) = &self.auto_updater { return match &updater.read(cx).status() { diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 66e4a5c78f..f320e58d00 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -21,94 +21,58 @@ test-support = [ [dependencies] agent_settings.workspace = true anyhow.workspace = true -assistant_context_editor.workspace = true -assistant_slash_command.workspace = true -assistant_slash_commands.workspace = true +assistant_context.workspace = true assistant_tool.workspace = true -audio.workspace = true -buffer_diff.workspace = true chrono.workspace = true client.workspace = true collections.workspace = true component.workspace = true context_server.workspace = true convert_case.workspace = true -db.workspace = true -editor.workspace = true -extension.workspace = true feature_flags.workspace = true -file_icons.workspace = true fs.workspace = true futures.workspace = true -fuzzy.workspace = true git.workspace = true gpui.workspace = true heed.workspace = true -html_to_markdown.workspace = true +icons.workspace = true indoc.workspace = true http_client.workspace = true -indexed_docs.workspace = true -inventory.workspace = true itertools.workspace = true -jsonschema.workspace = true language.workspace = true language_model.workspace = true log.workspace = true -lsp.workspace = true -markdown.workspace = true -menu.workspace = true -multi_buffer.workspace = true -notifications.workspace = true -ordered-float.workspace = true -parking_lot.workspace = true paths.workspace = true -picker.workspace = true postage.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true ref-cast.workspace = true -release_channel.workspace = true rope.workspace = true -rules_library.workspace = true schemars.workspace = true -search.workspace = true serde.workspace = true serde_json.workspace = true -serde_json_lenient.workspace = true settings.workspace = true smol.workspace = true sqlez.workspace = true -streaming_diff.workspace = true telemetry.workspace = true -telemetry_events.workspace = true -terminal.workspace = true -terminal_view.workspace = true text.workspace = true theme.workspace = true thiserror.workspace = true time.workspace = true -time_format.workspace = true -ui.workspace = true -ui_input.workspace = true -urlencoding.workspace = true util.workspace = true uuid.workspace = true -watch.workspace = true workspace-hack.workspace = true -workspace.workspace = true -zed_actions.workspace = true zed_llm_client.workspace = true zstd.workspace = true [dev-dependencies] assistant_tools.workspace = true -buffer_diff = { workspace = true, features = ["test-support"] } -editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, "features" = ["test-support"] } indoc.workspace = true language = { workspace = true, "features" = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } rand.workspace = true diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 0ac7869920..7e3590f05d 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1,294 +1,20 @@ -mod active_thread; -mod agent_configuration; -mod agent_diff; -mod agent_model_selector; -mod agent_panel; -mod agent_profile; -mod buffer_codegen; -mod context; -mod context_picker; -mod context_server_configuration; -mod context_server_tool; -mod context_store; -mod context_strip; -mod debug; -mod history_store; -mod inline_assistant; -mod inline_prompt_editor; -mod message_editor; -mod profile_selector; -mod slash_command_settings; -mod terminal_codegen; -mod terminal_inline_assistant; -mod thread; -mod thread_history; -mod thread_store; -mod tool_compatibility; -mod tool_use; -mod ui; +pub mod agent_profile; +pub mod context; +pub mod context_server_tool; +pub mod context_store; +pub mod history_store; +pub mod thread; +pub mod thread_store; +pub mod tool_use; -use std::sync::Arc; - -use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection}; -use assistant_slash_command::SlashCommandRegistry; -use client::Client; -use feature_flags::FeatureFlagAppExt as _; -use fs::Fs; -use gpui::{App, Entity, actions, impl_actions}; -use language::LanguageRegistry; -use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, -}; -use prompt_store::PromptBuilder; -use schemars::JsonSchema; -use serde::Deserialize; -use settings::{Settings as _, SettingsStore}; -use thread::ThreadId; - -pub use crate::active_thread::ActiveThread; -use crate::agent_configuration::{AddContextServerModal, ManageProfilesModal}; -pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate}; -pub use crate::context::{ContextLoadResult, LoadedContext}; -pub use crate::inline_assistant::InlineAssistant; -use crate::slash_command_settings::SlashCommandSettings; -pub use crate::thread::{Message, MessageSegment, Thread, ThreadEvent}; -pub use crate::thread_store::{SerializedThread, TextThreadStore, ThreadStore}; -pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; +pub use context::{AgentContext, ContextId, ContextLoadResult}; pub use context_store::ContextStore; -pub use ui::preview::{all_agent_previews, get_agent_preview}; +pub use thread::{ + LastRestoreCheckpoint, Message, MessageCrease, MessageId, MessageSegment, Thread, ThreadError, + ThreadEvent, ThreadFeedback, ThreadId, ThreadSummary, TokenUsageRatio, +}; +pub use thread_store::{SerializedThread, TextThreadStore, ThreadStore}; -actions!( - agent, - [ - NewTextThread, - ToggleContextPicker, - ToggleNavigationMenu, - ToggleOptionsMenu, - DeleteRecentlyOpenThread, - ToggleProfileSelector, - RemoveAllContext, - ExpandMessageEditor, - OpenHistory, - AddContextServer, - RemoveSelectedThread, - Chat, - ChatWithFollow, - CycleNextInlineAssist, - CyclePreviousInlineAssist, - FocusUp, - FocusDown, - FocusLeft, - FocusRight, - RemoveFocusedContext, - AcceptSuggestedContext, - OpenActiveThreadAsMarkdown, - OpenAgentDiff, - Keep, - Reject, - RejectAll, - KeepAll, - Follow, - ResetTrialUpsell, - ResetTrialEndUpsell, - ContinueThread, - ContinueWithBurnMode, - ToggleBurnMode, - ] -); - -#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema)] -pub struct NewThread { - #[serde(default)] - from_thread_id: Option, -} - -#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)] -pub struct ManageProfiles { - #[serde(default)] - pub customize_tools: Option, -} - -impl ManageProfiles { - pub fn customize_tools(profile_id: AgentProfileId) -> Self { - Self { - customize_tools: Some(profile_id), - } - } -} - -impl_actions!(agent, [NewThread, ManageProfiles]); - -#[derive(Clone)] -pub(crate) enum ModelUsageContext { - Thread(Entity), - InlineAssistant, -} - -impl ModelUsageContext { - pub fn configured_model(&self, cx: &App) -> Option { - match self { - Self::Thread(thread) => thread.read(cx).configured_model(), - Self::InlineAssistant => { - LanguageModelRegistry::read_global(cx).inline_assistant_model() - } - } - } - - pub fn language_model(&self, cx: &App) -> Option> { - self.configured_model(cx) - .map(|configured_model| configured_model.model) - } -} - -/// Initializes the `agent` crate. -pub fn init( - fs: Arc, - client: Arc, - prompt_builder: Arc, - language_registry: Arc, - is_eval: bool, - cx: &mut App, -) { - AgentSettings::register(cx); - SlashCommandSettings::register(cx); - - assistant_context_editor::init(client.clone(), cx); - rules_library::init(cx); - if !is_eval { - // Initializing the language model from the user settings messes with the eval, so we only initialize them when - // we're not running inside of the eval. - init_language_model_settings(cx); - } - assistant_slash_command::init(cx); +pub fn init(cx: &mut gpui::App) { thread_store::init(cx); - agent_panel::init(cx); - context_server_configuration::init(language_registry, cx); - - register_slash_commands(cx); - inline_assistant::init( - fs.clone(), - prompt_builder.clone(), - client.telemetry().clone(), - cx, - ); - terminal_inline_assistant::init( - fs.clone(), - prompt_builder.clone(), - client.telemetry().clone(), - cx, - ); - indexed_docs::init(cx); - cx.observe_new(AddContextServerModal::register).detach(); - cx.observe_new(ManageProfilesModal::register).detach(); -} - -fn init_language_model_settings(cx: &mut App) { - update_active_language_model_from_settings(cx); - - cx.observe_global::(update_active_language_model_from_settings) - .detach(); - cx.subscribe( - &LanguageModelRegistry::global(cx), - |_, event: &language_model::Event, cx| match event { - language_model::Event::ProviderStateChanged - | language_model::Event::AddedProvider(_) - | language_model::Event::RemovedProvider(_) => { - update_active_language_model_from_settings(cx); - } - _ => {} - }, - ) - .detach(); -} - -fn update_active_language_model_from_settings(cx: &mut App) { - let settings = AgentSettings::get_global(cx); - - fn to_selected_model(selection: &LanguageModelSelection) -> language_model::SelectedModel { - language_model::SelectedModel { - provider: LanguageModelProviderId::from(selection.provider.0.clone()), - model: LanguageModelId::from(selection.model.clone()), - } - } - - let default = to_selected_model(&settings.default_model); - let inline_assistant = settings - .inline_assistant_model - .as_ref() - .map(to_selected_model); - let commit_message = settings - .commit_message_model - .as_ref() - .map(to_selected_model); - let thread_summary = settings - .thread_summary_model - .as_ref() - .map(to_selected_model); - let inline_alternatives = settings - .inline_alternatives - .iter() - .map(to_selected_model) - .collect::>(); - - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.select_default_model(Some(&default), cx); - registry.select_inline_assistant_model(inline_assistant.as_ref(), cx); - registry.select_commit_message_model(commit_message.as_ref(), cx); - registry.select_thread_summary_model(thread_summary.as_ref(), cx); - registry.select_inline_alternative_models(inline_alternatives, cx); - }); -} - -fn register_slash_commands(cx: &mut App) { - let slash_command_registry = SlashCommandRegistry::global(cx); - - slash_command_registry.register_command(assistant_slash_commands::FileSlashCommand, true); - slash_command_registry.register_command(assistant_slash_commands::DeltaSlashCommand, true); - slash_command_registry.register_command(assistant_slash_commands::OutlineSlashCommand, true); - slash_command_registry.register_command(assistant_slash_commands::TabSlashCommand, true); - slash_command_registry - .register_command(assistant_slash_commands::CargoWorkspaceSlashCommand, true); - slash_command_registry.register_command(assistant_slash_commands::PromptSlashCommand, true); - slash_command_registry.register_command(assistant_slash_commands::SelectionCommand, true); - slash_command_registry.register_command(assistant_slash_commands::DefaultSlashCommand, false); - slash_command_registry.register_command(assistant_slash_commands::NowSlashCommand, false); - slash_command_registry - .register_command(assistant_slash_commands::DiagnosticsSlashCommand, true); - slash_command_registry.register_command(assistant_slash_commands::FetchSlashCommand, true); - - cx.observe_flag::({ - let slash_command_registry = slash_command_registry.clone(); - move |is_enabled, _cx| { - if is_enabled { - slash_command_registry.register_command( - assistant_slash_commands::StreamingExampleSlashCommand, - false, - ); - } - } - }) - .detach(); - - update_slash_commands_from_settings(cx); - cx.observe_global::(update_slash_commands_from_settings) - .detach(); -} - -fn update_slash_commands_from_settings(cx: &mut App) { - let slash_command_registry = SlashCommandRegistry::global(cx); - let settings = SlashCommandSettings::get_global(cx); - - if settings.docs.enabled { - slash_command_registry.register_command(assistant_slash_commands::DocsSlashCommand, true); - } else { - slash_command_registry.unregister_command(assistant_slash_commands::DocsSlashCommand); - } - - if settings.cargo_workspace.enabled { - slash_command_registry - .register_command(assistant_slash_commands::CargoWorkspaceSlashCommand, true); - } else { - slash_command_registry - .unregister_command(assistant_slash_commands::CargoWorkspaceSlashCommand); - } } diff --git a/crates/agent/src/agent_configuration/add_context_server_modal.rs b/crates/agent/src/agent_configuration/add_context_server_modal.rs deleted file mode 100644 index 47ba57b035..0000000000 --- a/crates/agent/src/agent_configuration/add_context_server_modal.rs +++ /dev/null @@ -1,197 +0,0 @@ -use context_server::ContextServerCommand; -use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, prelude::*}; -use project::project_settings::{ContextServerConfiguration, ProjectSettings}; -use serde_json::json; -use settings::update_settings_file; -use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*}; -use ui_input::SingleLineInput; -use workspace::{ModalView, Workspace}; - -use crate::AddContextServer; - -pub struct AddContextServerModal { - workspace: WeakEntity, - name_editor: Entity, - command_editor: Entity, -} - -impl AddContextServerModal { - pub fn register( - workspace: &mut Workspace, - _window: Option<&mut Window>, - _cx: &mut Context, - ) { - workspace.register_action(|workspace, _: &AddContextServer, window, cx| { - let workspace_handle = cx.entity().downgrade(); - workspace.toggle_modal(window, cx, |window, cx| { - Self::new(workspace_handle, window, cx) - }) - }); - } - - pub fn new( - workspace: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let name_editor = - cx.new(|cx| SingleLineInput::new(window, cx, "my-custom-server").label("Name")); - let command_editor = cx.new(|cx| { - SingleLineInput::new(window, cx, "Command").label("Command to run the MCP server") - }); - - Self { - name_editor, - command_editor, - workspace, - } - } - - fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context) { - let name = self - .name_editor - .read(cx) - .editor() - .read(cx) - .text(cx) - .trim() - .to_string(); - let command = self - .command_editor - .read(cx) - .editor() - .read(cx) - .text(cx) - .trim() - .to_string(); - - if name.is_empty() || command.is_empty() { - return; - } - - let mut command_parts = command.split(' ').map(|part| part.trim().to_string()); - let Some(path) = command_parts.next() else { - return; - }; - let args = command_parts.collect::>(); - - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - let fs = workspace.app_state().fs.clone(); - update_settings_file::(fs.clone(), cx, |settings, _| { - settings.context_servers.insert( - name.into(), - ContextServerConfiguration { - command: Some(ContextServerCommand { - path, - args, - env: None, - }), - settings: Some(json!({})), - }, - ); - }); - }); - } - - cx.emit(DismissEvent); - } - - fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context) { - cx.emit(DismissEvent); - } -} - -impl ModalView for AddContextServerModal {} - -impl Focusable for AddContextServerModal { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.name_editor.focus_handle(cx).clone() - } -} - -impl EventEmitter for AddContextServerModal {} - -impl Render for AddContextServerModal { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let is_name_empty = self.name_editor.read(cx).is_empty(cx); - let is_command_empty = self.command_editor.read(cx).is_empty(cx); - - let focus_handle = self.focus_handle(cx); - - div() - .elevation_3(cx) - .w(rems(34.)) - .key_context("AddContextServerModal") - .on_action( - cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)), - ) - .on_action( - cx.listener(|this, _: &menu::Confirm, _window, cx| { - this.confirm(&menu::Confirm, cx) - }), - ) - .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); - })) - .on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent))) - .child( - Modal::new("add-context-server", None) - .header(ModalHeader::new().headline("Add MCP Server")) - .section( - Section::new().child( - v_flex() - .gap_2() - .child(self.name_editor.clone()) - .child(self.command_editor.clone()), - ), - ) - .footer( - ModalFooter::new().end_slot( - h_flex() - .gap_2() - .child( - Button::new("cancel", "Cancel") - .key_binding( - KeyBinding::for_action_in( - &menu::Cancel, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, _window, cx| { - this.cancel(&menu::Cancel, cx) - })), - ) - .child( - Button::new("add-server", "Add Server") - .disabled(is_name_empty || is_command_empty) - .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .map(|button| { - if is_name_empty { - button.tooltip(Tooltip::text("Name is required")) - } else if is_command_empty { - button.tooltip(Tooltip::text("Command is required")) - } else { - button - } - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.confirm(&menu::Confirm, cx) - })), - ), - ), - ), - ) - } -} diff --git a/crates/agent/src/agent_configuration/configure_context_server_modal.rs b/crates/agent/src/agent_configuration/configure_context_server_modal.rs deleted file mode 100644 index c916e7dc32..0000000000 --- a/crates/agent/src/agent_configuration/configure_context_server_modal.rs +++ /dev/null @@ -1,553 +0,0 @@ -use std::{ - sync::{Arc, Mutex}, - time::Duration, -}; - -use anyhow::Context as _; -use context_server::ContextServerId; -use editor::{Editor, EditorElement, EditorStyle}; -use gpui::{ - Animation, AnimationExt, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, - TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, percentage, -}; -use language::{Language, LanguageRegistry}; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use notifications::status_toast::{StatusToast, ToastIcon}; -use project::{ - context_server_store::{ContextServerStatus, ContextServerStore}, - project_settings::{ContextServerConfiguration, ProjectSettings}, -}; -use settings::{Settings as _, update_settings_file}; -use theme::ThemeSettings; -use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*}; -use util::ResultExt; -use workspace::{ModalView, Workspace}; - -pub(crate) struct ConfigureContextServerModal { - workspace: WeakEntity, - focus_handle: FocusHandle, - context_servers_to_setup: Vec, - context_server_store: Entity, -} - -enum Configuration { - NotAvailable, - Required(ConfigurationRequiredState), -} - -struct ConfigurationRequiredState { - installation_instructions: Entity, - settings_validator: Option, - settings_editor: Entity, - last_error: Option, - waiting_for_context_server: bool, -} - -struct ContextServerSetup { - id: ContextServerId, - repository_url: Option, - configuration: Configuration, -} - -impl ConfigureContextServerModal { - pub fn new( - configurations: impl Iterator, - context_server_store: Entity, - jsonc_language: Option>, - language_registry: Arc, - workspace: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let context_servers_to_setup = configurations - .map(|config| match config { - crate::context_server_configuration::Configuration::NotAvailable( - context_server_id, - repository_url, - ) => ContextServerSetup { - id: context_server_id, - repository_url, - configuration: Configuration::NotAvailable, - }, - crate::context_server_configuration::Configuration::Required( - context_server_id, - repository_url, - config, - ) => { - let jsonc_language = jsonc_language.clone(); - let settings_validator = jsonschema::validator_for(&config.settings_schema) - .context("Failed to load JSON schema for context server settings") - .log_err(); - let state = ConfigurationRequiredState { - installation_instructions: cx.new(|cx| { - Markdown::new( - config.installation_instructions.clone().into(), - Some(language_registry.clone()), - None, - cx, - ) - }), - settings_validator, - settings_editor: cx.new(|cx| { - let mut editor = Editor::auto_height(16, window, cx); - editor.set_text(config.default_settings.trim(), window, cx); - editor.set_show_gutter(false, cx); - editor.set_soft_wrap_mode( - language::language_settings::SoftWrap::None, - cx, - ); - if let Some(buffer) = editor.buffer().read(cx).as_singleton() { - buffer.update(cx, |buffer, cx| { - buffer.set_language(jsonc_language, cx) - }) - } - editor - }), - waiting_for_context_server: false, - last_error: None, - }; - ContextServerSetup { - id: context_server_id, - repository_url, - configuration: Configuration::Required(state), - } - } - }) - .collect::>(); - - Self { - workspace, - focus_handle: cx.focus_handle(), - context_servers_to_setup, - context_server_store, - } - } -} - -impl ConfigureContextServerModal { - pub fn confirm(&mut self, cx: &mut Context) { - if self.context_servers_to_setup.is_empty() { - self.dismiss(cx); - return; - } - - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - - let id = self.context_servers_to_setup[0].id.clone(); - let configuration = match &mut self.context_servers_to_setup[0].configuration { - Configuration::NotAvailable => { - self.context_servers_to_setup.remove(0); - if self.context_servers_to_setup.is_empty() { - self.dismiss(cx); - } - return; - } - Configuration::Required(state) => state, - }; - - configuration.last_error.take(); - if configuration.waiting_for_context_server { - return; - } - - let settings_value = match serde_json_lenient::from_str::( - &configuration.settings_editor.read(cx).text(cx), - ) { - Ok(value) => value, - Err(error) => { - configuration.last_error = Some(error.to_string().into()); - cx.notify(); - return; - } - }; - - if let Some(validator) = configuration.settings_validator.as_ref() { - if let Err(error) = validator.validate(&settings_value) { - configuration.last_error = Some(error.to_string().into()); - cx.notify(); - return; - } - } - let id = id.clone(); - - let settings_changed = ProjectSettings::get_global(cx) - .context_servers - .get(&id.0) - .map_or(true, |config| { - config.settings.as_ref() != Some(&settings_value) - }); - - let is_running = self.context_server_store.read(cx).status_for_server(&id) - == Some(ContextServerStatus::Running); - - if !settings_changed && is_running { - self.complete_setup(id, cx); - return; - } - - configuration.waiting_for_context_server = true; - - let task = wait_for_context_server(&self.context_server_store, id.clone(), cx); - cx.spawn({ - let id = id.clone(); - async move |this, cx| { - let result = task.await; - this.update(cx, |this, cx| match result { - Ok(_) => { - this.complete_setup(id, cx); - } - Err(err) => { - if let Some(setup) = this.context_servers_to_setup.get_mut(0) { - match &mut setup.configuration { - Configuration::NotAvailable => {} - Configuration::Required(state) => { - state.last_error = Some(err.into()); - state.waiting_for_context_server = false; - } - } - } else { - this.dismiss(cx); - } - cx.notify(); - } - }) - } - }) - .detach(); - - // When we write the settings to the file, the context server will be restarted. - update_settings_file::(workspace.read(cx).app_state().fs.clone(), cx, { - let id = id.clone(); - |settings, _| { - if let Some(server_config) = settings.context_servers.get_mut(&id.0) { - server_config.settings = Some(settings_value); - } else { - settings.context_servers.insert( - id.0, - ContextServerConfiguration { - settings: Some(settings_value), - ..Default::default() - }, - ); - } - } - }); - } - - fn complete_setup(&mut self, id: ContextServerId, cx: &mut Context) { - self.context_servers_to_setup.remove(0); - cx.notify(); - - if !self.context_servers_to_setup.is_empty() { - return; - } - - self.workspace - .update(cx, { - |workspace, cx| { - let status_toast = StatusToast::new( - format!("{} configured successfully.", id), - cx, - |this, _cx| { - this.icon(ToastIcon::new(IconName::Hammer).color(Color::Muted)) - .action("Dismiss", |_, _| {}) - }, - ); - - workspace.toggle_status_toast(status_toast, cx); - } - }) - .log_err(); - - self.dismiss(cx); - } - - fn dismiss(&self, cx: &mut Context) { - cx.emit(DismissEvent); - } -} - -fn wait_for_context_server( - context_server_store: &Entity, - context_server_id: ContextServerId, - cx: &mut App, -) -> Task>> { - let (tx, rx) = futures::channel::oneshot::channel(); - let tx = Arc::new(Mutex::new(Some(tx))); - - let subscription = cx.subscribe(context_server_store, move |_, event, _cx| match event { - project::context_server_store::Event::ServerStatusChanged { server_id, status } => { - match status { - ContextServerStatus::Running => { - if server_id == &context_server_id { - if let Some(tx) = tx.lock().unwrap().take() { - let _ = tx.send(Ok(())); - } - } - } - ContextServerStatus::Stopped => { - if server_id == &context_server_id { - if let Some(tx) = tx.lock().unwrap().take() { - let _ = tx.send(Err("Context server stopped running".into())); - } - } - } - ContextServerStatus::Error(error) => { - if server_id == &context_server_id { - if let Some(tx) = tx.lock().unwrap().take() { - let _ = tx.send(Err(error.clone())); - } - } - } - _ => {} - } - } - }); - - cx.spawn(async move |_cx| { - let result = rx.await.unwrap(); - drop(subscription); - result - }) -} - -impl Render for ConfigureContextServerModal { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(setup) = self.context_servers_to_setup.first() else { - return div().into_any_element(); - }; - - let focus_handle = self.focus_handle(cx); - - div() - .elevation_3(cx) - .w(rems(42.)) - .key_context("ConfigureContextServerModal") - .track_focus(&focus_handle) - .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| this.confirm(cx))) - .on_action(cx.listener(|this, _: &menu::Cancel, _window, cx| this.dismiss(cx))) - .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); - })) - .child( - Modal::new("configure-context-server", None) - .header(ModalHeader::new().headline(format!("Configure {}", setup.id))) - .section(match &setup.configuration { - Configuration::NotAvailable => Section::new().child( - Label::new( - "No configuration options available for this context server. Visit the Repository for any further instructions.", - ) - .color(Color::Muted), - ), - Configuration::Required(configuration) => Section::new() - .child(div().pb_2().text_sm().child(MarkdownElement::new( - configuration.installation_instructions.clone(), - default_markdown_style(window, cx), - ))) - .child( - div() - .p_2() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border_variant) - .bg(cx.theme().colors().editor_background) - .gap_1() - .child({ - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.buffer_font.family.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_size: settings.buffer_font_size(cx).into(), - font_weight: settings.buffer_font.weight, - line_height: relative( - settings.buffer_line_height.value(), - ), - ..Default::default() - }; - EditorElement::new( - &configuration.settings_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - syntax: cx.theme().syntax().clone(), - ..Default::default() - }, - ) - }) - .when_some(configuration.last_error.clone(), |this, error| { - this.child( - h_flex() - .gap_2() - .px_2() - .py_1() - .child( - Icon::new(IconName::Warning) - .size(IconSize::XSmall) - .color(Color::Warning), - ) - .child( - div().w_full().child( - Label::new(error) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ), - ) - }), - ) - .when(configuration.waiting_for_context_server, |this| { - this.child( - h_flex() - .gap_1p5() - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Info) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate( - percentage(delta), - )) - }, - ) - .into_any_element(), - ) - .child( - Label::new("Waiting for Context Server") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }), - }) - .footer( - ModalFooter::new() - .when_some(setup.repository_url.clone(), |this, repository_url| { - this.start_slot( - h_flex().w_full().child( - Button::new("open-repository", "Open Repository") - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .tooltip({ - let repository_url = repository_url.clone(); - move |window, cx| { - Tooltip::with_meta( - "Open Repository", - None, - repository_url.clone(), - window, - cx, - ) - } - }) - .on_click(move |_, _, cx| cx.open_url(&repository_url)), - ), - ) - }) - .end_slot(match &setup.configuration { - Configuration::NotAvailable => Button::new("dismiss", "Dismiss") - .key_binding( - KeyBinding::for_action_in( - &menu::Cancel, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click( - cx.listener(|this, _event, _window, cx| this.dismiss(cx)), - ) - .into_any_element(), - Configuration::Required(state) => h_flex() - .gap_2() - .child( - Button::new("cancel", "Cancel") - .key_binding( - KeyBinding::for_action_in( - &menu::Cancel, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, _window, cx| { - this.dismiss(cx) - })), - ) - .child( - Button::new("configure-server", "Configure MCP") - .disabled(state.waiting_for_context_server) - .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, _window, cx| { - this.confirm(cx) - })), - ) - .into_any_element(), - }), - ), - ).into_any_element() - } -} - -pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let theme_settings = ThemeSettings::get_global(cx); - let colors = cx.theme().colors(); - let mut text_style = window.text_style(); - text_style.refine(&TextStyleRefinement { - font_family: Some(theme_settings.ui_font.family.clone()), - font_fallbacks: theme_settings.ui_font.fallbacks.clone(), - font_features: Some(theme_settings.ui_font.features.clone()), - font_size: Some(TextSize::XSmall.rems(cx).into()), - color: Some(colors.text_muted), - ..Default::default() - }); - - MarkdownStyle { - base_text_style: text_style.clone(), - selection_background_color: cx.theme().players().local().selection, - link: TextStyleRefinement { - background_color: Some(colors.editor_foreground.opacity(0.025)), - underline: Some(UnderlineStyle { - color: Some(colors.text_accent.opacity(0.5)), - thickness: px(1.), - ..Default::default() - }), - ..Default::default() - }, - ..Default::default() - } -} - -impl ModalView for ConfigureContextServerModal {} -impl EventEmitter for ConfigureContextServerModal {} -impl Focusable for ConfigureContextServerModal { - fn focus_handle(&self, cx: &App) -> FocusHandle { - if let Some(current) = self.context_servers_to_setup.first() { - match ¤t.configuration { - Configuration::NotAvailable => self.focus_handle.clone(), - Configuration::Required(configuration) => { - configuration.settings_editor.read(cx).focus_handle(cx) - } - } - } else { - self.focus_handle.clone() - } - } -} diff --git a/crates/agent/src/agent_profile.rs b/crates/agent/src/agent_profile.rs index 5cd69bd324..07030c744f 100644 --- a/crates/agent/src/agent_profile.rs +++ b/crates/agent/src/agent_profile.rs @@ -5,9 +5,8 @@ use assistant_tool::{Tool, ToolSource, ToolWorkingSet}; use collections::IndexMap; use convert_case::{Case, Casing}; use fs::Fs; -use gpui::{App, Entity}; +use gpui::{App, Entity, SharedString}; use settings::{Settings, update_settings_file}; -use ui::SharedString; use util::ResultExt; #[derive(Clone, Debug, Eq, PartialEq)] @@ -86,6 +85,14 @@ impl AgentProfile { .collect() } + pub fn is_tool_enabled(&self, source: ToolSource, tool_name: String, cx: &App) -> bool { + let Some(settings) = AgentSettings::get_global(cx).profiles.get(&self.id) else { + return false; + }; + + return Self::is_enabled(settings, source, tool_name); + } + fn is_enabled(settings: &AgentProfileSettings, source: ToolSource, name: String) -> bool { match source { ToolSource::Native => *settings.tools.get(name.as_str()).unwrap_or(&false), @@ -108,11 +115,11 @@ mod tests { use agent_settings::ContextServerPreset; use assistant_tool::ToolRegistry; use collections::IndexMap; + use gpui::SharedString; use gpui::{AppContext, TestAppContext}; use http_client::FakeHttpClient; use project::Project; use settings::{Settings, SettingsStore}; - use ui::SharedString; use super::*; @@ -302,7 +309,7 @@ mod tests { unimplemented!() } - fn icon(&self) -> ui::IconName { + fn icon(&self) -> icons::IconName { unimplemented!() } diff --git a/crates/agent/src/context.rs b/crates/agent/src/context.rs index aaf613ea5f..ddd13de491 100644 --- a/crates/agent/src/context.rs +++ b/crates/agent/src/context.rs @@ -1,30 +1,25 @@ -use std::fmt::{self, Display, Formatter, Write as _}; -use std::hash::{Hash, Hasher}; -use std::path::PathBuf; -use std::{ops::Range, path::Path, sync::Arc}; - -use assistant_context_editor::AssistantContext; +use crate::thread::Thread; +use assistant_context::AssistantContext; use assistant_tool::outline; -use collections::{HashMap, HashSet}; -use editor::display_map::CreaseId; -use editor::{Addon, Editor}; +use collections::HashSet; use futures::future; use futures::{FutureExt, future::Shared}; -use gpui::{App, AppContext as _, Entity, SharedString, Subscription, Task}; +use gpui::{App, AppContext as _, ElementId, Entity, SharedString, Task}; +use icons::IconName; use language::{Buffer, ParseStatus}; use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent}; use project::{Project, ProjectEntryId, ProjectPath, Worktree}; use prompt_store::{PromptStore, UserPromptId}; use ref_cast::RefCast; use rope::Point; +use std::fmt::{self, Display, Formatter, Write as _}; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; +use std::{ops::Range, path::Path, sync::Arc}; use text::{Anchor, OffsetRangeExt as _}; -use ui::{Context, ElementId, IconName}; use util::markdown::MarkdownCodeBlock; use util::{ResultExt as _, post_inc}; -use crate::context_store::{ContextStore, ContextStoreEvent}; -use crate::thread::Thread; - pub const RULES_ICON: IconName = IconName::Context; pub enum ContextKind { @@ -1117,69 +1112,6 @@ impl Hash for AgentContextKey { } } -#[derive(Default)] -pub struct ContextCreasesAddon { - creases: HashMap>, - _subscription: Option, -} - -impl Addon for ContextCreasesAddon { - fn to_any(&self) -> &dyn std::any::Any { - self - } - - fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { - Some(self) - } -} - -impl ContextCreasesAddon { - pub fn new() -> Self { - Self { - creases: HashMap::default(), - _subscription: None, - } - } - - pub fn add_creases( - &mut self, - context_store: &Entity, - key: AgentContextKey, - creases: impl IntoIterator, - cx: &mut Context, - ) { - self.creases.entry(key).or_default().extend(creases); - self._subscription = Some(cx.subscribe( - &context_store, - |editor, _, event, cx| match event { - ContextStoreEvent::ContextRemoved(key) => { - let Some(this) = editor.addon_mut::() else { - return; - }; - let (crease_ids, replacement_texts): (Vec<_>, Vec<_>) = this - .creases - .remove(key) - .unwrap_or_default() - .into_iter() - .unzip(); - let ranges = editor - .remove_creases(crease_ids, cx) - .into_iter() - .map(|(_, range)| range) - .collect::>(); - editor.unfold_ranges(&ranges, false, false, cx); - editor.edit(ranges.into_iter().zip(replacement_texts), cx); - cx.notify(); - } - }, - )) - } - - pub fn into_inner(self) -> HashMap> { - self.creases - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/agent/src/context_server_configuration.rs b/crates/agent/src/context_server_configuration.rs deleted file mode 100644 index 49effbdc0f..0000000000 --- a/crates/agent/src/context_server_configuration.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::sync::Arc; - -use anyhow::Context as _; -use context_server::ContextServerId; -use extension::{ContextServerConfiguration, ExtensionManifest}; -use gpui::Task; -use language::LanguageRegistry; -use project::context_server_store::registry::ContextServerDescriptorRegistry; -use ui::prelude::*; -use util::ResultExt; -use workspace::Workspace; - -use crate::agent_configuration::ConfigureContextServerModal; - -pub(crate) fn init(language_registry: Arc, cx: &mut App) { - cx.observe_new(move |_: &mut Workspace, window, cx| { - let Some(window) = window else { - return; - }; - - if let Some(extension_events) = extension::ExtensionEvents::try_global(cx).as_ref() { - cx.subscribe_in(extension_events, window, { - let language_registry = language_registry.clone(); - move |workspace, _, event, window, cx| match event { - extension::Event::ExtensionInstalled(manifest) => { - show_configure_mcp_modal( - language_registry.clone(), - manifest, - workspace, - window, - cx, - ); - } - extension::Event::ConfigureExtensionRequested(manifest) => { - if !manifest.context_servers.is_empty() { - show_configure_mcp_modal( - language_registry.clone(), - manifest, - workspace, - window, - cx, - ); - } - } - _ => {} - } - }) - .detach(); - } else { - log::info!( - "No extension events global found. Skipping context server configuration wizard" - ); - } - }) - .detach(); -} - -pub enum Configuration { - NotAvailable(ContextServerId, Option), - Required( - ContextServerId, - Option, - ContextServerConfiguration, - ), -} - -fn show_configure_mcp_modal( - language_registry: Arc, - manifest: &Arc, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context<'_, Workspace>, -) { - if !window.is_window_active() { - return; - } - - let context_server_store = workspace.project().read(cx).context_server_store(); - let repository: Option = manifest.repository.as_ref().map(|s| s.clone().into()); - - let registry = ContextServerDescriptorRegistry::default_global(cx).read(cx); - let worktree_store = workspace.project().read(cx).worktree_store(); - let configuration_tasks = manifest - .context_servers - .keys() - .cloned() - .map({ - |key| { - let Some(descriptor) = registry.context_server_descriptor(&key) else { - return Task::ready(Configuration::NotAvailable( - ContextServerId(key), - repository.clone(), - )); - }; - cx.spawn({ - let repository_url = repository.clone(); - let worktree_store = worktree_store.clone(); - async move |_, cx| { - let configuration = descriptor - .configuration(worktree_store.clone(), &cx) - .await - .context("Failed to resolve context server configuration") - .log_err() - .flatten(); - - match configuration { - Some(config) => Configuration::Required( - ContextServerId(key), - repository_url, - config, - ), - None => { - Configuration::NotAvailable(ContextServerId(key), repository_url) - } - } - } - }) - } - }) - .collect::>(); - - let jsonc_language = language_registry.language_for_name("jsonc"); - - cx.spawn_in(window, async move |this, cx| { - let configurations = futures::future::join_all(configuration_tasks).await; - let jsonc_language = jsonc_language.await.ok(); - - this.update_in(cx, |this, window, cx| { - let workspace = cx.entity().downgrade(); - this.toggle_modal(window, cx, |window, cx| { - ConfigureContextServerModal::new( - configurations.into_iter(), - context_server_store, - jsonc_language, - language_registry, - workspace, - window, - cx, - ) - }); - }) - }) - .detach(); -} diff --git a/crates/agent/src/context_server_tool.rs b/crates/agent/src/context_server_tool.rs index 17571fca04..da7de1e312 100644 --- a/crates/agent/src/context_server_tool.rs +++ b/crates/agent/src/context_server_tool.rs @@ -4,9 +4,9 @@ use anyhow::{Result, anyhow, bail}; use assistant_tool::{ActionLog, Tool, ToolResult, ToolSource}; use context_server::{ContextServerId, types}; use gpui::{AnyWindowHandle, App, Entity, Task}; +use icons::IconName; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use project::{Project, context_server_store::ContextServerStore}; -use ui::IconName; pub struct ContextServerTool { store: Entity, diff --git a/crates/agent/src/context_store.rs b/crates/agent/src/context_store.rs index f4697d9eb4..60ba5527dc 100644 --- a/crates/agent/src/context_store.rs +++ b/crates/agent/src/context_store.rs @@ -1,28 +1,28 @@ -use std::ops::Range; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - +use crate::{ + context::{ + AgentContextHandle, AgentContextKey, ContextId, ContextKind, DirectoryContextHandle, + FetchedUrlContext, FileContextHandle, ImageContext, RulesContextHandle, + SelectionContextHandle, SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle, + }, + thread::{MessageId, Thread, ThreadId}, + thread_store::ThreadStore, +}; use anyhow::{Context as _, Result, anyhow}; -use assistant_context_editor::AssistantContext; +use assistant_context::AssistantContext; use collections::{HashSet, IndexSet}; use futures::{self, FutureExt}; use gpui::{App, Context, Entity, EventEmitter, Image, SharedString, Task, WeakEntity}; use language::{Buffer, File as _}; use language_model::LanguageModelImage; -use project::image_store::is_image_file; -use project::{Project, ProjectItem, ProjectPath, Symbol}; +use project::{Project, ProjectItem, ProjectPath, Symbol, image_store::is_image_file}; use prompt_store::UserPromptId; use ref_cast::RefCast as _; -use text::{Anchor, OffsetRangeExt}; - -use crate::ThreadStore; -use crate::context::{ - AgentContextHandle, AgentContextKey, ContextId, DirectoryContextHandle, FetchedUrlContext, - FileContextHandle, ImageContext, RulesContextHandle, SelectionContextHandle, - SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle, +use std::{ + ops::Range, + path::{Path, PathBuf}, + sync::Arc, }; -use crate::context_strip::SuggestedContext; -use crate::thread::{MessageId, Thread, ThreadId}; +use text::{Anchor, OffsetRangeExt}; pub struct ContextStore { project: WeakEntity, @@ -561,6 +561,49 @@ impl ContextStore { } } +#[derive(Clone)] +pub enum SuggestedContext { + File { + name: SharedString, + icon_path: Option, + buffer: WeakEntity, + }, + Thread { + name: SharedString, + thread: WeakEntity, + }, + TextThread { + name: SharedString, + context: WeakEntity, + }, +} + +impl SuggestedContext { + pub fn name(&self) -> &SharedString { + match self { + Self::File { name, .. } => name, + Self::Thread { name, .. } => name, + Self::TextThread { name, .. } => name, + } + } + + pub fn icon_path(&self) -> Option { + match self { + Self::File { icon_path, .. } => icon_path.clone(), + Self::Thread { .. } => None, + Self::TextThread { .. } => None, + } + } + + pub fn kind(&self) -> ContextKind { + match self { + Self::File { .. } => ContextKind::File, + Self::Thread { .. } => ContextKind::Thread, + Self::TextThread { .. } => ContextKind::TextThread, + } + } +} + pub enum FileInclusion { Direct, InDirectory { full_path: PathBuf }, diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index 61fc430ddb..89f75a72bd 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -1,21 +1,17 @@ -use std::{collections::VecDeque, path::Path, sync::Arc}; - +use crate::{ + ThreadId, + thread_store::{SerializedThreadMetadata, ThreadStore}, +}; use anyhow::{Context as _, Result}; -use assistant_context_editor::SavedContextMetadata; +use assistant_context::SavedContextMetadata; use chrono::{DateTime, Utc}; -use gpui::{AsyncApp, Entity, SharedString, Task, prelude::*}; +use gpui::{App, AsyncApp, Entity, SharedString, Task, prelude::*}; use itertools::Itertools; use paths::contexts_dir; use serde::{Deserialize, Serialize}; -use std::time::Duration; -use ui::App; +use std::{collections::VecDeque, path::Path, sync::Arc, time::Duration}; use util::ResultExt as _; -use crate::{ - thread::ThreadId, - thread_store::{SerializedThreadMetadata, ThreadStore}, -}; - const MAX_RECENTLY_OPENED_ENTRIES: usize = 6; const NAVIGATION_HISTORY_PATH: &str = "agent-navigation-history.json"; const SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE: Duration = Duration::from_millis(50); @@ -66,7 +62,7 @@ enum SerializedRecentOpen { pub struct HistoryStore { thread_store: Entity, - context_store: Entity, + context_store: Entity, recently_opened_entries: VecDeque, _subscriptions: Vec, _save_recently_opened_entries_task: Task<()>, @@ -75,7 +71,7 @@ pub struct HistoryStore { impl HistoryStore { pub fn new( thread_store: Entity, - context_store: Entity, + context_store: Entity, initial_recent_entries: impl IntoIterator, cx: &mut Context, ) -> Self { diff --git a/crates/agent/src/prompts/stale_files_prompt_header.txt b/crates/agent/src/prompts/stale_files_prompt_header.txt deleted file mode 100644 index 6686aba1e2..0000000000 --- a/crates/agent/src/prompts/stale_files_prompt_header.txt +++ /dev/null @@ -1 +0,0 @@ -These files changed since last read: diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index eac99eefbe..33b9209f0c 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1,53 +1,49 @@ -use std::fmt::Write as _; -use std::io::Write; -use std::ops::Range; -use std::sync::Arc; -use std::time::Instant; - +use crate::{ + agent_profile::AgentProfile, + context::{AgentContext, AgentContextHandle, ContextLoadResult, LoadedContext}, + thread_store::{ + SerializedCrease, SerializedLanguageModel, SerializedMessage, SerializedMessageSegment, + SerializedThread, SerializedToolResult, SerializedToolUse, SharedProjectContext, + ThreadStore, + }, + tool_use::{PendingToolUse, ToolUse, ToolUseMetadata, ToolUseState}, +}; use agent_settings::{AgentProfileId, AgentSettings, CompletionMode}; use anyhow::{Result, anyhow}; use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolWorkingSet}; use chrono::{DateTime, Utc}; -use collections::HashMap; -use editor::display_map::CreaseMetadata; +use client::{ModelRequestUsage, RequestUsage}; +use collections::{HashMap, HashSet}; use feature_flags::{self, FeatureFlagAppExt}; -use futures::future::Shared; -use futures::{FutureExt, StreamExt as _}; +use futures::{FutureExt, StreamExt as _, future::Shared}; use git::repository::DiffType; use gpui::{ AnyWindowHandle, App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, - WeakEntity, + WeakEntity, Window, }; use language_model::{ ConfiguredModel, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelKnownError, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUseId, MessageContent, - ModelRequestLimitReachedError, PaymentRequiredError, RequestUsage, Role, SelectedModel, - StopReason, TokenUsage, + ModelRequestLimitReachedError, PaymentRequiredError, Role, SelectedModel, StopReason, + TokenUsage, }; use postage::stream::Stream as _; -use project::Project; -use project::git_store::{GitStore, GitStoreCheckpoint, RepositoryState}; +use project::{ + Project, + git_store::{GitStore, GitStoreCheckpoint, RepositoryState}, +}; use prompt_store::{ModelContext, PromptBuilder}; use proto::Plan; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; +use std::{io::Write, ops::Range, sync::Arc, time::Instant}; use thiserror::Error; -use ui::Window; use util::{ResultExt as _, post_inc}; use uuid::Uuid; -use zed_llm_client::{CompletionIntent, CompletionRequestStatus}; - -use crate::ThreadStore; -use crate::agent_profile::AgentProfile; -use crate::context::{AgentContext, AgentContextHandle, ContextLoadResult, LoadedContext}; -use crate::thread_store::{ - SerializedCrease, SerializedLanguageModel, SerializedMessage, SerializedMessageSegment, - SerializedThread, SerializedToolResult, SerializedToolUse, SharedProjectContext, -}; -use crate::tool_use::{PendingToolUse, ToolUse, ToolUseMetadata, ToolUseState}; +use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; #[derive( Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize, JsonSchema, @@ -97,13 +93,18 @@ impl MessageId { fn post_inc(&mut self) -> Self { Self(post_inc(&mut self.0)) } + + pub fn as_usize(&self) -> usize { + self.0 + } } /// Stored information that can be used to resurrect a context crease when creating an editor for a past message. #[derive(Clone, Debug)] pub struct MessageCrease { pub range: Range, - pub metadata: CreaseMetadata, + pub icon_path: SharedString, + pub label: SharedString, /// None for a deserialized message, Some otherwise. pub context: Option, } @@ -144,6 +145,10 @@ impl Message { } } + pub fn push_redacted_thinking(&mut self, data: String) { + self.segments.push(MessageSegment::RedactedThinking(data)); + } + pub fn push_text(&mut self, text: &str) { if let Some(MessageSegment::Text(segment)) = self.segments.last_mut() { segment.push_str(text); @@ -182,7 +187,7 @@ pub enum MessageSegment { text: String, signature: Option, }, - RedactedThinking(Vec), + RedactedThinking(String), } impl MessageSegment { @@ -193,6 +198,13 @@ impl MessageSegment { Self::RedactedThinking(_) => false, } } + + pub fn text(&self) -> Option<&str> { + match self { + MessageSegment::Text(text) => Some(text), + _ => None, + } + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -272,8 +284,8 @@ impl DetailedSummaryState { #[derive(Default, Debug)] pub struct TotalTokenUsage { - pub total: usize, - pub max: usize, + pub total: u64, + pub max: u64, } impl TotalTokenUsage { @@ -299,7 +311,7 @@ impl TotalTokenUsage { } } - pub fn add(&self, tokens: usize) -> TotalTokenUsage { + pub fn add(&self, tokens: u64) -> TotalTokenUsage { TotalTokenUsage { total: self.total + tokens, max: self.max, @@ -350,7 +362,6 @@ pub struct Thread { request_token_usage: Vec, cumulative_token_usage: TokenUsage, exceeded_window_error: Option, - last_usage: Option, tool_use_limit_reached: bool, feedback: Option, message_feedback: HashMap, @@ -396,7 +407,7 @@ pub struct ExceededWindowError { /// Model used when last message exceeded context window model_id: LanguageModelId, /// Token count including last message - token_count: usize, + token_count: u64, } impl Thread { @@ -443,7 +454,6 @@ impl Thread { request_token_usage: Vec::new(), cumulative_token_usage: TokenUsage::default(), exceeded_window_error: None, - last_usage: None, tool_use_limit_reached: false, feedback: None, message_feedback: HashMap::default(), @@ -541,10 +551,8 @@ impl Thread { .into_iter() .map(|crease| MessageCrease { range: crease.start..crease.end, - metadata: CreaseMetadata { - icon_path: crease.icon_path, - label: crease.label, - }, + icon_path: crease.icon_path, + label: crease.label, context: None, }) .collect(), @@ -568,7 +576,6 @@ impl Thread { request_token_usage: serialized.request_token_usage, cumulative_token_usage: serialized.cumulative_token_usage, exceeded_window_error: None, - last_usage: None, tool_use_limit_reached: serialized.tool_use_limit_reached, feedback: None, message_feedback: HashMap::default(), @@ -875,10 +882,6 @@ impl Thread { .unwrap_or(false) } - pub fn last_usage(&self) -> Option { - self.last_usage - } - pub fn tool_use_limit_reached(&self) -> bool { self.tool_use_limit_reached } @@ -938,14 +941,13 @@ impl Thread { model: Arc, ) -> Vec { if model.supports_tools() { - self.profile - .enabled_tools(cx) + resolve_tool_name_conflicts(self.profile.enabled_tools(cx).as_slice()) .into_iter() - .filter_map(|tool| { + .filter_map(|(name, tool)| { // Skip tools that cannot be supported let input_schema = tool.input_schema(model.tool_input_format()).ok()?; Some(LanguageModelRequestTool { - name: tool.name(), + name, description: tool.description(), input_schema, }) @@ -1177,8 +1179,8 @@ impl Thread { .map(|crease| SerializedCrease { start: crease.range.start, end: crease.range.end, - icon_path: crease.metadata.icon_path.clone(), - label: crease.metadata.label.clone(), + icon_path: crease.icon_path.clone(), + label: crease.label.clone(), }) .collect(), is_hidden: message.is_hidden, @@ -1389,8 +1391,6 @@ impl Thread { request.messages[message_ix_to_cache].cache = true; } - self.attached_tracked_files_state(&mut request.messages, cx); - request.tools = available_tools; request.mode = if model.supports_max_mode() { Some(self.completion_mode.into()) @@ -1453,46 +1453,6 @@ impl Thread { request } - fn attached_tracked_files_state( - &self, - messages: &mut Vec, - cx: &App, - ) { - const STALE_FILES_HEADER: &str = include_str!("./prompts/stale_files_prompt_header.txt"); - - let mut stale_message = String::new(); - - let action_log = self.action_log.read(cx); - - for stale_file in action_log.stale_buffers(cx) { - let Some(file) = stale_file.read(cx).file() else { - continue; - }; - - if stale_message.is_empty() { - write!(&mut stale_message, "{}\n", STALE_FILES_HEADER.trim()).ok(); - } - - writeln!(&mut stale_message, "- {}", file.path().display()).ok(); - } - - let mut content = Vec::with_capacity(2); - - if !stale_message.is_empty() { - content.push(stale_message.into()); - } - - if !content.is_empty() { - let context_message = LanguageModelRequestMessage { - role: Role::User, - content, - cache: false, - }; - - messages.push(context_message); - } - } - pub fn stream_completion( &mut self, request: LanguageModelRequest, @@ -1544,27 +1504,76 @@ impl Thread { thread.update(cx, |thread, cx| { let event = match event { Ok(event) => event, - Err(LanguageModelCompletionError::BadInputJson { - id, - tool_name, - raw_input: invalid_input_json, - json_parse_error, - }) => { - thread.receive_invalid_tool_json( - id, - tool_name, - invalid_input_json, - json_parse_error, - window, - cx, - ); - return Ok(()); - } - Err(LanguageModelCompletionError::Other(error)) => { - return Err(error); - } - Err(err @ LanguageModelCompletionError::RateLimit(..)) => { - return Err(err.into()); + Err(error) => { + match error { + LanguageModelCompletionError::RateLimitExceeded { retry_after } => { + anyhow::bail!(LanguageModelKnownError::RateLimitExceeded { retry_after }); + } + LanguageModelCompletionError::Overloaded => { + anyhow::bail!(LanguageModelKnownError::Overloaded); + } + LanguageModelCompletionError::ApiInternalServerError =>{ + anyhow::bail!(LanguageModelKnownError::ApiInternalServerError); + } + LanguageModelCompletionError::PromptTooLarge { tokens } => { + let tokens = tokens.unwrap_or_else(|| { + // We didn't get an exact token count from the API, so fall back on our estimate. + thread.total_token_usage() + .map(|usage| usage.total) + .unwrap_or(0) + // We know the context window was exceeded in practice, so if our estimate was + // lower than max tokens, the estimate was wrong; return that we exceeded by 1. + .max(model.max_token_count().saturating_add(1)) + }); + + anyhow::bail!(LanguageModelKnownError::ContextWindowLimitExceeded { tokens }) + } + LanguageModelCompletionError::ApiReadResponseError(io_error) => { + anyhow::bail!(LanguageModelKnownError::ReadResponseError(io_error)); + } + LanguageModelCompletionError::UnknownResponseFormat(error) => { + anyhow::bail!(LanguageModelKnownError::UnknownResponseFormat(error)); + } + LanguageModelCompletionError::HttpResponseError { status, ref body } => { + if let Some(known_error) = LanguageModelKnownError::from_http_response(status, body) { + anyhow::bail!(known_error); + } else { + return Err(error.into()); + } + } + LanguageModelCompletionError::DeserializeResponse(error) => { + anyhow::bail!(LanguageModelKnownError::DeserializeResponse(error)); + } + LanguageModelCompletionError::BadInputJson { + id, + tool_name, + raw_input: invalid_input_json, + json_parse_error, + } => { + thread.receive_invalid_tool_json( + id, + tool_name, + invalid_input_json, + json_parse_error, + window, + cx, + ); + return Ok(()); + } + // These are all errors we can't automatically attempt to recover from (e.g. by retrying) + err @ LanguageModelCompletionError::BadRequestFormat | + err @ LanguageModelCompletionError::AuthenticationError | + err @ LanguageModelCompletionError::PermissionError | + err @ LanguageModelCompletionError::ApiEndpointNotFound | + err @ LanguageModelCompletionError::SerializeRequest(_) | + err @ LanguageModelCompletionError::BuildRequestBody(_) | + err @ LanguageModelCompletionError::HttpSend(_) => { + anyhow::bail!(err); + } + LanguageModelCompletionError::Other(error) => { + return Err(error); + } + } } }; @@ -1645,6 +1654,25 @@ impl Thread { }; } } + LanguageModelCompletionEvent::RedactedThinking { + data + } => { + thread.received_chunk(); + + if let Some(last_message) = thread.messages.last_mut() { + if last_message.role == Role::Assistant + && !thread.tool_use.has_tool_results(last_message.id) + { + last_message.push_redacted_thinking(data); + } else { + request_assistant_message_id = + Some(thread.insert_assistant_message( + vec![MessageSegment::RedactedThinking(data)], + cx, + )); + }; + } + } LanguageModelCompletionEvent::ToolUse(tool_use) => { let last_assistant_message_id = request_assistant_message_id .unwrap_or_else(|| { @@ -1700,9 +1728,7 @@ impl Thread { CompletionRequestStatus::UsageUpdated { amount, limit } => { - let usage = RequestUsage { limit, amount: amount as i32 }; - - thread.last_usage = Some(usage); + thread.update_model_request_usage(amount as u32, limit, cx); } CompletionRequestStatus::ToolUseLimitReached => { thread.tool_use_limit_reached = true; @@ -1751,7 +1777,7 @@ impl Thread { match result.as_ref() { Ok(stop_reason) => match stop_reason { StopReason::ToolUse => { - let tool_uses = thread.use_pending_tools(window, cx, model.clone()); + let tool_uses = thread.use_pending_tools(window, model.clone(), cx); cx.emit(ThreadEvent::UsePendingTools { tool_uses }); } StopReason::EndTurn | StopReason::MaxTokens => { @@ -1802,6 +1828,18 @@ impl Thread { project.set_agent_location(None, cx); }); + fn emit_generic_error(error: &anyhow::Error, cx: &mut Context) { + let error_message = error + .chain() + .map(|err| err.to_string()) + .collect::>() + .join("\n"); + cx.emit(ThreadEvent::ShowError(ThreadError::Message { + header: "Error interacting with language model".into(), + message: SharedString::from(error_message.clone()), + })); + } + if error.is::() { cx.emit(ThreadEvent::ShowError(ThreadError::PaymentRequired)); } else if let Some(error) = @@ -1814,26 +1852,34 @@ impl Thread { error.downcast_ref::() { match known_error { - LanguageModelKnownError::ContextWindowLimitExceeded { - tokens, - } => { + LanguageModelKnownError::ContextWindowLimitExceeded { tokens } => { thread.exceeded_window_error = Some(ExceededWindowError { model_id: model.id(), token_count: *tokens, }); cx.notify(); } + LanguageModelKnownError::RateLimitExceeded { .. } => { + // In the future we will report the error to the user, wait retry_after, and then retry. + emit_generic_error(error, cx); + } + LanguageModelKnownError::Overloaded => { + // In the future we will wait and then retry, up to N times. + emit_generic_error(error, cx); + } + LanguageModelKnownError::ApiInternalServerError => { + // In the future we will retry the request, but only once. + emit_generic_error(error, cx); + } + LanguageModelKnownError::ReadResponseError(_) | + LanguageModelKnownError::DeserializeResponse(_) | + LanguageModelKnownError::UnknownResponseFormat(_) => { + // In the future we will attempt to re-roll response, but only once + emit_generic_error(error, cx); + } } } else { - let error_message = error - .chain() - .map(|err| err.to_string()) - .collect::>() - .join("\n"); - cx.emit(ThreadEvent::ShowError(ThreadError::Message { - header: "Error interacting with language model".into(), - message: SharedString::from(error_message.clone()), - })); + emit_generic_error(error, cx); } thread.cancel_last_completion(window, cx); @@ -1913,11 +1959,8 @@ impl Thread { LanguageModelCompletionEvent::StatusUpdate( CompletionRequestStatus::UsageUpdated { amount, limit }, ) => { - this.update(cx, |thread, _cx| { - thread.last_usage = Some(RequestUsage { - limit, - amount: amount as i32, - }); + this.update(cx, |thread, cx| { + thread.update_model_request_usage(amount as u32, limit, cx); })?; continue; } @@ -2084,8 +2127,8 @@ impl Thread { pub fn use_pending_tools( &mut self, window: Option, - cx: &mut Context, model: Arc, + cx: &mut Context, ) -> Vec { self.auto_capture_telemetry(cx); let request = @@ -2099,43 +2142,53 @@ impl Thread { .collect::>(); for tool_use in pending_tool_uses.iter() { - if let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) { - if tool.needs_confirmation(&tool_use.input, cx) - && !AgentSettings::get_global(cx).always_allow_tool_actions - { - self.tool_use.confirm_tool_use( - tool_use.id.clone(), - tool_use.ui_text.clone(), - tool_use.input.clone(), - request.clone(), - tool, - ); - cx.emit(ThreadEvent::ToolConfirmationNeeded); - } else { - self.run_tool( - tool_use.id.clone(), - tool_use.ui_text.clone(), - tool_use.input.clone(), - request.clone(), - tool, - model.clone(), - window, - cx, - ); - } - } else { - self.handle_hallucinated_tool_use( - tool_use.id.clone(), - tool_use.name.clone(), - window, - cx, - ); - } + self.use_pending_tool(tool_use.clone(), request.clone(), model.clone(), window, cx); } pending_tool_uses } + fn use_pending_tool( + &mut self, + tool_use: PendingToolUse, + request: Arc, + model: Arc, + window: Option, + cx: &mut Context, + ) { + let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) else { + return self.handle_hallucinated_tool_use(tool_use.id, tool_use.name, window, cx); + }; + + if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) { + return self.handle_hallucinated_tool_use(tool_use.id, tool_use.name, window, cx); + } + + if tool.needs_confirmation(&tool_use.input, cx) + && !AgentSettings::get_global(cx).always_allow_tool_actions + { + self.tool_use.confirm_tool_use( + tool_use.id, + tool_use.ui_text, + tool_use.input, + request, + tool, + ); + cx.emit(ThreadEvent::ToolConfirmationNeeded); + } else { + self.run_tool( + tool_use.id, + tool_use.ui_text, + tool_use.input, + request, + tool, + model, + window, + cx, + ); + } + } + pub fn handle_hallucinated_tool_use( &mut self, tool_use_id: LanguageModelToolUseId, @@ -2755,7 +2808,7 @@ impl Thread { .unwrap_or_default(); TotalTokenUsage { - total: token_usage.total_tokens() as usize, + total: token_usage.total_tokens(), max, } } @@ -2777,7 +2830,7 @@ impl Thread { let total = self .token_usage_at_last_message() .unwrap_or_default() - .total_tokens() as usize; + .total_tokens(); Some(TotalTokenUsage { total, max }) } @@ -2799,6 +2852,20 @@ impl Thread { } } + fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut Context) { + self.project.update(cx, |project, cx| { + project.user_store().update(cx, |user_store, cx| { + user_store.update_model_request_usage( + ModelRequestUsage(RequestUsage { + amount: amount as i32, + limit, + }), + cx, + ) + }) + }); + } + pub fn deny_tool_use( &mut self, tool_use_id: LanguageModelToolUseId, @@ -2886,14 +2953,95 @@ struct PendingCompletion { _task: Task<()>, } +/// Resolves tool name conflicts by ensuring all tool names are unique. +/// +/// When multiple tools have the same name, this function applies the following rules: +/// 1. Native tools always keep their original name +/// 2. Context server tools get prefixed with their server ID and an underscore +/// 3. All tool names are truncated to MAX_TOOL_NAME_LENGTH (64 characters) +/// 4. If conflicts still exist after prefixing, the conflicting tools are filtered out +/// +/// Note: This function assumes that built-in tools occur before MCP tools in the tools list. +fn resolve_tool_name_conflicts(tools: &[Arc]) -> Vec<(String, Arc)> { + fn resolve_tool_name(tool: &Arc) -> String { + let mut tool_name = tool.name(); + tool_name.truncate(MAX_TOOL_NAME_LENGTH); + tool_name + } + + const MAX_TOOL_NAME_LENGTH: usize = 64; + + let mut duplicated_tool_names = HashSet::default(); + let mut seen_tool_names = HashSet::default(); + for tool in tools { + let tool_name = resolve_tool_name(tool); + if seen_tool_names.contains(&tool_name) { + debug_assert!( + tool.source() != assistant_tool::ToolSource::Native, + "There are two built-in tools with the same name: {}", + tool_name + ); + duplicated_tool_names.insert(tool_name); + } else { + seen_tool_names.insert(tool_name); + } + } + + if duplicated_tool_names.is_empty() { + return tools + .into_iter() + .map(|tool| (resolve_tool_name(tool), tool.clone())) + .collect(); + } + + tools + .into_iter() + .filter_map(|tool| { + let mut tool_name = resolve_tool_name(tool); + if !duplicated_tool_names.contains(&tool_name) { + return Some((tool_name, tool.clone())); + } + match tool.source() { + assistant_tool::ToolSource::Native => { + // Built-in tools always keep their original name + Some((tool_name, tool.clone())) + } + assistant_tool::ToolSource::ContextServer { id } => { + // Context server tools are prefixed with the context server ID, and truncated if necessary + tool_name.insert(0, '_'); + if tool_name.len() + id.len() > MAX_TOOL_NAME_LENGTH { + let len = MAX_TOOL_NAME_LENGTH - tool_name.len(); + let mut id = id.to_string(); + id.truncate(len); + tool_name.insert_str(0, &id); + } else { + tool_name.insert_str(0, &id); + } + + tool_name.truncate(MAX_TOOL_NAME_LENGTH); + + if seen_tool_names.contains(&tool_name) { + log::error!("Cannot resolve tool name conflict for tool {}", tool.name()); + None + } else { + Some((tool_name, tool.clone())) + } + } + } + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; - use crate::{ThreadStore, context::load_context, context_store::ContextStore, thread_store}; + use crate::{ + context::load_context, context_store::ContextStore, thread_store, thread_store::ThreadStore, + }; use agent_settings::{AgentProfileId, AgentSettings, LanguageModelParameters}; use assistant_tool::ToolRegistry; - use editor::EditorSettings; use gpui::TestAppContext; + use icons::IconName; use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; use project::{FakeFs, Project}; use prompt_store::PromptBuilder; @@ -3215,94 +3363,6 @@ fn main() {{ ); } - #[gpui::test] - async fn test_stale_buffer_notification(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project( - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, _thread_store, thread, context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // Open buffer and add it to context - let buffer = add_file_to_context(&project, &context_store, "test/code.rs", cx) - .await - .unwrap(); - - let context = - context_store.read_with(cx, |store, _| store.context().next().cloned().unwrap()); - let loaded_context = cx - .update(|cx| load_context(vec![context], &project, &None, cx)) - .await; - - // Insert user message with the buffer as context - thread.update(cx, |thread, cx| { - thread.insert_user_message("Explain this code", loaded_context, None, Vec::new(), cx) - }); - - // Create a request and check that it doesn't have a stale buffer warning yet - let initial_request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - // Make sure we don't have a stale file warning yet - let has_stale_warning = initial_request.messages.iter().any(|msg| { - msg.string_contents() - .contains("These files changed since last read:") - }); - assert!( - !has_stale_warning, - "Should not have stale buffer warning before buffer is modified" - ); - - // Modify the buffer - buffer.update(cx, |buffer, cx| { - // Find a position at the end of line 1 - buffer.edit( - [(1..1, "\n println!(\"Added a new line\");\n")], - None, - cx, - ); - }); - - // Insert another user message without context - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "What does the code do now?", - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ) - }); - - // Create a new request and check for the stale buffer warning - let new_request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - // We should have a stale file warning as the last message - let last_message = new_request - .messages - .last() - .expect("Request should have messages"); - - // The last message should be the stale buffer notification - assert_eq!(last_message.role, Role::User); - - // Check the exact content of the message - let expected_content = "These files changed since last read:\n- code.rs\n"; - assert_eq!( - last_message.string_contents(), - expected_content, - "Last message should be exactly the stale buffer notification" - ); - } - #[gpui::test] async fn test_storing_profile_setting_per_thread(cx: &mut TestAppContext) { init_test_settings(cx); @@ -3620,6 +3680,148 @@ fn main() {{ }); } + #[gpui::test] + fn test_resolve_tool_name_conflicts() { + use assistant_tool::{Tool, ToolSource}; + + assert_resolve_tool_name_conflicts( + vec![ + TestTool::new("tool1", ToolSource::Native), + TestTool::new("tool2", ToolSource::Native), + TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }), + ], + vec!["tool1", "tool2", "tool3"], + ); + + assert_resolve_tool_name_conflicts( + vec![ + TestTool::new("tool1", ToolSource::Native), + TestTool::new("tool2", ToolSource::Native), + TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }), + TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-2".into() }), + ], + vec!["tool1", "tool2", "mcp-1_tool3", "mcp-2_tool3"], + ); + + assert_resolve_tool_name_conflicts( + vec![ + TestTool::new("tool1", ToolSource::Native), + TestTool::new("tool2", ToolSource::Native), + TestTool::new("tool3", ToolSource::Native), + TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }), + TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-2".into() }), + ], + vec!["tool1", "tool2", "tool3", "mcp-1_tool3", "mcp-2_tool3"], + ); + + // Test that tool with very long name is always truncated + assert_resolve_tool_name_conflicts( + vec![TestTool::new( + "tool-with-more-then-64-characters-blah-blah-blah-blah-blah-blah-blah-blah", + ToolSource::Native, + )], + vec!["tool-with-more-then-64-characters-blah-blah-blah-blah-blah-blah-"], + ); + + // Test deduplication of tools with very long names, in this case the mcp server name should be truncated + assert_resolve_tool_name_conflicts( + vec![ + TestTool::new("tool-with-very-very-very-long-name", ToolSource::Native), + TestTool::new( + "tool-with-very-very-very-long-name", + ToolSource::ContextServer { + id: "mcp-with-very-very-very-long-name".into(), + }, + ), + ], + vec![ + "tool-with-very-very-very-long-name", + "mcp-with-very-very-very-long-_tool-with-very-very-very-long-name", + ], + ); + + fn assert_resolve_tool_name_conflicts( + tools: Vec, + expected: Vec>, + ) { + let tools: Vec> = tools + .into_iter() + .map(|t| Arc::new(t) as Arc) + .collect(); + let tools = resolve_tool_name_conflicts(&tools); + assert_eq!(tools.len(), expected.len()); + for (i, expected_name) in expected.into_iter().enumerate() { + let expected_name = expected_name.into(); + let actual_name = &tools[i].0; + assert_eq!( + actual_name, &expected_name, + "Expected '{}' got '{}' at index {}", + expected_name, actual_name, i + ); + } + } + + struct TestTool { + name: String, + source: ToolSource, + } + + impl TestTool { + fn new(name: impl Into, source: ToolSource) -> Self { + Self { + name: name.into(), + source, + } + } + } + + impl Tool for TestTool { + fn name(&self) -> String { + self.name.clone() + } + + fn icon(&self) -> IconName { + IconName::Ai + } + + fn may_perform_edits(&self) -> bool { + false + } + + fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool { + true + } + + fn source(&self) -> ToolSource { + self.source.clone() + } + + fn description(&self) -> String { + "Test tool".to_string() + } + + fn ui_text(&self, _input: &serde_json::Value) -> String { + "Test tool".to_string() + } + + fn run( + self: Arc, + _input: serde_json::Value, + _request: Arc, + _project: Entity, + _action_log: Entity, + _model: Arc, + _window: Option, + _cx: &mut App, + ) -> assistant_tool::ToolResult { + assistant_tool::ToolResult { + output: Task::ready(Err(anyhow::anyhow!("No content"))), + card: None, + } + } + } + } + fn test_summarize_error( model: &Arc, thread: &Entity, @@ -3674,7 +3876,6 @@ fn main() {{ workspace::init_settings(cx); language_model::init_settings(cx); ThemeSettings::register(cx); - EditorSettings::register(cx); ToolRegistry::default_global(cx); }); } diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index db87bdd3a5..516151e9ff 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -1,22 +1,25 @@ -use std::cell::{Ref, RefCell}; -use std::path::{Path, PathBuf}; -use std::rc::Rc; -use std::sync::{Arc, Mutex}; - +use crate::{ + context_server_tool::ContextServerTool, + thread::{ + DetailedSummaryState, ExceededWindowError, MessageId, ProjectSnapshot, Thread, ThreadId, + }, +}; use agent_settings::{AgentProfileId, CompletionMode}; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{ToolId, ToolWorkingSet}; use chrono::{DateTime, Utc}; use collections::HashMap; use context_server::ContextServerId; -use futures::channel::{mpsc, oneshot}; -use futures::future::{self, BoxFuture, Shared}; -use futures::{FutureExt as _, StreamExt as _}; +use futures::{ + FutureExt as _, StreamExt as _, + channel::{mpsc, oneshot}, + future::{self, BoxFuture, Shared}, +}; use gpui::{ App, BackgroundExecutor, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString, - Subscription, Task, prelude::*, + Subscription, Task, Window, prelude::*, }; - +use indoc::indoc; use language_model::{LanguageModelToolResultContent, LanguageModelToolUseId, Role, TokenUsage}; use project::context_server_store::{ContextServerStatus, ContextServerStore}; use project::{Project, ProjectItem, ProjectPath, Worktree}; @@ -25,19 +28,18 @@ use prompt_store::{ UserRulesContext, WorktreeContext, }; use serde::{Deserialize, Serialize}; -use ui::Window; -use util::ResultExt as _; - -use crate::context_server_tool::ContextServerTool; -use crate::thread::{ - DetailedSummaryState, ExceededWindowError, MessageId, ProjectSnapshot, Thread, ThreadId, -}; -use indoc::indoc; use sqlez::{ bindable::{Bind, Column}, connection::Connection, statement::Statement, }; +use std::{ + cell::{Ref, RefCell}, + path::{Path, PathBuf}, + rc::Rc, + sync::{Arc, Mutex}, +}; +use util::ResultExt as _; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum DataType { @@ -69,7 +71,7 @@ impl Column for DataType { } } -const RULES_FILE_NAMES: [&'static str; 8] = [ +const RULES_FILE_NAMES: [&'static str; 9] = [ ".rules", ".cursorrules", ".windsurfrules", @@ -78,6 +80,7 @@ const RULES_FILE_NAMES: [&'static str; 8] = [ "CLAUDE.md", "AGENT.md", "AGENTS.md", + "GEMINI.md", ]; pub fn init(cx: &mut App) { @@ -94,7 +97,7 @@ impl SharedProjectContext { } } -pub type TextThreadStore = assistant_context_editor::ContextStore; +pub type TextThreadStore = assistant_context::ContextStore; pub struct ThreadStore { project: Entity, @@ -305,17 +308,19 @@ impl ThreadStore { project: Entity, cx: &mut App, ) -> Task<(WorktreeContext, Option)> { - let root_name = worktree.read(cx).root_name().into(); + let tree = worktree.read(cx); + let root_name = tree.root_name().into(); + let abs_path = tree.abs_path(); + + let mut context = WorktreeContext { + root_name, + abs_path, + rules_file: None, + }; let rules_task = Self::load_worktree_rules_file(worktree, project, cx); let Some(rules_task) = rules_task else { - return Task::ready(( - WorktreeContext { - root_name, - rules_file: None, - }, - None, - )); + return Task::ready((context, None)); }; cx.spawn(async move |_| { @@ -328,11 +333,8 @@ impl ThreadStore { }), ), }; - let worktree_info = WorktreeContext { - root_name, - rules_file, - }; - (worktree_info, rules_file_error) + context.rules_file = rules_file; + (context, rules_file_error) }) } @@ -341,12 +343,12 @@ impl ThreadStore { project: Entity, cx: &mut App, ) -> Option>> { - let worktree_ref = worktree.read(cx); - let worktree_id = worktree_ref.id(); + let worktree = worktree.read(cx); + let worktree_id = worktree.id(); let selected_rules_file = RULES_FILE_NAMES .into_iter() .filter_map(|name| { - worktree_ref + worktree .entry_for_path(name) .filter(|entry| entry.is_file()) .map(|entry| entry.path.clone()) @@ -730,7 +732,7 @@ pub enum SerializedMessageSegment { signature: Option, }, RedactedThinking { - data: Vec, + data: String, }, } diff --git a/crates/agent/src/tool_use.rs b/crates/agent/src/tool_use.rs index da6adc07f0..76de3d2022 100644 --- a/crates/agent/src/tool_use.rs +++ b/crates/agent/src/tool_use.rs @@ -1,24 +1,23 @@ -use std::sync::Arc; - +use crate::{ + thread::{MessageId, PromptId, ThreadId}, + thread_store::SerializedMessage, +}; use anyhow::Result; use assistant_tool::{ AnyToolCard, Tool, ToolResultContent, ToolResultOutput, ToolUseStatus, ToolWorkingSet, }; use collections::HashMap; -use futures::FutureExt as _; -use futures::future::Shared; -use gpui::{App, Entity, SharedString, Task}; +use futures::{FutureExt as _, future::Shared}; +use gpui::{App, Entity, SharedString, Task, Window}; +use icons::IconName; use language_model::{ ConfiguredModel, LanguageModel, LanguageModelRequest, LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, Role, }; use project::Project; -use ui::{IconName, Window}; +use std::sync::Arc; use util::truncate_lines_to_byte_limit; -use crate::thread::{MessageId, PromptId, ThreadId}; -use crate::thread_store::SerializedMessage; - #[derive(Debug)] pub struct ToolUse { pub id: LanguageModelToolUseId, @@ -26,7 +25,7 @@ pub struct ToolUse { pub ui_text: SharedString, pub status: ToolUseStatus, pub input: serde_json::Value, - pub icon: ui::IconName, + pub icon: icons::IconName, pub needs_confirmation: bool, } @@ -427,7 +426,7 @@ impl ToolUseState { // Protect from overly large output let tool_output_limit = configured_model - .map(|model| model.model.max_token_count() * BYTES_PER_TOKEN_ESTIMATE) + .map(|model| model.model.max_token_count() as usize * BYTES_PER_TOKEN_ESTIMATE) .unwrap_or(usize::MAX); let content = match tool_result { diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index c6a4bedbb5..3afe5ae547 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -12,17 +12,10 @@ workspace = true path = "src/agent_settings.rs" [dependencies] -anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true collections.workspace = true gpui.workspace = true language_model.workspace = true -lmstudio = { workspace = true, features = ["schemars"] } -log.workspace = true -ollama = { workspace = true, features = ["schemars"] } -open_ai = { workspace = true, features = ["schemars"] } -deepseek = { workspace = true, features = ["schemars"] } -mistral = { workspace = true, features = ["schemars"] } schemars.workspace = true serde.workspace = true settings.workspace = true diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 9e8fd0c699..294d793e79 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -2,16 +2,10 @@ mod agent_profile; use std::sync::Arc; -use ::open_ai::Model as OpenAiModel; -use anthropic::Model as AnthropicModel; use anyhow::{Result, bail}; use collections::IndexMap; -use deepseek::Model as DeepseekModel; use gpui::{App, Pixels, SharedString}; use language_model::LanguageModel; -use lmstudio::Model as LmStudioModel; -use mistral::Model as MistralModel; -use ollama::Model as OllamaModel; use schemars::{JsonSchema, schema::Schema}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsSources}; @@ -48,45 +42,6 @@ pub enum NotifyWhenAgentWaiting { Never, } -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] -#[serde(tag = "name", rename_all = "snake_case")] -#[schemars(deny_unknown_fields)] -pub enum AgentProviderContentV1 { - #[serde(rename = "zed.dev")] - ZedDotDev { default_model: Option }, - #[serde(rename = "openai")] - OpenAi { - default_model: Option, - api_url: Option, - available_models: Option>, - }, - #[serde(rename = "anthropic")] - Anthropic { - default_model: Option, - api_url: Option, - }, - #[serde(rename = "ollama")] - Ollama { - default_model: Option, - api_url: Option, - }, - #[serde(rename = "lmstudio")] - LmStudio { - default_model: Option, - api_url: Option, - }, - #[serde(rename = "deepseek")] - DeepSeek { - default_model: Option, - api_url: Option, - }, - #[serde(rename = "mistral")] - Mistral { - default_model: Option, - api_url: Option, - }, -} - #[derive(Default, Clone, Debug)] pub struct AgentSettings { pub enabled: bool, @@ -168,364 +123,56 @@ impl LanguageModelParameters { } } -/// Agent panel settings -#[derive(Clone, Serialize, Deserialize, Debug, Default)] -pub struct AgentSettingsContent { - #[serde(flatten)] - pub inner: Option, -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -#[serde(untagged)] -pub enum AgentSettingsContentInner { - Versioned(Box), - Legacy(LegacyAgentSettingsContent), -} - -impl AgentSettingsContentInner { - fn for_v2(content: AgentSettingsContentV2) -> Self { - AgentSettingsContentInner::Versioned(Box::new(VersionedAgentSettingsContent::V2(content))) - } -} - -impl JsonSchema for AgentSettingsContent { - fn schema_name() -> String { - VersionedAgentSettingsContent::schema_name() - } - - fn json_schema(r#gen: &mut schemars::r#gen::SchemaGenerator) -> Schema { - VersionedAgentSettingsContent::json_schema(r#gen) - } - - fn is_referenceable() -> bool { - VersionedAgentSettingsContent::is_referenceable() - } -} - impl AgentSettingsContent { - pub fn is_version_outdated(&self) -> bool { - match &self.inner { - Some(AgentSettingsContentInner::Versioned(settings)) => match **settings { - VersionedAgentSettingsContent::V1(_) => true, - VersionedAgentSettingsContent::V2(_) => false, - }, - Some(AgentSettingsContentInner::Legacy(_)) => true, - None => false, - } - } - - fn upgrade(&self) -> AgentSettingsContentV2 { - match &self.inner { - Some(AgentSettingsContentInner::Versioned(settings)) => match **settings { - VersionedAgentSettingsContent::V1(ref settings) => AgentSettingsContentV2 { - enabled: settings.enabled, - button: settings.button, - dock: settings.dock, - default_width: settings.default_width, - default_height: settings.default_width, - default_model: settings - .provider - .clone() - .and_then(|provider| match provider { - AgentProviderContentV1::ZedDotDev { default_model } => default_model - .map(|model| LanguageModelSelection { - provider: "zed.dev".into(), - model, - }), - AgentProviderContentV1::OpenAi { default_model, .. } => default_model - .map(|model| LanguageModelSelection { - provider: "openai".into(), - model: model.id().to_string(), - }), - AgentProviderContentV1::Anthropic { default_model, .. } => { - default_model.map(|model| LanguageModelSelection { - provider: "anthropic".into(), - model: model.id().to_string(), - }) - } - AgentProviderContentV1::Ollama { default_model, .. } => default_model - .map(|model| LanguageModelSelection { - provider: "ollama".into(), - model: model.id().to_string(), - }), - AgentProviderContentV1::LmStudio { default_model, .. } => default_model - .map(|model| LanguageModelSelection { - provider: "lmstudio".into(), - model: model.id().to_string(), - }), - AgentProviderContentV1::DeepSeek { default_model, .. } => default_model - .map(|model| LanguageModelSelection { - provider: "deepseek".into(), - model: model.id().to_string(), - }), - AgentProviderContentV1::Mistral { default_model, .. } => default_model - .map(|model| LanguageModelSelection { - provider: "mistral".into(), - model: model.id().to_string(), - }), - }), - inline_assistant_model: None, - commit_message_model: None, - thread_summary_model: None, - inline_alternatives: None, - default_profile: None, - default_view: None, - profiles: None, - always_allow_tool_actions: None, - notify_when_agent_waiting: None, - stream_edits: None, - single_file_review: None, - model_parameters: Vec::new(), - preferred_completion_mode: None, - enable_feedback: None, - play_sound_when_agent_done: None, - }, - VersionedAgentSettingsContent::V2(ref settings) => settings.clone(), - }, - Some(AgentSettingsContentInner::Legacy(settings)) => AgentSettingsContentV2 { - enabled: None, - button: settings.button, - dock: settings.dock, - default_width: settings.default_width, - default_height: settings.default_height, - default_model: Some(LanguageModelSelection { - provider: "openai".into(), - model: settings - .default_open_ai_model - .clone() - .unwrap_or_default() - .id() - .to_string(), - }), - inline_assistant_model: None, - commit_message_model: None, - thread_summary_model: None, - inline_alternatives: None, - default_profile: None, - default_view: None, - profiles: None, - always_allow_tool_actions: None, - notify_when_agent_waiting: None, - stream_edits: None, - single_file_review: None, - model_parameters: Vec::new(), - preferred_completion_mode: None, - enable_feedback: None, - play_sound_when_agent_done: None, - }, - None => AgentSettingsContentV2::default(), - } - } - pub fn set_dock(&mut self, dock: AgentDockPosition) { - match &mut self.inner { - Some(AgentSettingsContentInner::Versioned(settings)) => match **settings { - VersionedAgentSettingsContent::V1(ref mut settings) => { - settings.dock = Some(dock); - } - VersionedAgentSettingsContent::V2(ref mut settings) => { - settings.dock = Some(dock); - } - }, - Some(AgentSettingsContentInner::Legacy(settings)) => { - settings.dock = Some(dock); - } - None => { - self.inner = Some(AgentSettingsContentInner::for_v2(AgentSettingsContentV2 { - dock: Some(dock), - ..Default::default() - })) - } - } + self.dock = Some(dock); } pub fn set_model(&mut self, language_model: Arc) { let model = language_model.id().0.to_string(); let provider = language_model.provider_id().0.to_string(); - match &mut self.inner { - Some(AgentSettingsContentInner::Versioned(settings)) => match **settings { - VersionedAgentSettingsContent::V1(ref mut settings) => match provider.as_ref() { - "zed.dev" => { - log::warn!("attempted to set zed.dev model on outdated settings"); - } - "anthropic" => { - let api_url = match &settings.provider { - Some(AgentProviderContentV1::Anthropic { api_url, .. }) => { - api_url.clone() - } - _ => None, - }; - settings.provider = Some(AgentProviderContentV1::Anthropic { - default_model: AnthropicModel::from_id(&model).ok(), - api_url, - }); - } - "ollama" => { - let api_url = match &settings.provider { - Some(AgentProviderContentV1::Ollama { api_url, .. }) => api_url.clone(), - _ => None, - }; - settings.provider = Some(AgentProviderContentV1::Ollama { - default_model: Some(ollama::Model::new( - &model, - None, - None, - Some(language_model.supports_tools()), - Some(language_model.supports_images()), - None, - )), - api_url, - }); - } - "lmstudio" => { - let api_url = match &settings.provider { - Some(AgentProviderContentV1::LmStudio { api_url, .. }) => { - api_url.clone() - } - _ => None, - }; - settings.provider = Some(AgentProviderContentV1::LmStudio { - default_model: Some(lmstudio::Model::new(&model, None, None, false)), - api_url, - }); - } - "openai" => { - let (api_url, available_models) = match &settings.provider { - Some(AgentProviderContentV1::OpenAi { - api_url, - available_models, - .. - }) => (api_url.clone(), available_models.clone()), - _ => (None, None), - }; - settings.provider = Some(AgentProviderContentV1::OpenAi { - default_model: OpenAiModel::from_id(&model).ok(), - api_url, - available_models, - }); - } - "deepseek" => { - let api_url = match &settings.provider { - Some(AgentProviderContentV1::DeepSeek { api_url, .. }) => { - api_url.clone() - } - _ => None, - }; - settings.provider = Some(AgentProviderContentV1::DeepSeek { - default_model: DeepseekModel::from_id(&model).ok(), - api_url, - }); - } - _ => {} - }, - VersionedAgentSettingsContent::V2(ref mut settings) => { - settings.default_model = Some(LanguageModelSelection { - provider: provider.into(), - model, - }); - } - }, - Some(AgentSettingsContentInner::Legacy(settings)) => { - if let Ok(model) = OpenAiModel::from_id(&language_model.id().0) { - settings.default_open_ai_model = Some(model); - } - } - None => { - self.inner = Some(AgentSettingsContentInner::for_v2(AgentSettingsContentV2 { - default_model: Some(LanguageModelSelection { - provider: provider.into(), - model, - }), - ..Default::default() - })); - } - } + self.default_model = Some(LanguageModelSelection { + provider: provider.into(), + model, + }); } pub fn set_inline_assistant_model(&mut self, provider: String, model: String) { - self.v2_setting(|setting| { - setting.inline_assistant_model = Some(LanguageModelSelection { - provider: provider.into(), - model, - }); - Ok(()) - }) - .ok(); + self.inline_assistant_model = Some(LanguageModelSelection { + provider: provider.into(), + model, + }); } pub fn set_commit_message_model(&mut self, provider: String, model: String) { - self.v2_setting(|setting| { - setting.commit_message_model = Some(LanguageModelSelection { - provider: provider.into(), - model, - }); - Ok(()) - }) - .ok(); - } - - pub fn v2_setting( - &mut self, - f: impl FnOnce(&mut AgentSettingsContentV2) -> anyhow::Result<()>, - ) -> anyhow::Result<()> { - match self.inner.get_or_insert_with(|| { - AgentSettingsContentInner::for_v2(AgentSettingsContentV2 { - ..Default::default() - }) - }) { - AgentSettingsContentInner::Versioned(boxed) => { - if let VersionedAgentSettingsContent::V2(ref mut settings) = **boxed { - f(settings) - } else { - Ok(()) - } - } - _ => Ok(()), - } + self.commit_message_model = Some(LanguageModelSelection { + provider: provider.into(), + model, + }); } pub fn set_thread_summary_model(&mut self, provider: String, model: String) { - self.v2_setting(|setting| { - setting.thread_summary_model = Some(LanguageModelSelection { - provider: provider.into(), - model, - }); - Ok(()) - }) - .ok(); + self.thread_summary_model = Some(LanguageModelSelection { + provider: provider.into(), + model, + }); } pub fn set_always_allow_tool_actions(&mut self, allow: bool) { - self.v2_setting(|setting| { - setting.always_allow_tool_actions = Some(allow); - Ok(()) - }) - .ok(); + self.always_allow_tool_actions = Some(allow); } pub fn set_play_sound_when_agent_done(&mut self, allow: bool) { - self.v2_setting(|setting| { - setting.play_sound_when_agent_done = Some(allow); - Ok(()) - }) - .ok(); + self.play_sound_when_agent_done = Some(allow); } pub fn set_single_file_review(&mut self, allow: bool) { - self.v2_setting(|setting| { - setting.single_file_review = Some(allow); - Ok(()) - }) - .ok(); + self.single_file_review = Some(allow); } pub fn set_profile(&mut self, profile_id: AgentProfileId) { - self.v2_setting(|setting| { - setting.default_profile = Some(profile_id); - Ok(()) - }) - .ok(); + self.default_profile = Some(profile_id); } pub fn create_profile( @@ -533,79 +180,39 @@ impl AgentSettingsContent { profile_id: AgentProfileId, profile_settings: AgentProfileSettings, ) -> Result<()> { - self.v2_setting(|settings| { - let profiles = settings.profiles.get_or_insert_default(); - if profiles.contains_key(&profile_id) { - bail!("profile with ID '{profile_id}' already exists"); - } + let profiles = self.profiles.get_or_insert_default(); + if profiles.contains_key(&profile_id) { + bail!("profile with ID '{profile_id}' already exists"); + } - profiles.insert( - profile_id, - AgentProfileContent { - name: profile_settings.name.into(), - tools: profile_settings.tools, - enable_all_context_servers: Some(profile_settings.enable_all_context_servers), - context_servers: profile_settings - .context_servers - .into_iter() - .map(|(server_id, preset)| { - ( - server_id, - ContextServerPresetContent { - tools: preset.tools, - }, - ) - }) - .collect(), - }, - ); + profiles.insert( + profile_id, + AgentProfileContent { + name: profile_settings.name.into(), + tools: profile_settings.tools, + enable_all_context_servers: Some(profile_settings.enable_all_context_servers), + context_servers: profile_settings + .context_servers + .into_iter() + .map(|(server_id, preset)| { + ( + server_id, + ContextServerPresetContent { + tools: preset.tools, + }, + ) + }) + .collect(), + }, + ); - Ok(()) - }) - } -} - -#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] -#[serde(tag = "version")] -#[schemars(deny_unknown_fields)] -pub enum VersionedAgentSettingsContent { - #[serde(rename = "1")] - V1(AgentSettingsContentV1), - #[serde(rename = "2")] - V2(AgentSettingsContentV2), -} - -impl Default for VersionedAgentSettingsContent { - fn default() -> Self { - Self::V2(AgentSettingsContentV2 { - enabled: None, - button: None, - dock: None, - default_width: None, - default_height: None, - default_model: None, - inline_assistant_model: None, - commit_message_model: None, - thread_summary_model: None, - inline_alternatives: None, - default_profile: None, - default_view: None, - profiles: None, - always_allow_tool_actions: None, - notify_when_agent_waiting: None, - stream_edits: None, - single_file_review: None, - model_parameters: Vec::new(), - preferred_completion_mode: None, - enable_feedback: None, - play_sound_when_agent_done: None, - }) + Ok(()) } } #[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Default)] #[schemars(deny_unknown_fields)] -pub struct AgentSettingsContentV2 { +pub struct AgentSettingsContent { /// Whether the Agent is enabled. /// /// Default: true @@ -732,6 +339,7 @@ impl JsonSchema for LanguageModelProviderSetting { "deepseek".into(), "openrouter".into(), "mistral".into(), + "vercel".into(), ]), ..Default::default() } @@ -776,65 +384,6 @@ pub struct ContextServerPresetContent { pub tools: IndexMap, bool>, } -#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] -#[schemars(deny_unknown_fields)] -pub struct AgentSettingsContentV1 { - /// Whether the Agent is enabled. - /// - /// Default: true - enabled: Option, - /// Whether to show the Agent panel button in the status bar. - /// - /// Default: true - button: Option, - /// Where to dock the Agent. - /// - /// Default: right - dock: Option, - /// Default width in pixels when the Agent is docked to the left or right. - /// - /// Default: 640 - default_width: Option, - /// Default height in pixels when the Agent is docked to the bottom. - /// - /// Default: 320 - default_height: Option, - /// The provider of the Agent service. - /// - /// This can be "openai", "anthropic", "ollama", "lmstudio", "deepseek", "zed.dev" - /// each with their respective default models and configurations. - provider: Option, -} - -#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] -#[schemars(deny_unknown_fields)] -pub struct LegacyAgentSettingsContent { - /// Whether to show the Agent panel button in the status bar. - /// - /// Default: true - pub button: Option, - /// Where to dock the Agent. - /// - /// Default: right - pub dock: Option, - /// Default width in pixels when the Agent is docked to the left or right. - /// - /// Default: 640 - pub default_width: Option, - /// Default height in pixels when the Agent is docked to the bottom. - /// - /// Default: 320 - pub default_height: Option, - /// The default OpenAI model to use when creating new chats. - /// - /// Default: gpt-4-1106-preview - pub default_open_ai_model: Option, - /// OpenAI API base URL to use when creating new chats. - /// - /// Default: - pub openai_api_url: Option, -} - impl Settings for AgentSettings { const KEY: Option<&'static str> = Some("agent"); @@ -851,11 +400,6 @@ impl Settings for AgentSettings { let mut settings = AgentSettings::default(); for value in sources.defaults_and_customizations() { - if value.is_version_outdated() { - settings.using_outdated_settings_version = true; - } - - let value = value.upgrade(); merge(&mut settings.enabled, value.enabled); merge(&mut settings.button, value.button); merge(&mut settings.dock, value.dock); @@ -867,17 +411,23 @@ impl Settings for AgentSettings { &mut settings.default_height, value.default_height.map(Into::into), ); - merge(&mut settings.default_model, value.default_model); + merge(&mut settings.default_model, value.default_model.clone()); settings.inline_assistant_model = value .inline_assistant_model + .clone() .or(settings.inline_assistant_model.take()); settings.commit_message_model = value + .clone() .commit_message_model .or(settings.commit_message_model.take()); settings.thread_summary_model = value + .clone() .thread_summary_model .or(settings.thread_summary_model.take()); - merge(&mut settings.inline_alternatives, value.inline_alternatives); + merge( + &mut settings.inline_alternatives, + value.inline_alternatives.clone(), + ); merge( &mut settings.always_allow_tool_actions, value.always_allow_tool_actions, @@ -892,7 +442,7 @@ impl Settings for AgentSettings { ); merge(&mut settings.stream_edits, value.stream_edits); merge(&mut settings.single_file_review, value.single_file_review); - merge(&mut settings.default_profile, value.default_profile); + merge(&mut settings.default_profile, value.default_profile.clone()); merge(&mut settings.default_view, value.default_view); merge( &mut settings.preferred_completion_mode, @@ -904,24 +454,24 @@ impl Settings for AgentSettings { .model_parameters .extend_from_slice(&value.model_parameters); - if let Some(profiles) = value.profiles { + if let Some(profiles) = value.profiles.as_ref() { settings .profiles .extend(profiles.into_iter().map(|(id, profile)| { ( - id, + id.clone(), AgentProfileSettings { - name: profile.name.into(), - tools: profile.tools, + name: profile.name.clone().into(), + tools: profile.tools.clone(), enable_all_context_servers: profile .enable_all_context_servers .unwrap_or_default(), context_servers: profile .context_servers - .into_iter() + .iter() .map(|(context_server_id, preset)| { ( - context_server_id, + context_server_id.clone(), ContextServerPreset { tools: preset.tools.clone(), }, @@ -942,28 +492,8 @@ impl Settings for AgentSettings { .read_value("chat.agent.enabled") .and_then(|b| b.as_bool()) { - match &mut current.inner { - Some(AgentSettingsContentInner::Versioned(versioned)) => match versioned.as_mut() { - VersionedAgentSettingsContent::V1(setting) => { - setting.enabled = Some(b); - setting.button = Some(b); - } - - VersionedAgentSettingsContent::V2(setting) => { - setting.enabled = Some(b); - setting.button = Some(b); - } - }, - Some(AgentSettingsContentInner::Legacy(setting)) => setting.button = Some(b), - None => { - current.inner = - Some(AgentSettingsContentInner::for_v2(AgentSettingsContentV2 { - enabled: Some(b), - button: Some(b), - ..Default::default() - })); - } - } + current.enabled = Some(b); + current.button = Some(b); } } } @@ -973,149 +503,3 @@ fn merge(target: &mut T, value: Option) { *target = value; } } - -#[cfg(test)] -mod tests { - use fs::Fs; - use gpui::{ReadGlobal, TestAppContext}; - use settings::SettingsStore; - - use super::*; - - #[gpui::test] - async fn test_deserialize_agent_settings_with_version(cx: &mut TestAppContext) { - let fs = fs::FakeFs::new(cx.executor().clone()); - fs.create_dir(paths::settings_file().parent().unwrap()) - .await - .unwrap(); - - cx.update(|cx| { - let test_settings = settings::SettingsStore::test(cx); - cx.set_global(test_settings); - AgentSettings::register(cx); - }); - - cx.update(|cx| { - assert!(!AgentSettings::get_global(cx).using_outdated_settings_version); - assert_eq!( - AgentSettings::get_global(cx).default_model, - LanguageModelSelection { - provider: "zed.dev".into(), - model: "claude-sonnet-4".into(), - } - ); - }); - - cx.update(|cx| { - settings::SettingsStore::global(cx).update_settings_file::( - fs.clone(), - |settings, _| { - *settings = AgentSettingsContent { - inner: Some(AgentSettingsContentInner::for_v2(AgentSettingsContentV2 { - default_model: Some(LanguageModelSelection { - provider: "test-provider".into(), - model: "gpt-99".into(), - }), - inline_assistant_model: None, - commit_message_model: None, - thread_summary_model: None, - inline_alternatives: None, - enabled: None, - button: None, - dock: None, - default_width: None, - default_height: None, - default_profile: None, - default_view: None, - profiles: None, - always_allow_tool_actions: None, - play_sound_when_agent_done: None, - notify_when_agent_waiting: None, - stream_edits: None, - single_file_review: None, - enable_feedback: None, - model_parameters: Vec::new(), - preferred_completion_mode: None, - })), - } - }, - ); - }); - - cx.run_until_parked(); - - let raw_settings_value = fs.load(paths::settings_file()).await.unwrap(); - assert!(raw_settings_value.contains(r#""version": "2""#)); - - #[derive(Debug, Deserialize)] - struct AgentSettingsTest { - agent: AgentSettingsContent, - } - - let agent_settings: AgentSettingsTest = - serde_json_lenient::from_str(&raw_settings_value).unwrap(); - - assert!(!agent_settings.agent.is_version_outdated()); - } - - #[gpui::test] - async fn test_load_settings_from_old_key(cx: &mut TestAppContext) { - let fs = fs::FakeFs::new(cx.executor().clone()); - fs.create_dir(paths::settings_file().parent().unwrap()) - .await - .unwrap(); - - cx.update(|cx| { - let mut test_settings = settings::SettingsStore::test(cx); - let user_settings_content = r#"{ - "assistant": { - "enabled": true, - "version": "2", - "default_model": { - "provider": "zed.dev", - "model": "gpt-99" - }, - }}"#; - test_settings - .set_user_settings(user_settings_content, cx) - .unwrap(); - cx.set_global(test_settings); - AgentSettings::register(cx); - }); - - cx.run_until_parked(); - - let agent_settings = cx.update(|cx| AgentSettings::get_global(cx).clone()); - assert!(agent_settings.enabled); - assert!(!agent_settings.using_outdated_settings_version); - assert_eq!(agent_settings.default_model.model, "gpt-99"); - - cx.update_global::(|settings_store, cx| { - settings_store.update_user_settings::(cx, |settings| { - *settings = AgentSettingsContent { - inner: Some(AgentSettingsContentInner::for_v2(AgentSettingsContentV2 { - enabled: Some(false), - default_model: Some(LanguageModelSelection { - provider: "xai".to_owned().into(), - model: "grok".to_owned(), - }), - ..Default::default() - })), - }; - }); - }); - - cx.run_until_parked(); - - let settings = cx.update(|cx| SettingsStore::global(cx).raw_user_settings().clone()); - - #[derive(Debug, Deserialize)] - struct AgentSettingsTest { - assistant: AgentSettingsContent, - agent: Option, - } - - let agent_settings: AgentSettingsTest = serde_json::from_value(settings).unwrap(); - assert!(agent_settings.agent.is_none()); - } -} diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml new file mode 100644 index 0000000000..070e8eb585 --- /dev/null +++ b/crates/agent_ui/Cargo.toml @@ -0,0 +1,110 @@ +[package] +name = "agent_ui" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent_ui.rs" +doctest = false + +[features] +test-support = [ + "gpui/test-support", + "language/test-support", +] + +[dependencies] +agent.workspace = true +agent_settings.workspace = true +anyhow.workspace = true +assistant_context.workspace = true +assistant_slash_command.workspace = true +assistant_slash_commands.workspace = true +assistant_tool.workspace = true +audio.workspace = true +buffer_diff.workspace = true +chrono.workspace = true +client.workspace = true +collections.workspace = true +component.workspace = true +context_server.workspace = true +db.workspace = true +editor.workspace = true +extension.workspace = true +extension_host.workspace = true +feature_flags.workspace = true +file_icons.workspace = true +fs.workspace = true +futures.workspace = true +fuzzy.workspace = true +gpui.workspace = true +html_to_markdown.workspace = true +indoc.workspace = true +http_client.workspace = true +indexed_docs.workspace = true +inventory.workspace = true +itertools.workspace = true +jsonschema.workspace = true +language.workspace = true +language_model.workspace = true +log.workspace = true +lsp.workspace = true +markdown.workspace = true +menu.workspace = true +multi_buffer.workspace = true +notifications.workspace = true +ordered-float.workspace = true +parking_lot.workspace = true +paths.workspace = true +picker.workspace = true +project.workspace = true +prompt_store.workspace = true +proto.workspace = true +release_channel.workspace = true +rope.workspace = true +rules_library.workspace = true +schemars.workspace = true +search.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_json_lenient.workspace = true +settings.workspace = true +smol.workspace = true +streaming_diff.workspace = true +telemetry.workspace = true +telemetry_events.workspace = true +terminal.workspace = true +terminal_view.workspace = true +text.workspace = true +theme.workspace = true +time.workspace = true +time_format.workspace = true +ui.workspace = true +urlencoding.workspace = true +util.workspace = true +uuid.workspace = true +watch.workspace = true +workspace-hack.workspace = true +workspace.workspace = true +zed_actions.workspace = true +zed_llm_client.workspace = true + +[dev-dependencies] +assistant_tools.workspace = true +buffer_diff = { workspace = true, features = ["test-support"] } +editor = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, "features" = ["test-support"] } +indoc.workspace = true +language = { workspace = true, "features" = ["test-support"] } +languages = { workspace = true, features = ["test-support"] } +language_model = { workspace = true, "features" = ["test-support"] } +pretty_assertions.workspace = true +project = { workspace = true, features = ["test-support"] } +rand.workspace = true +tree-sitter-md.workspace = true +unindent.workspace = true diff --git a/crates/assistant_context_editor/LICENSE-GPL b/crates/agent_ui/LICENSE-GPL similarity index 100% rename from crates/assistant_context_editor/LICENSE-GPL rename to crates/agent_ui/LICENSE-GPL diff --git a/crates/agent/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs similarity index 93% rename from crates/agent/src/active_thread.rs rename to crates/agent_ui/src/active_thread.rs index eff74f1786..0e7ca9aa89 100644 --- a/crates/agent/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -1,18 +1,17 @@ -use crate::context::{AgentContextHandle, RULES_ICON}; use crate::context_picker::{ContextPicker, MentionLink}; -use crate::context_store::ContextStore; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; use crate::message_editor::{extract_message_creases, insert_message_creases}; -use crate::thread::{ - LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, Thread, ThreadError, - ThreadEvent, ThreadFeedback, ThreadSummary, -}; -use crate::thread_store::{RulesLoadingError, TextThreadStore, ThreadStore}; -use crate::tool_use::{PendingToolUseStatus, ToolUse}; use crate::ui::{ AddedContext, AgentNotification, AgentNotificationEvent, AnimatedLabel, ContextPill, }; use crate::{AgentPanel, ModelUsageContext}; +use agent::{ + ContextStore, LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, TextThreadStore, + Thread, ThreadError, ThreadEvent, ThreadFeedback, ThreadStore, ThreadSummary, + context::{self, AgentContextHandle, RULES_ICON}, + thread_store::RulesLoadingError, + tool_use::{PendingToolUseStatus, ToolUse}, +}; use agent_settings::{AgentSettings, NotifyWhenAgentWaiting}; use anyhow::Context as _; use assistant_tool::ToolUseStatus; @@ -24,7 +23,7 @@ use editor::{Editor, EditorElement, EditorEvent, EditorStyle, MultiBuffer}; use gpui::{ AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, ClipboardEntry, ClipboardItem, DefiniteLength, EdgesRefinement, Empty, Entity, EventEmitter, Focusable, Hsla, - ListAlignment, ListState, MouseButton, PlatformDisplay, ScrollHandle, Stateful, + ListAlignment, ListOffset, ListState, MouseButton, PlatformDisplay, ScrollHandle, Stateful, StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, percentage, pulsating_between, @@ -48,8 +47,8 @@ use std::time::Duration; use text::ToPoint; use theme::ThemeSettings; use ui::{ - Disclosure, IconButton, KeyBinding, PopoverMenuHandle, Scrollbar, ScrollbarState, TextSize, - Tooltip, prelude::*, + Disclosure, KeyBinding, PopoverMenuHandle, Scrollbar, ScrollbarState, TextSize, Tooltip, + prelude::*, }; use util::ResultExt as _; use util::markdown::MarkdownCodeBlock; @@ -57,6 +56,9 @@ use workspace::{CollaboratorId, Workspace}; use zed_actions::assistant::OpenRulesLibrary; use zed_llm_client::CompletionIntent; +const CODEBLOCK_CONTAINER_GROUP: &str = "codeblock_container"; +const EDIT_PREVIOUS_MESSAGE_MIN_LINES: usize = 1; + pub struct ActiveThread { context_store: Entity, language_registry: Arc, @@ -300,7 +302,7 @@ fn tool_use_markdown_style(window: &Window, cx: &mut App) -> MarkdownStyle { base_text_style: text_style, syntax: cx.theme().syntax().clone(), selection_background_color: cx.theme().players().local().selection, - code_block_overflow_x_scroll: true, + code_block_overflow_x_scroll: false, code_block: StyleRefinement { margin: EdgesRefinement::default(), padding: EdgesRefinement::default(), @@ -334,8 +336,6 @@ fn tool_use_markdown_style(window: &Window, cx: &mut App) -> MarkdownStyle { } } -const CODEBLOCK_CONTAINER_GROUP: &str = "codeblock_container"; - fn render_markdown_code_block( message_id: MessageId, ix: usize, @@ -750,7 +750,7 @@ struct EditingMessageState { editor: Entity, context_strip: Entity, context_picker_menu_handle: PopoverMenuHandle, - last_estimated_token_count: Option, + last_estimated_token_count: Option, _subscriptions: [Subscription; 2], _update_token_count_task: Option>, } @@ -809,7 +809,12 @@ impl ActiveThread { }; for message in thread.read(cx).messages().cloned().collect::>() { - this.push_message(&message.id, &message.segments, window, cx); + let rendered_message = RenderedMessage::from_segments( + &message.segments, + this.language_registry.clone(), + cx, + ); + this.push_rendered_message(message.id, rendered_message); for tool_use in thread.read(cx).tool_uses_for_message(message.id, cx) { this.render_tool_use_markdown( @@ -857,7 +862,7 @@ impl ActiveThread { } /// Returns the editing message id and the estimated token count in the content - pub fn editing_message_id(&self) -> Option<(MessageId, usize)> { + pub fn editing_message_id(&self) -> Option<(MessageId, u64)> { self.editing_message .as_ref() .map(|(id, state)| (*id, state.last_estimated_token_count.unwrap_or(0))) @@ -875,36 +880,11 @@ impl ActiveThread { &self.text_thread_store } - fn push_message( - &mut self, - id: &MessageId, - segments: &[MessageSegment], - _window: &mut Window, - cx: &mut Context, - ) { + fn push_rendered_message(&mut self, id: MessageId, rendered_message: RenderedMessage) { let old_len = self.messages.len(); - self.messages.push(*id); + self.messages.push(id); self.list_state.splice(old_len..old_len, 1); - - let rendered_message = - RenderedMessage::from_segments(segments, self.language_registry.clone(), cx); - self.rendered_messages_by_id.insert(*id, rendered_message); - } - - fn edited_message( - &mut self, - id: &MessageId, - segments: &[MessageSegment], - _window: &mut Window, - cx: &mut Context, - ) { - let Some(index) = self.messages.iter().position(|message_id| message_id == id) else { - return; - }; - self.list_state.splice(index..index + 1, 1); - let rendered_message = - RenderedMessage::from_segments(segments, self.language_registry.clone(), cx); - self.rendered_messages_by_id.insert(*id, rendered_message); + self.rendered_messages_by_id.insert(id, rendered_message); } fn deleted_message(&mut self, id: &MessageId) { @@ -1037,31 +1017,43 @@ impl ActiveThread { } } ThreadEvent::MessageAdded(message_id) => { - if let Some(message_segments) = self - .thread - .read(cx) - .message(*message_id) - .map(|message| message.segments.clone()) - { - self.push_message(message_id, &message_segments, window, cx); + if let Some(rendered_message) = self.thread.update(cx, |thread, cx| { + thread.message(*message_id).map(|message| { + RenderedMessage::from_segments( + &message.segments, + self.language_registry.clone(), + cx, + ) + }) + }) { + self.push_rendered_message(*message_id, rendered_message); } self.save_thread(cx); cx.notify(); } ThreadEvent::MessageEdited(message_id) => { - if let Some(message_segments) = self - .thread - .read(cx) - .message(*message_id) - .map(|message| message.segments.clone()) - { - self.edited_message(message_id, &message_segments, window, cx); + if let Some(index) = self.messages.iter().position(|id| id == message_id) { + if let Some(rendered_message) = self.thread.update(cx, |thread, cx| { + thread.message(*message_id).map(|message| { + let mut rendered_message = RenderedMessage { + language_registry: self.language_registry.clone(), + segments: Vec::with_capacity(message.segments.len()), + }; + for segment in &message.segments { + rendered_message.push_segment(segment, cx); + } + rendered_message + }) + }) { + self.list_state.splice(index..index + 1, 1); + self.rendered_messages_by_id + .insert(*message_id, rendered_message); + self.scroll_to_bottom(cx); + self.save_thread(cx); + cx.notify(); + } } - - self.scroll_to_bottom(cx); - self.save_thread(cx); - cx.notify(); } ThreadEvent::MessageDeleted(message_id) => { self.deleted_message(message_id); @@ -1311,27 +1303,23 @@ impl ActiveThread { fn start_editing_message( &mut self, message_id: MessageId, - message_segments: &[MessageSegment], + message_text: impl Into>, message_creases: &[MessageCrease], window: &mut Window, cx: &mut Context, ) { - // User message should always consist of a single text segment, - // therefore we can skip returning early if it's not a text segment. - let Some(MessageSegment::Text(message_text)) = message_segments.first() else { - return; - }; - let editor = crate::message_editor::create_editor( self.workspace.clone(), self.context_store.downgrade(), self.thread_store.downgrade(), self.text_thread_store.downgrade(), + EDIT_PREVIOUS_MESSAGE_MIN_LINES, + None, window, cx, ); editor.update(cx, |editor, cx| { - editor.set_text(message_text.clone(), window, cx); + editor.set_text(message_text, window, cx); insert_message_creases(editor, message_creases, &self.context_store, window, cx); editor.focus_handle(cx).focus(window); editor.move_to_end(&editor::actions::MoveToEnd, window, cx); @@ -1580,8 +1568,7 @@ impl ActiveThread { let git_store = project.read(cx).git_store().clone(); let checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx)); - let load_context_task = - crate::context::load_context(new_context, &project, &prompt_store, cx); + let load_context_task = context::load_context(new_context, &project, &prompt_store, cx); self._load_edited_message_context_task = Some(cx.spawn_in(window, async move |this, cx| { let (context, checkpoint) = @@ -1605,6 +1592,7 @@ impl ActiveThread { this.thread.update(cx, |thread, cx| { thread.advance_prompt_id(); + thread.cancel_last_completion(Some(window.window_handle()), cx); thread.send_to_model( model.model, CompletionIntent::UserPrompt, @@ -1617,6 +1605,14 @@ impl ActiveThread { }) .log_err(); })); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.focus_handle(cx).focus(window); + } + }); + } } fn messages_after(&self, message_id: MessageId) -> &[MessageId] { @@ -1680,7 +1676,10 @@ impl ActiveThread { let editor = cx.new(|cx| { let mut editor = Editor::new( - editor::EditorMode::AutoHeight { max_lines: 4 }, + editor::EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(4), + }, buffer, None, window, @@ -1722,7 +1721,7 @@ impl ActiveThread { telemetry::event!( "Assistant Thread Feedback Comments", thread_id, - message_id = message_id.0, + message_id = message_id.as_usize(), message_content, comments = comments_value ); @@ -1815,8 +1814,6 @@ impl ActiveThread { return div().children(loading_dots).into_any(); } - let message_creases = message.creases.clone(); - let Some(rendered_message) = self.rendered_messages_by_id.get(&message_id) else { return Empty.into_any(); }; @@ -1857,6 +1854,14 @@ impl ActiveThread { } }); + let scroll_to_top = IconButton::new(("scroll_to_top", ix), IconName::ArrowUpAlt) + .icon_size(IconSize::XSmall) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Scroll To Top")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_top(cx); + })); + // For all items that should be aligned with the LLM's response. const RESPONSE_PADDING_X: Pixels = px(19.); @@ -1966,11 +1971,14 @@ impl ActiveThread { ); })), ) - .child(open_as_markdown), + .child(open_as_markdown) + .child(scroll_to_top), ) .into_any_element(), None => feedback_container - .child(h_flex().child(open_as_markdown)) + .child(h_flex() + .child(open_as_markdown)) + .child(scroll_to_top) .into_any_element(), }; @@ -2120,15 +2128,30 @@ impl ActiveThread { }), ) .on_click(cx.listener({ - let message_segments = message.segments.clone(); + let message_creases = message.creases.clone(); move |this, _, window, cx| { - this.start_editing_message( - message_id, - &message_segments, - &message_creases, - window, - cx, - ); + if let Some(message_text) = + this.thread.read(cx).message(message_id).and_then(|message| { + message.segments.first().and_then(|segment| { + match segment { + MessageSegment::Text(message_text) => { + Some(Into::>::into(message_text.as_str())) + } + _ => { + None + } + } + }) + }) + { + this.start_editing_message( + message_id, + message_text, + &message_creases, + window, + cx, + ); + } } })), ), @@ -3084,6 +3107,7 @@ impl ActiveThread { .pr_1() .gap_1() .justify_between() + .flex_wrap() .bg(cx.theme().colors().editor_background) .border_t_1() .border_color(self.tool_card_border_color(cx)) @@ -3459,6 +3483,11 @@ impl ActiveThread { *is_expanded = !*is_expanded; } + pub fn scroll_to_top(&mut self, cx: &mut Context) { + self.list_state.scroll_to(ListOffset::default()); + cx.notify(); + } + pub fn scroll_to_bottom(&mut self, cx: &mut Context) { self.list_state.reset(self.messages.len()); cx.notify(); @@ -3691,8 +3720,10 @@ fn open_editor_at_position( #[cfg(test)] mod tests { + use super::*; + use agent::{MessageSegment, context::ContextLoadResult, thread_store}; use assistant_tool::{ToolRegistry, ToolWorkingSet}; - use editor::{EditorSettings, display_map::CreaseMetadata}; + use editor::EditorSettings; use fs::FakeFs; use gpui::{AppContext, TestAppContext, VisualTestContext}; use language_model::{ @@ -3706,10 +3737,6 @@ mod tests { use util::path; use workspace::CollaboratorId; - use crate::{ContextLoadResult, thread_store}; - - use super::*; - #[gpui::test] async fn test_agent_is_unfollowed_after_cancelling_completion(cx: &mut TestAppContext) { init_test_settings(cx); @@ -3781,10 +3808,8 @@ mod tests { let creases = vec![MessageCrease { range: 14..22, - metadata: CreaseMetadata { - icon_path: "icon".into(), - label: "foo.txt".into(), - }, + icon_path: "icon".into(), + label: "foo.txt".into(), context: None, }]; @@ -3800,13 +3825,15 @@ mod tests { }); active_thread.update_in(cx, |active_thread, window, cx| { - active_thread.start_editing_message( - message.id, - message.segments.as_slice(), - message.creases.as_slice(), - window, - cx, - ); + if let Some(message_text) = message.segments.first().and_then(MessageSegment::text) { + active_thread.start_editing_message( + message.id, + message_text, + message.creases.as_slice(), + window, + cx, + ); + } let editor = active_thread .editing_message .as_ref() @@ -3821,13 +3848,15 @@ mod tests { let message = thread.update(cx, |thread, _| thread.message(message.id).cloned().unwrap()); active_thread.update_in(cx, |active_thread, window, cx| { - active_thread.start_editing_message( - message.id, - message.segments.as_slice(), - message.creases.as_slice(), - window, - cx, - ); + if let Some(message_text) = message.segments.first().and_then(MessageSegment::text) { + active_thread.start_editing_message( + message.id, + message_text, + message.creases.as_slice(), + window, + cx, + ); + } let editor = active_thread .editing_message .as_ref() @@ -3840,6 +3869,116 @@ mod tests { }); } + #[gpui::test] + async fn test_editing_message_cancels_previous_completion(cx: &mut TestAppContext) { + init_test_settings(cx); + + let project = create_test_project(cx, json!({})).await; + + let (cx, active_thread, _, thread, model) = + setup_test_environment(cx, project.clone()).await; + + cx.update(|_, cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.set_default_model( + Some(ConfiguredModel { + provider: Arc::new(FakeLanguageModelProvider), + model: model.clone(), + }), + cx, + ); + }); + }); + + // Track thread events to verify cancellation + let cancellation_events = Arc::new(std::sync::Mutex::new(Vec::new())); + let new_request_events = Arc::new(std::sync::Mutex::new(Vec::new())); + + let _subscription = cx.update(|_, cx| { + let cancellation_events = cancellation_events.clone(); + let new_request_events = new_request_events.clone(); + cx.subscribe( + &thread, + move |_thread, event: &ThreadEvent, _cx| match event { + ThreadEvent::CompletionCanceled => { + cancellation_events.lock().unwrap().push(()); + } + ThreadEvent::NewRequest => { + new_request_events.lock().unwrap().push(()); + } + _ => {} + }, + ) + }); + + // Insert a user message and start streaming a response + let message = thread.update(cx, |thread, cx| { + let message_id = thread.insert_user_message( + "Hello, how are you?", + ContextLoadResult::default(), + None, + vec![], + cx, + ); + thread.advance_prompt_id(); + thread.send_to_model( + model.clone(), + CompletionIntent::UserPrompt, + cx.active_window(), + cx, + ); + thread.message(message_id).cloned().unwrap() + }); + + cx.run_until_parked(); + + // Verify that a completion is in progress + assert!(cx.read(|cx| thread.read(cx).is_generating())); + assert_eq!(new_request_events.lock().unwrap().len(), 1); + + // Edit the message while the completion is still running + active_thread.update_in(cx, |active_thread, window, cx| { + if let Some(message_text) = message.segments.first().and_then(MessageSegment::text) { + active_thread.start_editing_message( + message.id, + message_text, + message.creases.as_slice(), + window, + cx, + ); + } + let editor = active_thread + .editing_message + .as_ref() + .unwrap() + .1 + .editor + .clone(); + editor.update(cx, |editor, cx| { + editor.set_text("What is the weather like?", window, cx); + }); + active_thread.confirm_editing_message(&Default::default(), window, cx); + }); + + cx.run_until_parked(); + + // Verify that the previous completion was cancelled + assert_eq!(cancellation_events.lock().unwrap().len(), 1); + + // Verify that a new request was started after cancellation + assert_eq!(new_request_events.lock().unwrap().len(), 2); + + // Verify that the edited message contains the new text + let edited_message = + thread.update(cx, |thread, _| thread.message(message.id).cloned().unwrap()); + match &edited_message.segments[0] { + MessageSegment::Text(text) => { + assert_eq!(text, "What is the weather like?"); + } + _ => panic!("Expected text segment"), + } + } + fn init_test_settings(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/agent/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs similarity index 56% rename from crates/agent/src/agent_configuration.rs rename to crates/agent_ui/src/agent_configuration.rs index be76804bbf..e91a0f7ebe 100644 --- a/crates/agent/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1,4 +1,3 @@ -mod add_context_server_modal; mod configure_context_server_modal; mod manage_profiles_modal; mod tool_picker; @@ -9,22 +8,29 @@ use agent_settings::AgentSettings; use assistant_tool::{ToolSource, ToolWorkingSet}; use collections::HashMap; use context_server::ContextServerId; +use extension::ExtensionManifest; +use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, Animation, AnimationExt as _, AnyView, App, Entity, EventEmitter, FocusHandle, - Focusable, ScrollHandle, Subscription, Transformation, percentage, + Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle, + Focusable, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, }; +use language::LanguageRegistry; use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry}; -use project::context_server_store::{ContextServerStatus, ContextServerStore}; +use notifications::status_toast::{StatusToast, ToastIcon}; +use project::{ + context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, + project_settings::{ContextServerSettings, ProjectSettings}, +}; use settings::{Settings, update_settings_file}; use ui::{ - Disclosure, ElevationIndex, Indicator, Scrollbar, ScrollbarState, Switch, SwitchColor, Tooltip, - prelude::*, + ContextMenu, Disclosure, ElevationIndex, Indicator, PopoverMenu, Scrollbar, ScrollbarState, + Switch, SwitchColor, Tooltip, prelude::*, }; use util::ResultExt as _; +use workspace::Workspace; use zed_actions::ExtensionCategoryFilter; -pub(crate) use add_context_server_modal::AddContextServerModal; pub(crate) use configure_context_server_modal::ConfigureContextServerModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; @@ -32,6 +38,8 @@ use crate::AddContextServer; pub struct AgentConfiguration { fs: Arc, + language_registry: Arc, + workspace: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, @@ -48,6 +56,8 @@ impl AgentConfiguration { fs: Arc, context_server_store: Entity, tools: Entity, + language_registry: Arc, + workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -70,11 +80,16 @@ impl AgentConfiguration { }, ); + cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) + .detach(); + let scroll_handle = ScrollHandle::new(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); let mut this = Self { fs, + language_registry, + workspace, focus_handle, configuration_views_by_provider: HashMap::default(), context_server_store, @@ -133,6 +148,8 @@ impl AgentConfiguration { ) -> impl IntoElement + use<> { let provider_id = provider.id().0.clone(); let provider_name = provider.name().0.clone(); + let provider_id_string = SharedString::from(format!("provider-disclosure-{provider_id}")); + let configuration_view = self .configuration_views_by_provider .get(&provider.id()) @@ -145,72 +162,80 @@ impl AgentConfiguration { .unwrap_or(false); v_flex() - .pt_3() + .py_2() .gap_1p5() .border_t_1() .border_color(cx.theme().colors().border.opacity(0.6)) .child( h_flex() + .w_full() + .gap_1() .justify_between() .child( h_flex() - .gap_2() + .id(provider_id_string.clone()) + .cursor_pointer() + .py_0p5() + .w_full() + .justify_between() + .rounded_sm() + .hover(|hover| hover.bg(cx.theme().colors().element_hover)) .child( - Icon::new(provider.icon()) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child(Label::new(provider_name.clone()).size(LabelSize::Large)) - .when(provider.is_authenticated(cx) && !is_expanded, |parent| { - parent.child(Icon::new(IconName::Check).color(Color::Success)) - }), - ) - .child( - h_flex() - .gap_1() - .when(provider.is_authenticated(cx), |parent| { - parent.child( - Button::new( - SharedString::from(format!("new-thread-{provider_id}")), - "Start New Thread", + h_flex() + .gap_2() + .child( + Icon::new(provider.icon()) + .size(IconSize::Small) + .color(Color::Muted), ) - .icon_position(IconPosition::Start) - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .layer(ElevationIndex::ModalSurface) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let provider = provider.clone(); - move |_this, _event, _window, cx| { - cx.emit(AssistantConfigurationEvent::NewThread( - provider.clone(), - )) - } - })), - ) - }) + .child(Label::new(provider_name.clone()).size(LabelSize::Large)) + .when( + provider.is_authenticated(cx) && !is_expanded, + |parent| { + parent.child( + Icon::new(IconName::Check).color(Color::Success), + ) + }, + ), + ) .child( - Disclosure::new( - SharedString::from(format!( - "provider-disclosure-{provider_id}" - )), - is_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener({ - let provider_id = provider.id().clone(); - move |this, _event, _window, _cx| { - let is_expanded = this - .expanded_provider_configurations - .entry(provider_id.clone()) - .or_insert(false); + Disclosure::new(provider_id_string, is_expanded) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown), + ) + .on_click(cx.listener({ + let provider_id = provider.id().clone(); + move |this, _event, _window, _cx| { + let is_expanded = this + .expanded_provider_configurations + .entry(provider_id.clone()) + .or_insert(false); - *is_expanded = !*is_expanded; - } - })), - ), - ), + *is_expanded = !*is_expanded; + } + })), + ) + .when(provider.is_authenticated(cx), |parent| { + parent.child( + Button::new( + SharedString::from(format!("new-thread-{provider_id}")), + "Start New Thread", + ) + .icon_position(IconPosition::Start) + .icon(IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let provider = provider.clone(); + move |_this, _event, _window, cx| { + cx.emit(AssistantConfigurationEvent::NewThread( + provider.clone(), + )) + } + })), + ) + }), ) .when(is_expanded, |parent| match configuration_view { Some(configuration_view) => parent.child(configuration_view), @@ -229,11 +254,11 @@ impl AgentConfiguration { v_flex() .p(DynamicSpacing::Base16.rems(cx)) .pr(DynamicSpacing::Base20.rems(cx)) - .gap_4() .border_b_1() .border_color(cx.theme().colors().border) .child( v_flex() + .mb_2p5() .gap_0p5() .child(Headline::new("LLM Providers")) .child( @@ -460,9 +485,22 @@ impl AgentConfiguration { .read(cx) .status_for_server(&context_server_id) .unwrap_or(ContextServerStatus::Stopped); + let server_configuration = self + .context_server_store + .read(cx) + .configuration_for_server(&context_server_id); let is_running = matches!(server_status, ContextServerStatus::Running); let item_id = SharedString::from(context_server_id.0.clone()); + let is_from_extension = server_configuration + .as_ref() + .map(|config| { + matches!( + config.as_ref(), + ContextServerConfiguration::Extension { .. } + ) + }) + .unwrap_or(false); let error = if let ContextServerStatus::Error(error) = server_status.clone() { Some(error) @@ -484,6 +522,18 @@ impl AgentConfiguration { let border_color = cx.theme().colors().border.opacity(0.6); + let (source_icon, source_tooltip) = if is_from_extension { + ( + IconName::ZedMcpExtension, + "This MCP server was installed from an extension.", + ) + } else { + ( + IconName::ZedMcpCustom, + "This custom MCP server was installed directly.", + ) + }; + let (status_indicator, tooltip_text) = match server_status { ContextServerStatus::Starting => ( Icon::new(IconName::LoadCircle) @@ -511,6 +561,105 @@ impl AgentConfiguration { ), }; + let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu") + .trigger_with_tooltip( + IconButton::new("context-server-config-menu", IconName::Settings) + .icon_color(Color::Muted) + .icon_size(IconSize::Small), + Tooltip::text("Open MCP server options"), + ) + .anchor(Corner::TopRight) + .menu({ + let fs = self.fs.clone(); + let context_server_id = context_server_id.clone(); + let language_registry = self.language_registry.clone(); + let context_server_store = self.context_server_store.clone(); + let workspace = self.workspace.clone(); + move |window, cx| { + Some(ContextMenu::build(window, cx, |menu, _window, _cx| { + menu.entry("Configure Server", None, { + let context_server_id = context_server_id.clone(); + let language_registry = language_registry.clone(); + let workspace = workspace.clone(); + move |window, cx| { + ConfigureContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + .detach_and_log_err(cx); + } + }) + .separator() + .entry("Uninstall", None, { + let fs = fs.clone(); + let context_server_id = context_server_id.clone(); + let context_server_store = context_server_store.clone(); + let workspace = workspace.clone(); + move |_, cx| { + let is_provided_by_extension = context_server_store + .read(cx) + .configuration_for_server(&context_server_id) + .as_ref() + .map(|config| { + matches!( + config.as_ref(), + ContextServerConfiguration::Extension { .. } + ) + }) + .unwrap_or(false); + + let uninstall_extension_task = match ( + is_provided_by_extension, + resolve_extension_for_context_server(&context_server_id, cx), + ) { + (true, Some((id, manifest))) => { + if extension_only_provides_context_server(manifest.as_ref()) + { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.uninstall_extension(id, cx) + }) + } else { + workspace.update(cx, |workspace, cx| { + show_unable_to_uninstall_extension_with_context_server(workspace, context_server_id.clone(), cx); + }).log_err(); + Task::ready(Ok(())) + } + } + _ => Task::ready(Ok(())), + }; + + cx.spawn({ + let fs = fs.clone(); + let context_server_id = context_server_id.clone(); + async move |cx| { + uninstall_extension_task.await?; + cx.update(|cx| { + update_settings_file::( + fs.clone(), + cx, + { + let context_server_id = + context_server_id.clone(); + move |settings, _| { + settings + .context_servers + .remove(&context_server_id.0); + } + }, + ) + }) + } + }) + .detach_and_log_err(cx); + } + }) + })) + } + }); + v_flex() .id(item_id.clone()) .border_1() @@ -556,7 +705,19 @@ impl AgentConfiguration { .tooltip(Tooltip::text(tooltip_text)) .child(status_indicator), ) - .child(Label::new(item_id).ml_0p5().mr_1p5()) + .child(Label::new(item_id).ml_0p5()) + .child( + div() + .id("extension-source") + .mt_0p5() + .mx_1() + .tooltip(Tooltip::text(source_tooltip)) + .child( + Icon::new(source_icon) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) .when(is_running, |this| { this.child( Label::new(if tool_count == 1 { @@ -570,28 +731,72 @@ impl AgentConfiguration { }), ) .child( - Switch::new("context-server-switch", is_running.into()) - .color(SwitchColor::Accent) - .on_click({ - let context_server_manager = self.context_server_store.clone(); - let context_server_id = context_server_id.clone(); - move |state, _window, cx| match state { - ToggleState::Unselected | ToggleState::Indeterminate => { - context_server_manager.update(cx, |this, cx| { - this.stop_server(&context_server_id, cx).log_err(); - }); - } - ToggleState::Selected => { - context_server_manager.update(cx, |this, cx| { - if let Some(server) = - this.get_server(&context_server_id) - { - this.start_server(server, cx).log_err(); - } - }) - } - } - }), + h_flex() + .gap_1() + .child(context_server_configuration_menu) + .child( + Switch::new("context-server-switch", is_running.into()) + .color(SwitchColor::Accent) + .on_click({ + let context_server_manager = + self.context_server_store.clone(); + let context_server_id = context_server_id.clone(); + let fs = self.fs.clone(); + + move |state, _window, cx| { + let is_enabled = match state { + ToggleState::Unselected + | ToggleState::Indeterminate => { + context_server_manager.update( + cx, + |this, cx| { + this.stop_server( + &context_server_id, + cx, + ) + .log_err(); + }, + ); + false + } + ToggleState::Selected => { + context_server_manager.update( + cx, + |this, cx| { + if let Some(server) = + this.get_server(&context_server_id) + { + this.start_server(server, cx); + } + }, + ); + true + } + }; + update_settings_file::( + fs.clone(), + cx, + { + let context_server_id = + context_server_id.clone(); + + move |settings, _| { + settings + .context_servers + .entry(context_server_id.0) + .or_insert_with(|| { + ContextServerSettings::Extension { + enabled: is_enabled, + settings: serde_json::json!({}), + } + }) + .set_enabled(is_enabled); + } + }, + ); + } + }), + ), ), ) .map(|parent| { @@ -701,3 +906,92 @@ impl Render for AgentConfiguration { ) } } + +fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool { + manifest.context_servers.len() == 1 + && manifest.themes.is_empty() + && manifest.icon_themes.is_empty() + && manifest.languages.is_empty() + && manifest.grammars.is_empty() + && manifest.language_servers.is_empty() + && manifest.slash_commands.is_empty() + && manifest.indexed_docs_providers.is_empty() + && manifest.snippets.is_none() + && manifest.debug_locators.is_empty() +} + +pub(crate) fn resolve_extension_for_context_server( + id: &ContextServerId, + cx: &App, +) -> Option<(Arc, Arc)> { + ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .iter() + .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0)) + .map(|(id, entry)| (id.clone(), entry.manifest.clone())) +} + +// This notification appears when trying to delete +// an MCP server extension that not only provides +// the server, but other things, too, like language servers and more. +fn show_unable_to_uninstall_extension_with_context_server( + workspace: &mut Workspace, + id: ContextServerId, + cx: &mut App, +) { + let workspace_handle = workspace.weak_handle(); + let context_server_id = id.clone(); + + let status_toast = StatusToast::new( + format!( + "The {} extension provides more than just the MCP server. Proceed to uninstall anyway?", + id.0 + ), + cx, + move |this, _cx| { + let workspace_handle = workspace_handle.clone(); + let context_server_id = context_server_id.clone(); + + this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning)) + .dismiss_button(true) + .action("Uninstall", move |_, _cx| { + if let Some((extension_id, _)) = + resolve_extension_for_context_server(&context_server_id, _cx) + { + ExtensionStore::global(_cx).update(_cx, |store, cx| { + store + .uninstall_extension(extension_id, cx) + .detach_and_log_err(cx); + }); + + workspace_handle + .update(_cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + cx.spawn({ + let context_server_id = context_server_id.clone(); + async move |_workspace_handle, cx| { + cx.update(|cx| { + update_settings_file::( + fs, + cx, + move |settings, _| { + settings + .context_servers + .remove(&context_server_id.0); + }, + ); + })?; + anyhow::Ok(()) + } + }) + .detach_and_log_err(cx); + }) + .log_err(); + } + }) + }, + ); + + workspace.toggle_status_toast(status_toast, cx); +} diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs new file mode 100644 index 0000000000..30fad51cfc --- /dev/null +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -0,0 +1,763 @@ +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use anyhow::{Context as _, Result}; +use context_server::{ContextServerCommand, ContextServerId}; +use editor::{Editor, EditorElement, EditorStyle}; +use gpui::{ + Animation, AnimationExt as _, AsyncWindowContext, DismissEvent, Entity, EventEmitter, + FocusHandle, Focusable, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, + WeakEntity, percentage, prelude::*, +}; +use language::{Language, LanguageRegistry}; +use markdown::{Markdown, MarkdownElement, MarkdownStyle}; +use notifications::status_toast::{StatusToast, ToastIcon}; +use project::{ + context_server_store::{ + ContextServerStatus, ContextServerStore, registry::ContextServerDescriptorRegistry, + }, + project_settings::{ContextServerSettings, ProjectSettings}, + worktree_store::WorktreeStore, +}; +use settings::{Settings as _, update_settings_file}; +use theme::ThemeSettings; +use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*}; +use util::ResultExt as _; +use workspace::{ModalView, Workspace}; + +use crate::AddContextServer; + +enum ConfigurationTarget { + New, + Existing { + id: ContextServerId, + command: ContextServerCommand, + }, + Extension { + id: ContextServerId, + repository_url: Option, + installation: Option, + }, +} + +enum ConfigurationSource { + New { + editor: Entity, + }, + Existing { + editor: Entity, + }, + Extension { + id: ContextServerId, + editor: Option>, + repository_url: Option, + installation_instructions: Option>, + settings_validator: Option, + }, +} + +impl ConfigurationSource { + fn has_configuration_options(&self) -> bool { + !matches!(self, ConfigurationSource::Extension { editor: None, .. }) + } + + fn is_new(&self) -> bool { + matches!(self, ConfigurationSource::New { .. }) + } + + fn from_target( + target: ConfigurationTarget, + language_registry: Arc, + jsonc_language: Option>, + window: &mut Window, + cx: &mut App, + ) -> Self { + fn create_editor( + json: String, + jsonc_language: Option>, + window: &mut Window, + cx: &mut App, + ) -> Entity { + cx.new(|cx| { + let mut editor = Editor::auto_height(4, 16, window, cx); + editor.set_text(json, window, cx); + editor.set_show_gutter(false, cx); + editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx); + if let Some(buffer) = editor.buffer().read(cx).as_singleton() { + buffer.update(cx, |buffer, cx| buffer.set_language(jsonc_language, cx)) + } + editor + }) + } + + match target { + ConfigurationTarget::New => ConfigurationSource::New { + editor: create_editor(context_server_input(None), jsonc_language, window, cx), + }, + ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing { + editor: create_editor( + context_server_input(Some((id, command))), + jsonc_language, + window, + cx, + ), + }, + ConfigurationTarget::Extension { + id, + repository_url, + installation, + } => { + let settings_validator = installation.as_ref().and_then(|installation| { + jsonschema::validator_for(&installation.settings_schema) + .context("Failed to load JSON schema for context server settings") + .log_err() + }); + let installation_instructions = installation.as_ref().map(|installation| { + cx.new(|cx| { + Markdown::new( + installation.installation_instructions.clone().into(), + Some(language_registry.clone()), + None, + cx, + ) + }) + }); + ConfigurationSource::Extension { + id, + repository_url, + installation_instructions, + settings_validator, + editor: installation.map(|installation| { + create_editor(installation.default_settings, jsonc_language, window, cx) + }), + } + } + } + } + + fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> { + match self { + ConfigurationSource::New { editor } | ConfigurationSource::Existing { editor } => { + parse_input(&editor.read(cx).text(cx)).map(|(id, command)| { + ( + id, + ContextServerSettings::Custom { + enabled: true, + command, + }, + ) + }) + } + ConfigurationSource::Extension { + id, + editor, + settings_validator, + .. + } => { + let text = editor + .as_ref() + .context("No output available")? + .read(cx) + .text(cx); + let settings = serde_json_lenient::from_str::(&text)?; + if let Some(settings_validator) = settings_validator { + if let Err(error) = settings_validator.validate(&settings) { + return Err(anyhow::anyhow!(error.to_string())); + } + } + Ok(( + id.clone(), + ContextServerSettings::Extension { + enabled: true, + settings, + }, + )) + } + } + } +} + +fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)>) -> String { + let (name, path, args, env) = match existing { + Some((id, cmd)) => { + let args = serde_json::to_string(&cmd.args).unwrap(); + let env = serde_json::to_string(&cmd.env.unwrap_or_default()).unwrap(); + (id.0.to_string(), cmd.path, args, env) + } + None => ( + "some-mcp-server".to_string(), + "".to_string(), + "[]".to_string(), + "{}".to_string(), + ), + }; + + format!( + r#"{{ + /// The name of your MCP server + "{name}": {{ + "command": {{ + /// The path to the executable + "path": "{path}", + /// The arguments to pass to the executable + "args": {args}, + /// The environment variables to set for the executable + "env": {env} + }} + }} +}}"# + ) +} + +fn resolve_context_server_extension( + id: ContextServerId, + worktree_store: Entity, + cx: &mut App, +) -> Task> { + let registry = ContextServerDescriptorRegistry::default_global(cx).read(cx); + + let Some(descriptor) = registry.context_server_descriptor(&id.0) else { + return Task::ready(None); + }; + + let extension = crate::agent_configuration::resolve_extension_for_context_server(&id, cx); + cx.spawn(async move |cx| { + let installation = descriptor + .configuration(worktree_store, cx) + .await + .context("Failed to resolve context server configuration") + .log_err() + .flatten(); + + Some(ConfigurationTarget::Extension { + id, + repository_url: extension + .and_then(|(_, manifest)| manifest.repository.clone().map(SharedString::from)), + installation, + }) + }) +} + +enum State { + Idle, + Waiting, + Error(SharedString), +} + +pub struct ConfigureContextServerModal { + context_server_store: Entity, + workspace: WeakEntity, + source: ConfigurationSource, + state: State, +} + +impl ConfigureContextServerModal { + pub fn register( + workspace: &mut Workspace, + language_registry: Arc, + _window: Option<&mut Window>, + _cx: &mut Context, + ) { + workspace.register_action({ + let language_registry = language_registry.clone(); + move |_workspace, _: &AddContextServer, window, cx| { + let workspace_handle = cx.weak_entity(); + let language_registry = language_registry.clone(); + window + .spawn(cx, async move |cx| { + Self::show_modal( + ConfigurationTarget::New, + language_registry, + workspace_handle, + cx, + ) + .await + }) + .detach_and_log_err(cx); + } + }); + } + + pub fn show_modal_for_existing_server( + server_id: ContextServerId, + language_registry: Arc, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Task> { + let Some(settings) = ProjectSettings::get_global(cx) + .context_servers + .get(&server_id.0) + .cloned() + .or_else(|| { + ContextServerDescriptorRegistry::default_global(cx) + .read(cx) + .context_server_descriptor(&server_id.0) + .map(|_| ContextServerSettings::default_extension()) + }) + else { + return Task::ready(Err(anyhow::anyhow!("Context server not found"))); + }; + + window.spawn(cx, async move |cx| { + let target = match settings { + ContextServerSettings::Custom { + enabled: _, + command, + } => Some(ConfigurationTarget::Existing { + id: server_id, + command, + }), + ContextServerSettings::Extension { .. } => { + match workspace + .update(cx, |workspace, cx| { + resolve_context_server_extension( + server_id, + workspace.project().read(cx).worktree_store(), + cx, + ) + }) + .ok() + { + Some(task) => task.await, + None => None, + } + } + }; + + match target { + Some(target) => Self::show_modal(target, language_registry, workspace, cx).await, + None => Err(anyhow::anyhow!("Failed to resolve context server")), + } + }) + } + + fn show_modal( + target: ConfigurationTarget, + language_registry: Arc, + workspace: WeakEntity, + cx: &mut AsyncWindowContext, + ) -> Task> { + cx.spawn(async move |cx| { + let jsonc_language = language_registry.language_for_name("jsonc").await.ok(); + workspace.update_in(cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let context_server_store = workspace.project().read(cx).context_server_store(); + workspace.toggle_modal(window, cx, |window, cx| Self { + context_server_store, + workspace: workspace_handle, + state: State::Idle, + source: ConfigurationSource::from_target( + target, + language_registry, + jsonc_language, + window, + cx, + ), + }) + }) + }) + } + + fn set_error(&mut self, err: impl Into, cx: &mut Context) { + self.state = State::Error(err.into()); + cx.notify(); + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context) { + self.state = State::Idle; + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + let (id, settings) = match self.source.output(cx) { + Ok(val) => val, + Err(error) => { + self.set_error(error.to_string(), cx); + return; + } + }; + + self.state = State::Waiting; + let wait_for_context_server_task = + wait_for_context_server(&self.context_server_store, id.clone(), cx); + cx.spawn({ + let id = id.clone(); + async move |this, cx| { + let result = wait_for_context_server_task.await; + this.update(cx, |this, cx| match result { + Ok(_) => { + this.state = State::Idle; + this.show_configured_context_server_toast(id, cx); + cx.emit(DismissEvent); + } + Err(err) => { + this.set_error(err, cx); + } + }) + } + }) + .detach(); + + // When we write the settings to the file, the context server will be restarted. + workspace.update(cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + update_settings_file::(fs.clone(), cx, |project_settings, _| { + project_settings.context_servers.insert(id.0, settings); + }); + }); + } + + fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn show_configured_context_server_toast(&self, id: ContextServerId, cx: &mut App) { + self.workspace + .update(cx, { + |workspace, cx| { + let status_toast = StatusToast::new( + format!("{} configured successfully.", id.0), + cx, + |this, _cx| { + this.icon(ToastIcon::new(IconName::Hammer).color(Color::Muted)) + .action("Dismiss", |_, _| {}) + }, + ); + + workspace.toggle_status_toast(status_toast, cx); + } + }) + .log_err(); + } +} + +fn parse_input(text: &str) -> Result<(ContextServerId, ContextServerCommand)> { + let value: serde_json::Value = serde_json_lenient::from_str(text)?; + let object = value.as_object().context("Expected object")?; + anyhow::ensure!(object.len() == 1, "Expected exactly one key-value pair"); + let (context_server_name, value) = object.into_iter().next().unwrap(); + let command = value.get("command").context("Expected command")?; + let command: ContextServerCommand = serde_json::from_value(command.clone())?; + Ok((ContextServerId(context_server_name.clone().into()), command)) +} + +impl ModalView for ConfigureContextServerModal {} + +impl Focusable for ConfigureContextServerModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + match &self.source { + ConfigurationSource::New { editor } => editor.focus_handle(cx), + ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx), + ConfigurationSource::Extension { editor, .. } => editor + .as_ref() + .map(|editor| editor.focus_handle(cx)) + .unwrap_or_else(|| cx.focus_handle()), + } + } +} + +impl EventEmitter for ConfigureContextServerModal {} + +impl ConfigureContextServerModal { + fn render_modal_header(&self) -> ModalHeader { + let text: SharedString = match &self.source { + ConfigurationSource::New { .. } => "Add MCP Server".into(), + ConfigurationSource::Existing { .. } => "Configure MCP Server".into(), + ConfigurationSource::Extension { id, .. } => format!("Configure {}", id.0).into(), + }; + ModalHeader::new().headline(text) + } + + fn render_modal_description(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + const MODAL_DESCRIPTION: &'static str = "Visit the MCP server configuration docs to find all necessary arguments and environment variables."; + + if let ConfigurationSource::Extension { + installation_instructions: Some(installation_instructions), + .. + } = &self.source + { + div() + .pb_2() + .text_sm() + .child(MarkdownElement::new( + installation_instructions.clone(), + default_markdown_style(window, cx), + )) + .into_any_element() + } else { + Label::new(MODAL_DESCRIPTION) + .color(Color::Muted) + .into_any_element() + } + } + + fn render_modal_content(&self, cx: &App) -> AnyElement { + let editor = match &self.source { + ConfigurationSource::New { editor } => editor, + ConfigurationSource::Existing { editor } => editor, + ConfigurationSource::Extension { editor, .. } => { + let Some(editor) = editor else { + return div().into_any_element(); + }; + editor + } + }; + + div() + .p_2() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().editor_background) + .child({ + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }; + EditorElement::new( + editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + syntax: cx.theme().syntax().clone(), + ..Default::default() + }, + ) + }) + .into_any_element() + } + + fn render_modal_footer(&self, window: &mut Window, cx: &mut Context) -> ModalFooter { + let focus_handle = self.focus_handle(cx); + let is_connecting = matches!(self.state, State::Waiting); + + ModalFooter::new() + .start_slot::