diff --git a/.zed/settings.json b/.zed/settings.json index 2ecbd5623d2..521cf786abe 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -57,7 +57,6 @@ "remove_trailing_whitespace_on_save": true, "ensure_final_newline_on_save": true, "file_scan_exclusions": [ - "crates/agent/src/edit_agent/evals/fixtures", "crates/agent/src/tools/evals/fixtures", "**/.git", "**/.svn", diff --git a/Cargo.lock b/Cargo.lock index c4b5068f414..0a025b8eb18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,7 +163,6 @@ dependencies = [ "context_server", "ctor", "db", - "derive_more", "editor", "env_logger 0.11.8", "eval_utils", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index ce472fd9e36..13172212064 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -31,7 +31,6 @@ cloud_llm_client.workspace = true collections.workspace = true context_server.workspace = true db.workspace = true -derive_more.workspace = true feature_flags.workspace = true fs.workspace = true futures.workspace = true diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 45da8c92169..1a7aaffb580 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1,5 +1,4 @@ mod db; -mod edit_agent; mod legacy_thread; mod native_agent_server; pub mod outline; diff --git a/crates/agent/src/edit_agent.rs b/crates/agent/src/edit_agent.rs deleted file mode 100644 index afaa124de06..00000000000 --- a/crates/agent/src/edit_agent.rs +++ /dev/null @@ -1,1527 +0,0 @@ -mod create_file_parser; -mod edit_parser; -#[cfg(all(test, feature = "unit-eval"))] -mod evals; -pub mod reindent; -pub mod streaming_fuzzy_matcher; - -use crate::{Template, Templates}; -use action_log::ActionLog; -use anyhow::Result; -use create_file_parser::{CreateFileParser, CreateFileParserEvent}; -pub use edit_parser::EditFormat; -use edit_parser::{EditParser, EditParserEvent, EditParserMetrics}; -use futures::{ - Stream, StreamExt, - channel::mpsc::{self, UnboundedReceiver}, - pin_mut, - stream::BoxStream, -}; -use gpui::{AppContext, AsyncApp, Entity, Task}; -use language::{Anchor, Buffer, BufferSnapshot, LineIndent, Point, TextBufferSnapshot}; -use language_model::{ - CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelRequest, - LanguageModelRequestMessage, LanguageModelToolChoice, MessageContent, Role, -}; -use project::{AgentLocation, Project}; -use reindent::{IndentDelta, Reindenter}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{mem, ops::Range, pin::Pin, sync::Arc, task::Poll}; -use streaming_diff::{CharOperation, StreamingDiff}; -use streaming_fuzzy_matcher::StreamingFuzzyMatcher; - -#[derive(Serialize)] -struct CreateFilePromptTemplate { - path: Option, - edit_description: String, -} - -impl Template for CreateFilePromptTemplate { - const TEMPLATE_NAME: &'static str = "create_file_prompt.hbs"; -} - -#[derive(Serialize)] -struct EditFileXmlPromptTemplate { - path: Option, - edit_description: String, -} - -impl Template for EditFileXmlPromptTemplate { - const TEMPLATE_NAME: &'static str = "edit_file_prompt_xml.hbs"; -} - -#[derive(Serialize)] -struct EditFileDiffFencedPromptTemplate { - path: Option, - edit_description: String, -} - -impl Template for EditFileDiffFencedPromptTemplate { - const TEMPLATE_NAME: &'static str = "edit_file_prompt_diff_fenced.hbs"; -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum EditAgentOutputEvent { - ResolvingEditRange(Range), - UnresolvedEditRange, - AmbiguousEditRange(Vec>), - Edited(Range), -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct EditAgentOutput { - pub raw_edits: String, - pub parser_metrics: EditParserMetrics, -} - -#[derive(Clone)] -pub struct EditAgent { - model: Arc, - action_log: Entity, - project: Entity, - templates: Arc, - edit_format: EditFormat, - thinking_allowed: bool, - update_agent_location: bool, -} - -impl EditAgent { - pub fn new( - model: Arc, - project: Entity, - action_log: Entity, - templates: Arc, - edit_format: EditFormat, - allow_thinking: bool, - update_agent_location: bool, - ) -> Self { - EditAgent { - model, - project, - action_log, - templates, - edit_format, - thinking_allowed: allow_thinking, - update_agent_location, - } - } - - pub fn overwrite( - &self, - buffer: Entity, - edit_description: String, - conversation: &LanguageModelRequest, - cx: &mut AsyncApp, - ) -> ( - Task>, - mpsc::UnboundedReceiver, - ) { - let this = self.clone(); - let (events_tx, events_rx) = mpsc::unbounded(); - let conversation = conversation.clone(); - let output = cx.spawn(async move |cx| { - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let path = cx.update(|cx| snapshot.resolve_file_path(true, cx)); - let prompt = CreateFilePromptTemplate { - path, - edit_description, - } - .render(&this.templates)?; - let new_chunks = this - .request(conversation, CompletionIntent::CreateFile, prompt, cx) - .await?; - - let (output, mut inner_events) = this.overwrite_with_chunks(buffer, new_chunks, cx); - while let Some(event) = inner_events.next().await { - events_tx.unbounded_send(event).ok(); - } - output.await - }); - (output, events_rx) - } - - fn overwrite_with_chunks( - &self, - buffer: Entity, - edit_chunks: impl 'static + Send + Stream>, - cx: &mut AsyncApp, - ) -> ( - Task>, - mpsc::UnboundedReceiver, - ) { - let (output_events_tx, output_events_rx) = mpsc::unbounded(); - let (parse_task, parse_rx) = Self::parse_create_file_chunks(edit_chunks, cx); - let this = self.clone(); - let task = cx.spawn(async move |cx| { - this.action_log - .update(cx, |log, cx| log.buffer_created(buffer.clone(), cx)); - this.overwrite_with_chunks_internal(buffer, parse_rx, output_events_tx, cx) - .await?; - parse_task.await - }); - (task, output_events_rx) - } - - async fn overwrite_with_chunks_internal( - &self, - buffer: Entity, - mut parse_rx: UnboundedReceiver>, - output_events_tx: mpsc::UnboundedSender, - cx: &mut AsyncApp, - ) -> Result<()> { - let buffer_id = cx.update(|cx| { - let buffer_id = buffer.read(cx).remote_id(); - if self.update_agent_location { - self.project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: language::Anchor::min_for_buffer(buffer_id), - }), - cx, - ) - }); - } - buffer_id - }); - - let send_edit_event = || { - output_events_tx - .unbounded_send(EditAgentOutputEvent::Edited( - Anchor::min_max_range_for_buffer(buffer_id), - )) - .ok() - }; - let set_agent_location = |cx: &mut _| { - if self.update_agent_location { - self.project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: language::Anchor::max_for_buffer(buffer_id), - }), - cx, - ) - }) - } - }; - let mut first_chunk = true; - while let Some(event) = parse_rx.next().await { - match event? { - CreateFileParserEvent::NewTextChunk { chunk } => { - cx.update(|cx| { - buffer.update(cx, |buffer, cx| { - if mem::take(&mut first_chunk) { - buffer.set_text(chunk, cx) - } else { - buffer.append(chunk, cx) - } - }); - self.action_log - .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - set_agent_location(cx); - }); - send_edit_event(); - } - } - } - - if first_chunk { - cx.update(|cx| { - buffer.update(cx, |buffer, cx| buffer.set_text("", cx)); - self.action_log - .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - set_agent_location(cx); - }); - send_edit_event(); - } - - Ok(()) - } - - pub fn edit( - &self, - buffer: Entity, - edit_description: String, - conversation: &LanguageModelRequest, - cx: &mut AsyncApp, - ) -> ( - Task>, - mpsc::UnboundedReceiver, - ) { - let this = self.clone(); - let (events_tx, events_rx) = mpsc::unbounded(); - let conversation = conversation.clone(); - let edit_format = self.edit_format; - let output = cx.spawn(async move |cx| { - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let path = cx.update(|cx| snapshot.resolve_file_path(true, cx)); - let prompt = match edit_format { - EditFormat::XmlTags => EditFileXmlPromptTemplate { - path, - edit_description, - } - .render(&this.templates)?, - EditFormat::DiffFenced => EditFileDiffFencedPromptTemplate { - path, - edit_description, - } - .render(&this.templates)?, - }; - - let edit_chunks = this - .request(conversation, CompletionIntent::EditFile, prompt, cx) - .await?; - this.apply_edit_chunks(buffer, edit_chunks, events_tx, cx) - .await - }); - (output, events_rx) - } - - async fn apply_edit_chunks( - &self, - buffer: Entity, - edit_chunks: impl 'static + Send + Stream>, - output_events: mpsc::UnboundedSender, - cx: &mut AsyncApp, - ) -> Result { - self.action_log - .update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); - - let (output, edit_events) = Self::parse_edit_chunks(edit_chunks, self.edit_format, cx); - let mut edit_events = edit_events.peekable(); - while let Some(edit_event) = Pin::new(&mut edit_events).peek().await { - // Skip events until we're at the start of a new edit. - let Ok(EditParserEvent::OldTextChunk { .. }) = edit_event else { - edit_events.next().await.unwrap()?; - continue; - }; - - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - // Resolve the old text in the background, updating the agent - // location as we keep refining which range it corresponds to. - let (resolve_old_text, mut old_range) = - Self::resolve_old_text(snapshot.text.clone(), edit_events, cx); - while let Ok(old_range) = old_range.recv().await { - if let Some(old_range) = old_range { - let old_range = snapshot.anchor_before(old_range.start) - ..snapshot.anchor_before(old_range.end); - if self.update_agent_location { - self.project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: old_range.end, - }), - cx, - ); - }); - } - output_events - .unbounded_send(EditAgentOutputEvent::ResolvingEditRange(old_range)) - .ok(); - } - } - - let (edit_events_, mut resolved_old_text) = resolve_old_text.await?; - edit_events = edit_events_; - - // If we can't resolve the old text, restart the loop waiting for a - // new edit (or for the stream to end). - let resolved_old_text = match resolved_old_text.len() { - 1 => resolved_old_text.pop().unwrap(), - 0 => { - output_events - .unbounded_send(EditAgentOutputEvent::UnresolvedEditRange) - .ok(); - continue; - } - _ => { - let ranges = resolved_old_text - .into_iter() - .map(|text| { - let start_line = - (snapshot.offset_to_point(text.range.start).row + 1) as usize; - let end_line = - (snapshot.offset_to_point(text.range.end).row + 1) as usize; - start_line..end_line - }) - .collect(); - output_events - .unbounded_send(EditAgentOutputEvent::AmbiguousEditRange(ranges)) - .ok(); - continue; - } - }; - - // Compute edits in the background and apply them as they become - // available. - let (compute_edits, edits) = - Self::compute_edits(snapshot, resolved_old_text, edit_events, cx); - let mut edits = edits.ready_chunks(32); - while let Some(edits) = edits.next().await { - if edits.is_empty() { - continue; - } - - // Edit the buffer and report edits to the action log as part of the - // same effect cycle, otherwise the edit will be reported as if the - // user made it. - let (min_edit_start, max_edit_end) = cx.update(|cx| { - let (min_edit_start, max_edit_end) = buffer.update(cx, |buffer, cx| { - buffer.edit(edits.iter().cloned(), None, cx); - let max_edit_end = buffer - .summaries_for_anchors::( - edits.iter().map(|(range, _)| range.end), - ) - .max() - .unwrap(); - let min_edit_start = buffer - .summaries_for_anchors::( - edits.iter().map(|(range, _)| range.start), - ) - .min() - .unwrap(); - ( - buffer.anchor_after(min_edit_start), - buffer.anchor_before(max_edit_end), - ) - }); - self.action_log - .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - if self.update_agent_location { - self.project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: max_edit_end, - }), - cx, - ); - }); - } - (min_edit_start, max_edit_end) - }); - output_events - .unbounded_send(EditAgentOutputEvent::Edited(min_edit_start..max_edit_end)) - .ok(); - } - - edit_events = compute_edits.await?; - } - - output.await - } - - fn parse_edit_chunks( - chunks: impl 'static + Send + Stream>, - edit_format: EditFormat, - cx: &mut AsyncApp, - ) -> ( - Task>, - UnboundedReceiver>, - ) { - let (tx, rx) = mpsc::unbounded(); - let output = cx.background_spawn(async move { - pin_mut!(chunks); - - let mut parser = EditParser::new(edit_format); - let mut raw_edits = String::new(); - while let Some(chunk) = chunks.next().await { - match chunk { - Ok(chunk) => { - raw_edits.push_str(&chunk); - for event in parser.push(&chunk) { - tx.unbounded_send(Ok(event))?; - } - } - Err(error) => { - tx.unbounded_send(Err(error.into()))?; - } - } - } - Ok(EditAgentOutput { - raw_edits, - parser_metrics: parser.finish(), - }) - }); - (output, rx) - } - - fn parse_create_file_chunks( - chunks: impl 'static + Send + Stream>, - cx: &mut AsyncApp, - ) -> ( - Task>, - UnboundedReceiver>, - ) { - let (tx, rx) = mpsc::unbounded(); - let output = cx.background_spawn(async move { - pin_mut!(chunks); - - let mut parser = CreateFileParser::new(); - let mut raw_edits = String::new(); - while let Some(chunk) = chunks.next().await { - match chunk { - Ok(chunk) => { - raw_edits.push_str(&chunk); - for event in parser.push(Some(&chunk)) { - tx.unbounded_send(Ok(event))?; - } - } - Err(error) => { - tx.unbounded_send(Err(error.into()))?; - } - } - } - // Send final events with None to indicate completion - for event in parser.push(None) { - tx.unbounded_send(Ok(event))?; - } - Ok(EditAgentOutput { - raw_edits, - parser_metrics: EditParserMetrics::default(), - }) - }); - (output, rx) - } - - fn resolve_old_text( - snapshot: TextBufferSnapshot, - mut edit_events: T, - cx: &mut AsyncApp, - ) -> ( - Task)>>, - watch::Receiver>>, - ) - where - T: 'static + Send + Unpin + Stream>, - { - let (mut old_range_tx, old_range_rx) = watch::channel(None); - let task = cx.background_spawn(async move { - let mut matcher = StreamingFuzzyMatcher::new(snapshot); - while let Some(edit_event) = edit_events.next().await { - let EditParserEvent::OldTextChunk { - chunk, - done, - line_hint, - } = edit_event? - else { - break; - }; - - old_range_tx.send(matcher.push(&chunk, line_hint))?; - if done { - break; - } - } - - let matches = matcher.finish(); - let best_match = matcher.select_best_match(); - - old_range_tx.send(best_match.clone())?; - - let indent = LineIndent::from_iter( - matcher - .query_lines() - .first() - .unwrap_or(&String::new()) - .chars(), - ); - - let resolved_old_texts = if let Some(best_match) = best_match { - vec![ResolvedOldText { - range: best_match, - indent, - }] - } else { - matches - .into_iter() - .map(|range| ResolvedOldText { range, indent }) - .collect::>() - }; - - Ok((edit_events, resolved_old_texts)) - }); - - (task, old_range_rx) - } - - fn compute_edits( - snapshot: BufferSnapshot, - resolved_old_text: ResolvedOldText, - mut edit_events: T, - cx: &mut AsyncApp, - ) -> ( - Task>, - UnboundedReceiver<(Range, Arc)>, - ) - where - T: 'static + Send + Unpin + Stream>, - { - let (edits_tx, edits_rx) = mpsc::unbounded(); - let compute_edits = cx.background_spawn(async move { - let buffer_start_indent = snapshot - .line_indent_for_row(snapshot.offset_to_point(resolved_old_text.range.start).row); - let indent_delta = - reindent::compute_indent_delta(buffer_start_indent, resolved_old_text.indent); - - let old_text = snapshot - .text_for_range(resolved_old_text.range.clone()) - .collect::(); - let mut diff = StreamingDiff::new(old_text); - let mut edit_start = resolved_old_text.range.start; - let mut new_text_chunks = - Self::reindent_new_text_chunks(indent_delta, &mut edit_events); - let mut done = false; - while !done { - let char_operations = if let Some(new_text_chunk) = new_text_chunks.next().await { - diff.push_new(&new_text_chunk?) - } else { - done = true; - mem::take(&mut diff).finish() - }; - - for op in char_operations { - match op { - CharOperation::Insert { text } => { - let edit_start = snapshot.anchor_after(edit_start); - edits_tx.unbounded_send((edit_start..edit_start, Arc::from(text)))?; - } - CharOperation::Delete { bytes } => { - let edit_end = edit_start + bytes; - let edit_range = - snapshot.anchor_after(edit_start)..snapshot.anchor_before(edit_end); - edit_start = edit_end; - edits_tx.unbounded_send((edit_range, Arc::from("")))?; - } - CharOperation::Keep { bytes } => edit_start += bytes, - } - } - } - - drop(new_text_chunks); - anyhow::Ok(edit_events) - }); - - (compute_edits, edits_rx) - } - - fn reindent_new_text_chunks( - delta: IndentDelta, - mut stream: impl Unpin + Stream>, - ) -> impl Stream> { - let mut reindenter = Reindenter::new(delta); - let mut done = false; - futures::stream::poll_fn(move |cx| { - while !done { - let (chunk, is_last_chunk) = match stream.poll_next_unpin(cx) { - Poll::Ready(Some(Ok(EditParserEvent::NewTextChunk { chunk, done }))) => { - (chunk, done) - } - Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err))), - Poll::Pending => return Poll::Pending, - _ => return Poll::Ready(None), - }; - - let mut indented_new_text = reindenter.push(&chunk); - // This was the last chunk, push all the buffered content as-is. - if is_last_chunk { - indented_new_text.push_str(&reindenter.finish()); - done = true; - } - - if !indented_new_text.is_empty() { - return Poll::Ready(Some(Ok(indented_new_text))); - } - } - - Poll::Ready(None) - }) - } - - async fn request( - &self, - mut conversation: LanguageModelRequest, - intent: CompletionIntent, - prompt: String, - cx: &mut AsyncApp, - ) -> Result>> { - let mut messages_iter = conversation.messages.iter_mut(); - if let Some(last_message) = messages_iter.next_back() - && last_message.role == Role::Assistant - { - let old_content_len = last_message.content.len(); - last_message - .content - .retain(|content| !matches!(content, MessageContent::ToolUse(_))); - let new_content_len = last_message.content.len(); - - // We just removed pending tool uses from the content of the - // last message, so it doesn't make sense to cache it anymore - // (e.g., the message will look very different on the next - // request). Thus, we move the flag to the message prior to it, - // as it will still be a valid prefix of the conversation. - if old_content_len != new_content_len - && last_message.cache - && let Some(prev_message) = messages_iter.next_back() - { - last_message.cache = false; - prev_message.cache = true; - } - - if last_message.content.is_empty() { - conversation.messages.pop(); - } - } - - conversation.messages.push(LanguageModelRequestMessage { - role: Role::User, - content: vec![MessageContent::Text(prompt)], - cache: false, - reasoning_details: None, - }); - - // Include tools in the request so that we can take advantage of - // caching when ToolChoice::None is supported. - let mut tool_choice = None; - let mut tools = Vec::new(); - if !conversation.tools.is_empty() - && self - .model - .supports_tool_choice(LanguageModelToolChoice::None) - { - tool_choice = Some(LanguageModelToolChoice::None); - tools = conversation.tools.clone(); - } - - let request = LanguageModelRequest { - thread_id: conversation.thread_id, - prompt_id: conversation.prompt_id, - intent: Some(intent), - messages: conversation.messages, - tool_choice, - tools, - stop: Vec::new(), - temperature: None, - thinking_allowed: self.thinking_allowed, - thinking_effort: None, - speed: None, - }; - - Ok(self.model.stream_completion_text(request, cx).await?.stream) - } -} - -struct ResolvedOldText { - range: Range, - indent: LineIndent, -} - -#[cfg(test)] -mod tests { - use super::*; - use fs::FakeFs; - use futures::stream; - use gpui::{AppContext, TestAppContext}; - use indoc::indoc; - use language_model::fake_provider::FakeLanguageModel; - use pretty_assertions::assert_matches; - use project::{AgentLocation, Project}; - use rand::prelude::*; - use rand::rngs::StdRng; - use std::cmp; - - #[gpui::test(iterations = 100)] - async fn test_empty_old_text(cx: &mut TestAppContext, mut rng: StdRng) { - let agent = init_test(cx).await; - let buffer = cx.new(|cx| { - Buffer::local( - indoc! {" - abc - def - ghi - "}, - cx, - ) - }); - let (apply, _events) = agent.edit( - buffer.clone(), - String::new(), - &LanguageModelRequest::default(), - &mut cx.to_async(), - ); - cx.run_until_parked(); - - simulate_llm_output( - &agent, - indoc! {" - - jkl - def - DEF - "}, - &mut rng, - cx, - ); - apply.await.unwrap(); - - pretty_assertions::assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - indoc! {" - abc - DEF - ghi - "} - ); - } - - #[gpui::test(iterations = 100)] - async fn test_indentation(cx: &mut TestAppContext, mut rng: StdRng) { - let agent = init_test(cx).await; - let buffer = cx.new(|cx| { - Buffer::local( - indoc! {" - lorem - ipsum - dolor - sit - "}, - cx, - ) - }); - let (apply, _events) = agent.edit( - buffer.clone(), - String::new(), - &LanguageModelRequest::default(), - &mut cx.to_async(), - ); - cx.run_until_parked(); - - simulate_llm_output( - &agent, - indoc! {" - - ipsum - dolor - sit - - - ipsum - dolor - sit - amet - - "}, - &mut rng, - cx, - ); - apply.await.unwrap(); - - pretty_assertions::assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - indoc! {" - lorem - ipsum - dolor - sit - amet - "} - ); - } - - #[gpui::test(iterations = 100)] - async fn test_dependent_edits(cx: &mut TestAppContext, mut rng: StdRng) { - let agent = init_test(cx).await; - let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx)); - let (apply, _events) = agent.edit( - buffer.clone(), - String::new(), - &LanguageModelRequest::default(), - &mut cx.to_async(), - ); - cx.run_until_parked(); - - simulate_llm_output( - &agent, - indoc! {" - - def - - - DEF - - - - DEF - - - DeF - - "}, - &mut rng, - cx, - ); - apply.await.unwrap(); - - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abc\nDeF\nghi" - ); - } - - #[gpui::test(iterations = 100)] - async fn test_old_text_hallucination(cx: &mut TestAppContext, mut rng: StdRng) { - let agent = init_test(cx).await; - let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx)); - let (apply, _events) = agent.edit( - buffer.clone(), - String::new(), - &LanguageModelRequest::default(), - &mut cx.to_async(), - ); - cx.run_until_parked(); - - simulate_llm_output( - &agent, - indoc! {" - - jkl - - - mno - - - - abc - - - ABC - - "}, - &mut rng, - cx, - ); - apply.await.unwrap(); - - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "ABC\ndef\nghi" - ); - } - - #[gpui::test] - async fn test_edit_events(cx: &mut TestAppContext) { - let agent = init_test(cx).await; - let model = agent.model.as_fake(); - let project = agent - .action_log - .read_with(cx, |log, _| log.project().clone()); - let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl", cx)); - - let mut async_cx = cx.to_async(); - let (apply, mut events) = agent.edit( - buffer.clone(), - String::new(), - &LanguageModelRequest::default(), - &mut async_cx, - ); - cx.run_until_parked(); - - model.send_last_completion_stream_text_chunk("a"); - cx.run_until_parked(); - assert_eq!(drain_events(&mut events), vec![]); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abc\ndef\nghi\njkl" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - None - ); - - model.send_last_completion_stream_text_chunk("bc"); - cx.run_until_parked(); - assert_eq!( - drain_events(&mut events), - vec![EditAgentOutputEvent::ResolvingEditRange(buffer.read_with( - cx, - |buffer, _| buffer.anchor_before(Point::new(0, 0)) - ..buffer.anchor_before(Point::new(0, 3)) - ))] - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abc\ndef\nghi\njkl" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 3))) - }) - ); - - model.send_last_completion_stream_text_chunk("abX"); - cx.run_until_parked(); - assert_matches!( - drain_events(&mut events).as_slice(), - [EditAgentOutputEvent::Edited(_)] - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abXc\ndef\nghi\njkl" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 3))) - }) - ); - - model.send_last_completion_stream_text_chunk("cY"); - cx.run_until_parked(); - assert_matches!( - drain_events(&mut events).as_slice(), - [EditAgentOutputEvent::Edited { .. }] - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abXcY\ndef\nghi\njkl" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 5))) - }) - ); - - model.send_last_completion_stream_text_chunk(""); - model.send_last_completion_stream_text_chunk("hall"); - cx.run_until_parked(); - assert_eq!(drain_events(&mut events), vec![]); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abXcY\ndef\nghi\njkl" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 5))) - }) - ); - - model.send_last_completion_stream_text_chunk("ucinated old"); - model.send_last_completion_stream_text_chunk(""); - cx.run_until_parked(); - assert_eq!( - drain_events(&mut events), - vec![EditAgentOutputEvent::UnresolvedEditRange] - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abXcY\ndef\nghi\njkl" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 5))) - }) - ); - - model.send_last_completion_stream_text_chunk("hallucinated new"); - cx.run_until_parked(); - assert_eq!(drain_events(&mut events), vec![]); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abXcY\ndef\nghi\njkl" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 5))) - }) - ); - - model.send_last_completion_stream_text_chunk("\nghi\nj"); - cx.run_until_parked(); - assert_eq!( - drain_events(&mut events), - vec![EditAgentOutputEvent::ResolvingEditRange(buffer.read_with( - cx, - |buffer, _| buffer.anchor_before(Point::new(2, 0)) - ..buffer.anchor_before(Point::new(2, 3)) - ))] - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abXcY\ndef\nghi\njkl" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(2, 3))) - }) - ); - - model.send_last_completion_stream_text_chunk("kl"); - model.send_last_completion_stream_text_chunk(""); - cx.run_until_parked(); - assert_eq!( - drain_events(&mut events), - vec![EditAgentOutputEvent::ResolvingEditRange(buffer.read_with( - cx, - |buffer, _| buffer.anchor_before(Point::new(2, 0)) - ..buffer.anchor_before(Point::new(3, 3)) - ))] - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abXcY\ndef\nghi\njkl" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(3, 3))) - }) - ); - - model.send_last_completion_stream_text_chunk("GHI"); - cx.run_until_parked(); - assert_matches!( - drain_events(&mut events).as_slice(), - [EditAgentOutputEvent::Edited { .. }] - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abXcY\ndef\nGHI" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(2, 3))) - }) - ); - - model.end_last_completion_stream(); - apply.await.unwrap(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abXcY\ndef\nGHI" - ); - assert_eq!(drain_events(&mut events), vec![]); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(2, 3))) - }) - ); - } - - #[gpui::test] - async fn test_overwrite_events(cx: &mut TestAppContext) { - let agent = init_test(cx).await; - let project = agent - .action_log - .read_with(cx, |log, _| log.project().clone()); - let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx)); - let (chunks_tx, chunks_rx) = mpsc::unbounded(); - let (apply, mut events) = agent.overwrite_with_chunks( - buffer.clone(), - chunks_rx.map(|chunk: &str| Ok(chunk.to_string())), - &mut cx.to_async(), - ); - - cx.run_until_parked(); - assert_eq!(drain_events(&mut events).as_slice(), []); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "abc\ndef\nghi" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: language::Anchor::min_for_buffer( - cx.update(|cx| buffer.read(cx).remote_id()) - ), - }) - ); - - chunks_tx.unbounded_send("```\njkl\n").unwrap(); - cx.run_until_parked(); - assert_matches!( - drain_events(&mut events).as_slice(), - [EditAgentOutputEvent::Edited { .. }] - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "jkl" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: language::Anchor::max_for_buffer( - cx.update(|cx| buffer.read(cx).remote_id()) - ), - }) - ); - - chunks_tx.unbounded_send("mno\n").unwrap(); - cx.run_until_parked(); - assert_matches!( - drain_events(&mut events).as_slice(), - [EditAgentOutputEvent::Edited { .. }] - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "jkl\nmno" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: language::Anchor::max_for_buffer( - cx.update(|cx| buffer.read(cx).remote_id()) - ), - }) - ); - - chunks_tx.unbounded_send("pqr\n```").unwrap(); - cx.run_until_parked(); - assert_matches!( - drain_events(&mut events).as_slice(), - [EditAgentOutputEvent::Edited(_)], - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "jkl\nmno\npqr" - ); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: language::Anchor::max_for_buffer( - cx.update(|cx| buffer.read(cx).remote_id()) - ), - }) - ); - - drop(chunks_tx); - apply.await.unwrap(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "jkl\nmno\npqr" - ); - assert_eq!(drain_events(&mut events), vec![]); - assert_eq!( - project.read_with(cx, |project, _| project.agent_location()), - Some(AgentLocation { - buffer: buffer.downgrade(), - position: language::Anchor::max_for_buffer( - cx.update(|cx| buffer.read(cx).remote_id()) - ), - }) - ); - } - - #[gpui::test] - async fn test_overwrite_no_content(cx: &mut TestAppContext) { - let agent = init_test(cx).await; - let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx)); - let (chunks_tx, chunks_rx) = mpsc::unbounded::<&str>(); - let (apply, mut events) = agent.overwrite_with_chunks( - buffer.clone(), - chunks_rx.map(|chunk| Ok(chunk.to_string())), - &mut cx.to_async(), - ); - - drop(chunks_tx); - cx.run_until_parked(); - - let result = apply.await; - assert!(result.is_ok(),); - assert_matches!( - drain_events(&mut events).as_slice(), - [EditAgentOutputEvent::Edited { .. }] - ); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "" - ); - } - - #[gpui::test(iterations = 100)] - async fn test_indent_new_text_chunks(mut rng: StdRng) { - let chunks = to_random_chunks(&mut rng, " abc\n def\n ghi"); - let new_text_chunks = stream::iter(chunks.iter().enumerate().map(|(index, chunk)| { - Ok(EditParserEvent::NewTextChunk { - chunk: chunk.clone(), - done: index == chunks.len() - 1, - }) - })); - let indented_chunks = - EditAgent::reindent_new_text_chunks(IndentDelta::Spaces(2), new_text_chunks) - .collect::>() - .await; - let new_text = indented_chunks - .into_iter() - .collect::>() - .unwrap(); - assert_eq!(new_text, " abc\n def\n ghi"); - } - - #[gpui::test(iterations = 100)] - async fn test_outdent_new_text_chunks(mut rng: StdRng) { - let chunks = to_random_chunks(&mut rng, "\t\t\t\tabc\n\t\tdef\n\t\t\t\t\t\tghi"); - let new_text_chunks = stream::iter(chunks.iter().enumerate().map(|(index, chunk)| { - Ok(EditParserEvent::NewTextChunk { - chunk: chunk.clone(), - done: index == chunks.len() - 1, - }) - })); - let indented_chunks = - EditAgent::reindent_new_text_chunks(IndentDelta::Tabs(-2), new_text_chunks) - .collect::>() - .await; - let new_text = indented_chunks - .into_iter() - .collect::>() - .unwrap(); - assert_eq!(new_text, "\t\tabc\ndef\n\t\t\t\tghi"); - } - - #[gpui::test(iterations = 100)] - async fn test_random_indents(mut rng: StdRng) { - let len = rng.random_range(1..=100); - let new_text = util::RandomCharIter::new(&mut rng) - .with_simple_text() - .take(len) - .collect::(); - let new_text = new_text - .split('\n') - .map(|line| format!("{}{}", " ".repeat(rng.random_range(0..=8)), line)) - .collect::>() - .join("\n"); - let delta = IndentDelta::Spaces(rng.random_range(-4i8..=4i8) as isize); - - let chunks = to_random_chunks(&mut rng, &new_text); - let new_text_chunks = stream::iter(chunks.iter().enumerate().map(|(index, chunk)| { - Ok(EditParserEvent::NewTextChunk { - chunk: chunk.clone(), - done: index == chunks.len() - 1, - }) - })); - let reindented_chunks = EditAgent::reindent_new_text_chunks(delta, new_text_chunks) - .collect::>() - .await; - let actual_reindented_text = reindented_chunks - .into_iter() - .collect::>() - .unwrap(); - let expected_reindented_text = new_text - .split('\n') - .map(|line| { - if let Some(ix) = line.find(|c| c != ' ') { - let new_indent = cmp::max(0, ix as isize + delta.len()) as usize; - format!("{}{}", " ".repeat(new_indent), &line[ix..]) - } else { - line.to_string() - } - }) - .collect::>() - .join("\n"); - assert_eq!(actual_reindented_text, expected_reindented_text); - } - - fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec { - let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50)); - let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count); - chunk_indices.sort(); - chunk_indices.push(input.len()); - - let mut chunks = Vec::new(); - let mut last_ix = 0; - for chunk_ix in chunk_indices { - chunks.push(input[last_ix..chunk_ix].to_string()); - last_ix = chunk_ix; - } - chunks - } - - fn simulate_llm_output( - agent: &EditAgent, - output: &str, - rng: &mut StdRng, - cx: &mut TestAppContext, - ) { - let executor = cx.executor(); - let chunks = to_random_chunks(rng, output); - let model = agent.model.clone(); - cx.background_spawn(async move { - for chunk in chunks { - executor.simulate_random_delay().await; - model - .as_fake() - .send_last_completion_stream_text_chunk(chunk); - } - model.as_fake().end_last_completion_stream(); - }) - .detach(); - } - - async fn init_test(cx: &mut TestAppContext) -> EditAgent { - init_test_with_thinking(cx, true).await - } - - async fn init_test_with_thinking(cx: &mut TestAppContext, thinking_allowed: bool) -> EditAgent { - cx.update(settings::init); - - let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; - let model = Arc::new(FakeLanguageModel::default()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); - EditAgent::new( - model, - project, - action_log, - Templates::new(), - EditFormat::XmlTags, - thinking_allowed, - true, - ) - } - - #[gpui::test(iterations = 10)] - async fn test_non_unique_text_error(cx: &mut TestAppContext, mut rng: StdRng) { - let agent = init_test(cx).await; - let original_text = indoc! {" - function foo() { - return 42; - } - - function bar() { - return 42; - } - - function baz() { - return 42; - } - "}; - let buffer = cx.new(|cx| Buffer::local(original_text, cx)); - let (apply, mut events) = agent.edit( - buffer.clone(), - String::new(), - &LanguageModelRequest::default(), - &mut cx.to_async(), - ); - cx.run_until_parked(); - - // When matches text in more than one place - simulate_llm_output( - &agent, - indoc! {" - - return 42; - } - - - return 100; - } - - "}, - &mut rng, - cx, - ); - apply.await.unwrap(); - - // Then the text should remain unchanged - let result_text = buffer.read_with(cx, |buffer, _| buffer.snapshot().text()); - assert_eq!( - result_text, - indoc! {" - function foo() { - return 42; - } - - function bar() { - return 42; - } - - function baz() { - return 42; - } - "}, - "Text should remain unchanged when there are multiple matches" - ); - - // And AmbiguousEditRange even should be emitted - let events = drain_events(&mut events); - let ambiguous_ranges = vec![2..3, 6..7, 10..11]; - assert!( - events.contains(&EditAgentOutputEvent::AmbiguousEditRange(ambiguous_ranges)), - "Should emit AmbiguousEditRange for non-unique text" - ); - } - - #[gpui::test] - async fn test_thinking_allowed_forwarded_to_request(cx: &mut TestAppContext) { - let agent = init_test_with_thinking(cx, false).await; - let buffer = cx.new(|cx| Buffer::local("hello\n", cx)); - let (_apply, _events) = agent.edit( - buffer.clone(), - String::new(), - &LanguageModelRequest::default(), - &mut cx.to_async(), - ); - cx.run_until_parked(); - - let pending = agent.model.as_fake().pending_completions(); - assert_eq!(pending.len(), 1); - assert!( - !pending[0].thinking_allowed, - "Expected thinking_allowed to be false when EditAgent is constructed with allow_thinking=false" - ); - agent.model.as_fake().end_last_completion_stream(); - - let agent = init_test_with_thinking(cx, true).await; - let buffer = cx.new(|cx| Buffer::local("hello\n", cx)); - let (_apply, _events) = agent.edit( - buffer, - String::new(), - &LanguageModelRequest::default(), - &mut cx.to_async(), - ); - cx.run_until_parked(); - - let pending = agent.model.as_fake().pending_completions(); - assert_eq!(pending.len(), 1); - assert!( - pending[0].thinking_allowed, - "Expected thinking_allowed to be true when EditAgent is constructed with allow_thinking=true" - ); - agent.model.as_fake().end_last_completion_stream(); - } - - fn drain_events( - stream: &mut UnboundedReceiver, - ) -> Vec { - let mut events = Vec::new(); - while let Ok(event) = stream.try_recv() { - events.push(event); - } - events - } -} diff --git a/crates/agent/src/edit_agent/create_file_parser.rs b/crates/agent/src/edit_agent/create_file_parser.rs deleted file mode 100644 index 2272434d796..00000000000 --- a/crates/agent/src/edit_agent/create_file_parser.rs +++ /dev/null @@ -1,237 +0,0 @@ -use std::sync::OnceLock; - -use regex::Regex; -use smallvec::SmallVec; -use util::debug_panic; - -static START_MARKER: OnceLock = OnceLock::new(); -static END_MARKER: OnceLock = OnceLock::new(); - -#[derive(Debug)] -pub enum CreateFileParserEvent { - NewTextChunk { chunk: String }, -} - -#[derive(Debug)] -pub struct CreateFileParser { - state: ParserState, - buffer: String, -} - -#[derive(Debug, PartialEq)] -enum ParserState { - Pending, - WithinText, - Finishing, - Finished, -} - -impl CreateFileParser { - pub fn new() -> Self { - CreateFileParser { - state: ParserState::Pending, - buffer: String::new(), - } - } - - pub fn push(&mut self, chunk: Option<&str>) -> SmallVec<[CreateFileParserEvent; 1]> { - if chunk.is_none() { - self.state = ParserState::Finishing; - } - - let chunk = chunk.unwrap_or_default(); - - self.buffer.push_str(chunk); - - let mut edit_events = SmallVec::new(); - let start_marker_regex = START_MARKER.get_or_init(|| Regex::new(r"\n?```\S*\n").unwrap()); - let end_marker_regex = END_MARKER.get_or_init(|| Regex::new(r"(^|\n)```\s*$").unwrap()); - loop { - match &mut self.state { - ParserState::Pending => { - if let Some(m) = start_marker_regex.find(&self.buffer) { - self.buffer.drain(..m.end()); - self.state = ParserState::WithinText; - } else { - break; - } - } - ParserState::WithinText => { - let text = self.buffer.trim_end_matches(&['`', '\n', ' ']); - let text_len = text.len(); - - if text_len > 0 { - edit_events.push(CreateFileParserEvent::NewTextChunk { - chunk: self.buffer.drain(..text_len).collect(), - }); - } - break; - } - ParserState::Finishing => { - if let Some(m) = end_marker_regex.find(&self.buffer) { - self.buffer.drain(m.start()..); - } - if !self.buffer.is_empty() { - if !self.buffer.ends_with('\n') { - self.buffer.push('\n'); - } - edit_events.push(CreateFileParserEvent::NewTextChunk { - chunk: self.buffer.drain(..).collect(), - }); - } - self.state = ParserState::Finished; - break; - } - ParserState::Finished => debug_panic!("Can't call parser after finishing"), - } - } - edit_events - } -} - -#[cfg(test)] -mod tests { - use super::*; - use indoc::indoc; - use rand::prelude::*; - use std::cmp; - - #[gpui::test(iterations = 100)] - fn test_happy_path(mut rng: StdRng) { - let mut parser = CreateFileParser::new(); - assert_eq!( - parse_random_chunks("```\nHello world\n```", &mut parser, &mut rng), - "Hello world".to_string() - ); - } - - #[gpui::test(iterations = 100)] - fn test_cut_prefix(mut rng: StdRng) { - let mut parser = CreateFileParser::new(); - assert_eq!( - parse_random_chunks( - indoc! {" - Let me write this file for you: - - ``` - Hello world - ``` - - "}, - &mut parser, - &mut rng - ), - "Hello world".to_string() - ); - } - - #[gpui::test(iterations = 100)] - fn test_language_name_on_fences(mut rng: StdRng) { - let mut parser = CreateFileParser::new(); - assert_eq!( - parse_random_chunks( - indoc! {" - ```rust - Hello world - ``` - - "}, - &mut parser, - &mut rng - ), - "Hello world".to_string() - ); - } - - #[gpui::test(iterations = 100)] - fn test_leave_suffix(mut rng: StdRng) { - let mut parser = CreateFileParser::new(); - assert_eq!( - parse_random_chunks( - indoc! {" - Let me write this file for you: - - ``` - Hello world - ``` - - The end - "}, - &mut parser, - &mut rng - ), - // This output is malformed, so we're doing our best effort - "Hello world\n```\n\nThe end\n".to_string() - ); - } - - #[gpui::test(iterations = 100)] - fn test_inner_fences(mut rng: StdRng) { - let mut parser = CreateFileParser::new(); - assert_eq!( - parse_random_chunks( - indoc! {" - Let me write this file for you: - - ``` - ``` - Hello world - ``` - ``` - "}, - &mut parser, - &mut rng - ), - // This output is malformed, so we're doing our best effort - "```\nHello world\n```\n".to_string() - ); - } - - #[gpui::test(iterations = 10)] - fn test_empty_file(mut rng: StdRng) { - let mut parser = CreateFileParser::new(); - assert_eq!( - parse_random_chunks( - indoc! {" - ``` - ``` - "}, - &mut parser, - &mut rng - ), - "".to_string() - ); - } - - fn parse_random_chunks(input: &str, parser: &mut CreateFileParser, rng: &mut StdRng) -> String { - let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50)); - let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count); - chunk_indices.sort(); - chunk_indices.push(input.len()); - - let chunk_indices = chunk_indices - .into_iter() - .map(Some) - .chain(vec![None]) - .collect::>>(); - - let mut edit = String::default(); - let mut last_ix = 0; - for chunk_ix in chunk_indices { - let mut chunk = None; - if let Some(chunk_ix) = chunk_ix { - chunk = Some(&input[last_ix..chunk_ix]); - last_ix = chunk_ix; - } - - for event in parser.push(chunk) { - match event { - CreateFileParserEvent::NewTextChunk { chunk } => { - edit.push_str(&chunk); - } - } - } - } - edit - } -} diff --git a/crates/agent/src/edit_agent/edit_parser.rs b/crates/agent/src/edit_agent/edit_parser.rs deleted file mode 100644 index c1aa61e18d4..00000000000 --- a/crates/agent/src/edit_agent/edit_parser.rs +++ /dev/null @@ -1,1094 +0,0 @@ -use anyhow::bail; -use derive_more::{Add, AddAssign}; -use language_model::LanguageModel; -use regex::Regex; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use smallvec::SmallVec; -use std::{mem, ops::Range, str::FromStr, sync::Arc}; - -const OLD_TEXT_END_TAG: &str = ""; -const NEW_TEXT_END_TAG: &str = ""; -const EDITS_END_TAG: &str = ""; -const SEARCH_MARKER: &str = "<<<<<<< SEARCH"; -const SEPARATOR_MARKER: &str = "======="; -const REPLACE_MARKER: &str = ">>>>>>> REPLACE"; -const SONNET_PARAMETER_INVOKE_1: &str = "\n"; -const SONNET_PARAMETER_INVOKE_2: &str = ""; -const SONNET_PARAMETER_INVOKE_3: &str = ""; -const END_TAGS: [&str; 6] = [ - OLD_TEXT_END_TAG, - NEW_TEXT_END_TAG, - EDITS_END_TAG, - SONNET_PARAMETER_INVOKE_1, // Remove these after switching to streaming tool call - SONNET_PARAMETER_INVOKE_2, - SONNET_PARAMETER_INVOKE_3, -]; - -#[derive(Debug)] -pub enum EditParserEvent { - OldTextChunk { - chunk: String, - done: bool, - line_hint: Option, - }, - NewTextChunk { - chunk: String, - done: bool, - }, -} - -#[derive( - Clone, Debug, Default, PartialEq, Eq, Add, AddAssign, Serialize, Deserialize, JsonSchema, -)] -pub struct EditParserMetrics { - pub tags: usize, - pub mismatched_tags: usize, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EditFormat { - /// XML-like tags: - /// ... - /// ... - XmlTags, - /// Diff-fenced format, in which: - /// - Text before the SEARCH marker is ignored - /// - Fences are optional - /// - Line hint is optional. - /// - /// Example: - /// - /// ```diff - /// <<<<<<< SEARCH line=42 - /// ... - /// ======= - /// ... - /// >>>>>>> REPLACE - /// ``` - DiffFenced, -} - -impl FromStr for EditFormat { - type Err = anyhow::Error; - - fn from_str(s: &str) -> anyhow::Result { - match s.to_lowercase().as_str() { - "xml_tags" | "xml" => Ok(EditFormat::XmlTags), - "diff_fenced" | "diff-fenced" | "diff" => Ok(EditFormat::DiffFenced), - _ => bail!("Unknown EditFormat: {}", s), - } - } -} - -impl EditFormat { - /// Return an optimal edit format for the language model - pub fn from_model(model: Arc) -> anyhow::Result { - if model.provider_id().0 == "google" || model.id().0.to_lowercase().contains("gemini") { - Ok(EditFormat::DiffFenced) - } else { - Ok(EditFormat::XmlTags) - } - } - - /// Return an optimal edit format for the language model, - /// with the ability to override it by setting the - /// `ZED_EDIT_FORMAT` environment variable - #[allow(dead_code)] - pub fn from_env(model: Arc) -> anyhow::Result { - let default = EditFormat::from_model(model)?; - std::env::var("ZED_EDIT_FORMAT").map_or(Ok(default), |s| EditFormat::from_str(&s)) - } -} - -pub trait EditFormatParser: Send + std::fmt::Debug { - fn push(&mut self, chunk: &str) -> SmallVec<[EditParserEvent; 1]>; - fn take_metrics(&mut self) -> EditParserMetrics; -} - -#[derive(Debug)] -pub struct XmlEditParser { - state: XmlParserState, - buffer: String, - metrics: EditParserMetrics, -} - -#[derive(Debug, PartialEq)] -enum XmlParserState { - Pending, - WithinOldText { start: bool, line_hint: Option }, - AfterOldText, - WithinNewText { start: bool }, -} - -#[derive(Debug)] -pub struct DiffFencedEditParser { - state: DiffParserState, - buffer: String, - metrics: EditParserMetrics, -} - -#[derive(Debug, PartialEq)] -enum DiffParserState { - Pending, - WithinSearch { start: bool, line_hint: Option }, - WithinReplace { start: bool }, -} - -/// Main parser that delegates to format-specific parsers -pub struct EditParser { - parser: Box, -} - -impl XmlEditParser { - pub fn new() -> Self { - XmlEditParser { - state: XmlParserState::Pending, - buffer: String::new(), - metrics: EditParserMetrics::default(), - } - } - - fn find_end_tag(&self) -> Option> { - let (tag, start_ix) = END_TAGS - .iter() - .flat_map(|tag| Some((tag, self.buffer.find(tag)?))) - .min_by_key(|(_, ix)| *ix)?; - Some(start_ix..start_ix + tag.len()) - } - - fn ends_with_tag_prefix(&self) -> bool { - let mut end_prefixes = END_TAGS - .iter() - .flat_map(|tag| (1..tag.len()).map(move |i| &tag[..i])) - .chain(["\n"]); - end_prefixes.any(|prefix| self.buffer.ends_with(&prefix)) - } - - fn parse_line_hint(&self, tag: &str) -> Option { - use std::sync::LazyLock; - static LINE_HINT_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r#"line=(?:"?)(\d+)"#).unwrap()); - - LINE_HINT_REGEX - .captures(tag) - .and_then(|caps| caps.get(1)) - .and_then(|m| m.as_str().parse::().ok()) - } -} - -impl EditFormatParser for XmlEditParser { - fn push(&mut self, chunk: &str) -> SmallVec<[EditParserEvent; 1]> { - self.buffer.push_str(chunk); - - let mut edit_events = SmallVec::new(); - loop { - match &mut self.state { - XmlParserState::Pending => { - if let Some(start) = self.buffer.find("') { - let tag_end = start + tag_end + 1; - let tag = &self.buffer[start..tag_end]; - let line_hint = self.parse_line_hint(tag); - self.buffer.drain(..tag_end); - self.state = XmlParserState::WithinOldText { - start: true, - line_hint, - }; - } else { - break; - } - } else { - break; - } - } - XmlParserState::WithinOldText { start, line_hint } => { - if !self.buffer.is_empty() { - if *start && self.buffer.starts_with('\n') { - self.buffer.remove(0); - } - *start = false; - } - - let line_hint = *line_hint; - if let Some(tag_range) = self.find_end_tag() { - let mut chunk = self.buffer[..tag_range.start].to_string(); - if chunk.ends_with('\n') { - chunk.pop(); - } - - self.metrics.tags += 1; - if &self.buffer[tag_range.clone()] != OLD_TEXT_END_TAG { - self.metrics.mismatched_tags += 1; - } - - self.buffer.drain(..tag_range.end); - self.state = XmlParserState::AfterOldText; - edit_events.push(EditParserEvent::OldTextChunk { - chunk, - done: true, - line_hint, - }); - } else { - if !self.ends_with_tag_prefix() { - edit_events.push(EditParserEvent::OldTextChunk { - chunk: mem::take(&mut self.buffer), - done: false, - line_hint, - }); - } - break; - } - } - XmlParserState::AfterOldText => { - if let Some(start) = self.buffer.find("") { - self.buffer.drain(..start + "".len()); - self.state = XmlParserState::WithinNewText { start: true }; - } else { - break; - } - } - XmlParserState::WithinNewText { start } => { - if !self.buffer.is_empty() { - if *start && self.buffer.starts_with('\n') { - self.buffer.remove(0); - } - *start = false; - } - - if let Some(tag_range) = self.find_end_tag() { - let mut chunk = self.buffer[..tag_range.start].to_string(); - if chunk.ends_with('\n') { - chunk.pop(); - } - - self.metrics.tags += 1; - if &self.buffer[tag_range.clone()] != NEW_TEXT_END_TAG { - self.metrics.mismatched_tags += 1; - } - - self.buffer.drain(..tag_range.end); - self.state = XmlParserState::Pending; - edit_events.push(EditParserEvent::NewTextChunk { chunk, done: true }); - } else { - if !self.ends_with_tag_prefix() { - edit_events.push(EditParserEvent::NewTextChunk { - chunk: mem::take(&mut self.buffer), - done: false, - }); - } - break; - } - } - } - } - edit_events - } - - fn take_metrics(&mut self) -> EditParserMetrics { - std::mem::take(&mut self.metrics) - } -} - -impl DiffFencedEditParser { - pub fn new() -> Self { - DiffFencedEditParser { - state: DiffParserState::Pending, - buffer: String::new(), - metrics: EditParserMetrics::default(), - } - } - - fn ends_with_diff_marker_prefix(&self) -> bool { - let diff_markers = [SEPARATOR_MARKER, REPLACE_MARKER]; - let mut diff_prefixes = diff_markers - .iter() - .flat_map(|marker| (1..marker.len()).map(move |i| &marker[..i])) - .chain(["\n"]); - diff_prefixes.any(|prefix| self.buffer.ends_with(&prefix)) - } - - fn parse_line_hint(&self, search_line: &str) -> Option { - use regex::Regex; - use std::sync::LazyLock; - static LINE_HINT_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r#"line=(?:"?)(\d+)"#).unwrap()); - - LINE_HINT_REGEX - .captures(search_line) - .and_then(|caps| caps.get(1)) - .and_then(|m| m.as_str().parse::().ok()) - } -} - -impl EditFormatParser for DiffFencedEditParser { - fn push(&mut self, chunk: &str) -> SmallVec<[EditParserEvent; 1]> { - self.buffer.push_str(chunk); - - let mut edit_events = SmallVec::new(); - loop { - match &mut self.state { - DiffParserState::Pending => { - if let Some(diff) = self.buffer.find(SEARCH_MARKER) { - let search_end = diff + SEARCH_MARKER.len(); - if let Some(newline_pos) = self.buffer[search_end..].find('\n') { - let search_line = &self.buffer[diff..search_end + newline_pos]; - let line_hint = self.parse_line_hint(search_line); - self.buffer.drain(..search_end + newline_pos + 1); - self.state = DiffParserState::WithinSearch { - start: true, - line_hint, - }; - } else { - break; - } - } else { - break; - } - } - DiffParserState::WithinSearch { start, line_hint } => { - if !self.buffer.is_empty() { - if *start && self.buffer.starts_with('\n') { - self.buffer.remove(0); - } - *start = false; - } - - let line_hint = *line_hint; - if let Some(separator_pos) = self.buffer.find(SEPARATOR_MARKER) { - let mut chunk = self.buffer[..separator_pos].to_string(); - if chunk.ends_with('\n') { - chunk.pop(); - } - - let separator_end = separator_pos + SEPARATOR_MARKER.len(); - if let Some(newline_pos) = self.buffer[separator_end..].find('\n') { - self.buffer.drain(..separator_end + newline_pos + 1); - self.state = DiffParserState::WithinReplace { start: true }; - edit_events.push(EditParserEvent::OldTextChunk { - chunk, - done: true, - line_hint, - }); - } else { - break; - } - } else { - if !self.ends_with_diff_marker_prefix() { - edit_events.push(EditParserEvent::OldTextChunk { - chunk: mem::take(&mut self.buffer), - done: false, - line_hint, - }); - } - break; - } - } - DiffParserState::WithinReplace { start } => { - if !self.buffer.is_empty() { - if *start && self.buffer.starts_with('\n') { - self.buffer.remove(0); - } - *start = false; - } - - if let Some(replace_pos) = self.buffer.find(REPLACE_MARKER) { - let mut chunk = self.buffer[..replace_pos].to_string(); - if chunk.ends_with('\n') { - chunk.pop(); - } - - self.buffer.drain(..replace_pos + REPLACE_MARKER.len()); - if let Some(newline_pos) = self.buffer.find('\n') { - self.buffer.drain(..newline_pos + 1); - } else { - self.buffer.clear(); - } - - self.state = DiffParserState::Pending; - edit_events.push(EditParserEvent::NewTextChunk { chunk, done: true }); - } else { - if !self.ends_with_diff_marker_prefix() { - edit_events.push(EditParserEvent::NewTextChunk { - chunk: mem::take(&mut self.buffer), - done: false, - }); - } - break; - } - } - } - } - edit_events - } - - fn take_metrics(&mut self) -> EditParserMetrics { - std::mem::take(&mut self.metrics) - } -} - -impl EditParser { - pub fn new(format: EditFormat) -> Self { - let parser: Box = match format { - EditFormat::XmlTags => Box::new(XmlEditParser::new()), - EditFormat::DiffFenced => Box::new(DiffFencedEditParser::new()), - }; - EditParser { parser } - } - - pub fn push(&mut self, chunk: &str) -> SmallVec<[EditParserEvent; 1]> { - self.parser.push(chunk) - } - - pub fn finish(mut self) -> EditParserMetrics { - self.parser.take_metrics() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use indoc::indoc; - use rand::prelude::*; - use std::cmp; - - #[gpui::test(iterations = 1000)] - fn test_xml_single_edit(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::XmlTags); - assert_eq!( - parse_random_chunks( - "originalupdated", - &mut parser, - &mut rng - ), - vec![Edit { - old_text: "original".to_string(), - new_text: "updated".to_string(), - line_hint: None, - }] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 2, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 1000)] - fn test_xml_multiple_edits(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::XmlTags); - assert_eq!( - parse_random_chunks( - indoc! {" - - first old - first new - second old - second new - - "}, - &mut parser, - &mut rng - ), - vec![ - Edit { - old_text: "first old".to_string(), - new_text: "first new".to_string(), - line_hint: None, - }, - Edit { - old_text: "second old".to_string(), - new_text: "second new".to_string(), - line_hint: None, - }, - ] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 4, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 1000)] - fn test_xml_edits_with_extra_text(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::XmlTags); - assert_eq!( - parse_random_chunks( - indoc! {" - ignore this - contentextra stuffupdated contenttrailing data - more text second item - middle textmodified second itemend - third caseimproved third case with trailing text - "}, - &mut parser, - &mut rng - ), - vec![ - Edit { - old_text: "content".to_string(), - new_text: "updated content".to_string(), - line_hint: None, - }, - Edit { - old_text: "second item".to_string(), - new_text: "modified second item".to_string(), - line_hint: None, - }, - Edit { - old_text: "third case".to_string(), - new_text: "improved third case".to_string(), - line_hint: None, - }, - ] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 6, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 1000)] - fn test_xml_edits_with_closing_parameter_invoke(mut rng: StdRng) { - // This case is a regression with Claude Sonnet 4.5. - // Sometimes Sonnet thinks that it's doing a tool call - // and closes its response with '' - // instead of properly closing - - let mut parser = EditParser::new(EditFormat::XmlTags); - assert_eq!( - parse_random_chunks( - indoc! {" - some textupdated text - more textupd - "}, - &mut parser, - &mut rng - ), - vec![ - Edit { - old_text: "some text".to_string(), - new_text: "updated text".to_string(), - line_hint: None, - }, - Edit { - old_text: "more text".to_string(), - new_text: "upd".to_string(), - line_hint: None, - }, - ] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 4, - mismatched_tags: 2 - } - ); - } - - #[gpui::test(iterations = 1000)] - fn test_xml_nested_tags(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::XmlTags); - assert_eq!( - parse_random_chunks( - "code with nested elementsnew content", - &mut parser, - &mut rng - ), - vec![Edit { - old_text: "code with nested elements".to_string(), - new_text: "new content".to_string(), - line_hint: None, - }] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 2, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 1000)] - fn test_xml_empty_old_and_new_text(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::XmlTags); - assert_eq!( - parse_random_chunks( - "", - &mut parser, - &mut rng - ), - vec![Edit { - old_text: "".to_string(), - new_text: "".to_string(), - line_hint: None, - }] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 2, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 100)] - fn test_xml_multiline_content(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::XmlTags); - assert_eq!( - parse_random_chunks( - "line1\nline2\nline3line1\nmodified line2\nline3", - &mut parser, - &mut rng - ), - vec![Edit { - old_text: "line1\nline2\nline3".to_string(), - new_text: "line1\nmodified line2\nline3".to_string(), - line_hint: None, - }] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 2, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 1000)] - fn test_xml_mismatched_tags(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::XmlTags); - assert_eq!( - parse_random_chunks( - // Reduced from an actual Sonnet 3.7 output - indoc! {" - - a - b - c - - - a - B - c - - - d - e - f - - - D - e - F - - "}, - &mut parser, - &mut rng - ), - vec![ - Edit { - old_text: "a\nb\nc".to_string(), - new_text: "a\nB\nc".to_string(), - line_hint: None, - }, - Edit { - old_text: "d\ne\nf".to_string(), - new_text: "D\ne\nF".to_string(), - line_hint: None, - } - ] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 4, - mismatched_tags: 4 - } - ); - - let mut parser = EditParser::new(EditFormat::XmlTags); - assert_eq!( - parse_random_chunks( - // Reduced from an actual Opus 4 output - indoc! {" - - - Lorem - - - LOREM - - "}, - &mut parser, - &mut rng - ), - vec![Edit { - old_text: "Lorem".to_string(), - new_text: "LOREM".to_string(), - line_hint: None, - },] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 2, - mismatched_tags: 1 - } - ); - } - - #[gpui::test(iterations = 1000)] - fn test_diff_fenced_single_edit(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::DiffFenced); - assert_eq!( - parse_random_chunks( - indoc! {" - <<<<<<< SEARCH - original text - ======= - updated text - >>>>>>> REPLACE - "}, - &mut parser, - &mut rng - ), - vec![Edit { - old_text: "original text".to_string(), - new_text: "updated text".to_string(), - line_hint: None, - }] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 0, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 100)] - fn test_diff_fenced_with_markdown_fences(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::DiffFenced); - assert_eq!( - parse_random_chunks( - indoc! {" - ```diff - <<<<<<< SEARCH - from flask import Flask - ======= - import math - from flask import Flask - >>>>>>> REPLACE - ``` - "}, - &mut parser, - &mut rng - ), - vec![Edit { - old_text: "from flask import Flask".to_string(), - new_text: "import math\nfrom flask import Flask".to_string(), - line_hint: None, - }] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 0, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 100)] - fn test_diff_fenced_multiple_edits(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::DiffFenced); - assert_eq!( - parse_random_chunks( - indoc! {" - <<<<<<< SEARCH - first old - ======= - first new - >>>>>>> REPLACE - - <<<<<<< SEARCH - second old - ======= - second new - >>>>>>> REPLACE - "}, - &mut parser, - &mut rng - ), - vec![ - Edit { - old_text: "first old".to_string(), - new_text: "first new".to_string(), - line_hint: None, - }, - Edit { - old_text: "second old".to_string(), - new_text: "second new".to_string(), - line_hint: None, - }, - ] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 0, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 100)] - fn test_mixed_formats(mut rng: StdRng) { - // Test XML format parser only parses XML tags - let mut xml_parser = EditParser::new(EditFormat::XmlTags); - assert_eq!( - parse_random_chunks( - indoc! {" - xml style oldxml style new - - <<<<<<< SEARCH - diff style old - ======= - diff style new - >>>>>>> REPLACE - "}, - &mut xml_parser, - &mut rng - ), - vec![Edit { - old_text: "xml style old".to_string(), - new_text: "xml style new".to_string(), - line_hint: None, - },] - ); - assert_eq!( - xml_parser.finish(), - EditParserMetrics { - tags: 2, - mismatched_tags: 0 - } - ); - - // Test diff-fenced format parser only parses diff markers - let mut diff_parser = EditParser::new(EditFormat::DiffFenced); - assert_eq!( - parse_random_chunks( - indoc! {" - xml style oldxml style new - - <<<<<<< SEARCH - diff style old - ======= - diff style new - >>>>>>> REPLACE - "}, - &mut diff_parser, - &mut rng - ), - vec![Edit { - old_text: "diff style old".to_string(), - new_text: "diff style new".to_string(), - line_hint: None, - },] - ); - assert_eq!( - diff_parser.finish(), - EditParserMetrics { - tags: 0, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 100)] - fn test_diff_fenced_empty_sections(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::DiffFenced); - assert_eq!( - parse_random_chunks( - indoc! {" - <<<<<<< SEARCH - ======= - >>>>>>> REPLACE - "}, - &mut parser, - &mut rng - ), - vec![Edit { - old_text: "".to_string(), - new_text: "".to_string(), - line_hint: None, - }] - ); - assert_eq!( - parser.finish(), - EditParserMetrics { - tags: 0, - mismatched_tags: 0 - } - ); - } - - #[gpui::test(iterations = 100)] - fn test_diff_fenced_with_line_hint(mut rng: StdRng) { - let mut parser = EditParser::new(EditFormat::DiffFenced); - let edits = parse_random_chunks( - indoc! {" - <<<<<<< SEARCH line=42 - original text - ======= - updated text - >>>>>>> REPLACE - "}, - &mut parser, - &mut rng, - ); - assert_eq!( - edits, - vec![Edit { - old_text: "original text".to_string(), - line_hint: Some(42), - new_text: "updated text".to_string(), - }] - ); - } - #[gpui::test(iterations = 100)] - fn test_xml_line_hints(mut rng: StdRng) { - // Line hint is a single quoted line number - let mut parser = EditParser::new(EditFormat::XmlTags); - - let edits = parse_random_chunks( - r#" - original code - updated code"#, - &mut parser, - &mut rng, - ); - - assert_eq!(edits.len(), 1); - assert_eq!(edits[0].old_text, "original code"); - assert_eq!(edits[0].line_hint, Some(23)); - assert_eq!(edits[0].new_text, "updated code"); - - // Line hint is a single unquoted line number - let mut parser = EditParser::new(EditFormat::XmlTags); - - let edits = parse_random_chunks( - r#" - original code - updated code"#, - &mut parser, - &mut rng, - ); - - assert_eq!(edits.len(), 1); - assert_eq!(edits[0].old_text, "original code"); - assert_eq!(edits[0].line_hint, Some(45)); - assert_eq!(edits[0].new_text, "updated code"); - - // Line hint is a range - let mut parser = EditParser::new(EditFormat::XmlTags); - - let edits = parse_random_chunks( - r#" - original code - updated code"#, - &mut parser, - &mut rng, - ); - - assert_eq!(edits.len(), 1); - assert_eq!(edits[0].old_text, "original code"); - assert_eq!(edits[0].line_hint, Some(23)); - assert_eq!(edits[0].new_text, "updated code"); - - // No line hint - let mut parser = EditParser::new(EditFormat::XmlTags); - let edits = parse_random_chunks( - r#" - old - new"#, - &mut parser, - &mut rng, - ); - - assert_eq!(edits.len(), 1); - assert_eq!(edits[0].old_text, "old"); - assert_eq!(edits[0].line_hint, None); - assert_eq!(edits[0].new_text, "new"); - } - - #[derive(Default, Debug, PartialEq, Eq)] - struct Edit { - old_text: String, - new_text: String, - line_hint: Option, - } - - fn parse_random_chunks(input: &str, parser: &mut EditParser, rng: &mut StdRng) -> Vec { - let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50)); - let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count); - chunk_indices.sort(); - chunk_indices.push(input.len()); - - let mut old_text = Some(String::new()); - let mut new_text = None; - let mut pending_edit = Edit::default(); - let mut edits = Vec::new(); - let mut last_ix = 0; - for chunk_ix in chunk_indices { - for event in parser.push(&input[last_ix..chunk_ix]) { - match event { - EditParserEvent::OldTextChunk { - chunk, - done, - line_hint, - } => { - old_text.as_mut().unwrap().push_str(&chunk); - if done { - pending_edit.old_text = old_text.take().unwrap(); - pending_edit.line_hint = line_hint; - new_text = Some(String::new()); - } - } - EditParserEvent::NewTextChunk { chunk, done } => { - new_text.as_mut().unwrap().push_str(&chunk); - if done { - pending_edit.new_text = new_text.take().unwrap(); - edits.push(pending_edit); - pending_edit = Edit::default(); - old_text = Some(String::new()); - } - } - } - } - last_ix = chunk_ix; - } - - if new_text.is_some() { - pending_edit.new_text = new_text.take().unwrap(); - edits.push(pending_edit); - } - - edits - } -} diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs deleted file mode 100644 index 7e4f314afd0..00000000000 --- a/crates/agent/src/edit_agent/evals.rs +++ /dev/null @@ -1,1701 +0,0 @@ -use super::*; -use crate::{ - AgentTool, EditFileMode, EditFileTool, EditFileToolInput, GrepTool, GrepToolInput, - ListDirectoryTool, ListDirectoryToolInput, ReadFileTool, ReadFileToolInput, -}; -use Role::*; -use client::{Client, RefreshLlmTokenListener, UserStore}; -use eval_utils::{EvalOutput, EvalOutputProcessor, OutcomeKind}; -use fs::FakeFs; -use futures::{FutureExt, future::LocalBoxFuture}; -use gpui::{AppContext, TestAppContext}; -use http_client::StatusCode; -use indoc::{formatdoc, indoc}; -use language_model::{ - LanguageModelRegistry, LanguageModelToolResult, LanguageModelToolResultContent, - LanguageModelToolUse, LanguageModelToolUseId, SelectedModel, -}; -use project::Project; -use prompt_store::{ProjectContext, WorktreeContext}; -use rand::prelude::*; -use reqwest_client::ReqwestClient; -use serde_json::json; -use std::{ - fmt::{self, Display}, - path::Path, - str::FromStr, - time::Duration, -}; -use util::path; - -#[derive(Default, Clone, Debug)] -struct EditAgentOutputProcessor { - mismatched_tag_threshold: f32, - cumulative_tags: usize, - cumulative_mismatched_tags: usize, - eval_outputs: Vec>, -} - -fn mismatched_tag_threshold(mismatched_tag_threshold: f32) -> EditAgentOutputProcessor { - EditAgentOutputProcessor { - mismatched_tag_threshold, - cumulative_tags: 0, - cumulative_mismatched_tags: 0, - eval_outputs: Vec::new(), - } -} - -#[derive(Clone, Debug)] -struct EditEvalMetadata { - tags: usize, - mismatched_tags: usize, -} - -impl EvalOutputProcessor for EditAgentOutputProcessor { - type Metadata = EditEvalMetadata; - - fn process(&mut self, output: &EvalOutput) { - if matches!(output.outcome, OutcomeKind::Passed | OutcomeKind::Failed) { - self.cumulative_mismatched_tags += output.metadata.mismatched_tags; - self.cumulative_tags += output.metadata.tags; - self.eval_outputs.push(output.clone()); - } - } - - fn assert(&mut self) { - let mismatched_tag_ratio = - self.cumulative_mismatched_tags as f32 / self.cumulative_tags as f32; - if mismatched_tag_ratio > self.mismatched_tag_threshold { - for eval_output in &self.eval_outputs { - println!("{}", eval_output.data); - } - panic!( - "Too many mismatched tags: {:?}", - self.cumulative_mismatched_tags - ); - } - } -} - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_extract_handle_command_output() { - // Test how well agent generates multiple edit hunks. - // - // Model | Pass rate - // ----------------------------|---------- - // claude-3.7-sonnet | 0.99 (2025-06-14) - // claude-sonnet-4 | 0.97 (2025-06-14) - // gemini-2.5-pro-06-05 | 0.98 (2025-06-16) - // gemini-2.5-flash | 0.11 (2025-05-22) - - let input_file_path = "root/blame.rs"; - let input_file_content = include_str!("evals/fixtures/extract_handle_command_output/before.rs"); - let possible_diffs = vec![ - include_str!("evals/fixtures/extract_handle_command_output/possible-01.diff"), - include_str!("evals/fixtures/extract_handle_command_output/possible-02.diff"), - include_str!("evals/fixtures/extract_handle_command_output/possible-03.diff"), - include_str!("evals/fixtures/extract_handle_command_output/possible-04.diff"), - include_str!("evals/fixtures/extract_handle_command_output/possible-05.diff"), - include_str!("evals/fixtures/extract_handle_command_output/possible-06.diff"), - include_str!("evals/fixtures/extract_handle_command_output/possible-07.diff"), - ]; - let edit_description = "Extract `handle_command_output` method from `run_git_blame`."; - eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { - run_eval(EvalInput::from_conversation( - vec![ - message( - User, - [text(formatdoc! {" - Read the `{input_file_path}` file and extract a method in - the final stanza of `run_git_blame` to deal with command failures, - call it `handle_command_output` and take the std::process::Output as the only parameter. - Do not document the method and do not add any comments. - - Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`. - "})], - ), - message( - Assistant, - [tool_use( - "tool_1", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: None, - end_line: None, - }, - )], - ), - message( - User, - [tool_result( - "tool_1", - ReadFileTool::NAME, - input_file_content, - )], - ), - message( - Assistant, - [tool_use( - "tool_2", - EditFileTool::NAME, - EditFileToolInput { - display_description: edit_description.into(), - path: input_file_path.into(), - mode: EditFileMode::Edit, - }, - )], - ), - ], - Some(input_file_content.into()), - EvalAssertion::assert_diff_any(possible_diffs.clone()), - )) - }); -} - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_delete_run_git_blame() { - // Model | Pass rate - // ----------------------------|---------- - // claude-3.7-sonnet | 1.0 (2025-06-14) - // claude-sonnet-4 | 0.96 (2025-06-14) - // gemini-2.5-pro-06-05 | 1.0 (2025-06-16) - // gemini-2.5-flash | - - let input_file_path = "root/blame.rs"; - let input_file_content = include_str!("evals/fixtures/delete_run_git_blame/before.rs"); - let output_file_content = include_str!("evals/fixtures/delete_run_git_blame/after.rs"); - let edit_description = "Delete the `run_git_blame` function."; - - eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { - run_eval(EvalInput::from_conversation( - vec![ - message( - User, - [text(formatdoc! {" - Read the `{input_file_path}` file and delete `run_git_blame`. Just that - one function, not its usages. - "})], - ), - message( - Assistant, - [tool_use( - "tool_1", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: None, - end_line: None, - }, - )], - ), - message( - User, - [tool_result( - "tool_1", - ReadFileTool::NAME, - input_file_content, - )], - ), - message( - Assistant, - [tool_use( - "tool_2", - EditFileTool::NAME, - EditFileToolInput { - display_description: edit_description.into(), - path: input_file_path.into(), - mode: EditFileMode::Edit, - }, - )], - ), - ], - Some(input_file_content.into()), - EvalAssertion::assert_eq(output_file_content), - )) - }); -} - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_translate_doc_comments() { - // Model | Pass rate - // ============================================ - // - // claude-3.7-sonnet | 1.0 (2025-06-14) - // claude-sonnet-4 | 1.0 (2025-06-14) - // gemini-2.5-pro-preview-03-25 | 1.0 (2025-05-22) - // gemini-2.5-flash-preview-04-17 | - - let input_file_path = "root/canvas.rs"; - let input_file_content = include_str!("evals/fixtures/translate_doc_comments/before.rs"); - let edit_description = "Translate all doc comments to Italian"; - - eval_utils::eval(200, 1., mismatched_tag_threshold(0.05), move || { - run_eval(EvalInput::from_conversation( - vec![ - message( - User, - [text(formatdoc! {" - Read the {input_file_path} file and edit it (without overwriting it), - translating all the doc comments to italian. - "})], - ), - message( - Assistant, - [tool_use( - "tool_1", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: None, - end_line: None, - }, - )], - ), - message( - User, - [tool_result( - "tool_1", - ReadFileTool::NAME, - input_file_content, - )], - ), - message( - Assistant, - [tool_use( - "tool_2", - EditFileTool::NAME, - EditFileToolInput { - display_description: edit_description.into(), - path: input_file_path.into(), - mode: EditFileMode::Edit, - }, - )], - ), - ], - Some(input_file_content.into()), - EvalAssertion::judge_diff("Doc comments were translated to Italian"), - )) - }); -} - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { - // Model | Pass rate - // ============================================ - // - // claude-3.7-sonnet | 0.96 (2025-06-14) - // claude-sonnet-4 | 0.11 (2025-06-14) - // gemini-2.5-pro-preview-latest | 0.99 (2025-06-16) - // gemini-2.5-flash-preview-04-17 | - - let input_file_path = "root/lib.rs"; - let input_file_content = - include_str!("evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs"); - let edit_description = "Update compile_parser_to_wasm to use wasi-sdk instead of emscripten"; - - eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { - run_eval(EvalInput::from_conversation( - vec![ - message( - User, - [text(formatdoc! {" - Read the `{input_file_path}` file and change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. - Use `ureq` to download the SDK for the current platform and architecture. - Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. - Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) - that's inside of the archive. - Don't re-download the SDK if that executable already exists. - - Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{{language_name}} - - Here are the available wasi-sdk assets: - - wasi-sdk-25.0-x86_64-macos.tar.gz - - wasi-sdk-25.0-arm64-macos.tar.gz - - wasi-sdk-25.0-x86_64-linux.tar.gz - - wasi-sdk-25.0-arm64-linux.tar.gz - - wasi-sdk-25.0-x86_64-linux.tar.gz - - wasi-sdk-25.0-arm64-linux.tar.gz - - wasi-sdk-25.0-x86_64-windows.tar.gz - "})], - ), - message( - Assistant, - [tool_use( - "tool_1", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: Some(971), - end_line: Some(1050), - }, - )], - ), - message( - User, - [tool_result( - "tool_1", - ReadFileTool::NAME, - lines(input_file_content, 971..1050), - )], - ), - message( - Assistant, - [tool_use( - "tool_2", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: Some(1050), - end_line: Some(1100), - }, - )], - ), - message( - User, - [tool_result( - "tool_2", - ReadFileTool::NAME, - lines(input_file_content, 1050..1100), - )], - ), - message( - Assistant, - [tool_use( - "tool_3", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: Some(1100), - end_line: Some(1150), - }, - )], - ), - message( - User, - [tool_result( - "tool_3", - ReadFileTool::NAME, - lines(input_file_content, 1100..1150), - )], - ), - message( - Assistant, - [tool_use( - "tool_4", - EditFileTool::NAME, - EditFileToolInput { - display_description: edit_description.into(), - path: input_file_path.into(), - mode: EditFileMode::Edit, - }, - )], - ), - ], - Some(input_file_content.into()), - EvalAssertion::judge_diff(indoc! {" - - The compile_parser_to_wasm method has been changed to use wasi-sdk - - ureq is used to download the SDK for current platform and architecture - "}), - )) - }); -} - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_disable_cursor_blinking() { - // Model | Pass rate - // ============================================ - // - // claude-3.7-sonnet | 0.59 (2025-07-14) - // claude-sonnet-4 | 0.81 (2025-07-14) - // gemini-2.5-pro | 0.95 (2025-07-14) - // gemini-2.5-flash-preview-04-17 | 0.78 (2025-07-14) - - let input_file_path = "root/editor.rs"; - let input_file_content = include_str!("evals/fixtures/disable_cursor_blinking/before.rs"); - let edit_description = "Comment out the call to `BlinkManager::enable`"; - let possible_diffs = vec![ - include_str!("evals/fixtures/disable_cursor_blinking/possible-01.diff"), - include_str!("evals/fixtures/disable_cursor_blinking/possible-02.diff"), - include_str!("evals/fixtures/disable_cursor_blinking/possible-03.diff"), - include_str!("evals/fixtures/disable_cursor_blinking/possible-04.diff"), - ]; - eval_utils::eval(100, 0.51, mismatched_tag_threshold(0.05), move || { - run_eval(EvalInput::from_conversation( - vec![ - message(User, [text("Let's research how to cursor blinking works.")]), - message( - Assistant, - [tool_use( - "tool_1", - GrepTool::NAME, - GrepToolInput { - regex: "blink".into(), - include_pattern: None, - offset: 0, - case_sensitive: false, - }, - )], - ), - message( - User, - [tool_result( - "tool_1", - GrepTool::NAME, - [ - lines(input_file_content, 100..400), - lines(input_file_content, 800..1300), - lines(input_file_content, 1600..2000), - lines(input_file_content, 5000..5500), - lines(input_file_content, 8000..9000), - lines(input_file_content, 18455..18470), - lines(input_file_content, 20000..20500), - lines(input_file_content, 21000..21300), - ] - .join("Match found:\n\n"), - )], - ), - message( - User, - [text(indoc! {" - Comment out the lines that interact with the BlinkManager. - Keep the outer `update` blocks, but comments everything that's inside (including if statements). - Don't add additional comments. - "})], - ), - message( - Assistant, - [tool_use( - "tool_4", - EditFileTool::NAME, - EditFileToolInput { - display_description: edit_description.into(), - path: input_file_path.into(), - mode: EditFileMode::Edit, - }, - )], - ), - ], - Some(input_file_content.into()), - EvalAssertion::assert_diff_any(possible_diffs.clone()), - )) - }); -} - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_from_pixels_constructor() { - // Results for 2025-06-13 - // - // The outcome of this evaluation depends heavily on the LINE_HINT_TOLERANCE - // value. Higher values improve the pass rate but may sometimes cause - // edits to be misapplied. In the context of this eval, this means - // the agent might add from_pixels tests in incorrect locations - // (e.g., at the beginning of the file), yet the evaluation may still - // rate it highly. - // - // Model | Date | Pass rate - // ========================================================= - // claude-4.0-sonnet | 2025-06-14 | 0.99 - // claude-3.7-sonnet | 2025-06-14 | 0.88 - // gemini-2.5-pro-preview-06-05 | 2025-06-16 | 0.98 - - let input_file_path = "root/canvas.rs"; - let input_file_content = include_str!("evals/fixtures/from_pixels_constructor/before.rs"); - let edit_description = "Implement from_pixels constructor and add tests."; - - eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.25), move || { - run_eval(EvalInput::from_conversation( - vec![ - message( - User, - [text(indoc! {" - Introduce a new `from_pixels` constructor in Canvas and - also add tests for it in the same file. - "})], - ), - message( - Assistant, - [tool_use( - "tool_1", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: None, - end_line: None, - }, - )], - ), - message( - User, - [tool_result( - "tool_1", - ReadFileTool::NAME, - input_file_content, - )], - ), - message( - Assistant, - [tool_use( - "tool_2", - GrepTool::NAME, - GrepToolInput { - regex: "mod\\s+tests".into(), - include_pattern: Some("font-kit/src/canvas.rs".into()), - offset: 0, - case_sensitive: false, - }, - )], - ), - message( - User, - [tool_result("tool_2", GrepTool::NAME, "No matches found")], - ), - message( - Assistant, - [tool_use( - "tool_3", - GrepTool::NAME, - GrepToolInput { - regex: "mod\\s+tests".into(), - include_pattern: Some("font-kit/src/**/*.rs".into()), - offset: 0, - case_sensitive: false, - }, - )], - ), - message( - User, - [tool_result("tool_3", GrepTool::NAME, "No matches found")], - ), - message( - Assistant, - [tool_use( - "tool_4", - GrepTool::NAME, - GrepToolInput { - regex: "#\\[test\\]".into(), - include_pattern: Some("font-kit/src/**/*.rs".into()), - offset: 0, - case_sensitive: false, - }, - )], - ), - message( - User, - [tool_result( - "tool_4", - GrepTool::NAME, - indoc! {" - Found 6 matches: - - ## Matches in font-kit/src/loaders/core_text.rs - - ### mod test › L926-936 - ``` - mod test { - use super::Font; - use crate::properties::{Stretch, Weight}; - - #[cfg(feature = \"source\")] - use crate::source::SystemSource; - - static TEST_FONT_POSTSCRIPT_NAME: &'static str = \"ArialMT\"; - - #[cfg(feature = \"source\")] - #[test] - ``` - - 55 lines remaining in ancestor node. Read the file to see all. - - ### mod test › L947-951 - ``` - } - - #[test] - fn test_core_text_to_css_font_weight() { - // Exact matches - ``` - - ### mod test › L959-963 - ``` - } - - #[test] - fn test_core_text_to_css_font_stretch() { - // Exact matches - ``` - - ## Matches in font-kit/src/loaders/freetype.rs - - ### mod test › L1238-1248 - ``` - mod test { - use crate::loaders::freetype::Font; - - static PCF_FONT_PATH: &str = \"resources/tests/times-roman-pcf/timR12.pcf\"; - static PCF_FONT_POSTSCRIPT_NAME: &str = \"Times-Roman\"; - - #[test] - fn get_pcf_postscript_name() { - let font = Font::from_path(PCF_FONT_PATH, 0).unwrap(); - assert_eq!(font.postscript_name().unwrap(), PCF_FONT_POSTSCRIPT_NAME); - } - ``` - - 1 lines remaining in ancestor node. Read the file to see all. - - ## Matches in font-kit/src/sources/core_text.rs - - ### mod test › L265-275 - ``` - mod test { - use crate::properties::{Stretch, Weight}; - - #[test] - fn test_css_to_core_text_font_weight() { - // Exact matches - assert_eq!(super::css_to_core_text_font_weight(Weight(100.0)), -0.7); - assert_eq!(super::css_to_core_text_font_weight(Weight(400.0)), 0.0); - assert_eq!(super::css_to_core_text_font_weight(Weight(700.0)), 0.4); - assert_eq!(super::css_to_core_text_font_weight(Weight(900.0)), 0.8); - - ``` - - 27 lines remaining in ancestor node. Read the file to see all. - - ### mod test › L278-282 - ``` - } - - #[test] - fn test_css_to_core_text_font_stretch() { - // Exact matches - ``` - "}, - )], - ), - message( - Assistant, - [tool_use( - "tool_5", - EditFileTool::NAME, - EditFileToolInput { - display_description: edit_description.into(), - path: input_file_path.into(), - mode: EditFileMode::Edit, - }, - )], - ), - ], - Some(input_file_content.into()), - EvalAssertion::judge_diff(indoc! {" - - The diff contains a new `from_pixels` constructor - - The diff contains new tests for the `from_pixels` constructor - "}), - )) - }); -} - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_zode() { - // Model | Pass rate - // ============================================ - // - // claude-3.7-sonnet | 1.0 (2025-06-14) - // claude-sonnet-4 | 1.0 (2025-06-14) - // gemini-2.5-pro-preview-03-25 | 1.0 (2025-05-22) - // gemini-2.5-flash-preview-04-17 | 1.0 (2025-05-22) - - let input_file_path = "root/zode.py"; - let input_content = None; - let edit_description = "Create the main Zode CLI script"; - - eval_utils::eval(50, 1., mismatched_tag_threshold(0.05), move || { - run_eval(EvalInput::from_conversation( - vec![ - message(User, [text(include_str!("evals/fixtures/zode/prompt.md"))]), - message( - Assistant, - [ - tool_use( - "tool_1", - ReadFileTool::NAME, - ReadFileToolInput { - path: "root/eval/react.py".into(), - start_line: None, - end_line: None, - }, - ), - tool_use( - "tool_2", - ReadFileTool::NAME, - ReadFileToolInput { - path: "root/eval/react_test.py".into(), - start_line: None, - end_line: None, - }, - ), - ], - ), - message( - User, - [ - tool_result( - "tool_1", - ReadFileTool::NAME, - include_str!("evals/fixtures/zode/react.py"), - ), - tool_result( - "tool_2", - ReadFileTool::NAME, - include_str!("evals/fixtures/zode/react_test.py"), - ), - ], - ), - message( - Assistant, - [ - text( - "Now that I understand what we need to build, I'll create the main Python script:", - ), - tool_use( - "tool_3", - EditFileTool::NAME, - EditFileToolInput { - display_description: edit_description.into(), - path: input_file_path.into(), - mode: EditFileMode::Create, - }, - ), - ], - ), - ], - input_content.clone(), - EvalAssertion::new(async move |sample, _, _cx| { - let invalid_starts = [' ', '`', '\n']; - let mut message = String::new(); - for start in invalid_starts { - if sample.text_after.starts_with(start) { - message.push_str(&format!("The sample starts with a {:?}\n", start)); - break; - } - } - // Remove trailing newline. - message.pop(); - - if message.is_empty() { - Ok(EvalAssertionOutcome { - score: 100, - message: None, - }) - } else { - Ok(EvalAssertionOutcome { - score: 0, - message: Some(message), - }) - } - }), - )) - }); -} - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_add_overwrite_test() { - // Model | Pass rate - // ============================================ - // - // claude-3.7-sonnet | 0.65 (2025-06-14) - // claude-sonnet-4 | 0.07 (2025-06-14) - // gemini-2.5-pro-preview-03-25 | 0.35 (2025-05-22) - // gemini-2.5-flash-preview-04-17 | - - let input_file_path = "root/action_log.rs"; - let input_file_content = include_str!("evals/fixtures/add_overwrite_test/before.rs"); - let edit_description = "Add a new test for overwriting a file in action_log.rs"; - - eval_utils::eval(200, 0.5, mismatched_tag_threshold(0.05), move || { - run_eval(EvalInput::from_conversation( - vec![ - message( - User, - [text(indoc! {" - Introduce a new test in `action_log.rs` to test overwriting a file. - That is, a file already exists, but we call `buffer_created` as if the file were new. - Take inspiration from all the other tests in the file. - "})], - ), - message( - Assistant, - [tool_use( - "tool_1", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: None, - end_line: None, - }, - )], - ), - message( - User, - [tool_result( - "tool_1", - ReadFileTool::NAME, - indoc! {" - pub struct ActionLog [L13-20] - tracked_buffers [L15] - edited_since_project_diagnostics_check [L17] - project [L19] - impl ActionLog [L22-498] - pub fn new [L24-30] - pub fn project [L32-34] - pub fn checked_project_diagnostics [L37-39] - pub fn has_edited_files_since_project_diagnostics_check [L42-44] - fn track_buffer_internal [L46-101] - fn handle_buffer_event [L103-116] - fn handle_buffer_edited [L118-123] - fn handle_buffer_file_changed [L125-158] - async fn maintain_diff [L160-264] - pub fn buffer_read [L267-269] - pub fn buffer_created [L272-276] - pub fn buffer_edited [L279-287] - pub fn will_delete_buffer [L289-304] - pub fn keep_edits_in_range [L306-364] - pub fn reject_edits_in_ranges [L366-459] - pub fn keep_all_edits [L461-473] - pub fn changed_buffers [L476-482] - pub fn stale_buffers [L485-497] - fn apply_non_conflicting_edits [L500-561] - fn diff_snapshots [L563-585] - fn point_to_row_edit [L587-614] - enum ChangeAuthor [L617-620] - User [L618] - Agent [L619] - enum TrackedBufferStatus [L623-627] - Created [L624] - Modified [L625] - Deleted [L626] - struct TrackedBuffer [L629-641] - buffer [L630] - base_text [L631] - unreviewed_changes [L632] - status [L633] - version [L634] - diff [L635] - snapshot [L636] - diff_update [L637] - _open_lsp_handle [L638] - _maintain_diff [L639] - _subscription [L640] - impl TrackedBuffer [L643-657] - fn has_changes [L644-650] - fn schedule_diff_update [L652-656] - pub struct ChangedBuffer [L659-661] - pub diff [L660] - mod tests [L664-1574] - fn init_logger [L678-682] - fn init_test [L684-691] - async fn test_keep_edits [L694-769] - async fn test_deletions [L772-854] - async fn test_overlapping_user_edits [L857-951] - async fn test_creating_files [L954-1010] - async fn test_deleting_files [L1013-1120] - async fn test_reject_edits [L1123-1255] - async fn test_reject_multiple_edits [L1258-1331] - async fn test_reject_deleted_file [L1334-1388] - async fn test_reject_created_file [L1391-1443] - async fn test_random_diffs [L1446-1535] - fn quiesce [L1510-1534] - struct HunkStatus [L1538-1542] - range [L1539] - diff_status [L1540] - old_text [L1541] - fn unreviewed_hunks [L1544-1573] - - Showing symbols 1-69 (total symbols: 69) - - Using the line numbers in this outline, you can call this tool again while specifying - the start_line and end_line fields to see the implementations of symbols in the outline. - "}, - )], - ), - message( - Assistant, - [ - text( - "Let's look at the existing test cases that are related to file creation to understand the pattern:", - ), - tool_use( - "tool_2", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: Some(953), - end_line: Some(1010), - }, - ), - ], - ), - message( - User, - [tool_result( - "tool_2", - ReadFileTool::NAME, - lines(input_file_content, 953..1010), - )], - ), - message( - Assistant, - [ - text( - "Now let's look at another relevant test to understand how they're structured:", - ), - tool_use( - "tool_3", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: Some(1012), - end_line: Some(1120), - }, - ), - ], - ), - message( - User, - [tool_result( - "tool_3", - ReadFileTool::NAME, - lines(input_file_content, 1012..1120), - )], - ), - message( - Assistant, - [ - text("Now let's look at how `buffer_created` is implemented:"), - tool_use( - "tool_4", - ReadFileTool::NAME, - ReadFileToolInput { - path: input_file_path.into(), - start_line: Some(271), - end_line: Some(276), - }, - ), - ], - ), - message( - User, - [tool_result( - "tool_4", - ReadFileTool::NAME, - lines(input_file_content, 271..276), - )], - ), - message( - Assistant, - [ - text(indoc! {" - Based on my understanding of the existing tests and code, I'll now create a new test for overwriting a file. In this test, we'll: - - 1. Create a file - 2. Open a buffer for it - 3. Track it in the action log - 4. Modify the buffer - 5. Call `buffer_created` again as if the file were new - 6. Check that the changes are properly tracked - - Let's write the test: - "}), - tool_use( - "tool_5", - EditFileTool::NAME, - EditFileToolInput { - display_description: edit_description.into(), - path: input_file_path.into(), - mode: EditFileMode::Edit, - }, - ), - ], - ), - ], - Some(input_file_content.into()), - EvalAssertion::judge_diff( - "A new test for overwritten files was created, without changing any previous test", - ), - )) - }); -} - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_create_empty_file() { - // Check that Edit Agent can create a file without writing its - // thoughts into it. This issue is not specific to empty files, but - // it's easier to reproduce with them. - // - // Model | Pass rate - // ============================================ - // - // claude-3.7-sonnet | 1.00 (2025-06-14) - // claude-sonnet-4 | 1.00 (2025-06-14) - // gemini-2.5-pro-preview-03-25 | 1.00 (2025-05-21) - // gemini-2.5-flash-preview-04-17 | 1.00 (2025-05-21) - - let input_file_content = None; - let expected_output_content = String::new(); - - eval_utils::eval(100, 0.99, mismatched_tag_threshold(0.05), move || { - run_eval(EvalInput::from_conversation( - vec![ - message(User, [text("Create a second empty todo file ")]), - message( - Assistant, - [ - text(formatdoc! {" - I'll help you create a second empty todo file. - First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. - "}), - tool_use( - "toolu_01GAF8TtsgpjKxCr8fgQLDgR", - ListDirectoryTool::NAME, - ListDirectoryToolInput { - path: "root".to_string(), - }, - ), - ], - ), - message( - User, - [tool_result( - "toolu_01GAF8TtsgpjKxCr8fgQLDgR", - ListDirectoryTool::NAME, - "root/TODO\nroot/TODO2\nroot/new.txt\n", - )], - ), - message( - Assistant, - [ - text(formatdoc! {" - I can see there's already a `TODO` file in the `root` directory. Let me create a second empty todo file called `TODO3` in the same directory: - "}), - tool_use( - "toolu_01Tb3iQ9griqSYMmVuykQPWU", - EditFileTool::NAME, - EditFileToolInput { - display_description: "Create empty TODO3 file".to_string(), - mode: EditFileMode::Create, - path: "root/TODO3".into(), - }, - ), - ], - ), - ], - input_file_content.clone(), - // Bad behavior is to write something like - // "I'll create an empty TODO3 file as requested." - EvalAssertion::assert_eq(expected_output_content.clone()), - )) - }); -} - -fn message( - role: Role, - contents: impl IntoIterator, -) -> LanguageModelRequestMessage { - LanguageModelRequestMessage { - role, - content: contents.into_iter().collect(), - cache: false, - reasoning_details: None, - } -} - -fn text(text: impl Into) -> MessageContent { - MessageContent::Text(text.into()) -} - -fn lines(input: &str, range: Range) -> String { - input - .lines() - .skip(range.start) - .take(range.len()) - .collect::>() - .join("\n") -} - -fn tool_use( - id: impl Into>, - name: impl Into>, - input: impl Serialize, -) -> MessageContent { - MessageContent::ToolUse(LanguageModelToolUse { - id: LanguageModelToolUseId::from(id.into()), - name: name.into(), - raw_input: serde_json::to_string_pretty(&input).unwrap(), - input: serde_json::to_value(input).unwrap(), - is_input_complete: true, - thought_signature: None, - }) -} - -fn tool_result( - id: impl Into>, - name: impl Into>, - result: impl Into>, -) -> MessageContent { - MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: LanguageModelToolUseId::from(id.into()), - tool_name: name.into(), - is_error: false, - content: vec![LanguageModelToolResultContent::Text(result.into())], - output: None, - }) -} - -#[derive(Clone)] -struct EvalInput { - conversation: Vec, - edit_file_input: EditFileToolInput, - input_content: Option, - assertion: EvalAssertion, -} - -impl EvalInput { - fn from_conversation( - conversation: Vec, - input_content: Option, - assertion: EvalAssertion, - ) -> Self { - let msg = conversation.last().expect("Conversation must not be empty"); - if msg.role != Role::Assistant { - panic!("Conversation must end with an assistant message"); - } - let tool_use = msg - .content - .iter() - .flat_map(|content| match content { - MessageContent::ToolUse(tool_use) if tool_use.name == EditFileTool::NAME.into() => { - Some(tool_use) - } - _ => None, - }) - .next() - .expect("Conversation must end with an edit_file tool use") - .clone(); - - let edit_file_input: EditFileToolInput = serde_json::from_value(tool_use.input).unwrap(); - - EvalInput { - conversation, - edit_file_input, - input_content, - assertion, - } - } -} - -#[derive(Clone)] -struct EvalSample { - text_before: String, - text_after: String, - edit_output: EditAgentOutput, - diff: String, -} - -trait AssertionFn: 'static + Send + Sync { - fn assert<'a>( - &'a self, - sample: &'a EvalSample, - judge_model: Arc, - cx: &'a mut TestAppContext, - ) -> LocalBoxFuture<'a, Result>; -} - -impl AssertionFn for F -where - F: 'static - + Send - + Sync - + AsyncFn( - &EvalSample, - Arc, - &mut TestAppContext, - ) -> Result, -{ - fn assert<'a>( - &'a self, - sample: &'a EvalSample, - judge_model: Arc, - cx: &'a mut TestAppContext, - ) -> LocalBoxFuture<'a, Result> { - (self)(sample, judge_model, cx).boxed_local() - } -} - -#[derive(Clone)] -struct EvalAssertion(Arc); - -impl EvalAssertion { - fn new(f: F) -> Self - where - F: 'static - + Send - + Sync - + AsyncFn( - &EvalSample, - Arc, - &mut TestAppContext, - ) -> Result, - { - EvalAssertion(Arc::new(f)) - } - - fn assert_eq(expected: impl Into) -> Self { - let expected = expected.into(); - Self::new(async move |sample, _judge, _cx| { - Ok(EvalAssertionOutcome { - score: if strip_empty_lines(&sample.text_after) == strip_empty_lines(&expected) { - 100 - } else { - 0 - }, - message: None, - }) - }) - } - - fn assert_diff_any(expected_diffs: Vec>) -> Self { - let expected_diffs: Vec = expected_diffs.into_iter().map(Into::into).collect(); - Self::new(async move |sample, _judge, _cx| { - let matches = expected_diffs.iter().any(|possible_diff| { - let expected = - language::apply_diff_patch(&sample.text_before, possible_diff).unwrap(); - strip_empty_lines(&expected) == strip_empty_lines(&sample.text_after) - }); - - Ok(EvalAssertionOutcome { - score: if matches { 100 } else { 0 }, - message: None, - }) - }) - } - - fn judge_diff(assertions: &'static str) -> Self { - Self::new(async move |sample, judge, cx| { - let prompt = DiffJudgeTemplate { - diff: sample.diff.clone(), - assertions, - } - .render(&Templates::new()) - .unwrap(); - - let request = LanguageModelRequest { - messages: vec![LanguageModelRequestMessage { - role: Role::User, - content: vec![prompt.into()], - cache: false, - reasoning_details: None, - }], - thinking_allowed: true, - ..Default::default() - }; - let mut response = retry_on_rate_limit(async || { - Ok(judge - .stream_completion_text(request.clone(), &cx.to_async()) - .await?) - }) - .await?; - let mut output = String::new(); - while let Some(chunk) = response.stream.next().await { - let chunk = chunk?; - output.push_str(&chunk); - } - - // Parse the score from the response - let re = regex::Regex::new(r"(\d+)").unwrap(); - if let Some(captures) = re.captures(&output) - && let Some(score_match) = captures.get(1) - { - let score = score_match.as_str().parse().unwrap_or(0); - return Ok(EvalAssertionOutcome { - score, - message: Some(output), - }); - } - - anyhow::bail!("No score found in response. Raw output: {output}"); - }) - } - - async fn run( - &self, - input: &EvalSample, - judge_model: Arc, - cx: &mut TestAppContext, - ) -> Result { - self.0.assert(input, judge_model, cx).await - } -} - -fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput { - let dispatcher = gpui::TestDispatcher::new(rand::random()); - let mut cx = TestAppContext::build(dispatcher, None); - let foreground_executor = cx.foreground_executor().clone(); - let result = foreground_executor.block_test(async { - let test = EditAgentTest::new(&mut cx).await; - test.eval(eval, &mut cx).await - }); - cx.quit(); - match result { - Ok(output) => eval_utils::EvalOutput { - data: output.to_string(), - outcome: if output.assertion.score < 80 { - eval_utils::OutcomeKind::Failed - } else { - eval_utils::OutcomeKind::Passed - }, - metadata: EditEvalMetadata { - tags: output.sample.edit_output.parser_metrics.tags, - mismatched_tags: output.sample.edit_output.parser_metrics.mismatched_tags, - }, - }, - Err(e) => eval_utils::EvalOutput { - data: format!("{e:?}"), - outcome: eval_utils::OutcomeKind::Error, - metadata: EditEvalMetadata { - tags: 0, - mismatched_tags: 0, - }, - }, - } -} - -#[derive(Clone)] -struct EditEvalOutput { - sample: EvalSample, - assertion: EvalAssertionOutcome, -} - -impl Display for EditEvalOutput { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - writeln!(f, "Score: {:?}", self.assertion.score)?; - if let Some(message) = self.assertion.message.as_ref() { - writeln!(f, "Message: {}", message)?; - } - - writeln!(f, "Diff:\n{}", self.sample.diff)?; - - writeln!( - f, - "Parser Metrics:\n{:#?}", - self.sample.edit_output.parser_metrics - )?; - writeln!(f, "Raw Edits:\n{}", self.sample.edit_output.raw_edits)?; - Ok(()) - } -} - -struct EditAgentTest { - agent: EditAgent, - project: Entity, - judge_model: Arc, -} - -impl EditAgentTest { - async fn new(cx: &mut TestAppContext) -> Self { - cx.executor().allow_parking(); - - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| { - settings::init(cx); - gpui_tokio::init(cx); - let http_client = Arc::new(ReqwestClient::user_agent("agent tests").unwrap()); - cx.set_http_client(http_client); - let client = Client::production(cx); - let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - settings::init(cx); - language_model::init(cx); - RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); - language_models::init(user_store, client.clone(), cx); - }); - - fs.insert_tree("/root", json!({})).await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let agent_model = SelectedModel::from_str( - &std::env::var("ZED_AGENT_MODEL").unwrap_or("anthropic/claude-sonnet-4-latest".into()), - ) - .unwrap(); - let judge_model = SelectedModel::from_str( - &std::env::var("ZED_JUDGE_MODEL").unwrap_or("anthropic/claude-sonnet-4-latest".into()), - ) - .unwrap(); - - let authenticate_provider_tasks = cx.update(|cx| { - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry - .providers() - .iter() - .map(|p| p.authenticate(cx)) - .collect::>() - }) - }); - let (agent_model, judge_model) = cx - .update(|cx| { - cx.spawn(async move |cx| { - futures::future::join_all(authenticate_provider_tasks).await; - let agent_model = Self::load_model(&agent_model, cx).await; - let judge_model = Self::load_model(&judge_model, cx).await; - (agent_model.unwrap(), judge_model.unwrap()) - }) - }) - .await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - - let edit_format = EditFormat::from_env(agent_model.clone()).unwrap(); - - Self { - agent: EditAgent::new( - agent_model, - project.clone(), - action_log, - Templates::new(), - edit_format, - true, - true, - ), - project, - judge_model, - } - } - - async fn load_model( - selected_model: &SelectedModel, - cx: &mut AsyncApp, - ) -> Result> { - cx.update(|cx| { - let registry = LanguageModelRegistry::read_global(cx); - let provider = registry - .provider(&selected_model.provider) - .expect("Provider not found"); - provider.authenticate(cx) - }) - .await?; - Ok(cx.update(|cx| { - let models = LanguageModelRegistry::read_global(cx); - let model = models - .available_models(cx) - .find(|model| { - model.provider_id() == selected_model.provider - && model.id() == selected_model.model - }) - .unwrap_or_else(|| panic!("Model {} not found", selected_model.model.0)); - model - })) - } - - async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result { - // Make sure the last message in the conversation is cached. - eval.conversation.last_mut().unwrap().cache = true; - - let path = self - .project - .read_with(cx, |project, cx| { - project.find_project_path(eval.edit_file_input.path, cx) - }) - .unwrap(); - let buffer = self - .project - .update(cx, |project, cx| project.open_buffer(path, cx)) - .await - .unwrap(); - - let tools = crate::built_in_tools().collect::>(); - - let system_prompt = { - let worktrees = vec![WorktreeContext { - root_name: "root".to_string(), - abs_path: Path::new("/path/to/root").into(), - rules_file: None, - }]; - let project_context = ProjectContext::new(worktrees, Vec::default()); - let tool_names = tools - .iter() - .map(|tool| tool.name.clone().into()) - .collect::>(); - let template = crate::SystemPromptTemplate { - project: &project_context, - available_tools: tool_names, - model_name: None, - }; - let templates = Templates::new(); - template.render(&templates).unwrap() - }; - - let has_system_prompt = eval - .conversation - .first() - .is_some_and(|msg| msg.role == Role::System); - let messages = if has_system_prompt { - eval.conversation - } else { - [LanguageModelRequestMessage { - role: Role::System, - content: vec![MessageContent::Text(system_prompt)], - cache: true, - reasoning_details: None, - }] - .into_iter() - .chain(eval.conversation) - .collect::>() - }; - - let conversation = LanguageModelRequest { - messages, - tools, - thinking_allowed: true, - ..Default::default() - }; - - let edit_output = if matches!(eval.edit_file_input.mode, EditFileMode::Edit) { - if let Some(input_content) = eval.input_content.as_deref() { - buffer.update(cx, |buffer, cx| buffer.set_text(input_content, cx)); - } - retry_on_rate_limit(async || { - self.agent - .edit( - buffer.clone(), - eval.edit_file_input.display_description.clone(), - &conversation, - &mut cx.to_async(), - ) - .0 - .await - }) - .await? - } else { - retry_on_rate_limit(async || { - self.agent - .overwrite( - buffer.clone(), - eval.edit_file_input.display_description.clone(), - &conversation, - &mut cx.to_async(), - ) - .0 - .await - }) - .await? - }; - - let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); - let sample = EvalSample { - edit_output, - diff: language::unified_diff( - eval.input_content.as_deref().unwrap_or_default(), - &buffer_text, - ), - text_before: eval.input_content.unwrap_or_default(), - text_after: buffer_text, - }; - let assertion = eval - .assertion - .run(&sample, self.judge_model.clone(), cx) - .await?; - - Ok(EditEvalOutput { assertion, sample }) - } -} - -async fn retry_on_rate_limit(mut request: impl AsyncFnMut() -> Result) -> Result { - const MAX_RETRIES: usize = 20; - let mut attempt = 0; - - loop { - attempt += 1; - let response = request().await; - - if attempt >= MAX_RETRIES { - return response; - } - - let retry_delay = match &response { - Ok(_) => None, - Err(err) => match err.downcast_ref::() { - Some(err) => match &err { - LanguageModelCompletionError::RateLimitExceeded { retry_after, .. } - | LanguageModelCompletionError::ServerOverloaded { retry_after, .. } => { - Some(retry_after.unwrap_or(Duration::from_secs(5))) - } - LanguageModelCompletionError::UpstreamProviderError { - status, - retry_after, - .. - } => { - // Only retry for specific status codes - let should_retry = matches!( - *status, - StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE - ) || status.as_u16() == 529; - - if should_retry { - // Use server-provided retry_after if available, otherwise use default - Some(retry_after.unwrap_or(Duration::from_secs(5))) - } else { - None - } - } - LanguageModelCompletionError::ApiReadResponseError { .. } - | LanguageModelCompletionError::ApiInternalServerError { .. } - | LanguageModelCompletionError::HttpSend { .. } => { - // Exponential backoff for transient I/O and internal server errors - Some(Duration::from_secs(2_u64.pow((attempt - 1) as u32).min(30))) - } - _ => None, - }, - _ => None, - }, - }; - - if let Some(retry_after) = retry_delay { - let jitter = retry_after.mul_f64(rand::rng().random_range(0.0..1.0)); - eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}"); - // This code does not use the gpui::executor - #[allow(clippy::disallowed_methods)] - async_io::Timer::after(retry_after + jitter).await; - } else { - return response; - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -struct EvalAssertionOutcome { - score: usize, - message: Option, -} - -#[derive(Serialize)] -pub struct DiffJudgeTemplate { - diff: String, - assertions: &'static str, -} - -impl Template for DiffJudgeTemplate { - const TEMPLATE_NAME: &'static str = "diff_judge.hbs"; -} - -fn strip_empty_lines(text: &str) -> String { - text.lines() - .filter(|line| !line.trim().is_empty()) - .collect::>() - .join("\n") -} diff --git a/crates/agent/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs b/crates/agent/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs deleted file mode 100644 index 0d2a0be1fb8..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs +++ /dev/null @@ -1,1572 +0,0 @@ -use anyhow::{Context as _, Result}; -use buffer_diff::BufferDiff; -use collections::BTreeMap; -use futures::{StreamExt, channel::mpsc}; -use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity}; -use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint}; -use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle}; -use std::{cmp, ops::Range, sync::Arc}; -use text::{Edit, Patch, Rope}; -use util::RangeExt; - -/// Tracks actions performed by tools in a thread -pub struct ActionLog { - /// Buffers that we want to notify the model about when they change. - tracked_buffers: BTreeMap, TrackedBuffer>, - /// Has the model edited a file since it last checked diagnostics? - edited_since_project_diagnostics_check: bool, - /// The project this action log is associated with - project: Entity, -} - -impl ActionLog { - /// Creates a new, empty action log associated with the given project. - pub fn new(project: Entity) -> Self { - Self { - tracked_buffers: BTreeMap::default(), - edited_since_project_diagnostics_check: false, - project, - } - } - - pub fn project(&self) -> &Entity { - &self.project - } - - /// Notifies a diagnostics check - pub fn checked_project_diagnostics(&mut self) { - self.edited_since_project_diagnostics_check = false; - } - - /// Returns true if any files have been edited since the last project diagnostics check - pub fn has_edited_files_since_project_diagnostics_check(&self) -> bool { - self.edited_since_project_diagnostics_check - } - - fn track_buffer_internal( - &mut self, - buffer: Entity, - is_created: bool, - cx: &mut Context, - ) -> &mut TrackedBuffer { - let tracked_buffer = self - .tracked_buffers - .entry(buffer.clone()) - .or_insert_with(|| { - let open_lsp_handle = self.project.update(cx, |project, cx| { - project.register_buffer_with_language_servers(&buffer, cx) - }); - - let text_snapshot = buffer.read(cx).text_snapshot(); - let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx)); - let (diff_update_tx, diff_update_rx) = mpsc::unbounded(); - let base_text; - let status; - let unreviewed_changes; - if is_created { - base_text = Rope::default(); - status = TrackedBufferStatus::Created; - unreviewed_changes = Patch::new(vec![Edit { - old: 0..1, - new: 0..text_snapshot.max_point().row + 1, - }]) - } else { - base_text = buffer.read(cx).as_rope().clone(); - status = TrackedBufferStatus::Modified; - unreviewed_changes = Patch::default(); - } - TrackedBuffer { - buffer: buffer.clone(), - base_text, - unreviewed_changes, - snapshot: text_snapshot.clone(), - status, - version: buffer.read(cx).version(), - diff, - diff_update: diff_update_tx, - _open_lsp_handle: open_lsp_handle, - _maintain_diff: cx.spawn({ - let buffer = buffer.clone(); - async move |this, cx| { - Self::maintain_diff(this, buffer, diff_update_rx, cx) - .await - .ok(); - } - }), - _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), - } - }); - tracked_buffer.version = buffer.read(cx).version(); - tracked_buffer - } - - fn handle_buffer_event( - &mut self, - buffer: Entity, - event: &BufferEvent, - cx: &mut Context, - ) { - match event { - BufferEvent::Edited { .. } => self.handle_buffer_edited(buffer, cx), - BufferEvent::FileHandleChanged => { - self.handle_buffer_file_changed(buffer, cx); - } - _ => {} - }; - } - - fn handle_buffer_edited(&mut self, buffer: Entity, cx: &mut Context) { - let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { - return; - }; - tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); - } - - fn handle_buffer_file_changed(&mut self, buffer: Entity, cx: &mut Context) { - let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { - return; - }; - - match tracked_buffer.status { - TrackedBufferStatus::Created | TrackedBufferStatus::Modified => { - if buffer - .read(cx) - .file() - .map_or(false, |file| file.disk_state() == DiskState::Deleted) - { - // If the buffer had been edited by a tool, but it got - // deleted externally, we want to stop tracking it. - self.tracked_buffers.remove(&buffer); - } - cx.notify(); - } - TrackedBufferStatus::Deleted => { - if buffer - .read(cx) - .file() - .map_or(false, |file| file.disk_state() != DiskState::Deleted) - { - // If the buffer had been deleted by a tool, but it got - // resurrected externally, we want to clear the changes we - // were tracking and reset the buffer's state. - self.tracked_buffers.remove(&buffer); - self.track_buffer_internal(buffer, false, cx); - } - cx.notify(); - } - } - } - - async fn maintain_diff( - this: WeakEntity, - buffer: Entity, - mut diff_update: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>, - cx: &mut AsyncApp, - ) -> Result<()> { - while let Some((author, buffer_snapshot)) = diff_update.next().await { - let (rebase, diff, language, language_registry) = - this.read_with(cx, |this, cx| { - let tracked_buffer = this - .tracked_buffers - .get(&buffer) - .context("buffer not tracked")?; - - let rebase = cx.background_spawn({ - let mut base_text = tracked_buffer.base_text.clone(); - let old_snapshot = tracked_buffer.snapshot.clone(); - let new_snapshot = buffer_snapshot.clone(); - let unreviewed_changes = tracked_buffer.unreviewed_changes.clone(); - async move { - let edits = diff_snapshots(&old_snapshot, &new_snapshot); - if let ChangeAuthor::User = author { - apply_non_conflicting_edits( - &unreviewed_changes, - edits, - &mut base_text, - new_snapshot.as_rope(), - ); - } - (Arc::new(base_text.to_string()), base_text) - } - }); - - anyhow::Ok(( - rebase, - tracked_buffer.diff.clone(), - tracked_buffer.buffer.read(cx).language().cloned(), - tracked_buffer.buffer.read(cx).language_registry(), - )) - })??; - - let (new_base_text, new_base_text_rope) = rebase.await; - let diff_snapshot = BufferDiff::update_diff( - diff.clone(), - buffer_snapshot.clone(), - Some(new_base_text), - true, - false, - language, - language_registry, - cx, - ) - .await; - - let mut unreviewed_changes = Patch::default(); - if let Ok(diff_snapshot) = diff_snapshot { - unreviewed_changes = cx - .background_spawn({ - let diff_snapshot = diff_snapshot.clone(); - let buffer_snapshot = buffer_snapshot.clone(); - let new_base_text_rope = new_base_text_rope.clone(); - async move { - let mut unreviewed_changes = Patch::default(); - for hunk in diff_snapshot.hunks_intersecting_range( - Anchor::MIN..Anchor::MAX, - &buffer_snapshot, - ) { - let old_range = new_base_text_rope - .offset_to_point(hunk.diff_base_byte_range.start) - ..new_base_text_rope - .offset_to_point(hunk.diff_base_byte_range.end); - let new_range = hunk.range.start..hunk.range.end; - unreviewed_changes.push(point_to_row_edit( - Edit { - old: old_range, - new: new_range, - }, - &new_base_text_rope, - &buffer_snapshot.as_rope(), - )); - } - unreviewed_changes - } - }) - .await; - - diff.update(cx, |diff, cx| { - diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx) - })?; - } - this.update(cx, |this, cx| { - let tracked_buffer = this - .tracked_buffers - .get_mut(&buffer) - .context("buffer not tracked")?; - tracked_buffer.base_text = new_base_text_rope; - tracked_buffer.snapshot = buffer_snapshot; - tracked_buffer.unreviewed_changes = unreviewed_changes; - cx.notify(); - anyhow::Ok(()) - })??; - } - - Ok(()) - } - - /// Track a buffer as read, so we can notify the model about user edits. - pub fn buffer_read(&mut self, buffer: Entity, cx: &mut Context) { - self.track_buffer_internal(buffer, false, cx); - } - - /// Mark a buffer as edited, so we can refresh it in the context - pub fn buffer_created(&mut self, buffer: Entity, cx: &mut Context) { - self.edited_since_project_diagnostics_check = true; - self.tracked_buffers.remove(&buffer); - self.track_buffer_internal(buffer.clone(), true, cx); - } - - /// Mark a buffer as edited, so we can refresh it in the context - pub fn buffer_edited(&mut self, buffer: Entity, cx: &mut Context) { - self.edited_since_project_diagnostics_check = true; - - let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx); - if let TrackedBufferStatus::Deleted = tracked_buffer.status { - tracked_buffer.status = TrackedBufferStatus::Modified; - } - tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx); - } - - pub fn will_delete_buffer(&mut self, buffer: Entity, cx: &mut Context) { - let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx); - match tracked_buffer.status { - TrackedBufferStatus::Created => { - self.tracked_buffers.remove(&buffer); - cx.notify(); - } - TrackedBufferStatus::Modified => { - buffer.update(cx, |buffer, cx| buffer.set_text("", cx)); - tracked_buffer.status = TrackedBufferStatus::Deleted; - tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx); - } - TrackedBufferStatus::Deleted => {} - } - cx.notify(); - } - - pub fn keep_edits_in_range( - &mut self, - buffer: Entity, - buffer_range: Range, - cx: &mut Context, - ) { - let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { - return; - }; - - match tracked_buffer.status { - TrackedBufferStatus::Deleted => { - self.tracked_buffers.remove(&buffer); - cx.notify(); - } - _ => { - let buffer = buffer.read(cx); - let buffer_range = - buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer); - let mut delta = 0i32; - - tracked_buffer.unreviewed_changes.retain_mut(|edit| { - edit.old.start = (edit.old.start as i32 + delta) as u32; - edit.old.end = (edit.old.end as i32 + delta) as u32; - - if buffer_range.end.row < edit.new.start - || buffer_range.start.row > edit.new.end - { - true - } else { - let old_range = tracked_buffer - .base_text - .point_to_offset(Point::new(edit.old.start, 0)) - ..tracked_buffer.base_text.point_to_offset(cmp::min( - Point::new(edit.old.end, 0), - tracked_buffer.base_text.max_point(), - )); - let new_range = tracked_buffer - .snapshot - .point_to_offset(Point::new(edit.new.start, 0)) - ..tracked_buffer.snapshot.point_to_offset(cmp::min( - Point::new(edit.new.end, 0), - tracked_buffer.snapshot.max_point(), - )); - tracked_buffer.base_text.replace( - old_range, - &tracked_buffer - .snapshot - .text_for_range(new_range) - .collect::(), - ); - delta += edit.new_len() as i32 - edit.old_len() as i32; - false - } - }); - tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); - } - } - } - - pub fn reject_edits_in_ranges( - &mut self, - buffer: Entity, - buffer_ranges: Vec>, - cx: &mut Context, - ) -> Task> { - let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { - return Task::ready(Ok(())); - }; - - match tracked_buffer.status { - TrackedBufferStatus::Created => { - let delete = buffer - .read(cx) - .entry_id(cx) - .and_then(|entry_id| { - self.project - .update(cx, |project, cx| project.delete_entry(entry_id, false, cx)) - }) - .unwrap_or(Task::ready(Ok(()))); - self.tracked_buffers.remove(&buffer); - cx.notify(); - delete - } - TrackedBufferStatus::Deleted => { - buffer.update(cx, |buffer, cx| { - buffer.set_text(tracked_buffer.base_text.to_string(), cx) - }); - let save = self - .project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); - - // Clear all tracked changes for this buffer and start over as if we just read it. - self.tracked_buffers.remove(&buffer); - self.buffer_read(buffer.clone(), cx); - cx.notify(); - save - } - TrackedBufferStatus::Modified => { - buffer.update(cx, |buffer, cx| { - let mut buffer_row_ranges = buffer_ranges - .into_iter() - .map(|range| { - range.start.to_point(buffer).row..range.end.to_point(buffer).row - }) - .peekable(); - - let mut edits_to_revert = Vec::new(); - for edit in tracked_buffer.unreviewed_changes.edits() { - let new_range = tracked_buffer - .snapshot - .anchor_before(Point::new(edit.new.start, 0)) - ..tracked_buffer.snapshot.anchor_after(cmp::min( - Point::new(edit.new.end, 0), - tracked_buffer.snapshot.max_point(), - )); - let new_row_range = new_range.start.to_point(buffer).row - ..new_range.end.to_point(buffer).row; - - let mut revert = false; - while let Some(buffer_row_range) = buffer_row_ranges.peek() { - if buffer_row_range.end < new_row_range.start { - buffer_row_ranges.next(); - } else if buffer_row_range.start > new_row_range.end { - break; - } else { - revert = true; - break; - } - } - - if revert { - let old_range = tracked_buffer - .base_text - .point_to_offset(Point::new(edit.old.start, 0)) - ..tracked_buffer.base_text.point_to_offset(cmp::min( - Point::new(edit.old.end, 0), - tracked_buffer.base_text.max_point(), - )); - let old_text = tracked_buffer - .base_text - .chunks_in_range(old_range) - .collect::(); - edits_to_revert.push((new_range, old_text)); - } - } - - buffer.edit(edits_to_revert, None, cx); - }); - self.project - .update(cx, |project, cx| project.save_buffer(buffer, cx)) - } - } - } - - pub fn keep_all_edits(&mut self, cx: &mut Context) { - self.tracked_buffers - .retain(|_buffer, tracked_buffer| match tracked_buffer.status { - TrackedBufferStatus::Deleted => false, - _ => { - tracked_buffer.unreviewed_changes.clear(); - tracked_buffer.base_text = tracked_buffer.snapshot.as_rope().clone(); - tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); - true - } - }); - cx.notify(); - } - - /// Returns the set of buffers that contain changes that haven't been reviewed by the user. - pub fn changed_buffers(&self, cx: &App) -> BTreeMap, Entity> { - self.tracked_buffers - .iter() - .filter(|(_, tracked)| tracked.has_changes(cx)) - .map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone())) - .collect() - } - - /// Iterate over buffers changed since last read or edited by the model - pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator> { - self.tracked_buffers - .iter() - .filter(|(buffer, tracked)| { - let buffer = buffer.read(cx); - - tracked.version != buffer.version - && buffer - .file() - .map_or(false, |file| file.disk_state() != DiskState::Deleted) - }) - .map(|(buffer, _)| buffer) - } -} - -fn apply_non_conflicting_edits( - patch: &Patch, - edits: Vec>, - old_text: &mut Rope, - new_text: &Rope, -) { - let mut old_edits = patch.edits().iter().cloned().peekable(); - let mut new_edits = edits.into_iter().peekable(); - let mut applied_delta = 0i32; - let mut rebased_delta = 0i32; - - while let Some(mut new_edit) = new_edits.next() { - let mut conflict = false; - - // Push all the old edits that are before this new edit or that intersect with it. - while let Some(old_edit) = old_edits.peek() { - if new_edit.old.end < old_edit.new.start - || (!old_edit.new.is_empty() && new_edit.old.end == old_edit.new.start) - { - break; - } else if new_edit.old.start > old_edit.new.end - || (!old_edit.new.is_empty() && new_edit.old.start == old_edit.new.end) - { - let old_edit = old_edits.next().unwrap(); - rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32; - } else { - conflict = true; - if new_edits - .peek() - .map_or(false, |next_edit| next_edit.old.overlaps(&old_edit.new)) - { - new_edit = new_edits.next().unwrap(); - } else { - let old_edit = old_edits.next().unwrap(); - rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32; - } - } - } - - if !conflict { - // This edit doesn't intersect with any old edit, so we can apply it to the old text. - new_edit.old.start = (new_edit.old.start as i32 + applied_delta - rebased_delta) as u32; - new_edit.old.end = (new_edit.old.end as i32 + applied_delta - rebased_delta) as u32; - let old_bytes = old_text.point_to_offset(Point::new(new_edit.old.start, 0)) - ..old_text.point_to_offset(cmp::min( - Point::new(new_edit.old.end, 0), - old_text.max_point(), - )); - let new_bytes = new_text.point_to_offset(Point::new(new_edit.new.start, 0)) - ..new_text.point_to_offset(cmp::min( - Point::new(new_edit.new.end, 0), - new_text.max_point(), - )); - - old_text.replace( - old_bytes, - &new_text.chunks_in_range(new_bytes).collect::(), - ); - applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32; - } - } -} - -fn diff_snapshots( - old_snapshot: &text::BufferSnapshot, - new_snapshot: &text::BufferSnapshot, -) -> Vec> { - let mut edits = new_snapshot - .edits_since::(&old_snapshot.version) - .map(|edit| point_to_row_edit(edit, old_snapshot.as_rope(), new_snapshot.as_rope())) - .peekable(); - let mut row_edits = Vec::new(); - while let Some(mut edit) = edits.next() { - while let Some(next_edit) = edits.peek() { - if edit.old.end >= next_edit.old.start { - edit.old.end = next_edit.old.end; - edit.new.end = next_edit.new.end; - edits.next(); - } else { - break; - } - } - row_edits.push(edit); - } - row_edits -} - -fn point_to_row_edit(edit: Edit, old_text: &Rope, new_text: &Rope) -> Edit { - if edit.old.start.column == old_text.line_len(edit.old.start.row) - && new_text - .chars_at(new_text.point_to_offset(edit.new.start)) - .next() - == Some('\n') - && edit.old.start != old_text.max_point() - { - Edit { - old: edit.old.start.row + 1..edit.old.end.row + 1, - new: edit.new.start.row + 1..edit.new.end.row + 1, - } - } else if edit.old.start.column == 0 - && edit.old.end.column == 0 - && edit.new.end.column == 0 - && edit.old.end != old_text.max_point() - { - Edit { - old: edit.old.start.row..edit.old.end.row, - new: edit.new.start.row..edit.new.end.row, - } - } else { - Edit { - old: edit.old.start.row..edit.old.end.row + 1, - new: edit.new.start.row..edit.new.end.row + 1, - } - } -} - -#[derive(Copy, Clone, Debug)] -enum ChangeAuthor { - User, - Agent, -} - -#[derive(Copy, Clone, Eq, PartialEq)] -enum TrackedBufferStatus { - Created, - Modified, - Deleted, -} - -struct TrackedBuffer { - buffer: Entity, - base_text: Rope, - unreviewed_changes: Patch, - status: TrackedBufferStatus, - version: clock::Global, - diff: Entity, - snapshot: text::BufferSnapshot, - diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>, - _open_lsp_handle: OpenLspBufferHandle, - _maintain_diff: Task<()>, - _subscription: Subscription, -} - -impl TrackedBuffer { - fn has_changes(&self, cx: &App) -> bool { - self.diff - .read(cx) - .hunks(&self.buffer.read(cx), cx) - .next() - .is_some() - } - - fn schedule_diff_update(&self, author: ChangeAuthor, cx: &App) { - self.diff_update - .unbounded_send((author, self.buffer.read(cx).text_snapshot())) - .ok(); - } -} - -pub struct ChangedBuffer { - pub diff: Entity, -} - -#[cfg(test)] -mod tests { - use std::env; - - use super::*; - use buffer_diff::DiffHunkStatusKind; - use gpui::TestAppContext; - use language::Point; - use project::{FakeFs, Fs, Project, RemoveOptions}; - use rand::prelude::*; - use serde_json::json; - use settings::SettingsStore; - use util::{RandomCharIter, path}; - - #[ctor::ctor] - fn init_logger() { - zlog::init_test(); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } - - #[gpui::test(iterations = 10)] - async fn test_keep_edits(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"})) - .await; - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let file_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) - .unwrap(); - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path, cx)) - .await - .unwrap(); - - cx.update(|cx| { - action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(1, 1)..Point::new(1, 2), "E")], None, cx) - .unwrap() - }); - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(4, 2)..Point::new(4, 3), "O")], None, cx) - .unwrap() - }); - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - }); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\ndEf\nghi\njkl\nmnO" - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![ - HunkStatus { - range: Point::new(1, 0)..Point::new(2, 0), - diff_status: DiffHunkStatusKind::Modified, - old_text: "def\n".into(), - }, - HunkStatus { - range: Point::new(4, 0)..Point::new(4, 3), - diff_status: DiffHunkStatusKind::Modified, - old_text: "mno".into(), - } - ], - )] - ); - - action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), cx) - }); - cx.run_until_parked(); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(1, 0)..Point::new(2, 0), - diff_status: DiffHunkStatusKind::Modified, - old_text: "def\n".into(), - }], - )] - ); - - action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), cx) - }); - cx.run_until_parked(); - assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); - } - - #[gpui::test(iterations = 10)] - async fn test_deletions(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/dir"), - json!({"file": "abc\ndef\nghi\njkl\nmno\npqr"}), - ) - .await; - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let file_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) - .unwrap(); - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path, cx)) - .await - .unwrap(); - - cx.update(|cx| { - action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(1, 0)..Point::new(2, 0), "")], None, cx) - .unwrap(); - buffer.finalize_last_transaction(); - }); - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(3, 0)..Point::new(4, 0), "")], None, cx) - .unwrap(); - buffer.finalize_last_transaction(); - }); - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - }); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\nghi\njkl\npqr" - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![ - HunkStatus { - range: Point::new(1, 0)..Point::new(1, 0), - diff_status: DiffHunkStatusKind::Deleted, - old_text: "def\n".into(), - }, - HunkStatus { - range: Point::new(3, 0)..Point::new(3, 0), - diff_status: DiffHunkStatusKind::Deleted, - old_text: "mno\n".into(), - } - ], - )] - ); - - buffer.update(cx, |buffer, cx| buffer.undo(cx)); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\nghi\njkl\nmno\npqr" - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(1, 0)..Point::new(1, 0), - diff_status: DiffHunkStatusKind::Deleted, - old_text: "def\n".into(), - }], - )] - ); - - action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Point::new(1, 0)..Point::new(1, 0), cx) - }); - cx.run_until_parked(); - assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); - } - - #[gpui::test(iterations = 10)] - async fn test_overlapping_user_edits(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"})) - .await; - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let file_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) - .unwrap(); - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path, cx)) - .await - .unwrap(); - - cx.update(|cx| { - action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx) - .unwrap() - }); - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - }); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\ndeF\nGHI\njkl\nmno" - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(1, 0)..Point::new(3, 0), - diff_status: DiffHunkStatusKind::Modified, - old_text: "def\nghi\n".into(), - }], - )] - ); - - buffer.update(cx, |buffer, cx| { - buffer.edit( - [ - (Point::new(0, 2)..Point::new(0, 2), "X"), - (Point::new(3, 0)..Point::new(3, 0), "Y"), - ], - None, - cx, - ) - }); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abXc\ndeF\nGHI\nYjkl\nmno" - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(1, 0)..Point::new(3, 0), - diff_status: DiffHunkStatusKind::Modified, - old_text: "def\nghi\n".into(), - }], - )] - ); - - buffer.update(cx, |buffer, cx| { - buffer.edit([(Point::new(1, 1)..Point::new(1, 1), "Z")], None, cx) - }); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abXc\ndZeF\nGHI\nYjkl\nmno" - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(1, 0)..Point::new(3, 0), - diff_status: DiffHunkStatusKind::Modified, - old_text: "def\nghi\n".into(), - }], - )] - ); - - action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx) - }); - cx.run_until_parked(); - assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); - } - - #[gpui::test(iterations = 10)] - async fn test_creating_files(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({})).await; - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let file_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx)) - .unwrap(); - - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path, cx)) - .await - .unwrap(); - cx.update(|cx| { - action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx)); - buffer.update(cx, |buffer, cx| buffer.set_text("lorem", cx)); - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - }); - project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .await - .unwrap(); - cx.run_until_parked(); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(0, 0)..Point::new(0, 5), - diff_status: DiffHunkStatusKind::Added, - old_text: "".into(), - }], - )] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "X")], None, cx)); - cx.run_until_parked(); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(0, 0)..Point::new(0, 6), - diff_status: DiffHunkStatusKind::Added, - old_text: "".into(), - }], - )] - ); - - action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), 0..5, cx) - }); - cx.run_until_parked(); - assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); - } - - #[gpui::test(iterations = 10)] - async fn test_deleting_files(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/dir"), - json!({"file1": "lorem\n", "file2": "ipsum\n"}), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let file1_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx)) - .unwrap(); - let file2_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file2", cx)) - .unwrap(); - - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let buffer1 = project - .update(cx, |project, cx| { - project.open_buffer(file1_path.clone(), cx) - }) - .await - .unwrap(); - let buffer2 = project - .update(cx, |project, cx| { - project.open_buffer(file2_path.clone(), cx) - }) - .await - .unwrap(); - - action_log.update(cx, |log, cx| log.will_delete_buffer(buffer1.clone(), cx)); - action_log.update(cx, |log, cx| log.will_delete_buffer(buffer2.clone(), cx)); - project - .update(cx, |project, cx| { - project.delete_file(file1_path.clone(), false, cx) - }) - .unwrap() - .await - .unwrap(); - project - .update(cx, |project, cx| { - project.delete_file(file2_path.clone(), false, cx) - }) - .unwrap() - .await - .unwrap(); - cx.run_until_parked(); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![ - ( - buffer1.clone(), - vec![HunkStatus { - range: Point::new(0, 0)..Point::new(0, 0), - diff_status: DiffHunkStatusKind::Deleted, - old_text: "lorem\n".into(), - }] - ), - ( - buffer2.clone(), - vec![HunkStatus { - range: Point::new(0, 0)..Point::new(0, 0), - diff_status: DiffHunkStatusKind::Deleted, - old_text: "ipsum\n".into(), - }], - ) - ] - ); - - // Simulate file1 being recreated externally. - fs.insert_file(path!("/dir/file1"), "LOREM".as_bytes().to_vec()) - .await; - - // Simulate file2 being recreated by a tool. - let buffer2 = project - .update(cx, |project, cx| project.open_buffer(file2_path, cx)) - .await - .unwrap(); - action_log.update(cx, |log, cx| log.buffer_read(buffer2.clone(), cx)); - buffer2.update(cx, |buffer, cx| buffer.set_text("IPSUM", cx)); - action_log.update(cx, |log, cx| log.buffer_edited(buffer2.clone(), cx)); - project - .update(cx, |project, cx| project.save_buffer(buffer2.clone(), cx)) - .await - .unwrap(); - - cx.run_until_parked(); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer2.clone(), - vec![HunkStatus { - range: Point::new(0, 0)..Point::new(0, 5), - diff_status: DiffHunkStatusKind::Modified, - old_text: "ipsum\n".into(), - }], - )] - ); - - // Simulate file2 being deleted externally. - fs.remove_file(path!("/dir/file2").as_ref(), RemoveOptions::default()) - .await - .unwrap(); - cx.run_until_parked(); - assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); - } - - #[gpui::test(iterations = 10)] - async fn test_reject_edits(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"})) - .await; - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let file_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) - .unwrap(); - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path, cx)) - .await - .unwrap(); - - cx.update(|cx| { - action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx) - .unwrap() - }); - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx) - .unwrap() - }); - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - }); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\ndE\nXYZf\nghi\njkl\nmnO" - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![ - HunkStatus { - range: Point::new(1, 0)..Point::new(3, 0), - diff_status: DiffHunkStatusKind::Modified, - old_text: "def\n".into(), - }, - HunkStatus { - range: Point::new(5, 0)..Point::new(5, 3), - diff_status: DiffHunkStatusKind::Modified, - old_text: "mno".into(), - } - ], - )] - ); - - // If the rejected range doesn't overlap with any hunk, we ignore it. - action_log - .update(cx, |log, cx| { - log.reject_edits_in_ranges( - buffer.clone(), - vec![Point::new(4, 0)..Point::new(4, 0)], - cx, - ) - }) - .await - .unwrap(); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\ndE\nXYZf\nghi\njkl\nmnO" - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![ - HunkStatus { - range: Point::new(1, 0)..Point::new(3, 0), - diff_status: DiffHunkStatusKind::Modified, - old_text: "def\n".into(), - }, - HunkStatus { - range: Point::new(5, 0)..Point::new(5, 3), - diff_status: DiffHunkStatusKind::Modified, - old_text: "mno".into(), - } - ], - )] - ); - - action_log - .update(cx, |log, cx| { - log.reject_edits_in_ranges( - buffer.clone(), - vec![Point::new(0, 0)..Point::new(1, 0)], - cx, - ) - }) - .await - .unwrap(); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\ndef\nghi\njkl\nmnO" - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(4, 0)..Point::new(4, 3), - diff_status: DiffHunkStatusKind::Modified, - old_text: "mno".into(), - }], - )] - ); - - action_log - .update(cx, |log, cx| { - log.reject_edits_in_ranges( - buffer.clone(), - vec![Point::new(4, 0)..Point::new(4, 0)], - cx, - ) - }) - .await - .unwrap(); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\ndef\nghi\njkl\nmno" - ); - assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); - } - - #[gpui::test(iterations = 10)] - async fn test_reject_multiple_edits(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"})) - .await; - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let file_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) - .unwrap(); - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path, cx)) - .await - .unwrap(); - - cx.update(|cx| { - action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx) - .unwrap() - }); - buffer.update(cx, |buffer, cx| { - buffer - .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx) - .unwrap() - }); - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - }); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\ndE\nXYZf\nghi\njkl\nmnO" - ); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![ - HunkStatus { - range: Point::new(1, 0)..Point::new(3, 0), - diff_status: DiffHunkStatusKind::Modified, - old_text: "def\n".into(), - }, - HunkStatus { - range: Point::new(5, 0)..Point::new(5, 3), - diff_status: DiffHunkStatusKind::Modified, - old_text: "mno".into(), - } - ], - )] - ); - - action_log.update(cx, |log, cx| { - let range_1 = buffer.read(cx).anchor_before(Point::new(0, 0)) - ..buffer.read(cx).anchor_before(Point::new(1, 0)); - let range_2 = buffer.read(cx).anchor_before(Point::new(5, 0)) - ..buffer.read(cx).anchor_before(Point::new(5, 3)); - - log.reject_edits_in_ranges(buffer.clone(), vec![range_1, range_2], cx) - .detach(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\ndef\nghi\njkl\nmno" - ); - }); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |buffer, _| buffer.text()), - "abc\ndef\nghi\njkl\nmno" - ); - assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); - } - - #[gpui::test(iterations = 10)] - async fn test_reject_deleted_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({"file": "content"})) - .await; - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let file_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) - .unwrap(); - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx)) - .await - .unwrap(); - - cx.update(|cx| { - action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx)); - }); - project - .update(cx, |project, cx| { - project.delete_file(file_path.clone(), false, cx) - }) - .unwrap() - .await - .unwrap(); - cx.run_until_parked(); - assert!(!fs.is_file(path!("/dir/file").as_ref()).await); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(0, 0)..Point::new(0, 0), - diff_status: DiffHunkStatusKind::Deleted, - old_text: "content".into(), - }] - )] - ); - - action_log - .update(cx, |log, cx| { - log.reject_edits_in_ranges( - buffer.clone(), - vec![Point::new(0, 0)..Point::new(0, 0)], - cx, - ) - }) - .await - .unwrap(); - cx.run_until_parked(); - assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "content"); - assert!(fs.is_file(path!("/dir/file").as_ref()).await); - assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); - } - - #[gpui::test(iterations = 10)] - async fn test_reject_created_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let file_path = project - .read_with(cx, |project, cx| { - project.find_project_path("dir/new_file", cx) - }) - .unwrap(); - - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path, cx)) - .await - .unwrap(); - cx.update(|cx| { - action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx)); - buffer.update(cx, |buffer, cx| buffer.set_text("content", cx)); - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - }); - project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .await - .unwrap(); - assert!(fs.is_file(path!("/dir/new_file").as_ref()).await); - cx.run_until_parked(); - assert_eq!( - unreviewed_hunks(&action_log, cx), - vec![( - buffer.clone(), - vec![HunkStatus { - range: Point::new(0, 0)..Point::new(0, 7), - diff_status: DiffHunkStatusKind::Added, - old_text: "".into(), - }], - )] - ); - - action_log - .update(cx, |log, cx| { - log.reject_edits_in_ranges( - buffer.clone(), - vec![Point::new(0, 0)..Point::new(0, 11)], - cx, - ) - }) - .await - .unwrap(); - cx.run_until_parked(); - assert!(!fs.is_file(path!("/dir/new_file").as_ref()).await); - assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); - } - - #[gpui::test(iterations = 100)] - async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) { - init_test(cx); - - let operations = env::var("OPERATIONS") - .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) - .unwrap_or(20); - - let text = RandomCharIter::new(&mut rng).take(50).collect::(); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({"file": text})).await; - let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let file_path = project - .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) - .unwrap(); - let buffer = project - .update(cx, |project, cx| project.open_buffer(file_path, cx)) - .await - .unwrap(); - - action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); - - for _ in 0..operations { - match rng.gen_range(0..100) { - 0..25 => { - action_log.update(cx, |log, cx| { - let range = buffer.read(cx).random_byte_range(0, &mut rng); - log::info!("keeping edits in range {:?}", range); - log.keep_edits_in_range(buffer.clone(), range, cx) - }); - } - 25..50 => { - action_log - .update(cx, |log, cx| { - let range = buffer.read(cx).random_byte_range(0, &mut rng); - log::info!("rejecting edits in range {:?}", range); - log.reject_edits_in_ranges(buffer.clone(), vec![range], cx) - }) - .await - .unwrap(); - } - _ => { - let is_agent_change = rng.gen_bool(0.5); - if is_agent_change { - log::info!("agent edit"); - } else { - log::info!("user edit"); - } - cx.update(|cx| { - buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx)); - if is_agent_change { - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - } - }); - } - } - - if rng.gen_bool(0.2) { - quiesce(&action_log, &buffer, cx); - } - } - - quiesce(&action_log, &buffer, cx); - - fn quiesce( - action_log: &Entity, - buffer: &Entity, - cx: &mut TestAppContext, - ) { - log::info!("quiescing..."); - cx.run_until_parked(); - action_log.update(cx, |log, cx| { - let tracked_buffer = log.tracked_buffers.get(&buffer).unwrap(); - let mut old_text = tracked_buffer.base_text.clone(); - let new_text = buffer.read(cx).as_rope(); - for edit in tracked_buffer.unreviewed_changes.edits() { - let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0)); - let old_end = old_text.point_to_offset(cmp::min( - Point::new(edit.new.start + edit.old_len(), 0), - old_text.max_point(), - )); - old_text.replace( - old_start..old_end, - &new_text.slice_rows(edit.new.clone()).to_string(), - ); - } - pretty_assertions::assert_eq!(old_text.to_string(), new_text.to_string()); - }) - } - } - - #[derive(Debug, Clone, PartialEq, Eq)] - struct HunkStatus { - range: Range, - diff_status: DiffHunkStatusKind, - old_text: String, - } - - fn unreviewed_hunks( - action_log: &Entity, - cx: &TestAppContext, - ) -> Vec<(Entity, Vec)> { - cx.read(|cx| { - action_log - .read(cx) - .changed_buffers(cx) - .into_iter() - .map(|(buffer, diff)| { - let snapshot = buffer.read(cx).snapshot(); - ( - buffer, - diff.read(cx) - .hunks(&snapshot, cx) - .map(|hunk| HunkStatus { - diff_status: hunk.status().kind, - range: hunk.range, - old_text: diff - .read(cx) - .base_text() - .text_for_range(hunk.diff_base_byte_range) - .collect(), - }) - .collect(), - ) - }) - .collect() - }) - } -} diff --git a/crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs b/crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs deleted file mode 100644 index 89277be4436..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs +++ /dev/null @@ -1,328 +0,0 @@ -use crate::commit::get_messages; -use crate::{GitRemote, Oid}; -use anyhow::{Context as _, Result, anyhow}; -use collections::{HashMap, HashSet}; -use futures::AsyncWriteExt; -use gpui::SharedString; -use serde::{Deserialize, Serialize}; -use std::process::Stdio; -use std::{ops::Range, path::Path}; -use text::Rope; -use time::OffsetDateTime; -use time::UtcOffset; -use time::macros::format_description; - -pub use git2 as libgit; - -#[derive(Debug, Clone, Default)] -pub struct Blame { - pub entries: Vec, - pub messages: HashMap, - pub remote_url: Option, -} - -#[derive(Clone, Debug, Default)] -pub struct ParsedCommitMessage { - pub message: SharedString, - pub permalink: Option, - pub pull_request: Option, - pub remote: Option, -} - -impl Blame { - pub async fn for_path( - git_binary: &Path, - working_directory: &Path, - path: &Path, - content: &Rope, - remote_url: Option, - ) -> Result { - let output = run_git_blame(git_binary, working_directory, path, content).await?; - let mut entries = parse_git_blame(&output)?; - entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); - - let mut unique_shas = HashSet::default(); - - for entry in entries.iter_mut() { - unique_shas.insert(entry.sha); - } - - let shas = unique_shas.into_iter().collect::>(); - let messages = get_messages(working_directory, &shas) - .await - .context("failed to get commit messages")?; - - Ok(Self { - entries, - messages, - remote_url, - }) - } -} - -const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD"; -const GIT_BLAME_NO_PATH: &str = "fatal: no such path"; - -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] -pub struct BlameEntry { - pub sha: Oid, - - pub range: Range, - - pub original_line_number: u32, - - pub author: Option, - pub author_mail: Option, - pub author_time: Option, - pub author_tz: Option, - - pub committer_name: Option, - pub committer_email: Option, - pub committer_time: Option, - pub committer_tz: Option, - - pub summary: Option, - - pub previous: Option, - pub filename: String, -} - -impl BlameEntry { - // Returns a BlameEntry by parsing the first line of a `git blame --incremental` - // entry. The line MUST have this format: - // - // <40-byte-hex-sha1> - fn new_from_blame_line(line: &str) -> Result { - let mut parts = line.split_whitespace(); - - let sha = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing sha from {line}"))?; - - let original_line_number = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing original line number from {line}"))?; - let final_line_number = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing final line number from {line}"))?; - - let line_count = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing line count from {line}"))?; - - let start_line = final_line_number.saturating_sub(1); - let end_line = start_line + line_count; - let range = start_line..end_line; - - Ok(Self { - sha, - range, - original_line_number, - ..Default::default() - }) - } - - pub fn author_offset_date_time(&self) -> Result { - if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) { - let format = format_description!("[offset_hour][offset_minute]"); - let offset = UtcOffset::parse(author_tz, &format)?; - let date_time_utc = OffsetDateTime::from_unix_timestamp(author_time)?; - - Ok(date_time_utc.to_offset(offset)) - } else { - // Directly return current time in UTC if there's no committer time or timezone - Ok(time::OffsetDateTime::now_utc()) - } - } -} - -// parse_git_blame parses the output of `git blame --incremental`, which returns -// all the blame-entries for a given path incrementally, as it finds them. -// -// Each entry *always* starts with: -// -// <40-byte-hex-sha1> -// -// Each entry *always* ends with: -// -// filename -// -// Line numbers are 1-indexed. -// -// A `git blame --incremental` entry looks like this: -// -// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1 -// author Joe Schmoe -// author-mail -// author-time 1709741400 -// author-tz +0100 -// committer Joe Schmoe -// committer-mail -// committer-time 1709741400 -// committer-tz +0100 -// summary Joe's cool commit -// previous 486c2409237a2c627230589e567024a96751d475 index.js -// filename index.js -// -// If the entry has the same SHA as an entry that was already printed then no -// signature information is printed: -// -// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1 -// previous 486c2409237a2c627230589e567024a96751d475 index.js -// filename index.js -// -// More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html -fn parse_git_blame(output: &str) -> Result> { - let mut entries: Vec = Vec::new(); - let mut index: HashMap = HashMap::default(); - - let mut current_entry: Option = None; - - for line in output.lines() { - let mut done = false; - - match &mut current_entry { - None => { - let mut new_entry = BlameEntry::new_from_blame_line(line)?; - - if let Some(existing_entry) = index - .get(&new_entry.sha) - .and_then(|slot| entries.get(*slot)) - { - new_entry.author.clone_from(&existing_entry.author); - new_entry - .author_mail - .clone_from(&existing_entry.author_mail); - new_entry.author_time = existing_entry.author_time; - new_entry.author_tz.clone_from(&existing_entry.author_tz); - new_entry - .committer_name - .clone_from(&existing_entry.committer_name); - new_entry - .committer_email - .clone_from(&existing_entry.committer_email); - new_entry.committer_time = existing_entry.committer_time; - new_entry - .committer_tz - .clone_from(&existing_entry.committer_tz); - new_entry.summary.clone_from(&existing_entry.summary); - } - - current_entry.replace(new_entry); - } - Some(entry) => { - let Some((key, value)) = line.split_once(' ') else { - continue; - }; - let is_committed = !entry.sha.is_zero(); - match key { - "filename" => { - entry.filename = value.into(); - done = true; - } - "previous" => entry.previous = Some(value.into()), - - "summary" if is_committed => entry.summary = Some(value.into()), - "author" if is_committed => entry.author = Some(value.into()), - "author-mail" if is_committed => entry.author_mail = Some(value.into()), - "author-time" if is_committed => { - entry.author_time = Some(value.parse::()?) - } - "author-tz" if is_committed => entry.author_tz = Some(value.into()), - - "committer" if is_committed => entry.committer_name = Some(value.into()), - "committer-mail" if is_committed => entry.committer_email = Some(value.into()), - "committer-time" if is_committed => { - entry.committer_time = Some(value.parse::()?) - } - "committer-tz" if is_committed => entry.committer_tz = Some(value.into()), - _ => {} - } - } - }; - - if done { - if let Some(entry) = current_entry.take() { - index.insert(entry.sha, entries.len()); - - // We only want annotations that have a commit. - if !entry.sha.is_zero() { - entries.push(entry); - } - } - } - } - - Ok(entries) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::BlameEntry; - use super::parse_git_blame; - - fn read_test_data(filename: &str) -> String { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("test_data"); - path.push(filename); - - std::fs::read_to_string(&path) - .unwrap_or_else(|_| panic!("Could not read test data at {:?}. Is it generated?", path)) - } - - fn assert_eq_golden(entries: &Vec, golden_filename: &str) { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("test_data"); - path.push("golden"); - path.push(format!("{}.json", golden_filename)); - - let mut have_json = - serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON"); - // We always want to save with a trailing newline. - have_json.push('\n'); - - let update = std::env::var("UPDATE_GOLDEN") - .map(|val| val.eq_ignore_ascii_case("true")) - .unwrap_or(false); - - if update { - std::fs::create_dir_all(path.parent().unwrap()) - .expect("could not create golden test data directory"); - std::fs::write(&path, have_json).expect("could not write out golden data"); - } else { - let want_json = - std::fs::read_to_string(&path).unwrap_or_else(|_| { - panic!("could not read golden test data file at {:?}. Did you run the test with UPDATE_GOLDEN=true before?", path); - }).replace("\r\n", "\n"); - - pretty_assertions::assert_eq!(have_json, want_json, "wrong blame entries"); - } - } - - #[test] - fn test_parse_git_blame_not_committed() { - let output = read_test_data("blame_incremental_not_committed"); - let entries = parse_git_blame(&output).unwrap(); - assert_eq_golden(&entries, "blame_incremental_not_committed"); - } - - #[test] - fn test_parse_git_blame_simple() { - let output = read_test_data("blame_incremental_simple"); - let entries = parse_git_blame(&output).unwrap(); - assert_eq_golden(&entries, "blame_incremental_simple"); - } - - #[test] - fn test_parse_git_blame_complex() { - let output = read_test_data("blame_incremental_complex"); - let entries = parse_git_blame(&output).unwrap(); - assert_eq_golden(&entries, "blame_incremental_complex"); - } -} diff --git a/crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs b/crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs deleted file mode 100644 index 36fccb51327..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs +++ /dev/null @@ -1,371 +0,0 @@ -use crate::commit::get_messages; -use crate::{GitRemote, Oid}; -use anyhow::{Context as _, Result, anyhow}; -use collections::{HashMap, HashSet}; -use futures::AsyncWriteExt; -use gpui::SharedString; -use serde::{Deserialize, Serialize}; -use std::process::Stdio; -use std::{ops::Range, path::Path}; -use text::Rope; -use time::OffsetDateTime; -use time::UtcOffset; -use time::macros::format_description; - -pub use git2 as libgit; - -#[derive(Debug, Clone, Default)] -pub struct Blame { - pub entries: Vec, - pub messages: HashMap, - pub remote_url: Option, -} - -#[derive(Clone, Debug, Default)] -pub struct ParsedCommitMessage { - pub message: SharedString, - pub permalink: Option, - pub pull_request: Option, - pub remote: Option, -} - -impl Blame { - pub async fn for_path( - git_binary: &Path, - working_directory: &Path, - path: &Path, - content: &Rope, - remote_url: Option, - ) -> Result { - let output = run_git_blame(git_binary, working_directory, path, content).await?; - let mut entries = parse_git_blame(&output)?; - entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); - - let mut unique_shas = HashSet::default(); - - for entry in entries.iter_mut() { - unique_shas.insert(entry.sha); - } - - let shas = unique_shas.into_iter().collect::>(); - let messages = get_messages(working_directory, &shas) - .await - .context("failed to get commit messages")?; - - Ok(Self { - entries, - messages, - remote_url, - }) - } -} - -const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD"; -const GIT_BLAME_NO_PATH: &str = "fatal: no such path"; - -async fn run_git_blame( - git_binary: &Path, - working_directory: &Path, - path: &Path, - contents: &Rope, -) -> Result { - let mut child = util::command::new_smol_command(git_binary) - .current_dir(working_directory) - .arg("blame") - .arg("--incremental") - .arg("--contents") - .arg("-") - .arg(path.as_os_str()) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("starting git blame process")?; - - let stdin = child - .stdin - .as_mut() - .context("failed to get pipe to stdin of git blame command")?; - - for chunk in contents.chunks() { - stdin.write_all(chunk.as_bytes()).await?; - } - stdin.flush().await?; - - let output = child.output().await.context("reading git blame output")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let trimmed = stderr.trim(); - if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { - return Ok(String::new()); - } - anyhow::bail!("git blame process failed: {stderr}"); - } - - Ok(String::from_utf8(output.stdout)?) -} - -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] -pub struct BlameEntry { - pub sha: Oid, - - pub range: Range, - - pub original_line_number: u32, - - pub author: Option, - pub author_mail: Option, - pub author_time: Option, - pub author_tz: Option, - - pub committer_name: Option, - pub committer_email: Option, - pub committer_time: Option, - pub committer_tz: Option, - - pub summary: Option, - - pub previous: Option, - pub filename: String, -} - -impl BlameEntry { - // Returns a BlameEntry by parsing the first line of a `git blame --incremental` - // entry. The line MUST have this format: - // - // <40-byte-hex-sha1> - fn new_from_blame_line(line: &str) -> Result { - let mut parts = line.split_whitespace(); - - let sha = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing sha from {line}"))?; - - let original_line_number = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing original line number from {line}"))?; - let final_line_number = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing final line number from {line}"))?; - - let line_count = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing line count from {line}"))?; - - let start_line = final_line_number.saturating_sub(1); - let end_line = start_line + line_count; - let range = start_line..end_line; - - Ok(Self { - sha, - range, - original_line_number, - ..Default::default() - }) - } - - pub fn author_offset_date_time(&self) -> Result { - if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) { - let format = format_description!("[offset_hour][offset_minute]"); - let offset = UtcOffset::parse(author_tz, &format)?; - let date_time_utc = OffsetDateTime::from_unix_timestamp(author_time)?; - - Ok(date_time_utc.to_offset(offset)) - } else { - // Directly return current time in UTC if there's no committer time or timezone - Ok(time::OffsetDateTime::now_utc()) - } - } -} - -// parse_git_blame parses the output of `git blame --incremental`, which returns -// all the blame-entries for a given path incrementally, as it finds them. -// -// Each entry *always* starts with: -// -// <40-byte-hex-sha1> -// -// Each entry *always* ends with: -// -// filename -// -// Line numbers are 1-indexed. -// -// A `git blame --incremental` entry looks like this: -// -// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1 -// author Joe Schmoe -// author-mail -// author-time 1709741400 -// author-tz +0100 -// committer Joe Schmoe -// committer-mail -// committer-time 1709741400 -// committer-tz +0100 -// summary Joe's cool commit -// previous 486c2409237a2c627230589e567024a96751d475 index.js -// filename index.js -// -// If the entry has the same SHA as an entry that was already printed then no -// signature information is printed: -// -// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1 -// previous 486c2409237a2c627230589e567024a96751d475 index.js -// filename index.js -// -// More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html -fn parse_git_blame(output: &str) -> Result> { - let mut entries: Vec = Vec::new(); - let mut index: HashMap = HashMap::default(); - - let mut current_entry: Option = None; - - for line in output.lines() { - let mut done = false; - - match &mut current_entry { - None => { - let mut new_entry = BlameEntry::new_from_blame_line(line)?; - - if let Some(existing_entry) = index - .get(&new_entry.sha) - .and_then(|slot| entries.get(*slot)) - { - new_entry.author.clone_from(&existing_entry.author); - new_entry - .author_mail - .clone_from(&existing_entry.author_mail); - new_entry.author_time = existing_entry.author_time; - new_entry.author_tz.clone_from(&existing_entry.author_tz); - new_entry - .committer_name - .clone_from(&existing_entry.committer_name); - new_entry - .committer_email - .clone_from(&existing_entry.committer_email); - new_entry.committer_time = existing_entry.committer_time; - new_entry - .committer_tz - .clone_from(&existing_entry.committer_tz); - new_entry.summary.clone_from(&existing_entry.summary); - } - - current_entry.replace(new_entry); - } - Some(entry) => { - let Some((key, value)) = line.split_once(' ') else { - continue; - }; - let is_committed = !entry.sha.is_zero(); - match key { - "filename" => { - entry.filename = value.into(); - done = true; - } - "previous" => entry.previous = Some(value.into()), - - "summary" if is_committed => entry.summary = Some(value.into()), - "author" if is_committed => entry.author = Some(value.into()), - "author-mail" if is_committed => entry.author_mail = Some(value.into()), - "author-time" if is_committed => { - entry.author_time = Some(value.parse::()?) - } - "author-tz" if is_committed => entry.author_tz = Some(value.into()), - - "committer" if is_committed => entry.committer_name = Some(value.into()), - "committer-mail" if is_committed => entry.committer_email = Some(value.into()), - "committer-time" if is_committed => { - entry.committer_time = Some(value.parse::()?) - } - "committer-tz" if is_committed => entry.committer_tz = Some(value.into()), - _ => {} - } - } - }; - - if done { - if let Some(entry) = current_entry.take() { - index.insert(entry.sha, entries.len()); - - // We only want annotations that have a commit. - if !entry.sha.is_zero() { - entries.push(entry); - } - } - } - } - - Ok(entries) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::BlameEntry; - use super::parse_git_blame; - - fn read_test_data(filename: &str) -> String { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("test_data"); - path.push(filename); - - std::fs::read_to_string(&path) - .unwrap_or_else(|_| panic!("Could not read test data at {:?}. Is it generated?", path)) - } - - fn assert_eq_golden(entries: &Vec, golden_filename: &str) { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("test_data"); - path.push("golden"); - path.push(format!("{}.json", golden_filename)); - - let mut have_json = - serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON"); - // We always want to save with a trailing newline. - have_json.push('\n'); - - let update = std::env::var("UPDATE_GOLDEN") - .map(|val| val.eq_ignore_ascii_case("true")) - .unwrap_or(false); - - if update { - std::fs::create_dir_all(path.parent().unwrap()) - .expect("could not create golden test data directory"); - std::fs::write(&path, have_json).expect("could not write out golden data"); - } else { - let want_json = - std::fs::read_to_string(&path).unwrap_or_else(|_| { - panic!("could not read golden test data file at {:?}. Did you run the test with UPDATE_GOLDEN=true before?", path); - }).replace("\r\n", "\n"); - - pretty_assertions::assert_eq!(have_json, want_json, "wrong blame entries"); - } - } - - #[test] - fn test_parse_git_blame_not_committed() { - let output = read_test_data("blame_incremental_not_committed"); - let entries = parse_git_blame(&output).unwrap(); - assert_eq_golden(&entries, "blame_incremental_not_committed"); - } - - #[test] - fn test_parse_git_blame_simple() { - let output = read_test_data("blame_incremental_simple"); - let entries = parse_git_blame(&output).unwrap(); - assert_eq_golden(&entries, "blame_incremental_simple"); - } - - #[test] - fn test_parse_git_blame_complex() { - let output = read_test_data("blame_incremental_complex"); - let entries = parse_git_blame(&output).unwrap(); - assert_eq_golden(&entries, "blame_incremental_complex"); - } -} diff --git a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs deleted file mode 100644 index 198ab45b13f..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs +++ /dev/null @@ -1,21344 +0,0 @@ -#![allow(rustdoc::private_intra_doc_links)] -//! This is the place where everything editor-related is stored (data-wise) and displayed (ui-wise). -//! The main point of interest in this crate is [`Editor`] type, which is used in every other Zed part as a user input element. -//! It comes in different flavors: single line, multiline and a fixed height one. -//! -//! Editor contains of multiple large submodules: -//! * [`element`] — the place where all rendering happens -//! * [`display_map`] - chunks up text in the editor into the logical blocks, establishes coordinates and mapping between each of them. -//! Contains all metadata related to text transformations (folds, fake inlay text insertions, soft wraps, tab markup, etc.). -//! * [`inlay_hint_cache`] - is a storage of inlay hints out of LSP requests, responsible for querying LSP and updating `display_map`'s state accordingly. -//! -//! All other submodules and structs are mostly concerned with holding editor data about the way it displays current buffer region(s). -//! -//! If you're looking to improve Vim mode, you should check out Vim crate that wraps Editor and overrides its behavior. -pub mod actions; -mod blink_manager; -mod clangd_ext; -mod code_context_menus; -pub mod display_map; -mod editor_settings; -mod editor_settings_controls; -mod element; -mod git; -mod highlight_matching_bracket; -mod hover_links; -pub mod hover_popover; -mod indent_guides; -mod inlay_hint_cache; -pub mod items; -mod jsx_tag_auto_close; -mod linked_editing_ranges; -mod lsp_ext; -mod mouse_context_menu; -pub mod movement; -mod persistence; -mod proposed_changes_editor; -mod rust_analyzer_ext; -pub mod scroll; -mod selections_collection; -pub mod tasks; - -#[cfg(test)] -mod code_completion_tests; -#[cfg(test)] -mod editor_tests; -#[cfg(test)] -mod inline_completion_tests; -mod signature_help; -#[cfg(any(test, feature = "test-support"))] -pub mod test; - -pub(crate) use actions::*; -pub use actions::{AcceptEditPrediction, OpenExcerpts, OpenExcerptsSplit}; -use aho_corasick::AhoCorasick; -use anyhow::{Context as _, Result, anyhow}; -use blink_manager::BlinkManager; -use buffer_diff::DiffHunkStatus; -use client::{Collaborator, ParticipantIndex}; -use clock::ReplicaId; -use collections::{BTreeMap, HashMap, HashSet, VecDeque}; -use convert_case::{Case, Casing}; -use display_map::*; -pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder}; -use editor_settings::GoToDefinitionFallback; -pub use editor_settings::{ - CurrentLineHighlight, EditorSettings, HideMouseMode, ScrollBeyondLastLine, SearchSettings, - ShowScrollbar, -}; -pub use editor_settings_controls::*; -use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap, layout_line}; -pub use element::{ - CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition, -}; -use feature_flags::{DebuggerFeatureFlag, FeatureFlagAppExt}; -use futures::{ - FutureExt, - future::{self, Shared, join}, -}; -use fuzzy::StringMatchCandidate; - -use ::git::blame::BlameEntry; -use ::git::{Restore, blame::ParsedCommitMessage}; -use code_context_menus::{ - AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu, - CompletionsMenu, ContextMenuOrigin, -}; -use git::blame::{GitBlame, GlobalBlameRenderer}; -use gpui::{ - Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext, - AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context, - DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, - Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, - MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, ScrollHandle, - SharedString, Size, Stateful, Styled, Subscription, Task, TextStyle, TextStyleRefinement, - UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window, - div, impl_actions, point, prelude::*, pulsating_between, px, relative, size, -}; -use highlight_matching_bracket::refresh_matching_bracket_highlights; -use hover_links::{HoverLink, HoveredLinkState, InlayHighlight, find_file}; -pub use hover_popover::hover_markdown_style; -use hover_popover::{HoverState, hide_hover}; -use indent_guides::ActiveIndentGuidesState; -use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; -pub use inline_completion::Direction; -use inline_completion::{EditPredictionProvider, InlineCompletionProviderHandle}; -pub use items::MAX_TAB_TITLE_LEN; -use itertools::Itertools; -use language::{ - AutoindentMode, BracketMatch, BracketPair, Buffer, Capability, CharKind, CodeLabel, - CursorShape, DiagnosticEntry, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, - IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection, SelectionGoal, TextObject, - TransactionId, TreeSitterOptions, WordsQuery, - language_settings::{ - self, InlayHintSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode, - all_language_settings, language_settings, - }, - point_from_lsp, text_diff_with_options, -}; -use language::{BufferRow, CharClassifier, Runnable, RunnableRange, point_to_lsp}; -use linked_editing_ranges::refresh_linked_ranges; -use markdown::Markdown; -use mouse_context_menu::MouseContextMenu; -use persistence::DB; -use project::{ - ProjectPath, - debugger::{ - breakpoint_store::{ - BreakpointEditAction, BreakpointState, BreakpointStore, BreakpointStoreEvent, - }, - session::{Session, SessionEvent}, - }, -}; - -pub use git::blame::BlameRenderer; -pub use proposed_changes_editor::{ - ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar, -}; -use smallvec::smallvec; -use std::{cell::OnceCell, iter::Peekable}; -use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables}; - -pub use lsp::CompletionContext; -use lsp::{ - CodeActionKind, CompletionItemKind, CompletionTriggerKind, DiagnosticSeverity, - InsertTextFormat, InsertTextMode, LanguageServerId, LanguageServerName, -}; - -use language::BufferSnapshot; -pub use lsp_ext::lsp_tasks; -use movement::TextLayoutDetails; -pub use multi_buffer::{ - Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, PathKey, - RowInfo, ToOffset, ToPoint, -}; -use multi_buffer::{ - ExcerptInfo, ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow, - MultiOrSingleBufferOffsetRange, ToOffsetUtf16, -}; -use parking_lot::Mutex; -use project::{ - CodeAction, Completion, CompletionIntent, CompletionSource, DocumentHighlight, InlayHint, - Location, LocationLink, PrepareRenameResponse, Project, ProjectItem, ProjectTransaction, - TaskSourceKind, - debugger::breakpoint_store::Breakpoint, - lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle}, - project_settings::{GitGutterSetting, ProjectSettings}, -}; -use rand::prelude::*; -use rpc::{ErrorExt, proto::*}; -use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide}; -use selections_collection::{ - MutableSelectionsCollection, SelectionsCollection, resolve_selections, -}; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsLocation, SettingsStore, update_settings_file}; -use smallvec::SmallVec; -use snippet::Snippet; -use std::sync::Arc; -use std::{ - any::TypeId, - borrow::Cow, - cell::RefCell, - cmp::{self, Ordering, Reverse}, - mem, - num::NonZeroU32, - ops::{ControlFlow, Deref, DerefMut, Not as _, Range, RangeInclusive}, - path::{Path, PathBuf}, - rc::Rc, - time::{Duration, Instant}, -}; -pub use sum_tree::Bias; -use sum_tree::TreeMap; -use text::{BufferId, FromAnchor, OffsetUtf16, Rope}; -use theme::{ - ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, ThemeColors, ThemeSettings, - observe_buffer_font_size_adjustment, -}; -use ui::{ - ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, - IconSize, Key, Tooltip, h_flex, prelude::*, -}; -use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc}; -use workspace::{ - Item as WorkspaceItem, ItemId, ItemNavHistory, OpenInTerminal, OpenTerminal, - RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection, TabBarSettings, Toast, - ViewId, Workspace, WorkspaceId, WorkspaceSettings, - item::{ItemHandle, PreviewTabsSettings}, - notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt}, - searchable::SearchEvent, -}; - -use crate::hover_links::{find_url, find_url_from_range}; -use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState}; - -pub const FILE_HEADER_HEIGHT: u32 = 2; -pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1; -pub const DEFAULT_MULTIBUFFER_CONTEXT: u32 = 2; -const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); -const MAX_LINE_LEN: usize = 1024; -const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10; -const MAX_SELECTION_HISTORY_LEN: usize = 1024; -pub(crate) const CURSORS_VISIBLE_FOR: Duration = Duration::from_millis(2000); -#[doc(hidden)] -pub const CODE_ACTIONS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250); -const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); - -pub(crate) const CODE_ACTION_TIMEOUT: Duration = Duration::from_secs(5); -pub(crate) const FORMAT_TIMEOUT: Duration = Duration::from_secs(5); -pub(crate) const SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1); - -pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction"; -pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict"; -pub(crate) const MIN_LINE_NUMBER_DIGITS: u32 = 4; - -pub type RenderDiffHunkControlsFn = Arc< - dyn Fn( - u32, - &DiffHunkStatus, - Range, - bool, - Pixels, - &Entity, - &mut Window, - &mut App, - ) -> AnyElement, ->; - -const COLUMNAR_SELECTION_MODIFIERS: Modifiers = Modifiers { - alt: true, - shift: true, - control: false, - platform: false, - function: false, -}; - -struct InlineValueCache { - enabled: bool, - inlays: Vec, - refresh_task: Task>, -} - -impl InlineValueCache { - fn new(enabled: bool) -> Self { - Self { - enabled, - inlays: Vec::new(), - refresh_task: Task::ready(None), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum InlayId { - InlineCompletion(usize), - Hint(usize), - DebuggerValue(usize), -} - -impl InlayId { - fn id(&self) -> usize { - match self { - Self::InlineCompletion(id) => *id, - Self::Hint(id) => *id, - Self::DebuggerValue(id) => *id, - } - } -} - -pub enum ActiveDebugLine {} -enum DocumentHighlightRead {} -enum DocumentHighlightWrite {} -enum InputComposition {} -enum SelectedTextHighlight {} - -pub enum ConflictsOuter {} -pub enum ConflictsOurs {} -pub enum ConflictsTheirs {} -pub enum ConflictsOursMarker {} -pub enum ConflictsTheirsMarker {} - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum Navigated { - Yes, - No, -} - -impl Navigated { - pub fn from_bool(yes: bool) -> Navigated { - if yes { Navigated::Yes } else { Navigated::No } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum DisplayDiffHunk { - Folded { - display_row: DisplayRow, - }, - Unfolded { - is_created_file: bool, - diff_base_byte_range: Range, - display_row_range: Range, - multi_buffer_range: Range, - status: DiffHunkStatus, - }, -} - -pub enum HideMouseCursorOrigin { - TypingAction, - MovementAction, -} - -pub fn init_settings(cx: &mut App) { - EditorSettings::register(cx); -} - -pub fn init(cx: &mut App) { - init_settings(cx); - - cx.set_global(GlobalBlameRenderer(Arc::new(()))); - - workspace::register_project_item::(cx); - workspace::FollowableViewRegistry::register::(cx); - workspace::register_serializable_item::(cx); - - cx.observe_new( - |workspace: &mut Workspace, _: Option<&mut Window>, _cx: &mut Context| { - workspace.register_action(Editor::new_file); - workspace.register_action(Editor::new_file_vertical); - workspace.register_action(Editor::new_file_horizontal); - workspace.register_action(Editor::cancel_language_server_work); - }, - ) - .detach(); - - cx.on_action(move |_: &workspace::NewFile, cx| { - let app_state = workspace::AppState::global(cx); - if let Some(app_state) = app_state.upgrade() { - workspace::open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach(); - } - }); - cx.on_action(move |_: &workspace::NewWindow, cx| { - let app_state = workspace::AppState::global(cx); - if let Some(app_state) = app_state.upgrade() { - workspace::open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - cx.activate(true); - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach(); - } - }); -} - -pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App) { - cx.set_global(GlobalBlameRenderer(Arc::new(renderer))); -} - -pub trait DiagnosticRenderer { - fn render_group( - &self, - diagnostic_group: Vec>, - buffer_id: BufferId, - snapshot: EditorSnapshot, - editor: WeakEntity, - cx: &mut App, - ) -> Vec>; - - fn render_hover( - &self, - diagnostic_group: Vec>, - range: Range, - buffer_id: BufferId, - cx: &mut App, - ) -> Option>; - - fn open_link( - &self, - editor: &mut Editor, - link: SharedString, - window: &mut Window, - cx: &mut Context, - ); -} - -pub(crate) struct GlobalDiagnosticRenderer(pub Arc); - -impl GlobalDiagnosticRenderer { - fn global(cx: &App) -> Option> { - cx.try_global::().map(|g| g.0.clone()) - } -} - -impl gpui::Global for GlobalDiagnosticRenderer {} -pub fn set_diagnostic_renderer(renderer: impl DiagnosticRenderer + 'static, cx: &mut App) { - cx.set_global(GlobalDiagnosticRenderer(Arc::new(renderer))); -} - -pub struct SearchWithinRange; - -trait InvalidationRegion { - fn ranges(&self) -> &[Range]; -} - -#[derive(Clone, Debug, PartialEq)] -pub enum SelectPhase { - Begin { - position: DisplayPoint, - add: bool, - click_count: usize, - }, - BeginColumnar { - position: DisplayPoint, - reset: bool, - goal_column: u32, - }, - Extend { - position: DisplayPoint, - click_count: usize, - }, - Update { - position: DisplayPoint, - goal_column: u32, - scroll_delta: gpui::Point, - }, - End, -} - -#[derive(Clone, Debug)] -pub enum SelectMode { - Character, - Word(Range), - Line(Range), - All, -} - -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub enum EditorMode { - SingleLine { - auto_width: bool, - }, - AutoHeight { - max_lines: usize, - }, - Full { - /// When set to `true`, the editor will scale its UI elements with the buffer font size. - scale_ui_elements_with_buffer_font_size: bool, - /// When set to `true`, the editor will render a background for the active line. - show_active_line_background: bool, - /// When set to `true`, the editor's height will be determined by its content. - sized_by_content: bool, - }, -} - -impl EditorMode { - pub fn full() -> Self { - Self::Full { - scale_ui_elements_with_buffer_font_size: true, - show_active_line_background: true, - sized_by_content: false, - } - } - - pub fn is_full(&self) -> bool { - matches!(self, Self::Full { .. }) - } -} - -#[derive(Copy, Clone, Debug)] -pub enum SoftWrap { - /// Prefer not to wrap at all. - /// - /// Note: this is currently internal, as actually limited by [`crate::MAX_LINE_LEN`] until it wraps. - /// The mode is used inside git diff hunks, where it's seems currently more useful to not wrap as much as possible. - GitDiff, - /// Prefer a single line generally, unless an overly long line is encountered. - None, - /// Soft wrap lines that exceed the editor width. - EditorWidth, - /// Soft wrap lines at the preferred line length. - Column(u32), - /// Soft wrap line at the preferred line length or the editor width (whichever is smaller). - Bounded(u32), -} - -#[derive(Clone)] -pub struct EditorStyle { - pub background: Hsla, - pub local_player: PlayerColor, - pub text: TextStyle, - pub scrollbar_width: Pixels, - pub syntax: Arc, - pub status: StatusColors, - pub inlay_hints_style: HighlightStyle, - pub inline_completion_styles: InlineCompletionStyles, - pub unnecessary_code_fade: f32, -} - -impl Default for EditorStyle { - fn default() -> Self { - Self { - background: Hsla::default(), - local_player: PlayerColor::default(), - text: TextStyle::default(), - scrollbar_width: Pixels::default(), - syntax: Default::default(), - // HACK: Status colors don't have a real default. - // We should look into removing the status colors from the editor - // style and retrieve them directly from the theme. - status: StatusColors::dark(), - inlay_hints_style: HighlightStyle::default(), - inline_completion_styles: InlineCompletionStyles { - insertion: HighlightStyle::default(), - whitespace: HighlightStyle::default(), - }, - unnecessary_code_fade: Default::default(), - } - } -} - -pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { - let show_background = language_settings::language_settings(cx).get() - .inlay_hints - .show_background; - - HighlightStyle { - color: Some(cx.theme().status().hint), - background_color: show_background.then(|| cx.theme().status().hint_background), - ..HighlightStyle::default() - } -} - -pub fn make_suggestion_styles(cx: &mut App) -> InlineCompletionStyles { - InlineCompletionStyles { - insertion: HighlightStyle { - color: Some(cx.theme().status().predictive), - ..HighlightStyle::default() - }, - whitespace: HighlightStyle { - background_color: Some(cx.theme().status().created_background), - ..HighlightStyle::default() - }, - } -} - -type CompletionId = usize; - -pub(crate) enum EditDisplayMode { - TabAccept, - DiffPopover, - Inline, -} - -enum InlineCompletion { - Edit { - edits: Vec<(Range, String)>, - edit_preview: Option, - display_mode: EditDisplayMode, - snapshot: BufferSnapshot, - }, - Move { - target: Anchor, - snapshot: BufferSnapshot, - }, -} - -struct InlineCompletionState { - inlay_ids: Vec, - completion: InlineCompletion, - completion_id: Option, - invalidation_range: Range, -} - -enum EditPredictionSettings { - Disabled, - Enabled { - show_in_menu: bool, - preview_requires_modifier: bool, - }, -} - -enum InlineCompletionHighlight {} - -#[derive(Debug, Clone)] -struct InlineDiagnostic { - message: SharedString, - group_id: usize, - is_primary: bool, - start: Point, - severity: DiagnosticSeverity, -} - -pub enum MenuInlineCompletionsPolicy { - Never, - ByProvider, -} - -pub enum EditPredictionPreview { - /// Modifier is not pressed - Inactive { released_too_fast: bool }, - /// Modifier pressed - Active { - since: Instant, - previous_scroll_position: Option, - }, -} - -impl EditPredictionPreview { - pub fn released_too_fast(&self) -> bool { - match self { - EditPredictionPreview::Inactive { released_too_fast } => *released_too_fast, - EditPredictionPreview::Active { .. } => false, - } - } - - pub fn set_previous_scroll_position(&mut self, scroll_position: Option) { - if let EditPredictionPreview::Active { - previous_scroll_position, - .. - } = self - { - *previous_scroll_position = scroll_position; - } - } -} - -pub struct ContextMenuOptions { - pub min_entries_visible: usize, - pub max_entries_visible: usize, - pub placement: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ContextMenuPlacement { - Above, - Below, -} - -#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)] -struct EditorActionId(usize); - -impl EditorActionId { - pub fn post_inc(&mut self) -> Self { - let answer = self.0; - - *self = Self(answer + 1); - - Self(answer) - } -} - -// type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor; -// type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option; - -type BackgroundHighlight = (fn(&ThemeColors) -> Hsla, Arc<[Range]>); -type GutterHighlight = (fn(&App) -> Hsla, Arc<[Range]>); - -#[derive(Default)] -struct ScrollbarMarkerState { - scrollbar_size: Size, - dirty: bool, - markers: Arc<[PaintQuad]>, - pending_refresh: Option>>, -} - -impl ScrollbarMarkerState { - fn should_refresh(&self, scrollbar_size: Size) -> bool { - self.pending_refresh.is_none() && (self.scrollbar_size != scrollbar_size || self.dirty) - } -} - -#[derive(Clone, Debug)] -struct RunnableTasks { - templates: Vec<(TaskSourceKind, TaskTemplate)>, - offset: multi_buffer::Anchor, - // We need the column at which the task context evaluation should take place (when we're spawning it via gutter). - column: u32, - // Values of all named captures, including those starting with '_' - extra_variables: HashMap, - // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal. - context_range: Range, -} - -impl RunnableTasks { - fn resolve<'a>( - &'a self, - cx: &'a task::TaskContext, - ) -> impl Iterator + 'a { - self.templates.iter().filter_map(|(kind, template)| { - template - .resolve_task(&kind.to_id_base(), cx) - .map(|task| (kind.clone(), task)) - }) - } -} - -#[derive(Clone)] -struct ResolvedTasks { - templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, - position: Anchor, -} - -#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] -struct BufferOffset(usize); - -// Addons allow storing per-editor state in other crates (e.g. Vim) -pub trait Addon: 'static { - fn extend_key_context(&self, _: &mut KeyContext, _: &App) {} - - fn render_buffer_header_controls( - &self, - _: &ExcerptInfo, - _: &Window, - _: &App, - ) -> Option { - None - } - - fn to_any(&self) -> &dyn std::any::Any; - - fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { - None - } -} - -/// A set of caret positions, registered when the editor was edited. -pub struct ChangeList { - changes: Vec>, - /// Currently "selected" change. - position: Option, -} - -impl ChangeList { - pub fn new() -> Self { - Self { - changes: Vec::new(), - position: None, - } - } - - /// Moves to the next change in the list (based on the direction given) and returns the caret positions for the next change. - /// If reaches the end of the list in the direction, returns the corresponding change until called for a different direction. - pub fn next_change(&mut self, count: usize, direction: Direction) -> Option<&[Anchor]> { - if self.changes.is_empty() { - return None; - } - - let prev = self.position.unwrap_or(self.changes.len()); - let next = if direction == Direction::Prev { - prev.saturating_sub(count) - } else { - (prev + count).min(self.changes.len() - 1) - }; - self.position = Some(next); - self.changes.get(next).map(|anchors| anchors.as_slice()) - } - - /// Adds a new change to the list, resetting the change list position. - pub fn push_to_change_list(&mut self, pop_state: bool, new_positions: Vec) { - self.position.take(); - if pop_state { - self.changes.pop(); - } - self.changes.push(new_positions.clone()); - } - - pub fn last(&self) -> Option<&[Anchor]> { - self.changes.last().map(|anchors| anchors.as_slice()) - } -} - -#[derive(Clone)] -struct InlineBlamePopoverState { - scroll_handle: ScrollHandle, - commit_message: Option, - markdown: Entity, -} - -struct InlineBlamePopover { - position: gpui::Point, - show_task: Option>, - hide_task: Option>, - popover_bounds: Option>, - popover_state: InlineBlamePopoverState, -} - -/// Represents a breakpoint indicator that shows up when hovering over lines in the gutter that don't have -/// a breakpoint on them. -#[derive(Clone, Copy, Debug)] -struct PhantomBreakpointIndicator { - display_row: DisplayRow, - /// There's a small debounce between hovering over the line and showing the indicator. - /// We don't want to show the indicator when moving the mouse from editor to e.g. project panel. - is_active: bool, - collides_with_existing_breakpoint: bool, -} -/// Zed's primary implementation of text input, allowing users to edit a [`MultiBuffer`]. -/// -/// See the [module level documentation](self) for more information. -pub struct Editor { - focus_handle: FocusHandle, - last_focused_descendant: Option, - /// The text buffer being edited - buffer: Entity, - /// Map of how text in the buffer should be displayed. - /// Handles soft wraps, folds, fake inlay text insertions, etc. - pub display_map: Entity, - pub selections: SelectionsCollection, - pub scroll_manager: ScrollManager, - /// When inline assist editors are linked, they all render cursors because - /// typing enters text into each of them, even the ones that aren't focused. - pub(crate) show_cursor_when_unfocused: bool, - columnar_selection_tail: Option, - add_selections_state: Option, - select_next_state: Option, - select_prev_state: Option, - selection_history: SelectionHistory, - autoclose_regions: Vec, - snippet_stack: InvalidationStack, - select_syntax_node_history: SelectSyntaxNodeHistory, - ime_transaction: Option, - active_diagnostics: ActiveDiagnostic, - show_inline_diagnostics: bool, - inline_diagnostics_update: Task<()>, - inline_diagnostics_enabled: bool, - inline_diagnostics: Vec<(Anchor, InlineDiagnostic)>, - soft_wrap_mode_override: Option, - hard_wrap: Option, - - // TODO: make this a access method - pub project: Option>, - semantics_provider: Option>, - completion_provider: Option>, - collaboration_hub: Option>, - blink_manager: Entity, - show_cursor_names: bool, - hovered_cursors: HashMap>, - pub show_local_selections: bool, - mode: EditorMode, - show_breadcrumbs: bool, - show_gutter: bool, - show_scrollbars: bool, - disable_scrolling: bool, - disable_expand_excerpt_buttons: bool, - show_line_numbers: Option, - use_relative_line_numbers: Option, - show_git_diff_gutter: Option, - show_code_actions: Option, - show_runnables: Option, - show_breakpoints: Option, - show_wrap_guides: Option, - show_indent_guides: Option, - placeholder_text: Option>, - highlight_order: usize, - highlighted_rows: HashMap>, - background_highlights: TreeMap, - gutter_highlights: TreeMap, - scrollbar_marker_state: ScrollbarMarkerState, - active_indent_guides_state: ActiveIndentGuidesState, - nav_history: Option, - context_menu: RefCell>, - context_menu_options: Option, - mouse_context_menu: Option, - completion_tasks: Vec<(CompletionId, Task>)>, - inline_blame_popover: Option, - signature_help_state: SignatureHelpState, - auto_signature_help: Option, - find_all_references_task_sources: Vec, - next_completion_id: CompletionId, - available_code_actions: Option<(Location, Rc<[AvailableCodeAction]>)>, - code_actions_task: Option>>, - quick_selection_highlight_task: Option<(Range, Task<()>)>, - debounced_selection_highlight_task: Option<(Range, Task<()>)>, - document_highlights_task: Option>, - linked_editing_range_task: Option>>, - linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges, - pending_rename: Option, - searchable: bool, - cursor_shape: CursorShape, - current_line_highlight: Option, - collapse_matches: bool, - autoindent_mode: Option, - workspace: Option<(WeakEntity, Option)>, - input_enabled: bool, - use_modal_editing: bool, - read_only: bool, - leader_peer_id: Option, - remote_id: Option, - pub hover_state: HoverState, - pending_mouse_down: Option>>>, - gutter_hovered: bool, - hovered_link_state: Option, - edit_prediction_provider: Option, - code_action_providers: Vec>, - active_inline_completion: Option, - /// Used to prevent flickering as the user types while the menu is open - stale_inline_completion_in_menu: Option, - edit_prediction_settings: EditPredictionSettings, - inline_completions_hidden_for_vim_mode: bool, - show_inline_completions_override: Option, - menu_inline_completions_policy: MenuInlineCompletionsPolicy, - edit_prediction_preview: EditPredictionPreview, - edit_prediction_indent_conflict: bool, - edit_prediction_requires_modifier_in_indent_conflict: bool, - inlay_hint_cache: InlayHintCache, - next_inlay_id: usize, - _subscriptions: Vec, - pixel_position_of_newest_cursor: Option>, - gutter_dimensions: GutterDimensions, - style: Option, - text_style_refinement: Option, - next_editor_action_id: EditorActionId, - editor_actions: - Rc)>>>>, - use_autoclose: bool, - use_auto_surround: bool, - auto_replace_emoji_shortcode: bool, - jsx_tag_auto_close_enabled_in_any_buffer: bool, - show_git_blame_gutter: bool, - show_git_blame_inline: bool, - show_git_blame_inline_delay_task: Option>, - git_blame_inline_enabled: bool, - render_diff_hunk_controls: RenderDiffHunkControlsFn, - serialize_dirty_buffers: bool, - show_selection_menu: Option, - blame: Option>, - blame_subscription: Option, - custom_context_menu: Option< - Box< - dyn 'static - + Fn( - &mut Self, - DisplayPoint, - &mut Window, - &mut Context, - ) -> Option>, - >, - >, - last_bounds: Option>, - last_position_map: Option>, - expect_bounds_change: Option>, - tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, - tasks_update_task: Option>, - breakpoint_store: Option>, - gutter_breakpoint_indicator: (Option, Option>), - in_project_search: bool, - previous_search_ranges: Option]>>, - breadcrumb_header: Option, - focused_block: Option, - next_scroll_position: NextScrollCursorCenterTopBottom, - addons: HashMap>, - registered_buffers: HashMap, - load_diff_task: Option>>, - selection_mark_mode: bool, - toggle_fold_multiple_buffers: Task<()>, - _scroll_cursor_center_top_bottom_task: Task<()>, - serialize_selections: Task<()>, - serialize_folds: Task<()>, - mouse_cursor_hidden: bool, - hide_mouse_mode: HideMouseMode, - pub change_list: ChangeList, - inline_value_cache: InlineValueCache, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] -enum NextScrollCursorCenterTopBottom { - #[default] - Center, - Top, - Bottom, -} - -impl NextScrollCursorCenterTopBottom { - fn next(&self) -> Self { - match self { - Self::Center => Self::Top, - Self::Top => Self::Bottom, - Self::Bottom => Self::Center, - } - } -} - -#[derive(Clone)] -pub struct EditorSnapshot { - pub mode: EditorMode, - show_gutter: bool, - show_line_numbers: Option, - show_git_diff_gutter: Option, - show_code_actions: Option, - show_runnables: Option, - show_breakpoints: Option, - git_blame_gutter_max_author_length: Option, - pub display_snapshot: DisplaySnapshot, - pub placeholder_text: Option>, - is_focused: bool, - scroll_anchor: ScrollAnchor, - ongoing_scroll: OngoingScroll, - current_line_highlight: CurrentLineHighlight, - gutter_hovered: bool, -} - -#[derive(Default, Debug, Clone, Copy)] -pub struct GutterDimensions { - pub left_padding: Pixels, - pub right_padding: Pixels, - pub width: Pixels, - pub margin: Pixels, - pub git_blame_entries_width: Option, -} - -impl GutterDimensions { - /// The full width of the space taken up by the gutter. - pub fn full_width(&self) -> Pixels { - self.margin + self.width - } - - /// The width of the space reserved for the fold indicators, - /// use alongside 'justify_end' and `gutter_width` to - /// right align content with the line numbers - pub fn fold_area_width(&self) -> Pixels { - self.margin + self.right_padding - } -} - -#[derive(Debug)] -pub struct RemoteSelection { - pub replica_id: ReplicaId, - pub selection: Selection, - pub cursor_shape: CursorShape, - pub peer_id: PeerId, - pub line_mode: bool, - pub participant_index: Option, - pub user_name: Option, -} - -#[derive(Clone, Debug)] -struct SelectionHistoryEntry { - selections: Arc<[Selection]>, - select_next_state: Option, - select_prev_state: Option, - add_selections_state: Option, -} - -enum SelectionHistoryMode { - Normal, - Undoing, - Redoing, -} - -#[derive(Clone, PartialEq, Eq, Hash)] -struct HoveredCursor { - replica_id: u16, - selection_id: usize, -} - -impl Default for SelectionHistoryMode { - fn default() -> Self { - Self::Normal - } -} - -#[derive(Default)] -struct SelectionHistory { - #[allow(clippy::type_complexity)] - selections_by_transaction: - HashMap]>, Option]>>)>, - mode: SelectionHistoryMode, - undo_stack: VecDeque, - redo_stack: VecDeque, -} - -impl SelectionHistory { - fn insert_transaction( - &mut self, - transaction_id: TransactionId, - selections: Arc<[Selection]>, - ) { - self.selections_by_transaction - .insert(transaction_id, (selections, None)); - } - - #[allow(clippy::type_complexity)] - fn transaction( - &self, - transaction_id: TransactionId, - ) -> Option<&(Arc<[Selection]>, Option]>>)> { - self.selections_by_transaction.get(&transaction_id) - } - - #[allow(clippy::type_complexity)] - fn transaction_mut( - &mut self, - transaction_id: TransactionId, - ) -> Option<&mut (Arc<[Selection]>, Option]>>)> { - self.selections_by_transaction.get_mut(&transaction_id) - } - - fn push(&mut self, entry: SelectionHistoryEntry) { - if !entry.selections.is_empty() { - match self.mode { - SelectionHistoryMode::Normal => { - self.push_undo(entry); - self.redo_stack.clear(); - } - SelectionHistoryMode::Undoing => self.push_redo(entry), - SelectionHistoryMode::Redoing => self.push_undo(entry), - } - } - } - - fn push_undo(&mut self, entry: SelectionHistoryEntry) { - if self - .undo_stack - .back() - .map_or(true, |e| e.selections != entry.selections) - { - self.undo_stack.push_back(entry); - if self.undo_stack.len() > MAX_SELECTION_HISTORY_LEN { - self.undo_stack.pop_front(); - } - } - } - - fn push_redo(&mut self, entry: SelectionHistoryEntry) { - if self - .redo_stack - .back() - .map_or(true, |e| e.selections != entry.selections) - { - self.redo_stack.push_back(entry); - if self.redo_stack.len() > MAX_SELECTION_HISTORY_LEN { - self.redo_stack.pop_front(); - } - } - } -} - -#[derive(Clone, Copy)] -pub struct RowHighlightOptions { - pub autoscroll: bool, - pub include_gutter: bool, -} - -impl Default for RowHighlightOptions { - fn default() -> Self { - Self { - autoscroll: Default::default(), - include_gutter: true, - } - } -} - -struct RowHighlight { - index: usize, - range: Range, - color: Hsla, - options: RowHighlightOptions, - type_id: TypeId, -} - -#[derive(Clone, Debug)] -struct AddSelectionsState { - above: bool, - stack: Vec, -} - -#[derive(Clone)] -struct SelectNextState { - query: AhoCorasick, - wordwise: bool, - done: bool, -} - -impl std::fmt::Debug for SelectNextState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct(std::any::type_name::()) - .field("wordwise", &self.wordwise) - .field("done", &self.done) - .finish() - } -} - -#[derive(Debug)] -struct AutocloseRegion { - selection_id: usize, - range: Range, - pair: BracketPair, -} - -#[derive(Debug)] -struct SnippetState { - ranges: Vec>>, - active_index: usize, - choices: Vec>>, -} - -#[doc(hidden)] -pub struct RenameState { - pub range: Range, - pub old_name: Arc, - pub editor: Entity, - block_id: CustomBlockId, -} - -struct InvalidationStack(Vec); - -struct RegisteredInlineCompletionProvider { - provider: Arc, - _subscription: Subscription, -} - -#[derive(Debug, PartialEq, Eq)] -pub struct ActiveDiagnosticGroup { - pub active_range: Range, - pub active_message: String, - pub group_id: usize, - pub blocks: HashSet, -} - -#[derive(Debug, PartialEq, Eq)] - -pub(crate) enum ActiveDiagnostic { - None, - All, - Group(ActiveDiagnosticGroup), -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct ClipboardSelection { - /// The number of bytes in this selection. - pub len: usize, - /// Whether this was a full-line selection. - pub is_entire_line: bool, - /// The indentation of the first line when this content was originally copied. - pub first_line_indent: u32, -} - -// selections, scroll behavior, was newest selection reversed -type SelectSyntaxNodeHistoryState = ( - Box<[Selection]>, - SelectSyntaxNodeScrollBehavior, - bool, -); - -#[derive(Default)] -struct SelectSyntaxNodeHistory { - stack: Vec, - // disable temporarily to allow changing selections without losing the stack - pub disable_clearing: bool, -} - -impl SelectSyntaxNodeHistory { - pub fn try_clear(&mut self) { - if !self.disable_clearing { - self.stack.clear(); - } - } - - pub fn push(&mut self, selection: SelectSyntaxNodeHistoryState) { - self.stack.push(selection); - } - - pub fn pop(&mut self) -> Option { - self.stack.pop() - } -} - -enum SelectSyntaxNodeScrollBehavior { - CursorTop, - FitSelection, - CursorBottom, -} - -#[derive(Debug)] -pub(crate) struct NavigationData { - cursor_anchor: Anchor, - cursor_position: Point, - scroll_anchor: ScrollAnchor, - scroll_top_row: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GotoDefinitionKind { - Symbol, - Declaration, - Type, - Implementation, -} - -#[derive(Debug, Clone)] -enum InlayHintRefreshReason { - ModifiersChanged(bool), - Toggle(bool), - SettingsChange(InlayHintSettings), - NewLinesShown, - BufferEdited(HashSet>), - RefreshRequested, - ExcerptsRemoved(Vec), -} - -impl InlayHintRefreshReason { - fn description(&self) -> &'static str { - match self { - Self::ModifiersChanged(_) => "modifiers changed", - Self::Toggle(_) => "toggle", - Self::SettingsChange(_) => "settings change", - Self::NewLinesShown => "new lines shown", - Self::BufferEdited(_) => "buffer edited", - Self::RefreshRequested => "refresh requested", - Self::ExcerptsRemoved(_) => "excerpts removed", - } - } -} - -pub enum FormatTarget { - Buffers, - Ranges(Vec>), -} - -pub(crate) struct FocusedBlock { - id: BlockId, - focus_handle: WeakFocusHandle, -} - -#[derive(Clone)] -enum JumpData { - MultiBufferRow { - row: MultiBufferRow, - line_offset_from_top: u32, - }, - MultiBufferPoint { - excerpt_id: ExcerptId, - position: Point, - anchor: text::Anchor, - line_offset_from_top: u32, - }, -} - -pub enum MultibufferSelectionMode { - First, - All, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct RewrapOptions { - pub override_language_settings: bool, - pub preserve_existing_whitespace: bool, -} - -impl Editor { - pub fn single_line(window: &mut Window, cx: &mut Context) -> Self { - let buffer = cx.new(|cx| Buffer::local("", cx)); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - Self::new( - EditorMode::SingleLine { auto_width: false }, - buffer, - None, - window, - cx, - ) - } - - pub fn multi_line(window: &mut Window, cx: &mut Context) -> Self { - let buffer = cx.new(|cx| Buffer::local("", cx)); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - Self::new(EditorMode::full(), buffer, None, window, cx) - } - - pub fn auto_width(window: &mut Window, cx: &mut Context) -> Self { - let buffer = cx.new(|cx| Buffer::local("", cx)); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - Self::new( - EditorMode::SingleLine { auto_width: true }, - buffer, - None, - window, - cx, - ) - } - - pub fn auto_height(max_lines: usize, window: &mut Window, cx: &mut Context) -> Self { - let buffer = cx.new(|cx| Buffer::local("", cx)); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - Self::new( - EditorMode::AutoHeight { max_lines }, - buffer, - None, - window, - cx, - ) - } - - pub fn for_buffer( - buffer: Entity, - project: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - Self::new(EditorMode::full(), buffer, project, window, cx) - } - - pub fn for_multibuffer( - buffer: Entity, - project: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - Self::new(EditorMode::full(), buffer, project, window, cx) - } - - pub fn clone(&self, window: &mut Window, cx: &mut Context) -> Self { - let mut clone = Self::new( - self.mode, - self.buffer.clone(), - self.project.clone(), - window, - cx, - ); - self.display_map.update(cx, |display_map, cx| { - let snapshot = display_map.snapshot(cx); - clone.display_map.update(cx, |display_map, cx| { - display_map.set_state(&snapshot, cx); - }); - }); - clone.folds_did_change(cx); - clone.selections.clone_state(&self.selections); - clone.scroll_manager.clone_state(&self.scroll_manager); - clone.searchable = self.searchable; - clone.read_only = self.read_only; - clone - } - - pub fn new( - mode: EditorMode, - buffer: Entity, - project: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let style = window.text_style(); - let font_size = style.font_size.to_pixels(window.rem_size()); - let editor = cx.entity().downgrade(); - let fold_placeholder = FoldPlaceholder { - constrain_width: true, - render: Arc::new(move |fold_id, fold_range, cx| { - let editor = editor.clone(); - div() - .id(fold_id) - .bg(cx.theme().colors().ghost_element_background) - .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) - .active(|style| style.bg(cx.theme().colors().ghost_element_active)) - .rounded_xs() - .size_full() - .cursor_pointer() - .child("⋯") - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(move |_, _window, cx| { - editor - .update(cx, |editor, cx| { - editor.unfold_ranges( - &[fold_range.start..fold_range.end], - true, - false, - cx, - ); - cx.stop_propagation(); - }) - .ok(); - }) - .into_any() - }), - merge_adjacent: true, - ..Default::default() - }; - let display_map = cx.new(|cx| { - DisplayMap::new( - buffer.clone(), - style.font(), - font_size, - None, - FILE_HEADER_HEIGHT, - MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, - fold_placeholder, - cx, - ) - }); - - let selections = SelectionsCollection::new(display_map.clone(), buffer.clone()); - - let blink_manager = cx.new(|cx| BlinkManager::new(CURSOR_BLINK_INTERVAL, cx)); - - let soft_wrap_mode_override = matches!(mode, EditorMode::SingleLine { .. }) - .then(|| language_settings::SoftWrap::None); - - let mut project_subscriptions = Vec::new(); - if mode.is_full() { - if let Some(project) = project.as_ref() { - project_subscriptions.push(cx.subscribe_in( - project, - window, - |editor, _, event, window, cx| match event { - project::Event::RefreshCodeLens => { - // we always query lens with actions, without storing them, always refreshing them - } - project::Event::RefreshInlayHints => { - editor - .refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx); - } - project::Event::SnippetEdit(id, snippet_edits) => { - if let Some(buffer) = editor.buffer.read(cx).buffer(*id) { - let focus_handle = editor.focus_handle(cx); - if focus_handle.is_focused(window) { - let snapshot = buffer.read(cx).snapshot(); - for (range, snippet) in snippet_edits { - let editor_range = - language::range_from_lsp(*range).to_offset(&snapshot); - editor - .insert_snippet( - &[editor_range], - snippet.clone(), - window, - cx, - ) - .ok(); - } - } - } - } - _ => {} - }, - )); - if let Some(task_inventory) = project - .read(cx) - .task_store() - .read(cx) - .task_inventory() - .cloned() - { - project_subscriptions.push(cx.observe_in( - &task_inventory, - window, - |editor, _, window, cx| { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); - }, - )); - }; - - project_subscriptions.push(cx.subscribe_in( - &project.read(cx).breakpoint_store(), - window, - |editor, _, event, window, cx| match event { - BreakpointStoreEvent::ClearDebugLines => { - editor.clear_row_highlights::(); - editor.refresh_inline_values(cx); - } - BreakpointStoreEvent::SetDebugLine => { - if editor.go_to_active_debug_line(window, cx) { - cx.stop_propagation(); - } - - editor.refresh_inline_values(cx); - } - _ => {} - }, - )); - } - } - - let buffer_snapshot = buffer.read(cx).snapshot(cx); - - let inlay_hint_settings = - inlay_hint_settings(selections.newest_anchor().head(), &buffer_snapshot, cx); - let focus_handle = cx.focus_handle(); - cx.on_focus(&focus_handle, window, Self::handle_focus) - .detach(); - cx.on_focus_in(&focus_handle, window, Self::handle_focus_in) - .detach(); - cx.on_focus_out(&focus_handle, window, Self::handle_focus_out) - .detach(); - cx.on_blur(&focus_handle, window, Self::handle_blur) - .detach(); - - let show_indent_guides = if matches!(mode, EditorMode::SingleLine { .. }) { - Some(false) - } else { - None - }; - - let breakpoint_store = match (mode, project.as_ref()) { - (EditorMode::Full { .. }, Some(project)) => Some(project.read(cx).breakpoint_store()), - _ => None, - }; - - let mut code_action_providers = Vec::new(); - let mut load_uncommitted_diff = None; - if let Some(project) = project.clone() { - load_uncommitted_diff = Some( - get_uncommitted_diff_for_buffer( - &project, - buffer.read(cx).all_buffers(), - buffer.clone(), - cx, - ) - .shared(), - ); - code_action_providers.push(Rc::new(project) as Rc<_>); - } - - let mut this = Self { - focus_handle, - show_cursor_when_unfocused: false, - last_focused_descendant: None, - buffer: buffer.clone(), - display_map: display_map.clone(), - selections, - scroll_manager: ScrollManager::new(cx), - columnar_selection_tail: None, - add_selections_state: None, - select_next_state: None, - select_prev_state: None, - selection_history: Default::default(), - autoclose_regions: Default::default(), - snippet_stack: Default::default(), - select_syntax_node_history: SelectSyntaxNodeHistory::default(), - ime_transaction: Default::default(), - active_diagnostics: ActiveDiagnostic::None, - show_inline_diagnostics: ProjectSettings::get_global(cx).diagnostics.inline.enabled, - inline_diagnostics_update: Task::ready(()), - inline_diagnostics: Vec::new(), - soft_wrap_mode_override, - hard_wrap: None, - completion_provider: project.clone().map(|project| Box::new(project) as _), - semantics_provider: project.clone().map(|project| Rc::new(project) as _), - collaboration_hub: project.clone().map(|project| Box::new(project) as _), - project, - blink_manager: blink_manager.clone(), - show_local_selections: true, - show_scrollbars: true, - disable_scrolling: false, - mode, - show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs, - show_gutter: mode.is_full(), - show_line_numbers: None, - use_relative_line_numbers: None, - disable_expand_excerpt_buttons: false, - show_git_diff_gutter: None, - show_code_actions: None, - show_runnables: None, - show_breakpoints: None, - show_wrap_guides: None, - show_indent_guides, - placeholder_text: None, - highlight_order: 0, - highlighted_rows: HashMap::default(), - background_highlights: Default::default(), - gutter_highlights: TreeMap::default(), - scrollbar_marker_state: ScrollbarMarkerState::default(), - active_indent_guides_state: ActiveIndentGuidesState::default(), - nav_history: None, - context_menu: RefCell::new(None), - context_menu_options: None, - mouse_context_menu: None, - completion_tasks: Default::default(), - inline_blame_popover: Default::default(), - signature_help_state: SignatureHelpState::default(), - auto_signature_help: None, - find_all_references_task_sources: Vec::new(), - next_completion_id: 0, - next_inlay_id: 0, - code_action_providers, - available_code_actions: Default::default(), - code_actions_task: Default::default(), - quick_selection_highlight_task: Default::default(), - debounced_selection_highlight_task: Default::default(), - document_highlights_task: Default::default(), - linked_editing_range_task: Default::default(), - pending_rename: Default::default(), - searchable: true, - cursor_shape: EditorSettings::get_global(cx) - .cursor_shape - .unwrap_or_default(), - current_line_highlight: None, - autoindent_mode: Some(AutoindentMode::EachLine), - collapse_matches: false, - workspace: None, - input_enabled: true, - use_modal_editing: mode.is_full(), - read_only: false, - use_autoclose: true, - use_auto_surround: true, - auto_replace_emoji_shortcode: false, - jsx_tag_auto_close_enabled_in_any_buffer: false, - leader_peer_id: None, - remote_id: None, - hover_state: Default::default(), - pending_mouse_down: None, - hovered_link_state: Default::default(), - edit_prediction_provider: None, - active_inline_completion: None, - stale_inline_completion_in_menu: None, - edit_prediction_preview: EditPredictionPreview::Inactive { - released_too_fast: false, - }, - inline_diagnostics_enabled: mode.is_full(), - inline_value_cache: InlineValueCache::new(inlay_hint_settings.show_value_hints), - inlay_hint_cache: InlayHintCache::new(inlay_hint_settings), - - gutter_hovered: false, - pixel_position_of_newest_cursor: None, - last_bounds: None, - last_position_map: None, - expect_bounds_change: None, - gutter_dimensions: GutterDimensions::default(), - style: None, - show_cursor_names: false, - hovered_cursors: Default::default(), - next_editor_action_id: EditorActionId::default(), - editor_actions: Rc::default(), - inline_completions_hidden_for_vim_mode: false, - show_inline_completions_override: None, - menu_inline_completions_policy: MenuInlineCompletionsPolicy::ByProvider, - edit_prediction_settings: EditPredictionSettings::Disabled, - edit_prediction_indent_conflict: false, - edit_prediction_requires_modifier_in_indent_conflict: true, - custom_context_menu: None, - show_git_blame_gutter: false, - show_git_blame_inline: false, - show_selection_menu: None, - show_git_blame_inline_delay_task: None, - git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(), - render_diff_hunk_controls: Arc::new(render_diff_hunk_controls), - serialize_dirty_buffers: ProjectSettings::get_global(cx) - .session - .restore_unsaved_buffers, - blame: None, - blame_subscription: None, - tasks: Default::default(), - - breakpoint_store, - gutter_breakpoint_indicator: (None, None), - _subscriptions: vec![ - cx.observe(&buffer, Self::on_buffer_changed), - cx.subscribe_in(&buffer, window, Self::on_buffer_event), - cx.observe_in(&display_map, window, Self::on_display_map_changed), - cx.observe(&blink_manager, |_, _, cx| cx.notify()), - cx.observe_global_in::(window, Self::settings_changed), - observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), - cx.observe_window_activation(window, |editor, window, cx| { - let active = window.is_window_active(); - editor.blink_manager.update(cx, |blink_manager, cx| { - if active { - blink_manager.enable(cx); - } else { - blink_manager.disable(cx); - } - }); - }), - ], - tasks_update_task: None, - linked_edit_ranges: Default::default(), - in_project_search: false, - previous_search_ranges: None, - breadcrumb_header: None, - focused_block: None, - next_scroll_position: NextScrollCursorCenterTopBottom::default(), - addons: HashMap::default(), - registered_buffers: HashMap::default(), - _scroll_cursor_center_top_bottom_task: Task::ready(()), - selection_mark_mode: false, - toggle_fold_multiple_buffers: Task::ready(()), - serialize_selections: Task::ready(()), - serialize_folds: Task::ready(()), - text_style_refinement: None, - load_diff_task: load_uncommitted_diff, - mouse_cursor_hidden: false, - hide_mouse_mode: EditorSettings::get_global(cx) - .hide_mouse - .unwrap_or_default(), - change_list: ChangeList::new(), - }; - if let Some(breakpoints) = this.breakpoint_store.as_ref() { - this._subscriptions - .push(cx.observe(breakpoints, |_, _, cx| { - cx.notify(); - })); - } - this.tasks_update_task = Some(this.refresh_runnables(window, cx)); - this._subscriptions.extend(project_subscriptions); - - this._subscriptions.push(cx.subscribe_in( - &cx.entity(), - window, - |editor, _, e: &EditorEvent, window, cx| match e { - EditorEvent::ScrollPositionChanged { local, .. } => { - if *local { - let new_anchor = editor.scroll_manager.anchor(); - let snapshot = editor.snapshot(window, cx); - editor.update_restoration_data(cx, move |data| { - data.scroll_position = ( - new_anchor.top_row(&snapshot.buffer_snapshot), - new_anchor.offset, - ); - }); - editor.hide_signature_help(cx, SignatureHelpHiddenBy::Escape); - editor.inline_blame_popover.take(); - } - } - EditorEvent::Edited { .. } => { - if !vim_enabled(cx) { - let (map, selections) = editor.selections.all_adjusted_display(cx); - let pop_state = editor - .change_list - .last() - .map(|previous| { - previous.len() == selections.len() - && previous.iter().enumerate().all(|(ix, p)| { - p.to_display_point(&map).row() - == selections[ix].head().row() - }) - }) - .unwrap_or(false); - let new_positions = selections - .into_iter() - .map(|s| map.display_point_to_anchor(s.head(), Bias::Left)) - .collect(); - editor - .change_list - .push_to_change_list(pop_state, new_positions); - } - } - _ => (), - }, - )); - - if let Some(dap_store) = this - .project - .as_ref() - .map(|project| project.read(cx).dap_store()) - { - let weak_editor = cx.weak_entity(); - - this._subscriptions - .push( - cx.observe_new::(move |_, _, cx| { - let session_entity = cx.entity(); - weak_editor - .update(cx, |editor, cx| { - editor._subscriptions.push( - cx.subscribe(&session_entity, Self::on_debug_session_event), - ); - }) - .ok(); - }), - ); - - for session in dap_store.read(cx).sessions().cloned().collect::>() { - this._subscriptions - .push(cx.subscribe(&session, Self::on_debug_session_event)); - } - } - - this.end_selection(window, cx); - this.scroll_manager.show_scrollbars(window, cx); - jsx_tag_auto_close::refresh_enabled_in_any_buffer(&mut this, &buffer, cx); - - if mode.is_full() { - let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars(); - cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars)); - - if this.git_blame_inline_enabled { - this.git_blame_inline_enabled = true; - this.start_git_blame_inline(false, window, cx); - } - - this.go_to_active_debug_line(window, cx); - - if let Some(buffer) = buffer.read(cx).as_singleton() { - if let Some(project) = this.project.as_ref() { - let handle = project.update(cx, |project, cx| { - project.register_buffer_with_language_servers(&buffer, cx) - }); - this.registered_buffers - .insert(buffer.read(cx).remote_id(), handle); - } - } - } - - this.report_editor_event("Editor Opened", None, cx); - this - } - - pub fn deploy_mouse_context_menu( - &mut self, - position: gpui::Point, - context_menu: Entity, - window: &mut Window, - cx: &mut Context, - ) { - self.mouse_context_menu = Some(MouseContextMenu::new( - self, - crate::mouse_context_menu::MenuPosition::PinnedToScreen(position), - context_menu, - window, - cx, - )); - } - - pub fn mouse_menu_is_focused(&self, window: &Window, cx: &App) -> bool { - self.mouse_context_menu - .as_ref() - .is_some_and(|menu| menu.context_menu.focus_handle(cx).is_focused(window)) - } - - fn key_context(&self, window: &Window, cx: &App) -> KeyContext { - self.key_context_internal(self.has_active_inline_completion(), window, cx) - } - - fn key_context_internal( - &self, - has_active_edit_prediction: bool, - window: &Window, - cx: &App, - ) -> KeyContext { - let mut key_context = KeyContext::new_with_defaults(); - key_context.add("Editor"); - let mode = match self.mode { - EditorMode::SingleLine { .. } => "single_line", - EditorMode::AutoHeight { .. } => "auto_height", - EditorMode::Full { .. } => "full", - }; - - if EditorSettings::jupyter_enabled(cx) { - key_context.add("jupyter"); - } - - key_context.set("mode", mode); - if self.pending_rename.is_some() { - key_context.add("renaming"); - } - - match self.context_menu.borrow().as_ref() { - Some(CodeContextMenu::Completions(_)) => { - key_context.add("menu"); - key_context.add("showing_completions"); - } - Some(CodeContextMenu::CodeActions(_)) => { - key_context.add("menu"); - key_context.add("showing_code_actions") - } - None => {} - } - - // Disable vim contexts when a sub-editor (e.g. rename/inline assistant) is focused. - if !self.focus_handle(cx).contains_focused(window, cx) - || (self.is_focused(window) || self.mouse_menu_is_focused(window, cx)) - { - for addon in self.addons.values() { - addon.extend_key_context(&mut key_context, cx) - } - } - - if let Some(singleton_buffer) = self.buffer.read(cx).as_singleton() { - if let Some(extension) = singleton_buffer - .read(cx) - .file() - .and_then(|file| file.path().extension()?.to_str()) - { - key_context.set("extension", extension.to_string()); - } - } else { - key_context.add("multibuffer"); - } - - if has_active_edit_prediction { - if self.edit_prediction_in_conflict() { - key_context.add(EDIT_PREDICTION_CONFLICT_KEY_CONTEXT); - } else { - key_context.add(EDIT_PREDICTION_KEY_CONTEXT); - key_context.add("copilot_suggestion"); - } - } - - if self.selection_mark_mode { - key_context.add("selection_mode"); - } - - key_context - } - - pub fn hide_mouse_cursor(&mut self, origin: &HideMouseCursorOrigin) { - self.mouse_cursor_hidden = match origin { - HideMouseCursorOrigin::TypingAction => { - matches!( - self.hide_mouse_mode, - HideMouseMode::OnTyping | HideMouseMode::OnTypingAndMovement - ) - } - HideMouseCursorOrigin::MovementAction => { - matches!(self.hide_mouse_mode, HideMouseMode::OnTypingAndMovement) - } - }; - } - - pub fn edit_prediction_in_conflict(&self) -> bool { - if !self.show_edit_predictions_in_menu() { - return false; - } - - let showing_completions = self - .context_menu - .borrow() - .as_ref() - .map_or(false, |context| { - matches!(context, CodeContextMenu::Completions(_)) - }); - - showing_completions - || self.edit_prediction_requires_modifier() - // Require modifier key when the cursor is on leading whitespace, to allow `tab` - // bindings to insert tab characters. - || (self.edit_prediction_requires_modifier_in_indent_conflict && self.edit_prediction_indent_conflict) - } - - pub fn accept_edit_prediction_keybind( - &self, - window: &Window, - cx: &App, - ) -> AcceptEditPredictionBinding { - let key_context = self.key_context_internal(true, window, cx); - let in_conflict = self.edit_prediction_in_conflict(); - - AcceptEditPredictionBinding( - window - .bindings_for_action_in_context(&AcceptEditPrediction, key_context) - .into_iter() - .filter(|binding| { - !in_conflict - || binding - .keystrokes() - .first() - .map_or(false, |keystroke| keystroke.modifiers.modified()) - }) - .rev() - .min_by_key(|binding| { - binding - .keystrokes() - .first() - .map_or(u8::MAX, |k| k.modifiers.number_of_modifiers()) - }), - ) - } - - pub fn new_file( - workspace: &mut Workspace, - _: &workspace::NewFile, - window: &mut Window, - cx: &mut Context, - ) { - Self::new_in_workspace(workspace, window, cx).detach_and_prompt_err( - "Failed to create buffer", - window, - cx, - |e, _, _| match e.error_code() { - ErrorCode::RemoteUpgradeRequired => Some(format!( - "The remote instance of Zed does not support this yet. It must be upgraded to {}", - e.error_tag("required").unwrap_or("the latest version") - )), - _ => None, - }, - ); - } - - pub fn new_in_workspace( - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) -> Task>> { - let project = workspace.project().clone(); - let create = project.update(cx, |project, cx| project.create_buffer(cx)); - - cx.spawn_in(window, async move |workspace, cx| { - let buffer = create.await?; - workspace.update_in(cx, |workspace, window, cx| { - let editor = - cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)); - workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); - editor - }) - }) - } - - fn new_file_vertical( - workspace: &mut Workspace, - _: &workspace::NewFileSplitVertical, - window: &mut Window, - cx: &mut Context, - ) { - Self::new_file_in_direction(workspace, SplitDirection::vertical(cx), window, cx) - } - - fn new_file_horizontal( - workspace: &mut Workspace, - _: &workspace::NewFileSplitHorizontal, - window: &mut Window, - cx: &mut Context, - ) { - Self::new_file_in_direction(workspace, SplitDirection::horizontal(cx), window, cx) - } - - fn new_file_in_direction( - workspace: &mut Workspace, - direction: SplitDirection, - window: &mut Window, - cx: &mut Context, - ) { - let project = workspace.project().clone(); - let create = project.update(cx, |project, cx| project.create_buffer(cx)); - - cx.spawn_in(window, async move |workspace, cx| { - let buffer = create.await?; - workspace.update_in(cx, move |workspace, window, cx| { - workspace.split_item( - direction, - Box::new( - cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)), - ), - window, - cx, - ) - })?; - anyhow::Ok(()) - }) - .detach_and_prompt_err("Failed to create buffer", window, cx, |e, _, _| { - match e.error_code() { - ErrorCode::RemoteUpgradeRequired => Some(format!( - "The remote instance of Zed does not support this yet. It must be upgraded to {}", - e.error_tag("required").unwrap_or("the latest version") - )), - _ => None, - } - }); - } - - pub fn leader_peer_id(&self) -> Option { - self.leader_peer_id - } - - pub fn buffer(&self) -> &Entity { - &self.buffer - } - - pub fn workspace(&self) -> Option> { - self.workspace.as_ref()?.0.upgrade() - } - - pub fn title<'a>(&self, cx: &'a App) -> Cow<'a, str> { - self.buffer().read(cx).title(cx) - } - - pub fn snapshot(&self, window: &mut Window, cx: &mut App) -> EditorSnapshot { - let git_blame_gutter_max_author_length = self - .render_git_blame_gutter(cx) - .then(|| { - if let Some(blame) = self.blame.as_ref() { - let max_author_length = - blame.update(cx, |blame, cx| blame.max_author_length(cx)); - Some(max_author_length) - } else { - None - } - }) - .flatten(); - - EditorSnapshot { - mode: self.mode, - show_gutter: self.show_gutter, - show_line_numbers: self.show_line_numbers, - show_git_diff_gutter: self.show_git_diff_gutter, - show_code_actions: self.show_code_actions, - show_runnables: self.show_runnables, - show_breakpoints: self.show_breakpoints, - git_blame_gutter_max_author_length, - display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)), - scroll_anchor: self.scroll_manager.anchor(), - ongoing_scroll: self.scroll_manager.ongoing_scroll(), - placeholder_text: self.placeholder_text.clone(), - is_focused: self.focus_handle.is_focused(window), - current_line_highlight: self - .current_line_highlight - .unwrap_or_else(|| EditorSettings::get_global(cx).current_line_highlight), - gutter_hovered: self.gutter_hovered, - } - } - - pub fn language_at(&self, point: T, cx: &App) -> Option> { - self.buffer.read(cx).language_at(point, cx) - } - - pub fn file_at(&self, point: T, cx: &App) -> Option> { - self.buffer.read(cx).read(cx).file_at(point).cloned() - } - - pub fn active_excerpt( - &self, - cx: &App, - ) -> Option<(ExcerptId, Entity, Range)> { - self.buffer - .read(cx) - .excerpt_containing(self.selections.newest_anchor().head(), cx) - } - - pub fn mode(&self) -> EditorMode { - self.mode - } - - pub fn set_mode(&mut self, mode: EditorMode) { - self.mode = mode; - } - - pub fn collaboration_hub(&self) -> Option<&dyn CollaborationHub> { - self.collaboration_hub.as_deref() - } - - pub fn set_collaboration_hub(&mut self, hub: Box) { - self.collaboration_hub = Some(hub); - } - - pub fn set_in_project_search(&mut self, in_project_search: bool) { - self.in_project_search = in_project_search; - } - - pub fn set_custom_context_menu( - &mut self, - f: impl 'static - + Fn( - &mut Self, - DisplayPoint, - &mut Window, - &mut Context, - ) -> Option>, - ) { - self.custom_context_menu = Some(Box::new(f)) - } - - pub fn set_completion_provider(&mut self, provider: Option>) { - self.completion_provider = provider; - } - - pub fn semantics_provider(&self) -> Option> { - self.semantics_provider.clone() - } - - pub fn set_semantics_provider(&mut self, provider: Option>) { - self.semantics_provider = provider; - } - - pub fn set_edit_prediction_provider( - &mut self, - provider: Option>, - window: &mut Window, - cx: &mut Context, - ) where - T: EditPredictionProvider, - { - self.edit_prediction_provider = - provider.map(|provider| RegisteredInlineCompletionProvider { - _subscription: cx.observe_in(&provider, window, |this, _, window, cx| { - if this.focus_handle.is_focused(window) { - this.update_visible_inline_completion(window, cx); - } - }), - provider: Arc::new(provider), - }); - self.update_edit_prediction_settings(cx); - self.refresh_inline_completion(false, false, window, cx); - } - - pub fn placeholder_text(&self) -> Option<&str> { - self.placeholder_text.as_deref() - } - - pub fn set_placeholder_text( - &mut self, - placeholder_text: impl Into>, - cx: &mut Context, - ) { - let placeholder_text = Some(placeholder_text.into()); - if self.placeholder_text != placeholder_text { - self.placeholder_text = placeholder_text; - cx.notify(); - } - } - - pub fn set_cursor_shape(&mut self, cursor_shape: CursorShape, cx: &mut Context) { - self.cursor_shape = cursor_shape; - - // Disrupt blink for immediate user feedback that the cursor shape has changed - self.blink_manager.update(cx, BlinkManager::show_cursor); - - cx.notify(); - } - - pub fn set_current_line_highlight( - &mut self, - current_line_highlight: Option, - ) { - self.current_line_highlight = current_line_highlight; - } - - pub fn set_collapse_matches(&mut self, collapse_matches: bool) { - self.collapse_matches = collapse_matches; - } - - fn register_buffers_with_language_servers(&mut self, cx: &mut Context) { - let buffers = self.buffer.read(cx).all_buffers(); - let Some(project) = self.project.as_ref() else { - return; - }; - project.update(cx, |project, cx| { - for buffer in buffers { - self.registered_buffers - .entry(buffer.read(cx).remote_id()) - .or_insert_with(|| project.register_buffer_with_language_servers(&buffer, cx)); - } - }) - } - - pub fn range_for_match(&self, range: &Range) -> Range { - if self.collapse_matches { - return range.start..range.start; - } - range.clone() - } - - pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut Context) { - if self.display_map.read(cx).clip_at_line_ends != clip { - self.display_map - .update(cx, |map, _| map.clip_at_line_ends = clip); - } - } - - pub fn set_input_enabled(&mut self, input_enabled: bool) { - self.input_enabled = input_enabled; - } - - pub fn set_inline_completions_hidden_for_vim_mode( - &mut self, - hidden: bool, - window: &mut Window, - cx: &mut Context, - ) { - if hidden != self.inline_completions_hidden_for_vim_mode { - self.inline_completions_hidden_for_vim_mode = hidden; - if hidden { - self.update_visible_inline_completion(window, cx); - } else { - self.refresh_inline_completion(true, false, window, cx); - } - } - } - - pub fn set_menu_inline_completions_policy(&mut self, value: MenuInlineCompletionsPolicy) { - self.menu_inline_completions_policy = value; - } - - pub fn set_autoindent(&mut self, autoindent: bool) { - if autoindent { - self.autoindent_mode = Some(AutoindentMode::EachLine); - } else { - self.autoindent_mode = None; - } - } - - pub fn read_only(&self, cx: &App) -> bool { - self.read_only || self.buffer.read(cx).read_only() - } - - pub fn set_read_only(&mut self, read_only: bool) { - self.read_only = read_only; - } - - pub fn set_use_autoclose(&mut self, autoclose: bool) { - self.use_autoclose = autoclose; - } - - pub fn set_use_auto_surround(&mut self, auto_surround: bool) { - self.use_auto_surround = auto_surround; - } - - pub fn set_auto_replace_emoji_shortcode(&mut self, auto_replace: bool) { - self.auto_replace_emoji_shortcode = auto_replace; - } - - pub fn toggle_edit_predictions( - &mut self, - _: &ToggleEditPrediction, - window: &mut Window, - cx: &mut Context, - ) { - if self.show_inline_completions_override.is_some() { - self.set_show_edit_predictions(None, window, cx); - } else { - let show_edit_predictions = !self.edit_predictions_enabled(); - self.set_show_edit_predictions(Some(show_edit_predictions), window, cx); - } - } - - pub fn set_show_edit_predictions( - &mut self, - show_edit_predictions: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.show_inline_completions_override = show_edit_predictions; - self.update_edit_prediction_settings(cx); - - if let Some(false) = show_edit_predictions { - self.discard_inline_completion(false, cx); - } else { - self.refresh_inline_completion(false, true, window, cx); - } - } - - fn inline_completions_disabled_in_scope( - &self, - buffer: &Entity, - buffer_position: language::Anchor, - cx: &App, - ) -> bool { - let snapshot = buffer.read(cx).snapshot(); - let settings = snapshot.settings_at(buffer_position, cx); - - let Some(scope) = snapshot.language_scope_at(buffer_position) else { - return false; - }; - - scope.override_name().map_or(false, |scope_name| { - settings - .edit_predictions_disabled_in - .iter() - .any(|s| s == scope_name) - }) - } - - pub fn set_use_modal_editing(&mut self, to: bool) { - self.use_modal_editing = to; - } - - pub fn use_modal_editing(&self) -> bool { - self.use_modal_editing - } - - fn selections_did_change( - &mut self, - local: bool, - old_cursor_position: &Anchor, - show_completions: bool, - window: &mut Window, - cx: &mut Context, - ) { - window.invalidate_character_coordinates(); - - // Copy selections to primary selection buffer - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - if local { - let selections = self.selections.all::(cx); - let buffer_handle = self.buffer.read(cx).read(cx); - - let mut text = String::new(); - for (index, selection) in selections.iter().enumerate() { - let text_for_selection = buffer_handle - .text_for_range(selection.start..selection.end) - .collect::(); - - text.push_str(&text_for_selection); - if index != selections.len() - 1 { - text.push('\n'); - } - } - - if !text.is_empty() { - cx.write_to_primary(ClipboardItem::new_string(text)); - } - } - - if self.focus_handle.is_focused(window) && self.leader_peer_id.is_none() { - self.buffer.update(cx, |buffer, cx| { - buffer.set_active_selections( - &self.selections.disjoint_anchors(), - self.selections.line_mode, - self.cursor_shape, - cx, - ) - }); - } - let display_map = self - .display_map - .update(cx, |display_map, cx| display_map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; - self.add_selections_state = None; - self.select_next_state = None; - self.select_prev_state = None; - self.select_syntax_node_history.try_clear(); - self.invalidate_autoclose_regions(&self.selections.disjoint_anchors(), buffer); - self.snippet_stack - .invalidate(&self.selections.disjoint_anchors(), buffer); - self.take_rename(false, window, cx); - - let new_cursor_position = self.selections.newest_anchor().head(); - - self.push_to_nav_history( - *old_cursor_position, - Some(new_cursor_position.to_point(buffer)), - false, - cx, - ); - - if local { - let new_cursor_position = self.selections.newest_anchor().head(); - let mut context_menu = self.context_menu.borrow_mut(); - let completion_menu = match context_menu.as_ref() { - Some(CodeContextMenu::Completions(menu)) => Some(menu), - _ => { - *context_menu = None; - None - } - }; - if let Some(buffer_id) = new_cursor_position.buffer_id { - if !self.registered_buffers.contains_key(&buffer_id) { - if let Some(project) = self.project.as_ref() { - project.update(cx, |project, cx| { - let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else { - return; - }; - self.registered_buffers.insert( - buffer_id, - project.register_buffer_with_language_servers(&buffer, cx), - ); - }) - } - } - } - - if let Some(completion_menu) = completion_menu { - let cursor_position = new_cursor_position.to_offset(buffer); - let (word_range, kind) = - buffer.surrounding_word(completion_menu.initial_position, true); - if kind == Some(CharKind::Word) - && word_range.to_inclusive().contains(&cursor_position) - { - let mut completion_menu = completion_menu.clone(); - drop(context_menu); - - let query = Self::completion_query(buffer, cursor_position); - cx.spawn(async move |this, cx| { - completion_menu - .filter(query.as_deref(), cx.background_executor().clone()) - .await; - - this.update(cx, |this, cx| { - let mut context_menu = this.context_menu.borrow_mut(); - let Some(CodeContextMenu::Completions(menu)) = context_menu.as_ref() - else { - return; - }; - - if menu.id > completion_menu.id { - return; - } - - *context_menu = Some(CodeContextMenu::Completions(completion_menu)); - drop(context_menu); - cx.notify(); - }) - }) - .detach(); - - if show_completions { - self.show_completions(&ShowCompletions { trigger: None }, window, cx); - } - } else { - drop(context_menu); - self.hide_context_menu(window, cx); - } - } else { - drop(context_menu); - } - - hide_hover(self, cx); - - if old_cursor_position.to_display_point(&display_map).row() - != new_cursor_position.to_display_point(&display_map).row() - { - self.available_code_actions.take(); - } - self.refresh_code_actions(window, cx); - self.refresh_document_highlights(cx); - self.refresh_selected_text_highlights(false, window, cx); - refresh_matching_bracket_highlights(self, window, cx); - self.update_visible_inline_completion(window, cx); - self.edit_prediction_requires_modifier_in_indent_conflict = true; - linked_editing_ranges::refresh_linked_ranges(self, window, cx); - self.inline_blame_popover.take(); - if self.git_blame_inline_enabled { - self.start_inline_blame_timer(window, cx); - } - } - - self.blink_manager.update(cx, BlinkManager::pause_blinking); - cx.emit(EditorEvent::SelectionsChanged { local }); - - let selections = &self.selections.disjoint; - if selections.len() == 1 { - cx.emit(SearchEvent::ActiveMatchChanged) - } - if local { - if let Some((_, _, buffer_snapshot)) = buffer.as_singleton() { - let inmemory_selections = selections - .iter() - .map(|s| { - text::ToPoint::to_point(&s.range().start.text_anchor, buffer_snapshot) - ..text::ToPoint::to_point(&s.range().end.text_anchor, buffer_snapshot) - }) - .collect(); - self.update_restoration_data(cx, |data| { - data.selections = inmemory_selections; - }); - - if WorkspaceSettings::get(None, cx).restore_on_startup - != RestoreOnStartupBehavior::None - { - if let Some(workspace_id) = - self.workspace.as_ref().and_then(|workspace| workspace.1) - { - let snapshot = self.buffer().read(cx).snapshot(cx); - let selections = selections.clone(); - let background_executor = cx.background_executor().clone(); - let editor_id = cx.entity().entity_id().as_u64() as ItemId; - self.serialize_selections = cx.background_spawn(async move { - background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; - let db_selections = selections - .iter() - .map(|selection| { - ( - selection.start.to_offset(&snapshot), - selection.end.to_offset(&snapshot), - ) - }) - .collect(); - - DB.save_editor_selections(editor_id, workspace_id, db_selections) - .await - .with_context(|| format!("persisting editor selections for editor {editor_id}, workspace {workspace_id:?}")) - .log_err(); - }); - } - } - } - } - - cx.notify(); - } - - fn folds_did_change(&mut self, cx: &mut Context) { - use text::ToOffset as _; - use text::ToPoint as _; - - if WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None { - return; - } - - let Some(singleton) = self.buffer().read(cx).as_singleton() else { - return; - }; - - let snapshot = singleton.read(cx).snapshot(); - let inmemory_folds = self.display_map.update(cx, |display_map, cx| { - let display_snapshot = display_map.snapshot(cx); - - display_snapshot - .folds_in_range(0..display_snapshot.buffer_snapshot.len()) - .map(|fold| { - fold.range.start.text_anchor.to_point(&snapshot) - ..fold.range.end.text_anchor.to_point(&snapshot) - }) - .collect() - }); - self.update_restoration_data(cx, |data| { - data.folds = inmemory_folds; - }); - - let Some(workspace_id) = self.workspace.as_ref().and_then(|workspace| workspace.1) else { - return; - }; - let background_executor = cx.background_executor().clone(); - let editor_id = cx.entity().entity_id().as_u64() as ItemId; - let db_folds = self.display_map.update(cx, |display_map, cx| { - display_map - .snapshot(cx) - .folds_in_range(0..snapshot.len()) - .map(|fold| { - ( - fold.range.start.text_anchor.to_offset(&snapshot), - fold.range.end.text_anchor.to_offset(&snapshot), - ) - }) - .collect() - }); - self.serialize_folds = cx.background_spawn(async move { - background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; - DB.save_editor_folds(editor_id, workspace_id, db_folds) - .await - .with_context(|| { - format!( - "persisting editor folds for editor {editor_id}, workspace {workspace_id:?}" - ) - }) - .log_err(); - }); - } - - pub fn sync_selections( - &mut self, - other: Entity, - cx: &mut Context, - ) -> gpui::Subscription { - let other_selections = other.read(cx).selections.disjoint.to_vec(); - self.selections.change_with(cx, |selections| { - selections.select_anchors(other_selections); - }); - - let other_subscription = - cx.subscribe(&other, |this, other, other_evt, cx| match other_evt { - EditorEvent::SelectionsChanged { local: true } => { - let other_selections = other.read(cx).selections.disjoint.to_vec(); - if other_selections.is_empty() { - return; - } - this.selections.change_with(cx, |selections| { - selections.select_anchors(other_selections); - }); - } - _ => {} - }); - - let this_subscription = - cx.subscribe_self::(move |this, this_evt, cx| match this_evt { - EditorEvent::SelectionsChanged { local: true } => { - let these_selections = this.selections.disjoint.to_vec(); - if these_selections.is_empty() { - return; - } - other.update(cx, |other_editor, cx| { - other_editor.selections.change_with(cx, |selections| { - selections.select_anchors(these_selections); - }) - }); - } - _ => {} - }); - - Subscription::join(other_subscription, this_subscription) - } - - pub fn change_selections( - &mut self, - autoscroll: Option, - window: &mut Window, - cx: &mut Context, - change: impl FnOnce(&mut MutableSelectionsCollection<'_>) -> R, - ) -> R { - self.change_selections_inner(autoscroll, true, window, cx, change) - } - - fn change_selections_inner( - &mut self, - autoscroll: Option, - request_completions: bool, - window: &mut Window, - cx: &mut Context, - change: impl FnOnce(&mut MutableSelectionsCollection<'_>) -> R, - ) -> R { - let old_cursor_position = self.selections.newest_anchor().head(); - self.push_to_selection_history(); - - let (changed, result) = self.selections.change_with(cx, change); - - if changed { - if let Some(autoscroll) = autoscroll { - self.request_autoscroll(autoscroll, cx); - } - self.selections_did_change(true, &old_cursor_position, request_completions, window, cx); - - if self.should_open_signature_help_automatically( - &old_cursor_position, - self.signature_help_state.backspace_pressed(), - cx, - ) { - self.show_signature_help(&ShowSignatureHelp, window, cx); - } - self.signature_help_state.set_backspace_pressed(false); - } - - result - } - - pub fn edit(&mut self, edits: I, cx: &mut Context) - where - I: IntoIterator, T)>, - S: ToOffset, - T: Into>, - { - if self.read_only(cx) { - return; - } - - self.buffer - .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); - } - - pub fn edit_with_autoindent(&mut self, edits: I, cx: &mut Context) - where - I: IntoIterator, T)>, - S: ToOffset, - T: Into>, - { - if self.read_only(cx) { - return; - } - - self.buffer.update(cx, |buffer, cx| { - buffer.edit(edits, self.autoindent_mode.clone(), cx) - }); - } - - pub fn edit_with_block_indent( - &mut self, - edits: I, - original_indent_columns: Vec>, - cx: &mut Context, - ) where - I: IntoIterator, T)>, - S: ToOffset, - T: Into>, - { - if self.read_only(cx) { - return; - } - - self.buffer.update(cx, |buffer, cx| { - buffer.edit( - edits, - Some(AutoindentMode::Block { - original_indent_columns, - }), - cx, - ) - }); - } - - fn select(&mut self, phase: SelectPhase, window: &mut Window, cx: &mut Context) { - self.hide_context_menu(window, cx); - - match phase { - SelectPhase::Begin { - position, - add, - click_count, - } => self.begin_selection(position, add, click_count, window, cx), - SelectPhase::BeginColumnar { - position, - goal_column, - reset, - } => self.begin_columnar_selection(position, goal_column, reset, window, cx), - SelectPhase::Extend { - position, - click_count, - } => self.extend_selection(position, click_count, window, cx), - SelectPhase::Update { - position, - goal_column, - scroll_delta, - } => self.update_selection(position, goal_column, scroll_delta, window, cx), - SelectPhase::End => self.end_selection(window, cx), - } - } - - fn extend_selection( - &mut self, - position: DisplayPoint, - click_count: usize, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let tail = self.selections.newest::(cx).tail(); - self.begin_selection(position, false, click_count, window, cx); - - let position = position.to_offset(&display_map, Bias::Left); - let tail_anchor = display_map.buffer_snapshot.anchor_before(tail); - - let mut pending_selection = self - .selections - .pending_anchor() - .expect("extend_selection not called with pending selection"); - if position >= tail { - pending_selection.start = tail_anchor; - } else { - pending_selection.end = tail_anchor; - pending_selection.reversed = true; - } - - let mut pending_mode = self.selections.pending_mode().unwrap(); - match &mut pending_mode { - SelectMode::Word(range) | SelectMode::Line(range) => *range = tail_anchor..tail_anchor, - _ => {} - } - - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.set_pending(pending_selection, pending_mode) - }); - } - - fn begin_selection( - &mut self, - position: DisplayPoint, - add: bool, - click_count: usize, - window: &mut Window, - cx: &mut Context, - ) { - if !self.focus_handle.is_focused(window) { - self.last_focused_descendant = None; - window.focus(&self.focus_handle); - } - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; - let newest_selection = self.selections.newest_anchor().clone(); - let position = display_map.clip_point(position, Bias::Left); - - let start; - let end; - let mode; - let mut auto_scroll; - match click_count { - 1 => { - start = buffer.anchor_before(position.to_point(&display_map)); - end = start; - mode = SelectMode::Character; - auto_scroll = true; - } - 2 => { - let range = movement::surrounding_word(&display_map, position); - start = buffer.anchor_before(range.start.to_point(&display_map)); - end = buffer.anchor_before(range.end.to_point(&display_map)); - mode = SelectMode::Word(start..end); - auto_scroll = true; - } - 3 => { - let position = display_map - .clip_point(position, Bias::Left) - .to_point(&display_map); - let line_start = display_map.prev_line_boundary(position).0; - let next_line_start = buffer.clip_point( - display_map.next_line_boundary(position).0 + Point::new(1, 0), - Bias::Left, - ); - start = buffer.anchor_before(line_start); - end = buffer.anchor_before(next_line_start); - mode = SelectMode::Line(start..end); - auto_scroll = true; - } - _ => { - start = buffer.anchor_before(0); - end = buffer.anchor_before(buffer.len()); - mode = SelectMode::All; - auto_scroll = false; - } - } - auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks; - - let point_to_delete: Option = { - let selected_points: Vec> = - self.selections.disjoint_in_range(start..end, cx); - - if !add || click_count > 1 { - None - } else if !selected_points.is_empty() { - Some(selected_points[0].id) - } else { - let clicked_point_already_selected = - self.selections.disjoint.iter().find(|selection| { - selection.start.to_point(buffer) == start.to_point(buffer) - || selection.end.to_point(buffer) == end.to_point(buffer) - }); - - clicked_point_already_selected.map(|selection| selection.id) - } - }; - - let selections_count = self.selections.count(); - - self.change_selections(auto_scroll.then(Autoscroll::newest), window, cx, |s| { - if let Some(point_to_delete) = point_to_delete { - s.delete(point_to_delete); - - if selections_count == 1 { - s.set_pending_anchor_range(start..end, mode); - } - } else { - if !add { - s.clear_disjoint(); - } else if click_count > 1 { - s.delete(newest_selection.id) - } - - s.set_pending_anchor_range(start..end, mode); - } - }); - } - - fn begin_columnar_selection( - &mut self, - position: DisplayPoint, - goal_column: u32, - reset: bool, - window: &mut Window, - cx: &mut Context, - ) { - if !self.focus_handle.is_focused(window) { - self.last_focused_descendant = None; - window.focus(&self.focus_handle); - } - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - if reset { - let pointer_position = display_map - .buffer_snapshot - .anchor_before(position.to_point(&display_map)); - - self.change_selections(Some(Autoscroll::newest()), window, cx, |s| { - s.clear_disjoint(); - s.set_pending_anchor_range( - pointer_position..pointer_position, - SelectMode::Character, - ); - }); - } - - let tail = self.selections.newest::(cx).tail(); - self.columnar_selection_tail = Some(display_map.buffer_snapshot.anchor_before(tail)); - - if !reset { - self.select_columns( - tail.to_display_point(&display_map), - position, - goal_column, - &display_map, - window, - cx, - ); - } - } - - fn update_selection( - &mut self, - position: DisplayPoint, - goal_column: u32, - scroll_delta: gpui::Point, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - if let Some(tail) = self.columnar_selection_tail.as_ref() { - let tail = tail.to_display_point(&display_map); - self.select_columns(tail, position, goal_column, &display_map, window, cx); - } else if let Some(mut pending) = self.selections.pending_anchor() { - let buffer = self.buffer.read(cx).snapshot(cx); - let head; - let tail; - let mode = self.selections.pending_mode().unwrap(); - match &mode { - SelectMode::Character => { - head = position.to_point(&display_map); - tail = pending.tail().to_point(&buffer); - } - SelectMode::Word(original_range) => { - let original_display_range = original_range.start.to_display_point(&display_map) - ..original_range.end.to_display_point(&display_map); - let original_buffer_range = original_display_range.start.to_point(&display_map) - ..original_display_range.end.to_point(&display_map); - if movement::is_inside_word(&display_map, position) - || original_display_range.contains(&position) - { - let word_range = movement::surrounding_word(&display_map, position); - if word_range.start < original_display_range.start { - head = word_range.start.to_point(&display_map); - } else { - head = word_range.end.to_point(&display_map); - } - } else { - head = position.to_point(&display_map); - } - - if head <= original_buffer_range.start { - tail = original_buffer_range.end; - } else { - tail = original_buffer_range.start; - } - } - SelectMode::Line(original_range) => { - let original_range = original_range.to_point(&display_map.buffer_snapshot); - - let position = display_map - .clip_point(position, Bias::Left) - .to_point(&display_map); - let line_start = display_map.prev_line_boundary(position).0; - let next_line_start = buffer.clip_point( - display_map.next_line_boundary(position).0 + Point::new(1, 0), - Bias::Left, - ); - - if line_start < original_range.start { - head = line_start - } else { - head = next_line_start - } - - if head <= original_range.start { - tail = original_range.end; - } else { - tail = original_range.start; - } - } - SelectMode::All => { - return; - } - }; - - if head < tail { - pending.start = buffer.anchor_before(head); - pending.end = buffer.anchor_before(tail); - pending.reversed = true; - } else { - pending.start = buffer.anchor_before(tail); - pending.end = buffer.anchor_before(head); - pending.reversed = false; - } - - self.change_selections(None, window, cx, |s| { - s.set_pending(pending, mode); - }); - } else { - log::error!("update_selection dispatched with no pending selection"); - return; - } - - self.apply_scroll_delta(scroll_delta, window, cx); - cx.notify(); - } - - fn end_selection(&mut self, window: &mut Window, cx: &mut Context) { - self.columnar_selection_tail.take(); - if self.selections.pending_anchor().is_some() { - let selections = self.selections.all::(cx); - self.change_selections(None, window, cx, |s| { - s.select(selections); - s.clear_pending(); - }); - } - } - - fn select_columns( - &mut self, - tail: DisplayPoint, - head: DisplayPoint, - goal_column: u32, - display_map: &DisplaySnapshot, - window: &mut Window, - cx: &mut Context, - ) { - let start_row = cmp::min(tail.row(), head.row()); - let end_row = cmp::max(tail.row(), head.row()); - let start_column = cmp::min(tail.column(), goal_column); - let end_column = cmp::max(tail.column(), goal_column); - let reversed = start_column < tail.column(); - - let selection_ranges = (start_row.0..=end_row.0) - .map(DisplayRow) - .filter_map(|row| { - if start_column <= display_map.line_len(row) && !display_map.is_block_line(row) { - let start = display_map - .clip_point(DisplayPoint::new(row, start_column), Bias::Left) - .to_point(display_map); - let end = display_map - .clip_point(DisplayPoint::new(row, end_column), Bias::Right) - .to_point(display_map); - if reversed { - Some(end..start) - } else { - Some(start..end) - } - } else { - None - } - }) - .collect::>(); - - self.change_selections(None, window, cx, |s| { - s.select_ranges(selection_ranges); - }); - cx.notify(); - } - - pub fn has_non_empty_selection(&self, cx: &mut App) -> bool { - self.selections - .all_adjusted(cx) - .iter() - .any(|selection| !selection.is_empty()) - } - - pub fn has_pending_nonempty_selection(&self) -> bool { - let pending_nonempty_selection = match self.selections.pending_anchor() { - Some(Selection { start, end, .. }) => start != end, - None => false, - }; - - pending_nonempty_selection - || (self.columnar_selection_tail.is_some() && self.selections.disjoint.len() > 1) - } - - pub fn has_pending_selection(&self) -> bool { - self.selections.pending_anchor().is_some() || self.columnar_selection_tail.is_some() - } - - pub fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - self.selection_mark_mode = false; - - if self.clear_expanded_diff_hunks(cx) { - cx.notify(); - return; - } - if self.dismiss_menus_and_popups(true, window, cx) { - return; - } - - if self.mode.is_full() - && self.change_selections(Some(Autoscroll::fit()), window, cx, |s| s.try_cancel()) - { - return; - } - - cx.propagate(); - } - - pub fn dismiss_menus_and_popups( - &mut self, - is_user_requested: bool, - window: &mut Window, - cx: &mut Context, - ) -> bool { - if self.take_rename(false, window, cx).is_some() { - return true; - } - - if hide_hover(self, cx) { - return true; - } - - if self.hide_signature_help(cx, SignatureHelpHiddenBy::Escape) { - return true; - } - - if self.hide_context_menu(window, cx).is_some() { - return true; - } - - if self.mouse_context_menu.take().is_some() { - return true; - } - - if is_user_requested && self.discard_inline_completion(true, cx) { - return true; - } - - if self.snippet_stack.pop().is_some() { - return true; - } - - if self.mode.is_full() && matches!(self.active_diagnostics, ActiveDiagnostic::Group(_)) { - self.dismiss_diagnostics(cx); - return true; - } - - false - } - - fn linked_editing_ranges_for( - &self, - selection: Range, - cx: &App, - ) -> Option, Vec>>> { - if self.linked_edit_ranges.is_empty() { - return None; - } - let ((base_range, linked_ranges), buffer_snapshot, buffer) = - selection.end.buffer_id.and_then(|end_buffer_id| { - if selection.start.buffer_id != Some(end_buffer_id) { - return None; - } - let buffer = self.buffer.read(cx).buffer(end_buffer_id)?; - let snapshot = buffer.read(cx).snapshot(); - self.linked_edit_ranges - .get(end_buffer_id, selection.start..selection.end, &snapshot) - .map(|ranges| (ranges, snapshot, buffer)) - })?; - use text::ToOffset as TO; - // find offset from the start of current range to current cursor position - let start_byte_offset = TO::to_offset(&base_range.start, &buffer_snapshot); - - let start_offset = TO::to_offset(&selection.start, &buffer_snapshot); - let start_difference = start_offset - start_byte_offset; - let end_offset = TO::to_offset(&selection.end, &buffer_snapshot); - let end_difference = end_offset - start_byte_offset; - // Current range has associated linked ranges. - let mut linked_edits = HashMap::<_, Vec<_>>::default(); - for range in linked_ranges.iter() { - let start_offset = TO::to_offset(&range.start, &buffer_snapshot); - let end_offset = start_offset + end_difference; - let start_offset = start_offset + start_difference; - if start_offset > buffer_snapshot.len() || end_offset > buffer_snapshot.len() { - continue; - } - if self.selections.disjoint_anchor_ranges().any(|s| { - if s.start.buffer_id != selection.start.buffer_id - || s.end.buffer_id != selection.end.buffer_id - { - return false; - } - TO::to_offset(&s.start.text_anchor, &buffer_snapshot) <= end_offset - && TO::to_offset(&s.end.text_anchor, &buffer_snapshot) >= start_offset - }) { - continue; - } - let start = buffer_snapshot.anchor_after(start_offset); - let end = buffer_snapshot.anchor_after(end_offset); - linked_edits - .entry(buffer.clone()) - .or_default() - .push(start..end); - } - Some(linked_edits) - } - - pub fn handle_input(&mut self, text: &str, window: &mut Window, cx: &mut Context) { - let text: Arc = text.into(); - - if self.read_only(cx) { - return; - } - - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let selections = self.selections.all_adjusted(cx); - let mut bracket_inserted = false; - let mut edits = Vec::new(); - let mut linked_edits = HashMap::<_, Vec<_>>::default(); - let mut new_selections = Vec::with_capacity(selections.len()); - let mut new_autoclose_regions = Vec::new(); - let snapshot = self.buffer.read(cx).read(cx); - let mut clear_linked_edit_ranges = false; - - for (selection, autoclose_region) in - self.selections_with_autoclose_regions(selections, &snapshot) - { - if let Some(scope) = snapshot.language_scope_at(selection.head()) { - // Determine if the inserted text matches the opening or closing - // bracket of any of this language's bracket pairs. - let mut bracket_pair = None; - let mut is_bracket_pair_start = false; - let mut is_bracket_pair_end = false; - if !text.is_empty() { - let mut bracket_pair_matching_end = None; - // `text` can be empty when a user is using IME (e.g. Chinese Wubi Simplified) - // and they are removing the character that triggered IME popup. - for (pair, enabled) in scope.brackets() { - if !pair.close && !pair.surround { - continue; - } - - if enabled && pair.start.ends_with(text.as_ref()) { - let prefix_len = pair.start.len() - text.len(); - let preceding_text_matches_prefix = prefix_len == 0 - || (selection.start.column >= (prefix_len as u32) - && snapshot.contains_str_at( - Point::new( - selection.start.row, - selection.start.column - (prefix_len as u32), - ), - &pair.start[..prefix_len], - )); - if preceding_text_matches_prefix { - bracket_pair = Some(pair.clone()); - is_bracket_pair_start = true; - break; - } - } - if pair.end.as_str() == text.as_ref() && bracket_pair_matching_end.is_none() - { - // take first bracket pair matching end, but don't break in case a later bracket - // pair matches start - bracket_pair_matching_end = Some(pair.clone()); - } - } - if bracket_pair.is_none() && bracket_pair_matching_end.is_some() { - bracket_pair = Some(bracket_pair_matching_end.unwrap()); - is_bracket_pair_end = true; - } - } - - if let Some(bracket_pair) = bracket_pair { - let snapshot_settings = snapshot.language_settings_at(selection.start, cx); - let autoclose = self.use_autoclose && snapshot_settings.use_autoclose; - let auto_surround = - self.use_auto_surround && snapshot_settings.use_auto_surround; - if selection.is_empty() { - if is_bracket_pair_start { - // If the inserted text is a suffix of an opening bracket and the - // selection is preceded by the rest of the opening bracket, then - // insert the closing bracket. - let following_text_allows_autoclose = snapshot - .chars_at(selection.start) - .next() - .map_or(true, |c| scope.should_autoclose_before(c)); - - let preceding_text_allows_autoclose = selection.start.column == 0 - || snapshot.reversed_chars_at(selection.start).next().map_or( - true, - |c| { - bracket_pair.start != bracket_pair.end - || !snapshot - .char_classifier_at(selection.start) - .is_word(c) - }, - ); - - let is_closing_quote = if bracket_pair.end == bracket_pair.start - && bracket_pair.start.len() == 1 - { - let target = bracket_pair.start.chars().next().unwrap(); - let current_line_count = snapshot - .reversed_chars_at(selection.start) - .take_while(|&c| c != '\n') - .filter(|&c| c == target) - .count(); - current_line_count % 2 == 1 - } else { - false - }; - - if autoclose - && bracket_pair.close - && following_text_allows_autoclose - && preceding_text_allows_autoclose - && !is_closing_quote - { - let anchor = snapshot.anchor_before(selection.end); - new_selections.push((selection.map(|_| anchor), text.len())); - new_autoclose_regions.push(( - anchor, - text.len(), - selection.id, - bracket_pair.clone(), - )); - edits.push(( - selection.range(), - format!("{}{}", text, bracket_pair.end).into(), - )); - bracket_inserted = true; - continue; - } - } - - if let Some(region) = autoclose_region { - // If the selection is followed by an auto-inserted closing bracket, - // then don't insert that closing bracket again; just move the selection - // past the closing bracket. - let should_skip = selection.end == region.range.end.to_point(&snapshot) - && text.as_ref() == region.pair.end.as_str(); - if should_skip { - let anchor = snapshot.anchor_after(selection.end); - new_selections - .push((selection.map(|_| anchor), region.pair.end.len())); - continue; - } - } - - let always_treat_brackets_as_autoclosed = snapshot - .language_settings_at(selection.start, cx) - .always_treat_brackets_as_autoclosed; - if always_treat_brackets_as_autoclosed - && is_bracket_pair_end - && snapshot.contains_str_at(selection.end, text.as_ref()) - { - // Otherwise, when `always_treat_brackets_as_autoclosed` is set to `true - // and the inserted text is a closing bracket and the selection is followed - // by the closing bracket then move the selection past the closing bracket. - let anchor = snapshot.anchor_after(selection.end); - new_selections.push((selection.map(|_| anchor), text.len())); - continue; - } - } - // If an opening bracket is 1 character long and is typed while - // text is selected, then surround that text with the bracket pair. - else if auto_surround - && bracket_pair.surround - && is_bracket_pair_start - && bracket_pair.start.chars().count() == 1 - { - edits.push((selection.start..selection.start, text.clone())); - edits.push(( - selection.end..selection.end, - bracket_pair.end.as_str().into(), - )); - bracket_inserted = true; - new_selections.push(( - Selection { - id: selection.id, - start: snapshot.anchor_after(selection.start), - end: snapshot.anchor_before(selection.end), - reversed: selection.reversed, - goal: selection.goal, - }, - 0, - )); - continue; - } - } - } - - if self.auto_replace_emoji_shortcode - && selection.is_empty() - && text.as_ref().ends_with(':') - { - if let Some(possible_emoji_short_code) = - Self::find_possible_emoji_shortcode_at_position(&snapshot, selection.start) - { - if !possible_emoji_short_code.is_empty() { - if let Some(emoji) = emojis::get_by_shortcode(&possible_emoji_short_code) { - let emoji_shortcode_start = Point::new( - selection.start.row, - selection.start.column - possible_emoji_short_code.len() as u32 - 1, - ); - - // Remove shortcode from buffer - edits.push(( - emoji_shortcode_start..selection.start, - "".to_string().into(), - )); - new_selections.push(( - Selection { - id: selection.id, - start: snapshot.anchor_after(emoji_shortcode_start), - end: snapshot.anchor_before(selection.start), - reversed: selection.reversed, - goal: selection.goal, - }, - 0, - )); - - // Insert emoji - let selection_start_anchor = snapshot.anchor_after(selection.start); - new_selections.push((selection.map(|_| selection_start_anchor), 0)); - edits.push((selection.start..selection.end, emoji.to_string().into())); - - continue; - } - } - } - } - - // If not handling any auto-close operation, then just replace the selected - // text with the given input and move the selection to the end of the - // newly inserted text. - let anchor = snapshot.anchor_after(selection.end); - if !self.linked_edit_ranges.is_empty() { - let start_anchor = snapshot.anchor_before(selection.start); - - let is_word_char = text.chars().next().map_or(true, |char| { - let classifier = snapshot.char_classifier_at(start_anchor.to_offset(&snapshot)); - classifier.is_word(char) - }); - - if is_word_char { - if let Some(ranges) = self - .linked_editing_ranges_for(start_anchor.text_anchor..anchor.text_anchor, cx) - { - for (buffer, edits) in ranges { - linked_edits - .entry(buffer.clone()) - .or_default() - .extend(edits.into_iter().map(|range| (range, text.clone()))); - } - } - } else { - clear_linked_edit_ranges = true; - } - } - - new_selections.push((selection.map(|_| anchor), 0)); - edits.push((selection.start..selection.end, text.clone())); - } - - drop(snapshot); - - self.transact(window, cx, |this, window, cx| { - if clear_linked_edit_ranges { - this.linked_edit_ranges.clear(); - } - let initial_buffer_versions = - jsx_tag_auto_close::construct_initial_buffer_versions_map(this, &edits, cx); - - this.buffer.update(cx, |buffer, cx| { - buffer.edit(edits, this.autoindent_mode.clone(), cx); - }); - for (buffer, edits) in linked_edits { - buffer.update(cx, |buffer, cx| { - let snapshot = buffer.snapshot(); - let edits = edits - .into_iter() - .map(|(range, text)| { - use text::ToPoint as TP; - let end_point = TP::to_point(&range.end, &snapshot); - let start_point = TP::to_point(&range.start, &snapshot); - (start_point..end_point, text) - }) - .sorted_by_key(|(range, _)| range.start); - buffer.edit(edits, None, cx); - }) - } - let new_anchor_selections = new_selections.iter().map(|e| &e.0); - let new_selection_deltas = new_selections.iter().map(|e| e.1); - let map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); - let new_selections = resolve_selections::(new_anchor_selections, &map) - .zip(new_selection_deltas) - .map(|(selection, delta)| Selection { - id: selection.id, - start: selection.start + delta, - end: selection.end + delta, - reversed: selection.reversed, - goal: SelectionGoal::None, - }) - .collect::>(); - - let mut i = 0; - for (position, delta, selection_id, pair) in new_autoclose_regions { - let position = position.to_offset(&map.buffer_snapshot) + delta; - let start = map.buffer_snapshot.anchor_before(position); - let end = map.buffer_snapshot.anchor_after(position); - while let Some(existing_state) = this.autoclose_regions.get(i) { - match existing_state.range.start.cmp(&start, &map.buffer_snapshot) { - Ordering::Less => i += 1, - Ordering::Greater => break, - Ordering::Equal => { - match end.cmp(&existing_state.range.end, &map.buffer_snapshot) { - Ordering::Less => i += 1, - Ordering::Equal => break, - Ordering::Greater => break, - } - } - } - } - this.autoclose_regions.insert( - i, - AutocloseRegion { - selection_id, - range: start..end, - pair, - }, - ); - } - - let had_active_inline_completion = this.has_active_inline_completion(); - this.change_selections_inner(Some(Autoscroll::fit()), false, window, cx, |s| { - s.select(new_selections) - }); - - if !bracket_inserted { - if let Some(on_type_format_task) = - this.trigger_on_type_formatting(text.to_string(), window, cx) - { - on_type_format_task.detach_and_log_err(cx); - } - } - - let editor_settings = EditorSettings::get_global(cx); - if bracket_inserted - && (editor_settings.auto_signature_help - || editor_settings.show_signature_help_after_edits) - { - this.show_signature_help(&ShowSignatureHelp, window, cx); - } - - let trigger_in_words = - this.show_edit_predictions_in_menu() || !had_active_inline_completion; - if this.hard_wrap.is_some() { - let latest: Range = this.selections.newest(cx).range(); - if latest.is_empty() - && this - .buffer() - .read(cx) - .snapshot(cx) - .line_len(MultiBufferRow(latest.start.row)) - == latest.start.column - { - this.rewrap_impl( - RewrapOptions { - override_language_settings: true, - preserve_existing_whitespace: true, - }, - cx, - ) - } - } - this.trigger_completion_on_input(&text, trigger_in_words, window, cx); - linked_editing_ranges::refresh_linked_ranges(this, window, cx); - this.refresh_inline_completion(true, false, window, cx); - jsx_tag_auto_close::handle_from(this, initial_buffer_versions, window, cx); - }); - } - - fn find_possible_emoji_shortcode_at_position( - snapshot: &MultiBufferSnapshot, - position: Point, - ) -> Option { - let mut chars = Vec::new(); - let mut found_colon = false; - for char in snapshot.reversed_chars_at(position).take(100) { - // Found a possible emoji shortcode in the middle of the buffer - if found_colon { - if char.is_whitespace() { - chars.reverse(); - return Some(chars.iter().collect()); - } - // If the previous character is not a whitespace, we are in the middle of a word - // and we only want to complete the shortcode if the word is made up of other emojis - let mut containing_word = String::new(); - for ch in snapshot - .reversed_chars_at(position) - .skip(chars.len() + 1) - .take(100) - { - if ch.is_whitespace() { - break; - } - containing_word.push(ch); - } - let containing_word = containing_word.chars().rev().collect::(); - if util::word_consists_of_emojis(containing_word.as_str()) { - chars.reverse(); - return Some(chars.iter().collect()); - } - } - - if char.is_whitespace() || !char.is_ascii() { - return None; - } - if char == ':' { - found_colon = true; - } else { - chars.push(char); - } - } - // Found a possible emoji shortcode at the beginning of the buffer - chars.reverse(); - Some(chars.iter().collect()) - } - - pub fn newline(&mut self, _: &Newline, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - let (edits, selection_fixup_info): (Vec<_>, Vec<_>) = { - let selections = this.selections.all::(cx); - let multi_buffer = this.buffer.read(cx); - let buffer = multi_buffer.snapshot(cx); - selections - .iter() - .map(|selection| { - let start_point = selection.start.to_point(&buffer); - let mut indent = - buffer.indent_size_for_line(MultiBufferRow(start_point.row)); - indent.len = cmp::min(indent.len, start_point.column); - let start = selection.start; - let end = selection.end; - let selection_is_empty = start == end; - let language_scope = buffer.language_scope_at(start); - let (comment_delimiter, insert_extra_newline) = if let Some(language) = - &language_scope - { - let insert_extra_newline = - insert_extra_newline_brackets(&buffer, start..end, language) - || insert_extra_newline_tree_sitter(&buffer, start..end); - - // Comment extension on newline is allowed only for cursor selections - let comment_delimiter = maybe!({ - if !selection_is_empty { - return None; - } - - if !multi_buffer.language_settings(cx).extend_comment_on_newline { - return None; - } - - let delimiters = language.line_comment_prefixes(); - let max_len_of_delimiter = - delimiters.iter().map(|delimiter| delimiter.len()).max()?; - let (snapshot, range) = - buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; - - let mut index_of_first_non_whitespace = 0; - let comment_candidate = snapshot - .chars_for_range(range) - .skip_while(|c| { - let should_skip = c.is_whitespace(); - if should_skip { - index_of_first_non_whitespace += 1; - } - should_skip - }) - .take(max_len_of_delimiter) - .collect::(); - let comment_prefix = delimiters.iter().find(|comment_prefix| { - comment_candidate.starts_with(comment_prefix.as_ref()) - })?; - let cursor_is_placed_after_comment_marker = - index_of_first_non_whitespace + comment_prefix.len() - <= start_point.column as usize; - if cursor_is_placed_after_comment_marker { - Some(comment_prefix.clone()) - } else { - None - } - }); - (comment_delimiter, insert_extra_newline) - } else { - (None, false) - }; - - let capacity_for_delimiter = comment_delimiter - .as_deref() - .map(str::len) - .unwrap_or_default(); - let mut new_text = - String::with_capacity(1 + capacity_for_delimiter + indent.len as usize); - new_text.push('\n'); - new_text.extend(indent.chars()); - if let Some(delimiter) = &comment_delimiter { - new_text.push_str(delimiter); - } - if insert_extra_newline { - new_text = new_text.repeat(2); - } - - let anchor = buffer.anchor_after(end); - let new_selection = selection.map(|_| anchor); - ( - (start..end, new_text), - (insert_extra_newline, new_selection), - ) - }) - .unzip() - }; - - this.edit_with_autoindent(edits, cx); - let buffer = this.buffer.read(cx).snapshot(cx); - let new_selections = selection_fixup_info - .into_iter() - .map(|(extra_newline_inserted, new_selection)| { - let mut cursor = new_selection.end.to_point(&buffer); - if extra_newline_inserted { - cursor.row -= 1; - cursor.column = buffer.line_len(MultiBufferRow(cursor.row)); - } - new_selection.map(|_| cursor) - }) - .collect(); - - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(new_selections) - }); - this.refresh_inline_completion(true, false, window, cx); - }); - } - - pub fn newline_above(&mut self, _: &NewlineAbove, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let buffer = self.buffer.read(cx); - let snapshot = buffer.snapshot(cx); - - let mut edits = Vec::new(); - let mut rows = Vec::new(); - - for (rows_inserted, selection) in self.selections.all_adjusted(cx).into_iter().enumerate() { - let cursor = selection.head(); - let row = cursor.row; - - let start_of_line = snapshot.clip_point(Point::new(row, 0), Bias::Left); - - let newline = "\n".to_string(); - edits.push((start_of_line..start_of_line, newline)); - - rows.push(row + rows_inserted as u32); - } - - self.transact(window, cx, |editor, window, cx| { - editor.edit(edits, cx); - - editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - let mut index = 0; - s.move_cursors_with(|map, _, _| { - let row = rows[index]; - index += 1; - - let point = Point::new(row, 0); - let boundary = map.next_line_boundary(point).1; - let clipped = map.clip_point(boundary, Bias::Left); - - (clipped, SelectionGoal::None) - }); - }); - - let mut indent_edits = Vec::new(); - let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); - for row in rows { - let indents = multibuffer_snapshot.suggested_indents(row..row + 1, cx); - for (row, indent) in indents { - if indent.len == 0 { - continue; - } - - let text = match indent.kind { - IndentKind::Space => " ".repeat(indent.len as usize), - IndentKind::Tab => "\t".repeat(indent.len as usize), - }; - let point = Point::new(row.0, 0); - indent_edits.push((point..point, text)); - } - } - editor.edit(indent_edits, cx); - }); - } - - pub fn newline_below(&mut self, _: &NewlineBelow, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let buffer = self.buffer.read(cx); - let snapshot = buffer.snapshot(cx); - - let mut edits = Vec::new(); - let mut rows = Vec::new(); - let mut rows_inserted = 0; - - for selection in self.selections.all_adjusted(cx) { - let cursor = selection.head(); - let row = cursor.row; - - let point = Point::new(row + 1, 0); - let start_of_line = snapshot.clip_point(point, Bias::Left); - - let newline = "\n".to_string(); - edits.push((start_of_line..start_of_line, newline)); - - rows_inserted += 1; - rows.push(row + rows_inserted); - } - - self.transact(window, cx, |editor, window, cx| { - editor.edit(edits, cx); - - editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - let mut index = 0; - s.move_cursors_with(|map, _, _| { - let row = rows[index]; - index += 1; - - let point = Point::new(row, 0); - let boundary = map.next_line_boundary(point).1; - let clipped = map.clip_point(boundary, Bias::Left); - - (clipped, SelectionGoal::None) - }); - }); - - let mut indent_edits = Vec::new(); - let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); - for row in rows { - let indents = multibuffer_snapshot.suggested_indents(row..row + 1, cx); - for (row, indent) in indents { - if indent.len == 0 { - continue; - } - - let text = match indent.kind { - IndentKind::Space => " ".repeat(indent.len as usize), - IndentKind::Tab => "\t".repeat(indent.len as usize), - }; - let point = Point::new(row.0, 0); - indent_edits.push((point..point, text)); - } - } - editor.edit(indent_edits, cx); - }); - } - - pub fn insert(&mut self, text: &str, window: &mut Window, cx: &mut Context) { - let autoindent = text.is_empty().not().then(|| AutoindentMode::Block { - original_indent_columns: Vec::new(), - }); - self.insert_with_autoindent_mode(text, autoindent, window, cx); - } - - fn insert_with_autoindent_mode( - &mut self, - text: &str, - autoindent_mode: Option, - window: &mut Window, - cx: &mut Context, - ) { - if self.read_only(cx) { - return; - } - - let text: Arc = text.into(); - self.transact(window, cx, |this, window, cx| { - let old_selections = this.selections.all_adjusted(cx); - let selection_anchors = this.buffer.update(cx, |buffer, cx| { - let anchors = { - let snapshot = buffer.read(cx); - old_selections - .iter() - .map(|s| { - let anchor = snapshot.anchor_after(s.head()); - s.map(|_| anchor) - }) - .collect::>() - }; - buffer.edit( - old_selections - .iter() - .map(|s| (s.start..s.end, text.clone())), - autoindent_mode, - cx, - ); - anchors - }); - - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_anchors(selection_anchors); - }); - - cx.notify(); - }); - } - - fn trigger_completion_on_input( - &mut self, - text: &str, - trigger_in_words: bool, - window: &mut Window, - cx: &mut Context, - ) { - let ignore_completion_provider = self - .context_menu - .borrow() - .as_ref() - .map(|menu| match menu { - CodeContextMenu::Completions(completions_menu) => { - completions_menu.ignore_completion_provider - } - CodeContextMenu::CodeActions(_) => false, - }) - .unwrap_or(false); - - if ignore_completion_provider { - self.show_word_completions(&ShowWordCompletions, window, cx); - } else if self.is_completion_trigger(text, trigger_in_words, cx) { - self.show_completions( - &ShowCompletions { - trigger: Some(text.to_owned()).filter(|x| !x.is_empty()), - }, - window, - cx, - ); - } else { - self.hide_context_menu(window, cx); - } - } - - fn is_completion_trigger( - &self, - text: &str, - trigger_in_words: bool, - cx: &mut Context, - ) -> bool { - let position = self.selections.newest_anchor().head(); - let multibuffer = self.buffer.read(cx); - let Some(buffer) = position - .buffer_id - .and_then(|buffer_id| multibuffer.buffer(buffer_id).clone()) - else { - return false; - }; - - if let Some(completion_provider) = &self.completion_provider { - completion_provider.is_completion_trigger( - &buffer, - position.text_anchor, - text, - trigger_in_words, - cx, - ) - } else { - false - } - } - - /// If any empty selections is touching the start of its innermost containing autoclose - /// region, expand it to select the brackets. - fn select_autoclose_pair(&mut self, window: &mut Window, cx: &mut Context) { - let selections = self.selections.all::(cx); - let buffer = self.buffer.read(cx).read(cx); - let new_selections = self - .selections_with_autoclose_regions(selections, &buffer) - .map(|(mut selection, region)| { - if !selection.is_empty() { - return selection; - } - - if let Some(region) = region { - let mut range = region.range.to_offset(&buffer); - if selection.start == range.start && range.start >= region.pair.start.len() { - range.start -= region.pair.start.len(); - if buffer.contains_str_at(range.start, ®ion.pair.start) - && buffer.contains_str_at(range.end, ®ion.pair.end) - { - range.end += region.pair.end.len(); - selection.start = range.start; - selection.end = range.end; - - return selection; - } - } - } - - let always_treat_brackets_as_autoclosed = buffer - .language_settings_at(selection.start, cx) - .always_treat_brackets_as_autoclosed; - - if !always_treat_brackets_as_autoclosed { - return selection; - } - - if let Some(scope) = buffer.language_scope_at(selection.start) { - for (pair, enabled) in scope.brackets() { - if !enabled || !pair.close { - continue; - } - - if buffer.contains_str_at(selection.start, &pair.end) { - let pair_start_len = pair.start.len(); - if buffer.contains_str_at( - selection.start.saturating_sub(pair_start_len), - &pair.start, - ) { - selection.start -= pair_start_len; - selection.end += pair.end.len(); - - return selection; - } - } - } - } - - selection - }) - .collect(); - - drop(buffer); - self.change_selections(None, window, cx, |selections| { - selections.select(new_selections) - }); - } - - /// Iterate the given selections, and for each one, find the smallest surrounding - /// autoclose region. This uses the ordering of the selections and the autoclose - /// regions to avoid repeated comparisons. - fn selections_with_autoclose_regions<'a, D: ToOffset + Clone>( - &'a self, - selections: impl IntoIterator>, - buffer: &'a MultiBufferSnapshot, - ) -> impl Iterator, Option<&'a AutocloseRegion>)> { - let mut i = 0; - let mut regions = self.autoclose_regions.as_slice(); - selections.into_iter().map(move |selection| { - let range = selection.start.to_offset(buffer)..selection.end.to_offset(buffer); - - let mut enclosing = None; - while let Some(pair_state) = regions.get(i) { - if pair_state.range.end.to_offset(buffer) < range.start { - regions = ®ions[i + 1..]; - i = 0; - } else if pair_state.range.start.to_offset(buffer) > range.end { - break; - } else { - if pair_state.selection_id == selection.id { - enclosing = Some(pair_state); - } - i += 1; - } - } - - (selection, enclosing) - }) - } - - /// Remove any autoclose regions that no longer contain their selection. - fn invalidate_autoclose_regions( - &mut self, - mut selections: &[Selection], - buffer: &MultiBufferSnapshot, - ) { - self.autoclose_regions.retain(|state| { - let mut i = 0; - while let Some(selection) = selections.get(i) { - if selection.end.cmp(&state.range.start, buffer).is_lt() { - selections = &selections[1..]; - continue; - } - if selection.start.cmp(&state.range.end, buffer).is_gt() { - break; - } - if selection.id == state.selection_id { - return true; - } else { - i += 1; - } - } - false - }); - } - - fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option { - let offset = position.to_offset(buffer); - let (word_range, kind) = buffer.surrounding_word(offset, true); - if offset > word_range.start && kind == Some(CharKind::Word) { - Some( - buffer - .text_for_range(word_range.start..offset) - .collect::(), - ) - } else { - None - } - } - - pub fn toggle_inline_values( - &mut self, - _: &ToggleInlineValues, - _: &mut Window, - cx: &mut Context, - ) { - self.inline_value_cache.enabled = !self.inline_value_cache.enabled; - - self.refresh_inline_values(cx); - } - - pub fn toggle_inlay_hints( - &mut self, - _: &ToggleInlayHints, - _: &mut Window, - cx: &mut Context, - ) { - self.refresh_inlay_hints( - InlayHintRefreshReason::Toggle(!self.inlay_hints_enabled()), - cx, - ); - } - - pub fn inlay_hints_enabled(&self) -> bool { - self.inlay_hint_cache.enabled - } - - pub fn inline_values_enabled(&self) -> bool { - self.inline_value_cache.enabled - } - - fn refresh_inlay_hints(&mut self, reason: InlayHintRefreshReason, cx: &mut Context) { - if self.semantics_provider.is_none() || !self.mode.is_full() { - return; - } - - let reason_description = reason.description(); - let ignore_debounce = matches!( - reason, - InlayHintRefreshReason::SettingsChange(_) - | InlayHintRefreshReason::Toggle(_) - | InlayHintRefreshReason::ExcerptsRemoved(_) - | InlayHintRefreshReason::ModifiersChanged(_) - ); - let (invalidate_cache, required_languages) = match reason { - InlayHintRefreshReason::ModifiersChanged(enabled) => { - match self.inlay_hint_cache.modifiers_override(enabled) { - Some(enabled) => { - if enabled { - (InvalidationStrategy::RefreshRequested, None) - } else { - self.splice_inlays( - &self - .visible_inlay_hints(cx) - .iter() - .map(|inlay| inlay.id) - .collect::>(), - Vec::new(), - cx, - ); - return; - } - } - None => return, - } - } - InlayHintRefreshReason::Toggle(enabled) => { - if self.inlay_hint_cache.toggle(enabled) { - if enabled { - (InvalidationStrategy::RefreshRequested, None) - } else { - self.splice_inlays( - &self - .visible_inlay_hints(cx) - .iter() - .map(|inlay| inlay.id) - .collect::>(), - Vec::new(), - cx, - ); - return; - } - } else { - return; - } - } - InlayHintRefreshReason::SettingsChange(new_settings) => { - match self.inlay_hint_cache.update_settings( - &self.buffer, - new_settings, - self.visible_inlay_hints(cx), - cx, - ) { - ControlFlow::Break(Some(InlaySplice { - to_remove, - to_insert, - })) => { - self.splice_inlays(&to_remove, to_insert, cx); - return; - } - ControlFlow::Break(None) => return, - ControlFlow::Continue(()) => (InvalidationStrategy::RefreshRequested, None), - } - } - InlayHintRefreshReason::ExcerptsRemoved(excerpts_removed) => { - if let Some(InlaySplice { - to_remove, - to_insert, - }) = self.inlay_hint_cache.remove_excerpts(&excerpts_removed) - { - self.splice_inlays(&to_remove, to_insert, cx); - } - self.display_map.update(cx, |display_map, _| { - display_map.remove_inlays_for_excerpts(&excerpts_removed) - }); - return; - } - InlayHintRefreshReason::NewLinesShown => (InvalidationStrategy::None, None), - InlayHintRefreshReason::BufferEdited(buffer_languages) => { - (InvalidationStrategy::BufferEdited, Some(buffer_languages)) - } - InlayHintRefreshReason::RefreshRequested => { - (InvalidationStrategy::RefreshRequested, None) - } - }; - - if let Some(InlaySplice { - to_remove, - to_insert, - }) = self.inlay_hint_cache.spawn_hint_refresh( - reason_description, - self.excerpts_for_inlay_hints_query(required_languages.as_ref(), cx), - invalidate_cache, - ignore_debounce, - cx, - ) { - self.splice_inlays(&to_remove, to_insert, cx); - } - } - - fn visible_inlay_hints(&self, cx: &Context) -> Vec { - self.display_map - .read(cx) - .current_inlays() - .filter(move |inlay| matches!(inlay.id, InlayId::Hint(_))) - .cloned() - .collect() - } - - pub fn excerpts_for_inlay_hints_query( - &self, - restrict_to_languages: Option<&HashSet>>, - cx: &mut Context, - ) -> HashMap, clock::Global, Range)> { - let Some(project) = self.project.as_ref() else { - return HashMap::default(); - }; - let project = project.read(cx); - let multi_buffer = self.buffer().read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let multi_buffer_visible_start = self - .scroll_manager - .anchor() - .anchor - .to_point(&multi_buffer_snapshot); - let multi_buffer_visible_end = multi_buffer_snapshot.clip_point( - multi_buffer_visible_start - + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), - Bias::Left, - ); - let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end; - multi_buffer_snapshot - .range_to_buffer_ranges(multi_buffer_visible_range) - .into_iter() - .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) - .filter_map(|(buffer, excerpt_visible_range, excerpt_id)| { - let buffer_file = project::File::from_dyn(buffer.file())?; - let buffer_worktree = project.worktree_for_id(buffer_file.worktree_id(cx), cx)?; - let worktree_entry = buffer_worktree - .read(cx) - .entry_for_id(buffer_file.project_entry_id(cx)?)?; - if worktree_entry.is_ignored { - return None; - } - - let language = buffer.language()?; - if let Some(restrict_to_languages) = restrict_to_languages { - if !restrict_to_languages.contains(language) { - return None; - } - } - Some(( - excerpt_id, - ( - multi_buffer.buffer(buffer.remote_id()).unwrap(), - buffer.version().clone(), - excerpt_visible_range, - ), - )) - }) - .collect() - } - - pub fn text_layout_details(&self, window: &mut Window) -> TextLayoutDetails { - TextLayoutDetails { - text_system: window.text_system().clone(), - editor_style: self.style.clone().unwrap(), - rem_size: window.rem_size(), - scroll_anchor: self.scroll_manager.anchor(), - visible_rows: self.visible_line_count(), - vertical_scroll_margin: self.scroll_manager.vertical_scroll_margin, - } - } - - pub fn splice_inlays( - &self, - to_remove: &[InlayId], - to_insert: Vec, - cx: &mut Context, - ) { - self.display_map.update(cx, |display_map, cx| { - display_map.splice_inlays(to_remove, to_insert, cx) - }); - cx.notify(); - } - - fn trigger_on_type_formatting( - &self, - input: String, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if input.len() != 1 { - return None; - } - - let project = self.project.as_ref()?; - let position = self.selections.newest_anchor().head(); - let (buffer, buffer_position) = self - .buffer - .read(cx) - .text_anchor_for_position(position, cx)?; - - let settings = language_settings::language_settings( - buffer - .read(cx) - .language_at(buffer_position) - .map(|l| l.name()), - buffer.read(cx).file(), - cx, - ); - if !settings.use_on_type_format { - return None; - } - - // OnTypeFormatting returns a list of edits, no need to pass them between Zed instances, - // hence we do LSP request & edit on host side only — add formats to host's history. - let push_to_lsp_host_history = true; - // If this is not the host, append its history with new edits. - let push_to_client_history = project.read(cx).is_via_collab(); - - let on_type_formatting = project.update(cx, |project, cx| { - project.on_type_format( - buffer.clone(), - buffer_position, - input, - push_to_lsp_host_history, - cx, - ) - }); - Some(cx.spawn_in(window, async move |editor, cx| { - if let Some(transaction) = on_type_formatting.await? { - if push_to_client_history { - buffer - .update(cx, |buffer, _| { - buffer.push_transaction(transaction, Instant::now()); - buffer.finalize_last_transaction(); - }) - .ok(); - } - editor.update(cx, |editor, cx| { - editor.refresh_document_highlights(cx); - })?; - } - Ok(()) - })) - } - - pub fn show_word_completions( - &mut self, - _: &ShowWordCompletions, - window: &mut Window, - cx: &mut Context, - ) { - self.open_completions_menu(true, None, window, cx); - } - - pub fn show_completions( - &mut self, - options: &ShowCompletions, - window: &mut Window, - cx: &mut Context, - ) { - self.open_completions_menu(false, options.trigger.as_deref(), window, cx); - } - - fn open_completions_menu( - &mut self, - ignore_completion_provider: bool, - trigger: Option<&str>, - window: &mut Window, - cx: &mut Context, - ) { - if self.pending_rename.is_some() { - return; - } - if !self.snippet_stack.is_empty() && self.context_menu.borrow().as_ref().is_some() { - return; - } - - let position = self.selections.newest_anchor().head(); - if position.diff_base_anchor.is_some() { - return; - } - let (buffer, buffer_position) = - if let Some(output) = self.buffer.read(cx).text_anchor_for_position(position, cx) { - output - } else { - return; - }; - let buffer_snapshot = buffer.read(cx).snapshot(); - let show_completion_documentation = buffer_snapshot - .settings_at(buffer_position, cx) - .show_completion_documentation; - - let query = Self::completion_query(&self.buffer.read(cx).read(cx), position); - - let trigger_kind = match trigger { - Some(trigger) if buffer.read(cx).completion_triggers().contains(trigger) => { - CompletionTriggerKind::TRIGGER_CHARACTER - } - _ => CompletionTriggerKind::INVOKED, - }; - let completion_context = CompletionContext { - trigger_character: trigger.and_then(|trigger| { - if trigger_kind == CompletionTriggerKind::TRIGGER_CHARACTER { - Some(String::from(trigger)) - } else { - None - } - }), - trigger_kind, - }; - - let (old_range, word_kind) = buffer_snapshot.surrounding_word(buffer_position); - let (old_range, word_to_exclude) = if word_kind == Some(CharKind::Word) { - let word_to_exclude = buffer_snapshot - .text_for_range(old_range.clone()) - .collect::(); - ( - buffer_snapshot.anchor_before(old_range.start) - ..buffer_snapshot.anchor_after(old_range.end), - Some(word_to_exclude), - ) - } else { - (buffer_position..buffer_position, None) - }; - - let completion_settings = language_settings( - buffer_snapshot - .language_at(buffer_position) - .map(|language| language.name()), - buffer_snapshot.file(), - cx, - ) - .completions; - - // The document can be large, so stay in reasonable bounds when searching for words, - // otherwise completion pop-up might be slow to appear. - const WORD_LOOKUP_ROWS: u32 = 5_000; - let buffer_row = text::ToPoint::to_point(&buffer_position, &buffer_snapshot).row; - let min_word_search = buffer_snapshot.clip_point( - Point::new(buffer_row.saturating_sub(WORD_LOOKUP_ROWS), 0), - Bias::Left, - ); - let max_word_search = buffer_snapshot.clip_point( - Point::new(buffer_row + WORD_LOOKUP_ROWS, 0).min(buffer_snapshot.max_point()), - Bias::Right, - ); - let word_search_range = buffer_snapshot.point_to_offset(min_word_search) - ..buffer_snapshot.point_to_offset(max_word_search); - - let provider = self - .completion_provider - .as_ref() - .filter(|_| !ignore_completion_provider); - let skip_digits = query - .as_ref() - .map_or(true, |query| !query.chars().any(|c| c.is_digit(10))); - - let (mut words, provided_completions) = match provider { - Some(provider) => { - let completions = provider.completions( - position.excerpt_id, - &buffer, - buffer_position, - completion_context, - window, - cx, - ); - - let words = match completion_settings.words { - WordsCompletionMode::Disabled => Task::ready(BTreeMap::default()), - WordsCompletionMode::Enabled | WordsCompletionMode::Fallback => cx - .background_spawn(async move { - buffer_snapshot.words_in_range(WordsQuery { - fuzzy_contents: None, - range: word_search_range, - skip_digits, - }) - }), - }; - - (words, completions) - } - None => ( - cx.background_spawn(async move { - buffer_snapshot.words_in_range(WordsQuery { - fuzzy_contents: None, - range: word_search_range, - skip_digits, - }) - }), - Task::ready(Ok(None)), - ), - }; - - let sort_completions = provider - .as_ref() - .map_or(false, |provider| provider.sort_completions()); - - let filter_completions = provider - .as_ref() - .map_or(true, |provider| provider.filter_completions()); - - let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; - - let id = post_inc(&mut self.next_completion_id); - let task = cx.spawn_in(window, async move |editor, cx| { - async move { - editor.update(cx, |this, _| { - this.completion_tasks.retain(|(task_id, _)| *task_id >= id); - })?; - - let mut completions = Vec::new(); - if let Some(provided_completions) = provided_completions.await.log_err().flatten() { - completions.extend(provided_completions); - if completion_settings.words == WordsCompletionMode::Fallback { - words = Task::ready(BTreeMap::default()); - } - } - - let mut words = words.await; - if let Some(word_to_exclude) = &word_to_exclude { - words.remove(word_to_exclude); - } - for lsp_completion in &completions { - words.remove(&lsp_completion.new_text); - } - completions.extend(words.into_iter().map(|(word, word_range)| Completion { - replace_range: old_range.clone(), - new_text: word.clone(), - label: CodeLabel::plain(word, None), - icon_path: None, - documentation: None, - source: CompletionSource::BufferWord { - word_range, - resolved: false, - }, - insert_text_mode: Some(InsertTextMode::AS_IS), - confirm: None, - })); - - let menu = if completions.is_empty() { - None - } else { - let mut menu = CompletionsMenu::new( - id, - sort_completions, - show_completion_documentation, - ignore_completion_provider, - position, - buffer.clone(), - completions.into(), - snippet_sort_order, - ); - - menu.filter( - if filter_completions { - query.as_deref() - } else { - None - }, - cx.background_executor().clone(), - ) - .await; - - menu.visible().then_some(menu) - }; - - editor.update_in(cx, |editor, window, cx| { - match editor.context_menu.borrow().as_ref() { - None => {} - Some(CodeContextMenu::Completions(prev_menu)) => { - if prev_menu.id > id { - return; - } - } - _ => return, - } - - if editor.focus_handle.is_focused(window) && menu.is_some() { - let mut menu = menu.unwrap(); - menu.resolve_visible_completions(editor.completion_provider.as_deref(), cx); - - *editor.context_menu.borrow_mut() = - Some(CodeContextMenu::Completions(menu)); - - if editor.show_edit_predictions_in_menu() { - editor.update_visible_inline_completion(window, cx); - } else { - editor.discard_inline_completion(false, cx); - } - - cx.notify(); - } else if editor.completion_tasks.len() <= 1 { - // If there are no more completion tasks and the last menu was - // empty, we should hide it. - let was_hidden = editor.hide_context_menu(window, cx).is_none(); - // If it was already hidden and we don't show inline - // completions in the menu, we should also show the - // inline-completion when available. - if was_hidden && editor.show_edit_predictions_in_menu() { - editor.update_visible_inline_completion(window, cx); - } - } - })?; - - anyhow::Ok(()) - } - .log_err() - .await - }); - - self.completion_tasks.push((id, task)); - } - - #[cfg(feature = "test-support")] - pub fn current_completions(&self) -> Option> { - let menu = self.context_menu.borrow(); - if let CodeContextMenu::Completions(menu) = menu.as_ref()? { - let completions = menu.completions.borrow(); - Some(completions.to_vec()) - } else { - None - } - } - - pub fn confirm_completion( - &mut self, - action: &ConfirmCompletion, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.do_completion(action.item_ix, CompletionIntent::Complete, window, cx) - } - - pub fn confirm_completion_insert( - &mut self, - _: &ConfirmCompletionInsert, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.do_completion(None, CompletionIntent::CompleteWithInsert, window, cx) - } - - pub fn confirm_completion_replace( - &mut self, - _: &ConfirmCompletionReplace, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.do_completion(None, CompletionIntent::CompleteWithReplace, window, cx) - } - - pub fn compose_completion( - &mut self, - action: &ComposeCompletion, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.do_completion(action.item_ix, CompletionIntent::Compose, window, cx) - } - - fn do_completion( - &mut self, - item_ix: Option, - intent: CompletionIntent, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - use language::ToOffset as _; - - let CodeContextMenu::Completions(completions_menu) = self.hide_context_menu(window, cx)? - else { - return None; - }; - - let candidate_id = { - let entries = completions_menu.entries.borrow(); - let mat = entries.get(item_ix.unwrap_or(completions_menu.selected_item))?; - if self.show_edit_predictions_in_menu() { - self.discard_inline_completion(true, cx); - } - mat.candidate_id - }; - - let buffer_handle = completions_menu.buffer; - let completion = completions_menu - .completions - .borrow() - .get(candidate_id)? - .clone(); - cx.stop_propagation(); - - let snippet; - let new_text; - if completion.is_snippet() { - snippet = Some(Snippet::parse(&completion.new_text).log_err()?); - new_text = snippet.as_ref().unwrap().text.clone(); - } else { - snippet = None; - new_text = completion.new_text.clone(); - }; - - let replace_range = choose_completion_range(&completion, intent, &buffer_handle, cx); - let buffer = buffer_handle.read(cx); - let snapshot = self.buffer.read(cx).snapshot(cx); - let replace_range_multibuffer = { - let excerpt = snapshot - .excerpt_containing(self.selections.newest_anchor().range()) - .unwrap(); - let multibuffer_anchor = snapshot - .anchor_in_excerpt(excerpt.id(), buffer.anchor_before(replace_range.start)) - .unwrap() - ..snapshot - .anchor_in_excerpt(excerpt.id(), buffer.anchor_before(replace_range.end)) - .unwrap(); - multibuffer_anchor.start.to_offset(&snapshot) - ..multibuffer_anchor.end.to_offset(&snapshot) - }; - let newest_anchor = self.selections.newest_anchor(); - if newest_anchor.head().buffer_id != Some(buffer.remote_id()) { - return None; - } - - let old_text = buffer - .text_for_range(replace_range.clone()) - .collect::(); - let lookbehind = newest_anchor - .start - .text_anchor - .to_offset(buffer) - .saturating_sub(replace_range.start); - let lookahead = replace_range - .end - .saturating_sub(newest_anchor.end.text_anchor.to_offset(buffer)); - let prefix = &old_text[..old_text.len().saturating_sub(lookahead)]; - let suffix = &old_text[lookbehind.min(old_text.len())..]; - - let selections = self.selections.all::(cx); - let mut ranges = Vec::new(); - let mut linked_edits = HashMap::<_, Vec<_>>::default(); - - for selection in &selections { - let range = if selection.id == newest_anchor.id { - replace_range_multibuffer.clone() - } else { - let mut range = selection.range(); - - // if prefix is present, don't duplicate it - if snapshot.contains_str_at(range.start.saturating_sub(lookbehind), prefix) { - range.start = range.start.saturating_sub(lookbehind); - - // if suffix is also present, mimic the newest cursor and replace it - if selection.id != newest_anchor.id - && snapshot.contains_str_at(range.end, suffix) - { - range.end += lookahead; - } - } - range - }; - - ranges.push(range); - - if !self.linked_edit_ranges.is_empty() { - let start_anchor = snapshot.anchor_before(selection.head()); - let end_anchor = snapshot.anchor_after(selection.tail()); - if let Some(ranges) = self - .linked_editing_ranges_for(start_anchor.text_anchor..end_anchor.text_anchor, cx) - { - for (buffer, edits) in ranges { - linked_edits - .entry(buffer.clone()) - .or_default() - .extend(edits.into_iter().map(|range| (range, new_text.to_owned()))); - } - } - } - } - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: None, - text: new_text.clone().into(), - }); - - self.transact(window, cx, |this, window, cx| { - if let Some(mut snippet) = snippet { - snippet.text = new_text.to_string(); - this.insert_snippet(&ranges, snippet, window, cx).log_err(); - } else { - this.buffer.update(cx, |buffer, cx| { - let auto_indent = match completion.insert_text_mode { - Some(InsertTextMode::AS_IS) => None, - _ => this.autoindent_mode.clone(), - }; - let edits = ranges.into_iter().map(|range| (range, new_text.as_str())); - buffer.edit(edits, auto_indent, cx); - }); - } - for (buffer, edits) in linked_edits { - buffer.update(cx, |buffer, cx| { - let snapshot = buffer.snapshot(); - let edits = edits - .into_iter() - .map(|(range, text)| { - use text::ToPoint as TP; - let end_point = TP::to_point(&range.end, &snapshot); - let start_point = TP::to_point(&range.start, &snapshot); - (start_point..end_point, text) - }) - .sorted_by_key(|(range, _)| range.start); - buffer.edit(edits, None, cx); - }) - } - - this.refresh_inline_completion(true, false, window, cx); - }); - - let show_new_completions_on_confirm = completion - .confirm - .as_ref() - .map_or(false, |confirm| confirm(intent, window, cx)); - if show_new_completions_on_confirm { - self.show_completions(&ShowCompletions { trigger: None }, window, cx); - } - - let provider = self.completion_provider.as_ref()?; - drop(completion); - let apply_edits = provider.apply_additional_edits_for_completion( - buffer_handle, - completions_menu.completions.clone(), - candidate_id, - true, - cx, - ); - - let editor_settings = EditorSettings::get_global(cx); - if editor_settings.show_signature_help_after_edits || editor_settings.auto_signature_help { - // After the code completion is finished, users often want to know what signatures are needed. - // so we should automatically call signature_help - self.show_signature_help(&ShowSignatureHelp, window, cx); - } - - Some(cx.foreground_executor().spawn(async move { - apply_edits.await?; - Ok(()) - })) - } - - pub fn toggle_code_actions( - &mut self, - action: &ToggleCodeActions, - window: &mut Window, - cx: &mut Context, - ) { - let quick_launch = action.quick_launch; - let mut context_menu = self.context_menu.borrow_mut(); - if let Some(CodeContextMenu::CodeActions(code_actions)) = context_menu.as_ref() { - if code_actions.deployed_from_indicator == action.deployed_from_indicator { - // Toggle if we're selecting the same one - *context_menu = None; - cx.notify(); - return; - } else { - // Otherwise, clear it and start a new one - *context_menu = None; - cx.notify(); - } - } - drop(context_menu); - let snapshot = self.snapshot(window, cx); - let deployed_from_indicator = action.deployed_from_indicator; - let mut task = self.code_actions_task.take(); - let action = action.clone(); - cx.spawn_in(window, async move |editor, cx| { - while let Some(prev_task) = task { - prev_task.await.log_err(); - task = editor.update(cx, |this, _| this.code_actions_task.take())?; - } - - let spawned_test_task = editor.update_in(cx, |editor, window, cx| { - if editor.focus_handle.is_focused(window) { - let multibuffer_point = action - .deployed_from_indicator - .map(|row| DisplayPoint::new(row, 0).to_point(&snapshot)) - .unwrap_or_else(|| editor.selections.newest::(cx).head()); - let (buffer, buffer_row) = snapshot - .buffer_snapshot - .buffer_line_for_row(MultiBufferRow(multibuffer_point.row)) - .and_then(|(buffer_snapshot, range)| { - editor - .buffer - .read(cx) - .buffer(buffer_snapshot.remote_id()) - .map(|buffer| (buffer, range.start.row)) - })?; - let (_, code_actions) = editor - .available_code_actions - .clone() - .and_then(|(location, code_actions)| { - let snapshot = location.buffer.read(cx).snapshot(); - let point_range = location.range.to_point(&snapshot); - let point_range = point_range.start.row..=point_range.end.row; - if point_range.contains(&buffer_row) { - Some((location, code_actions)) - } else { - None - } - }) - .unzip(); - let buffer_id = buffer.read(cx).remote_id(); - let tasks = editor - .tasks - .get(&(buffer_id, buffer_row)) - .map(|t| Arc::new(t.to_owned())); - if tasks.is_none() && code_actions.is_none() { - return None; - } - - editor.completion_tasks.clear(); - editor.discard_inline_completion(false, cx); - let task_context = - tasks - .as_ref() - .zip(editor.project.clone()) - .map(|(tasks, project)| { - Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx) - }); - - Some(cx.spawn_in(window, async move |editor, cx| { - let task_context = match task_context { - Some(task_context) => task_context.await, - None => None, - }; - let resolved_tasks = - tasks - .zip(task_context.clone()) - .map(|(tasks, task_context)| ResolvedTasks { - templates: tasks.resolve(&task_context).collect(), - position: snapshot.buffer_snapshot.anchor_before(Point::new( - multibuffer_point.row, - tasks.column, - )), - }); - let spawn_straight_away = quick_launch - && resolved_tasks - .as_ref() - .map_or(false, |tasks| tasks.templates.len() == 1) - && code_actions - .as_ref() - .map_or(true, |actions| actions.is_empty()); - let debug_scenarios = editor.update(cx, |editor, cx| { - if cx.has_flag::() { - maybe!({ - let project = editor.project.as_ref()?; - let dap_store = project.read(cx).dap_store(); - let mut scenarios = vec![]; - let resolved_tasks = resolved_tasks.as_ref()?; - let debug_adapter: SharedString = buffer - .read(cx) - .language()? - .context_provider()? - .debug_adapter()? - .into(); - dap_store.update(cx, |this, cx| { - for (_, task) in &resolved_tasks.templates { - if let Some(scenario) = this - .debug_scenario_for_build_task( - task.resolved.clone(), - SharedString::from( - task.original_task().label.clone(), - ), - debug_adapter.clone(), - cx, - ) - { - scenarios.push(scenario); - } - } - }); - Some(scenarios) - }) - .unwrap_or_default() - } else { - vec![] - } - })?; - if let Ok(task) = editor.update_in(cx, |editor, window, cx| { - *editor.context_menu.borrow_mut() = - Some(CodeContextMenu::CodeActions(CodeActionsMenu { - buffer, - actions: CodeActionContents::new( - resolved_tasks, - code_actions, - debug_scenarios, - task_context.unwrap_or_default(), - ), - selected_item: Default::default(), - scroll_handle: UniformListScrollHandle::default(), - deployed_from_indicator, - })); - if spawn_straight_away { - if let Some(task) = editor.confirm_code_action( - &ConfirmCodeAction { item_ix: Some(0) }, - window, - cx, - ) { - cx.notify(); - return task; - } - } - cx.notify(); - Task::ready(Ok(())) - }) { - task.await - } else { - Ok(()) - } - })) - } else { - Some(Task::ready(Ok(()))) - } - })?; - if let Some(task) = spawned_test_task { - task.await?; - } - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - pub fn confirm_code_action( - &mut self, - action: &ConfirmCodeAction, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let actions_menu = - if let CodeContextMenu::CodeActions(menu) = self.hide_context_menu(window, cx)? { - menu - } else { - return None; - }; - - let action_ix = action.item_ix.unwrap_or(actions_menu.selected_item); - let action = actions_menu.actions.get(action_ix)?; - let title = action.label(); - let buffer = actions_menu.buffer; - let workspace = self.workspace()?; - - match action { - CodeActionsItem::Task(task_source_kind, resolved_task) => { - workspace.update(cx, |workspace, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - - Some(Task::ready(Ok(()))) - }) - } - CodeActionsItem::CodeAction { - excerpt_id, - action, - provider, - } => { - let apply_code_action = - provider.apply_code_action(buffer, action, excerpt_id, true, window, cx); - let workspace = workspace.downgrade(); - Some(cx.spawn_in(window, async move |editor, cx| { - let project_transaction = apply_code_action.await?; - Self::open_project_transaction( - &editor, - workspace, - project_transaction, - title, - cx, - ) - .await - })) - } - CodeActionsItem::DebugScenario(scenario) => { - let context = actions_menu.actions.context.clone(); - - workspace.update(cx, |workspace, cx| { - workspace.start_debug_session(scenario, context, Some(buffer), window, cx); - }); - Some(Task::ready(Ok(()))) - } - } - } - - pub async fn open_project_transaction( - this: &WeakEntity, - workspace: WeakEntity, - transaction: ProjectTransaction, - title: String, - cx: &mut AsyncWindowContext, - ) -> Result<()> { - let mut entries = transaction.0.into_iter().collect::>(); - cx.update(|_, cx| { - entries.sort_unstable_by_key(|(buffer, _)| { - buffer.read(cx).file().map(|f| f.path().clone()) - }); - })?; - - // If the project transaction's edits are all contained within this editor, then - // avoid opening a new editor to display them. - - if let Some((buffer, transaction)) = entries.first() { - if entries.len() == 1 { - let excerpt = this.update(cx, |editor, cx| { - editor - .buffer() - .read(cx) - .excerpt_containing(editor.selections.newest_anchor().head(), cx) - })?; - if let Some((_, excerpted_buffer, excerpt_range)) = excerpt { - if excerpted_buffer == *buffer { - let all_edits_within_excerpt = buffer.read_with(cx, |buffer, _| { - let excerpt_range = excerpt_range.to_offset(buffer); - buffer - .edited_ranges_for_transaction::(transaction) - .all(|range| { - excerpt_range.start <= range.start - && excerpt_range.end >= range.end - }) - })?; - - if all_edits_within_excerpt { - return Ok(()); - } - } - } - } - } else { - return Ok(()); - } - - let mut ranges_to_highlight = Vec::new(); - let excerpt_buffer = cx.new(|cx| { - let mut multibuffer = MultiBuffer::new(Capability::ReadWrite).with_title(title); - for (buffer_handle, transaction) in &entries { - let edited_ranges = buffer_handle - .read(cx) - .edited_ranges_for_transaction::(transaction) - .collect::>(); - let (ranges, _) = multibuffer.set_excerpts_for_path( - PathKey::for_buffer(buffer_handle, cx), - buffer_handle.clone(), - edited_ranges, - DEFAULT_MULTIBUFFER_CONTEXT, - cx, - ); - - ranges_to_highlight.extend(ranges); - } - multibuffer.push_transaction(entries.iter().map(|(b, t)| (b, t)), cx); - multibuffer - })?; - - workspace.update_in(cx, |workspace, window, cx| { - let project = workspace.project().clone(); - let editor = - cx.new(|cx| Editor::for_multibuffer(excerpt_buffer, Some(project), window, cx)); - workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); - editor.update(cx, |editor, cx| { - editor.highlight_background::( - &ranges_to_highlight, - |theme| theme.editor_highlighted_line_background, - cx, - ); - }); - })?; - - Ok(()) - } - - pub fn clear_code_action_providers(&mut self) { - self.code_action_providers.clear(); - self.available_code_actions.take(); - } - - pub fn add_code_action_provider( - &mut self, - provider: Rc, - window: &mut Window, - cx: &mut Context, - ) { - if self - .code_action_providers - .iter() - .any(|existing_provider| existing_provider.id() == provider.id()) - { - return; - } - - self.code_action_providers.push(provider); - self.refresh_code_actions(window, cx); - } - - pub fn remove_code_action_provider( - &mut self, - id: Arc, - window: &mut Window, - cx: &mut Context, - ) { - self.code_action_providers - .retain(|provider| provider.id() != id); - self.refresh_code_actions(window, cx); - } - - fn refresh_code_actions(&mut self, window: &mut Window, cx: &mut Context) -> Option<()> { - let newest_selection = self.selections.newest_anchor().clone(); - let newest_selection_adjusted = self.selections.newest_adjusted(cx).clone(); - let buffer = self.buffer.read(cx); - if newest_selection.head().diff_base_anchor.is_some() { - return None; - } - let (start_buffer, start) = - buffer.text_anchor_for_position(newest_selection_adjusted.start, cx)?; - let (end_buffer, end) = - buffer.text_anchor_for_position(newest_selection_adjusted.end, cx)?; - if start_buffer != end_buffer { - return None; - } - - self.code_actions_task = Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor() - .timer(CODE_ACTIONS_DEBOUNCE_TIMEOUT) - .await; - - let (providers, tasks) = this.update_in(cx, |this, window, cx| { - let providers = this.code_action_providers.clone(); - let tasks = this - .code_action_providers - .iter() - .map(|provider| provider.code_actions(&start_buffer, start..end, window, cx)) - .collect::>(); - (providers, tasks) - })?; - - let mut actions = Vec::new(); - for (provider, provider_actions) in - providers.into_iter().zip(future::join_all(tasks).await) - { - if let Some(provider_actions) = provider_actions.log_err() { - actions.extend(provider_actions.into_iter().map(|action| { - AvailableCodeAction { - excerpt_id: newest_selection.start.excerpt_id, - action, - provider: provider.clone(), - } - })); - } - } - - this.update(cx, |this, cx| { - this.available_code_actions = if actions.is_empty() { - None - } else { - Some(( - Location { - buffer: start_buffer, - range: start..end, - }, - actions.into(), - )) - }; - cx.notify(); - }) - })); - None - } - - fn start_inline_blame_timer(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(delay) = ProjectSettings::get_global(cx).git.inline_blame_delay() { - self.show_git_blame_inline = false; - - self.show_git_blame_inline_delay_task = - Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(delay).await; - - this.update(cx, |this, cx| { - this.show_git_blame_inline = true; - cx.notify(); - }) - .log_err(); - })); - } - } - - fn show_blame_popover( - &mut self, - blame_entry: &BlameEntry, - position: gpui::Point, - cx: &mut Context, - ) { - if let Some(state) = &mut self.inline_blame_popover { - state.hide_task.take(); - cx.notify(); - } else { - let delay = EditorSettings::get_global(cx).hover_popover_delay; - let show_task = cx.spawn(async move |editor, cx| { - cx.background_executor() - .timer(std::time::Duration::from_millis(delay)) - .await; - editor - .update(cx, |editor, cx| { - if let Some(state) = &mut editor.inline_blame_popover { - state.show_task = None; - cx.notify(); - } - }) - .ok(); - }); - let Some(blame) = self.blame.as_ref() else { - return; - }; - let blame = blame.read(cx); - let details = blame.details_for_entry(&blame_entry); - let markdown = cx.new(|cx| { - Markdown::new( - details - .as_ref() - .map(|message| message.message.clone()) - .unwrap_or_default(), - None, - None, - cx, - ) - }); - self.inline_blame_popover = Some(InlineBlamePopover { - position, - show_task: Some(show_task), - hide_task: None, - popover_bounds: None, - popover_state: InlineBlamePopoverState { - scroll_handle: ScrollHandle::new(), - commit_message: details, - markdown, - }, - }); - } - } - - fn hide_blame_popover(&mut self, cx: &mut Context) { - if let Some(state) = &mut self.inline_blame_popover { - if state.show_task.is_some() { - self.inline_blame_popover.take(); - cx.notify(); - } else { - let hide_task = cx.spawn(async move |editor, cx| { - cx.background_executor() - .timer(std::time::Duration::from_millis(100)) - .await; - editor - .update(cx, |editor, cx| { - editor.inline_blame_popover.take(); - cx.notify(); - }) - .ok(); - }); - state.hide_task = Some(hide_task); - } - } - } - - fn refresh_document_highlights(&mut self, cx: &mut Context) -> Option<()> { - if self.pending_rename.is_some() { - return None; - } - - let provider = self.semantics_provider.clone()?; - let buffer = self.buffer.read(cx); - let newest_selection = self.selections.newest_anchor().clone(); - let cursor_position = newest_selection.head(); - let (cursor_buffer, cursor_buffer_position) = - buffer.text_anchor_for_position(cursor_position, cx)?; - let (tail_buffer, _) = buffer.text_anchor_for_position(newest_selection.tail(), cx)?; - if cursor_buffer != tail_buffer { - return None; - } - let debounce = EditorSettings::get_global(cx).lsp_highlight_debounce; - self.document_highlights_task = Some(cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(debounce)) - .await; - - let highlights = if let Some(highlights) = cx - .update(|cx| { - provider.document_highlights(&cursor_buffer, cursor_buffer_position, cx) - }) - .ok() - .flatten() - { - highlights.await.log_err() - } else { - None - }; - - if let Some(highlights) = highlights { - this.update(cx, |this, cx| { - if this.pending_rename.is_some() { - return; - } - - let buffer_id = cursor_position.buffer_id; - let buffer = this.buffer.read(cx); - if !buffer - .text_anchor_for_position(cursor_position, cx) - .map_or(false, |(buffer, _)| buffer == cursor_buffer) - { - return; - } - - let cursor_buffer_snapshot = cursor_buffer.read(cx); - let mut write_ranges = Vec::new(); - let mut read_ranges = Vec::new(); - for highlight in highlights { - for (excerpt_id, excerpt_range) in - buffer.excerpts_for_buffer(cursor_buffer.read(cx).remote_id(), cx) - { - let start = highlight - .range - .start - .max(&excerpt_range.context.start, cursor_buffer_snapshot); - let end = highlight - .range - .end - .min(&excerpt_range.context.end, cursor_buffer_snapshot); - if start.cmp(&end, cursor_buffer_snapshot).is_ge() { - continue; - } - - let range = Anchor { - buffer_id, - excerpt_id, - text_anchor: start, - diff_base_anchor: None, - }..Anchor { - buffer_id, - excerpt_id, - text_anchor: end, - diff_base_anchor: None, - }; - if highlight.kind == lsp::DocumentHighlightKind::WRITE { - write_ranges.push(range); - } else { - read_ranges.push(range); - } - } - } - - this.highlight_background::( - &read_ranges, - |theme| theme.editor_document_highlight_read_background, - cx, - ); - this.highlight_background::( - &write_ranges, - |theme| theme.editor_document_highlight_write_background, - cx, - ); - cx.notify(); - }) - .log_err(); - } - })); - None - } - - fn prepare_highlight_query_from_selection( - &mut self, - cx: &mut Context, - ) -> Option<(String, Range)> { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - return None; - } - if !EditorSettings::get_global(cx).selection_highlight { - return None; - } - if self.selections.count() != 1 || self.selections.line_mode { - return None; - } - let selection = self.selections.newest::(cx); - if selection.is_empty() || selection.start.row != selection.end.row { - return None; - } - let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); - let selection_anchor_range = selection.range().to_anchors(&multi_buffer_snapshot); - let query = multi_buffer_snapshot - .text_for_range(selection_anchor_range.clone()) - .collect::(); - if query.trim().is_empty() { - return None; - } - Some((query, selection_anchor_range)) - } - - fn update_selection_occurrence_highlights( - &mut self, - query_text: String, - query_range: Range, - multi_buffer_range_to_query: Range, - use_debounce: bool, - window: &mut Window, - cx: &mut Context, - ) -> Task<()> { - let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); - cx.spawn_in(window, async move |editor, cx| { - if use_debounce { - cx.background_executor() - .timer(SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT) - .await; - } - let match_task = cx.background_spawn(async move { - let buffer_ranges = multi_buffer_snapshot - .range_to_buffer_ranges(multi_buffer_range_to_query) - .into_iter() - .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()); - let mut match_ranges = Vec::new(); - for (buffer_snapshot, search_range, excerpt_id) in buffer_ranges { - match_ranges.extend( - project::search::SearchQuery::text( - query_text.clone(), - false, - false, - false, - Default::default(), - Default::default(), - false, - None, - ) - .unwrap() - .search(&buffer_snapshot, Some(search_range.clone())) - .await - .into_iter() - .filter_map(|match_range| { - let match_start = buffer_snapshot - .anchor_after(search_range.start + match_range.start); - let match_end = - buffer_snapshot.anchor_before(search_range.start + match_range.end); - let match_anchor_range = Anchor::range_in_buffer( - excerpt_id, - buffer_snapshot.remote_id(), - match_start..match_end, - ); - (match_anchor_range != query_range).then_some(match_anchor_range) - }), - ); - } - match_ranges - }); - let match_ranges = match_task.await; - editor - .update_in(cx, |editor, _, cx| { - editor.clear_background_highlights::(cx); - if !match_ranges.is_empty() { - editor.highlight_background::( - &match_ranges, - |theme| theme.editor_document_highlight_bracket_background, - cx, - ) - } - }) - .log_err(); - }) - } - - fn refresh_selected_text_highlights( - &mut self, - on_buffer_edit: bool, - window: &mut Window, - cx: &mut Context, - ) { - let Some((query_text, query_range)) = self.prepare_highlight_query_from_selection(cx) - else { - self.clear_background_highlights::(cx); - self.quick_selection_highlight_task.take(); - self.debounced_selection_highlight_task.take(); - return; - }; - let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); - if on_buffer_edit - || self - .quick_selection_highlight_task - .as_ref() - .map_or(true, |(prev_anchor_range, _)| { - prev_anchor_range != &query_range - }) - { - let multi_buffer_visible_start = self - .scroll_manager - .anchor() - .anchor - .to_point(&multi_buffer_snapshot); - let multi_buffer_visible_end = multi_buffer_snapshot.clip_point( - multi_buffer_visible_start - + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), - Bias::Left, - ); - let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end; - self.quick_selection_highlight_task = Some(( - query_range.clone(), - self.update_selection_occurrence_highlights( - query_text.clone(), - query_range.clone(), - multi_buffer_visible_range, - false, - window, - cx, - ), - )); - } - if on_buffer_edit - || self - .debounced_selection_highlight_task - .as_ref() - .map_or(true, |(prev_anchor_range, _)| { - prev_anchor_range != &query_range - }) - { - let multi_buffer_start = multi_buffer_snapshot - .anchor_before(0) - .to_point(&multi_buffer_snapshot); - let multi_buffer_end = multi_buffer_snapshot - .anchor_after(multi_buffer_snapshot.len()) - .to_point(&multi_buffer_snapshot); - let multi_buffer_full_range = multi_buffer_start..multi_buffer_end; - self.debounced_selection_highlight_task = Some(( - query_range.clone(), - self.update_selection_occurrence_highlights( - query_text, - query_range, - multi_buffer_full_range, - true, - window, - cx, - ), - )); - } - } - - pub fn refresh_inline_completion( - &mut self, - debounce: bool, - user_requested: bool, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let provider = self.edit_prediction_provider()?; - let cursor = self.selections.newest_anchor().head(); - let (buffer, cursor_buffer_position) = - self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; - - if !self.edit_predictions_enabled_in_buffer(&buffer, cursor_buffer_position, cx) { - self.discard_inline_completion(false, cx); - return None; - } - - if !user_requested - && (!self.should_show_edit_predictions() - || !self.is_focused(window) - || buffer.read(cx).is_empty()) - { - self.discard_inline_completion(false, cx); - return None; - } - - self.update_visible_inline_completion(window, cx); - provider.refresh( - self.project.clone(), - buffer, - cursor_buffer_position, - debounce, - cx, - ); - Some(()) - } - - fn show_edit_predictions_in_menu(&self) -> bool { - match self.edit_prediction_settings { - EditPredictionSettings::Disabled => false, - EditPredictionSettings::Enabled { show_in_menu, .. } => show_in_menu, - } - } - - pub fn edit_predictions_enabled(&self) -> bool { - match self.edit_prediction_settings { - EditPredictionSettings::Disabled => false, - EditPredictionSettings::Enabled { .. } => true, - } - } - - fn edit_prediction_requires_modifier(&self) -> bool { - match self.edit_prediction_settings { - EditPredictionSettings::Disabled => false, - EditPredictionSettings::Enabled { - preview_requires_modifier, - .. - } => preview_requires_modifier, - } - } - - pub fn update_edit_prediction_settings(&mut self, cx: &mut Context) { - if self.edit_prediction_provider.is_none() { - self.edit_prediction_settings = EditPredictionSettings::Disabled; - } else { - let selection = self.selections.newest_anchor(); - let cursor = selection.head(); - - if let Some((buffer, cursor_buffer_position)) = - self.buffer.read(cx).text_anchor_for_position(cursor, cx) - { - self.edit_prediction_settings = - self.edit_prediction_settings_at_position(&buffer, cursor_buffer_position, cx); - } - } - } - - fn edit_prediction_settings_at_position( - &self, - buffer: &Entity, - buffer_position: language::Anchor, - cx: &App, - ) -> EditPredictionSettings { - if !self.mode.is_full() - || !self.show_inline_completions_override.unwrap_or(true) - || self.inline_completions_disabled_in_scope(buffer, buffer_position, cx) - { - return EditPredictionSettings::Disabled; - } - - let buffer = buffer.read(cx); - - let file = buffer.file(); - - if !language_settings(cx).buffer(buffer).get().show_edit_predictions { - return EditPredictionSettings::Disabled; - }; - - let by_provider = matches!( - self.menu_inline_completions_policy, - MenuInlineCompletionsPolicy::ByProvider - ); - - let show_in_menu = by_provider - && self - .edit_prediction_provider - .as_ref() - .map_or(false, |provider| { - provider.provider.show_completions_in_menu() - }); - - let preview_requires_modifier = - all_language_settings(file, cx).edit_predictions_mode() == EditPredictionsMode::Subtle; - - EditPredictionSettings::Enabled { - show_in_menu, - preview_requires_modifier, - } - } - - fn should_show_edit_predictions(&self) -> bool { - self.snippet_stack.is_empty() && self.edit_predictions_enabled() - } - - pub fn edit_prediction_preview_is_active(&self) -> bool { - matches!( - self.edit_prediction_preview, - EditPredictionPreview::Active { .. } - ) - } - - pub fn edit_predictions_enabled_at_cursor(&self, cx: &App) -> bool { - let cursor = self.selections.newest_anchor().head(); - if let Some((buffer, cursor_position)) = - self.buffer.read(cx).text_anchor_for_position(cursor, cx) - { - self.edit_predictions_enabled_in_buffer(&buffer, cursor_position, cx) - } else { - false - } - } - - fn edit_predictions_enabled_in_buffer( - &self, - buffer: &Entity, - buffer_position: language::Anchor, - cx: &App, - ) -> bool { - maybe!({ - if self.read_only(cx) { - return Some(false); - } - let provider = self.edit_prediction_provider()?; - if !provider.is_enabled(&buffer, buffer_position, cx) { - return Some(false); - } - let buffer = buffer.read(cx); - let Some(file) = buffer.file() else { - return Some(true); - }; - let settings = all_language_settings(Some(file), cx); - Some(settings.edit_predictions_enabled_for_file(file, cx)) - }) - .unwrap_or(false) - } - - fn cycle_inline_completion( - &mut self, - direction: Direction, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let provider = self.edit_prediction_provider()?; - let cursor = self.selections.newest_anchor().head(); - let (buffer, cursor_buffer_position) = - self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; - if self.inline_completions_hidden_for_vim_mode || !self.should_show_edit_predictions() { - return None; - } - - provider.cycle(buffer, cursor_buffer_position, direction, cx); - self.update_visible_inline_completion(window, cx); - - Some(()) - } - - pub fn show_inline_completion( - &mut self, - _: &ShowEditPrediction, - window: &mut Window, - cx: &mut Context, - ) { - if !self.has_active_inline_completion() { - self.refresh_inline_completion(false, true, window, cx); - return; - } - - self.update_visible_inline_completion(window, cx); - } - - pub fn display_cursor_names( - &mut self, - _: &DisplayCursorNames, - window: &mut Window, - cx: &mut Context, - ) { - self.show_cursor_names(window, cx); - } - - fn show_cursor_names(&mut self, window: &mut Window, cx: &mut Context) { - self.show_cursor_names = true; - cx.notify(); - cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(CURSORS_VISIBLE_FOR).await; - this.update(cx, |this, cx| { - this.show_cursor_names = false; - cx.notify() - }) - .ok() - }) - .detach(); - } - - pub fn next_edit_prediction( - &mut self, - _: &NextEditPrediction, - window: &mut Window, - cx: &mut Context, - ) { - if self.has_active_inline_completion() { - self.cycle_inline_completion(Direction::Next, window, cx); - } else { - let is_copilot_disabled = self - .refresh_inline_completion(false, true, window, cx) - .is_none(); - if is_copilot_disabled { - cx.propagate(); - } - } - } - - pub fn previous_edit_prediction( - &mut self, - _: &PreviousEditPrediction, - window: &mut Window, - cx: &mut Context, - ) { - if self.has_active_inline_completion() { - self.cycle_inline_completion(Direction::Prev, window, cx); - } else { - let is_copilot_disabled = self - .refresh_inline_completion(false, true, window, cx) - .is_none(); - if is_copilot_disabled { - cx.propagate(); - } - } - } - - pub fn accept_edit_prediction( - &mut self, - _: &AcceptEditPrediction, - window: &mut Window, - cx: &mut Context, - ) { - if self.show_edit_predictions_in_menu() { - self.hide_context_menu(window, cx); - } - - let Some(active_inline_completion) = self.active_inline_completion.as_ref() else { - return; - }; - - self.report_inline_completion_event( - active_inline_completion.completion_id.clone(), - true, - cx, - ); - - match &active_inline_completion.completion { - InlineCompletion::Move { target, .. } => { - let target = *target; - - if let Some(position_map) = &self.last_position_map { - if position_map - .visible_row_range - .contains(&target.to_display_point(&position_map.snapshot).row()) - || !self.edit_prediction_requires_modifier() - { - self.unfold_ranges(&[target..target], true, false, cx); - // Note that this is also done in vim's handler of the Tab action. - self.change_selections( - Some(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_anchor_ranges([target..target]); - }, - ); - self.clear_row_highlights::(); - - self.edit_prediction_preview - .set_previous_scroll_position(None); - } else { - self.edit_prediction_preview - .set_previous_scroll_position(Some( - position_map.snapshot.scroll_anchor, - )); - - self.highlight_rows::( - target..target, - cx.theme().colors().editor_highlighted_line_background, - RowHighlightOptions { - autoscroll: true, - ..Default::default() - }, - cx, - ); - self.request_autoscroll(Autoscroll::fit(), cx); - } - } - } - InlineCompletion::Edit { edits, .. } => { - if let Some(provider) = self.edit_prediction_provider() { - provider.accept(cx); - } - - let snapshot = self.buffer.read(cx).snapshot(cx); - let last_edit_end = edits.last().unwrap().0.end.bias_right(&snapshot); - - self.buffer.update(cx, |buffer, cx| { - buffer.edit(edits.iter().cloned(), None, cx) - }); - - self.change_selections(None, window, cx, |s| { - s.select_anchor_ranges([last_edit_end..last_edit_end]) - }); - - self.update_visible_inline_completion(window, cx); - if self.active_inline_completion.is_none() { - self.refresh_inline_completion(true, true, window, cx); - } - - cx.notify(); - } - } - - self.edit_prediction_requires_modifier_in_indent_conflict = false; - } - - pub fn accept_partial_inline_completion( - &mut self, - _: &AcceptPartialEditPrediction, - window: &mut Window, - cx: &mut Context, - ) { - let Some(active_inline_completion) = self.active_inline_completion.as_ref() else { - return; - }; - if self.selections.count() != 1 { - return; - } - - self.report_inline_completion_event( - active_inline_completion.completion_id.clone(), - true, - cx, - ); - - match &active_inline_completion.completion { - InlineCompletion::Move { target, .. } => { - let target = *target; - self.change_selections(Some(Autoscroll::newest()), window, cx, |selections| { - selections.select_anchor_ranges([target..target]); - }); - } - InlineCompletion::Edit { edits, .. } => { - // Find an insertion that starts at the cursor position. - let snapshot = self.buffer.read(cx).snapshot(cx); - let cursor_offset = self.selections.newest::(cx).head(); - let insertion = edits.iter().find_map(|(range, text)| { - let range = range.to_offset(&snapshot); - if range.is_empty() && range.start == cursor_offset { - Some(text) - } else { - None - } - }); - - if let Some(text) = insertion { - let mut partial_completion = text - .chars() - .by_ref() - .take_while(|c| c.is_alphabetic()) - .collect::(); - if partial_completion.is_empty() { - partial_completion = text - .chars() - .by_ref() - .take_while(|c| c.is_whitespace() || !c.is_alphabetic()) - .collect::(); - } - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: None, - text: partial_completion.clone().into(), - }); - - self.insert_with_autoindent_mode(&partial_completion, None, window, cx); - - self.refresh_inline_completion(true, true, window, cx); - cx.notify(); - } else { - self.accept_edit_prediction(&Default::default(), window, cx); - } - } - } - } - - fn discard_inline_completion( - &mut self, - should_report_inline_completion_event: bool, - cx: &mut Context, - ) -> bool { - if should_report_inline_completion_event { - let completion_id = self - .active_inline_completion - .as_ref() - .and_then(|active_completion| active_completion.completion_id.clone()); - - self.report_inline_completion_event(completion_id, false, cx); - } - - if let Some(provider) = self.edit_prediction_provider() { - provider.discard(cx); - } - - self.take_active_inline_completion(cx) - } - - fn report_inline_completion_event(&self, id: Option, accepted: bool, cx: &App) { - let Some(provider) = self.edit_prediction_provider() else { - return; - }; - - let Some((_, buffer, _)) = self - .buffer - .read(cx) - .excerpt_containing(self.selections.newest_anchor().head(), cx) - else { - return; - }; - - let extension = buffer - .read(cx) - .file() - .and_then(|file| Some(file.path().extension()?.to_string_lossy().to_string())); - - let event_type = match accepted { - true => "Edit Prediction Accepted", - false => "Edit Prediction Discarded", - }; - telemetry::event!( - event_type, - provider = provider.name(), - prediction_id = id, - suggestion_accepted = accepted, - file_extension = extension, - ); - } - - pub fn has_active_inline_completion(&self) -> bool { - self.active_inline_completion.is_some() - } - - fn take_active_inline_completion(&mut self, cx: &mut Context) -> bool { - let Some(active_inline_completion) = self.active_inline_completion.take() else { - return false; - }; - - self.splice_inlays(&active_inline_completion.inlay_ids, Default::default(), cx); - self.clear_highlights::(cx); - self.stale_inline_completion_in_menu = Some(active_inline_completion); - true - } - - /// Returns true when we're displaying the edit prediction popover below the cursor - /// like we are not previewing and the LSP autocomplete menu is visible - /// or we are in `when_holding_modifier` mode. - pub fn edit_prediction_visible_in_cursor_popover(&self, has_completion: bool) -> bool { - if self.edit_prediction_preview_is_active() - || !self.show_edit_predictions_in_menu() - || !self.edit_predictions_enabled() - { - return false; - } - - if self.has_visible_completions_menu() { - return true; - } - - has_completion && self.edit_prediction_requires_modifier() - } - - fn handle_modifiers_changed( - &mut self, - modifiers: Modifiers, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - if self.show_edit_predictions_in_menu() { - self.update_edit_prediction_preview(&modifiers, window, cx); - } - - self.update_selection_mode(&modifiers, position_map, window, cx); - - let mouse_position = window.mouse_position(); - if !position_map.text_hitbox.is_hovered(window) { - return; - } - - self.update_hovered_link( - position_map.point_for_position(mouse_position), - &position_map.snapshot, - modifiers, - window, - cx, - ) - } - - fn update_selection_mode( - &mut self, - modifiers: &Modifiers, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - if modifiers != &COLUMNAR_SELECTION_MODIFIERS || self.selections.pending.is_none() { - return; - } - - let mouse_position = window.mouse_position(); - let point_for_position = position_map.point_for_position(mouse_position); - let position = point_for_position.previous_valid; - - self.select( - SelectPhase::BeginColumnar { - position, - reset: false, - goal_column: point_for_position.exact_unclipped.column(), - }, - window, - cx, - ); - } - - fn update_edit_prediction_preview( - &mut self, - modifiers: &Modifiers, - window: &mut Window, - cx: &mut Context, - ) { - let accept_keybind = self.accept_edit_prediction_keybind(window, cx); - let Some(accept_keystroke) = accept_keybind.keystroke() else { - return; - }; - - if &accept_keystroke.modifiers == modifiers && accept_keystroke.modifiers.modified() { - if matches!( - self.edit_prediction_preview, - EditPredictionPreview::Inactive { .. } - ) { - self.edit_prediction_preview = EditPredictionPreview::Active { - previous_scroll_position: None, - since: Instant::now(), - }; - - self.update_visible_inline_completion(window, cx); - cx.notify(); - } - } else if let EditPredictionPreview::Active { - previous_scroll_position, - since, - } = self.edit_prediction_preview - { - if let (Some(previous_scroll_position), Some(position_map)) = - (previous_scroll_position, self.last_position_map.as_ref()) - { - self.set_scroll_position( - previous_scroll_position - .scroll_position(&position_map.snapshot.display_snapshot), - window, - cx, - ); - } - - self.edit_prediction_preview = EditPredictionPreview::Inactive { - released_too_fast: since.elapsed() < Duration::from_millis(200), - }; - self.clear_row_highlights::(); - self.update_visible_inline_completion(window, cx); - cx.notify(); - } - } - - fn update_visible_inline_completion( - &mut self, - _window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let selection = self.selections.newest_anchor(); - let cursor = selection.head(); - let multibuffer = self.buffer.read(cx).snapshot(cx); - let offset_selection = selection.map(|endpoint| endpoint.to_offset(&multibuffer)); - let excerpt_id = cursor.excerpt_id; - - let show_in_menu = self.show_edit_predictions_in_menu(); - let completions_menu_has_precedence = !show_in_menu - && (self.context_menu.borrow().is_some() - || (!self.completion_tasks.is_empty() && !self.has_active_inline_completion())); - - if completions_menu_has_precedence - || !offset_selection.is_empty() - || self - .active_inline_completion - .as_ref() - .map_or(false, |completion| { - let invalidation_range = completion.invalidation_range.to_offset(&multibuffer); - let invalidation_range = invalidation_range.start..=invalidation_range.end; - !invalidation_range.contains(&offset_selection.head()) - }) - { - self.discard_inline_completion(false, cx); - return None; - } - - self.take_active_inline_completion(cx); - let Some(provider) = self.edit_prediction_provider() else { - self.edit_prediction_settings = EditPredictionSettings::Disabled; - return None; - }; - - let (buffer, cursor_buffer_position) = - self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; - - self.edit_prediction_settings = - self.edit_prediction_settings_at_position(&buffer, cursor_buffer_position, cx); - - self.edit_prediction_indent_conflict = multibuffer.is_line_whitespace_upto(cursor); - - if self.edit_prediction_indent_conflict { - let cursor_point = cursor.to_point(&multibuffer); - - let indents = multibuffer.suggested_indents(cursor_point.row..cursor_point.row + 1, cx); - - if let Some((_, indent)) = indents.iter().next() { - if indent.len == cursor_point.column { - self.edit_prediction_indent_conflict = false; - } - } - } - - let inline_completion = provider.suggest(&buffer, cursor_buffer_position, cx)?; - let edits = inline_completion - .edits - .into_iter() - .flat_map(|(range, new_text)| { - let start = multibuffer.anchor_in_excerpt(excerpt_id, range.start)?; - let end = multibuffer.anchor_in_excerpt(excerpt_id, range.end)?; - Some((start..end, new_text)) - }) - .collect::>(); - if edits.is_empty() { - return None; - } - - let first_edit_start = edits.first().unwrap().0.start; - let first_edit_start_point = first_edit_start.to_point(&multibuffer); - let edit_start_row = first_edit_start_point.row.saturating_sub(2); - - let last_edit_end = edits.last().unwrap().0.end; - let last_edit_end_point = last_edit_end.to_point(&multibuffer); - let edit_end_row = cmp::min(multibuffer.max_point().row, last_edit_end_point.row + 2); - - let cursor_row = cursor.to_point(&multibuffer).row; - - let snapshot = multibuffer.buffer_for_excerpt(excerpt_id).cloned()?; - - let mut inlay_ids = Vec::new(); - let invalidation_row_range; - let move_invalidation_row_range = if cursor_row < edit_start_row { - Some(cursor_row..edit_end_row) - } else if cursor_row > edit_end_row { - Some(edit_start_row..cursor_row) - } else { - None - }; - let is_move = - move_invalidation_row_range.is_some() || self.inline_completions_hidden_for_vim_mode; - let completion = if is_move { - invalidation_row_range = - move_invalidation_row_range.unwrap_or(edit_start_row..edit_end_row); - let target = first_edit_start; - InlineCompletion::Move { target, snapshot } - } else { - let show_completions_in_buffer = !self.edit_prediction_visible_in_cursor_popover(true) - && !self.inline_completions_hidden_for_vim_mode; - - if show_completions_in_buffer { - if edits - .iter() - .all(|(range, _)| range.to_offset(&multibuffer).is_empty()) - { - let mut inlays = Vec::new(); - for (range, new_text) in &edits { - let inlay = Inlay::inline_completion( - post_inc(&mut self.next_inlay_id), - range.start, - new_text.as_str(), - ); - inlay_ids.push(inlay.id); - inlays.push(inlay); - } - - self.splice_inlays(&[], inlays, cx); - } else { - let background_color = cx.theme().status().deleted_background; - self.highlight_text::( - edits.iter().map(|(range, _)| range.clone()).collect(), - HighlightStyle { - background_color: Some(background_color), - ..Default::default() - }, - cx, - ); - } - } - - invalidation_row_range = edit_start_row..edit_end_row; - - let display_mode = if all_edits_insertions_or_deletions(&edits, &multibuffer) { - if provider.show_tab_accept_marker() { - EditDisplayMode::TabAccept - } else { - EditDisplayMode::Inline - } - } else { - EditDisplayMode::DiffPopover - }; - - InlineCompletion::Edit { - edits, - edit_preview: inline_completion.edit_preview, - display_mode, - snapshot, - } - }; - - let invalidation_range = multibuffer - .anchor_before(Point::new(invalidation_row_range.start, 0)) - ..multibuffer.anchor_after(Point::new( - invalidation_row_range.end, - multibuffer.line_len(MultiBufferRow(invalidation_row_range.end)), - )); - - self.stale_inline_completion_in_menu = None; - self.active_inline_completion = Some(InlineCompletionState { - inlay_ids, - completion, - completion_id: inline_completion.id, - invalidation_range, - }); - - cx.notify(); - - Some(()) - } - - pub fn edit_prediction_provider(&self) -> Option> { - Some(self.edit_prediction_provider.as_ref()?.provider.clone()) - } - - fn render_code_actions_indicator( - &self, - _style: &EditorStyle, - row: DisplayRow, - is_active: bool, - breakpoint: Option<&(Anchor, Breakpoint)>, - cx: &mut Context, - ) -> Option { - let color = Color::Muted; - let position = breakpoint.as_ref().map(|(anchor, _)| *anchor); - let show_tooltip = !self.context_menu_visible(); - - if self.available_code_actions.is_some() { - Some( - IconButton::new("code_actions_indicator", ui::IconName::Bolt) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(color) - .toggle_state(is_active) - .when(show_tooltip, |this| { - this.tooltip({ - let focus_handle = self.focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Toggle Code Actions", - &ToggleCodeActions { - deployed_from_indicator: None, - quick_launch: false, - }, - &focus_handle, - window, - cx, - ) - } - }) - }) - .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| { - let quick_launch = e.down.button == MouseButton::Left; - window.focus(&editor.focus_handle(cx)); - editor.toggle_code_actions( - &ToggleCodeActions { - deployed_from_indicator: Some(row), - quick_launch, - }, - window, - cx, - ); - })) - .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { - editor.set_breakpoint_context_menu( - row, - position, - event.down.position, - window, - cx, - ); - })), - ) - } else { - None - } - } - - fn clear_tasks(&mut self) { - self.tasks.clear() - } - - fn insert_tasks(&mut self, key: (BufferId, BufferRow), value: RunnableTasks) { - if self.tasks.insert(key, value).is_some() { - // This case should hopefully be rare, but just in case... - log::error!( - "multiple different run targets found on a single line, only the last target will be rendered" - ) - } - } - - /// Get all display points of breakpoints that will be rendered within editor - /// - /// This function is used to handle overlaps between breakpoints and Code action/runner symbol. - /// It's also used to set the color of line numbers with breakpoints to the breakpoint color. - /// TODO debugger: Use this function to color toggle symbols that house nested breakpoints - fn active_breakpoints( - &self, - range: Range, - window: &mut Window, - cx: &mut Context, - ) -> HashMap { - let mut breakpoint_display_points = HashMap::default(); - - let Some(breakpoint_store) = self.breakpoint_store.clone() else { - return breakpoint_display_points; - }; - - let snapshot = self.snapshot(window, cx); - - let multi_buffer_snapshot = &snapshot.display_snapshot.buffer_snapshot; - let Some(project) = self.project.as_ref() else { - return breakpoint_display_points; - }; - - let range = snapshot.display_point_to_point(DisplayPoint::new(range.start, 0), Bias::Left) - ..snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right); - - for (buffer_snapshot, range, excerpt_id) in - multi_buffer_snapshot.range_to_buffer_ranges(range) - { - let Some(buffer) = project.read_with(cx, |this, cx| { - this.buffer_for_id(buffer_snapshot.remote_id(), cx) - }) else { - continue; - }; - let breakpoints = breakpoint_store.read(cx).breakpoints( - &buffer, - Some( - buffer_snapshot.anchor_before(range.start) - ..buffer_snapshot.anchor_after(range.end), - ), - buffer_snapshot, - cx, - ); - for (anchor, breakpoint) in breakpoints { - let multi_buffer_anchor = - Anchor::in_buffer(excerpt_id, buffer_snapshot.remote_id(), *anchor); - let position = multi_buffer_anchor - .to_point(&multi_buffer_snapshot) - .to_display_point(&snapshot); - - breakpoint_display_points - .insert(position.row(), (multi_buffer_anchor, breakpoint.clone())); - } - } - - breakpoint_display_points - } - - fn breakpoint_context_menu( - &self, - anchor: Anchor, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - let weak_editor = cx.weak_entity(); - let focus_handle = self.focus_handle(cx); - - let row = self - .buffer - .read(cx) - .snapshot(cx) - .summary_for_anchor::(&anchor) - .row; - - let breakpoint = self - .breakpoint_at_row(row, window, cx) - .map(|(anchor, bp)| (anchor, Arc::from(bp))); - - let log_breakpoint_msg = if breakpoint.as_ref().is_some_and(|bp| bp.1.message.is_some()) { - "Edit Log Breakpoint" - } else { - "Set Log Breakpoint" - }; - - let condition_breakpoint_msg = if breakpoint - .as_ref() - .is_some_and(|bp| bp.1.condition.is_some()) - { - "Edit Condition Breakpoint" - } else { - "Set Condition Breakpoint" - }; - - let hit_condition_breakpoint_msg = if breakpoint - .as_ref() - .is_some_and(|bp| bp.1.hit_condition.is_some()) - { - "Edit Hit Condition Breakpoint" - } else { - "Set Hit Condition Breakpoint" - }; - - let set_breakpoint_msg = if breakpoint.as_ref().is_some() { - "Unset Breakpoint" - } else { - "Set Breakpoint" - }; - - let run_to_cursor = command_palette_hooks::CommandPaletteFilter::try_global(cx) - .map_or(false, |filter| !filter.is_hidden(&DebuggerRunToCursor)); - - let toggle_state_msg = breakpoint.as_ref().map_or(None, |bp| match bp.1.state { - BreakpointState::Enabled => Some("Disable"), - BreakpointState::Disabled => Some("Enable"), - }); - - let (anchor, breakpoint) = - breakpoint.unwrap_or_else(|| (anchor, Arc::new(Breakpoint::new_standard()))); - - ui::ContextMenu::build(window, cx, |menu, _, _cx| { - menu.on_blur_subscription(Subscription::new(|| {})) - .context(focus_handle) - .when(run_to_cursor, |this| { - let weak_editor = weak_editor.clone(); - this.entry("Run to cursor", None, move |window, cx| { - weak_editor - .update(cx, |editor, cx| { - editor.change_selections(None, window, cx, |s| { - s.select_ranges([Point::new(row, 0)..Point::new(row, 0)]) - }); - }) - .ok(); - - window.dispatch_action(Box::new(DebuggerRunToCursor), cx); - }) - .separator() - }) - .when_some(toggle_state_msg, |this, msg| { - this.entry(msg, None, { - let weak_editor = weak_editor.clone(); - let breakpoint = breakpoint.clone(); - move |_window, cx| { - weak_editor - .update(cx, |this, cx| { - this.edit_breakpoint_at_anchor( - anchor, - breakpoint.as_ref().clone(), - BreakpointEditAction::InvertState, - cx, - ); - }) - .log_err(); - } - }) - }) - .entry(set_breakpoint_msg, None, { - let weak_editor = weak_editor.clone(); - let breakpoint = breakpoint.clone(); - move |_window, cx| { - weak_editor - .update(cx, |this, cx| { - this.edit_breakpoint_at_anchor( - anchor, - breakpoint.as_ref().clone(), - BreakpointEditAction::Toggle, - cx, - ); - }) - .log_err(); - } - }) - .entry(log_breakpoint_msg, None, { - let breakpoint = breakpoint.clone(); - let weak_editor = weak_editor.clone(); - move |window, cx| { - weak_editor - .update(cx, |this, cx| { - this.add_edit_breakpoint_block( - anchor, - breakpoint.as_ref(), - BreakpointPromptEditAction::Log, - window, - cx, - ); - }) - .log_err(); - } - }) - .entry(condition_breakpoint_msg, None, { - let breakpoint = breakpoint.clone(); - let weak_editor = weak_editor.clone(); - move |window, cx| { - weak_editor - .update(cx, |this, cx| { - this.add_edit_breakpoint_block( - anchor, - breakpoint.as_ref(), - BreakpointPromptEditAction::Condition, - window, - cx, - ); - }) - .log_err(); - } - }) - .entry(hit_condition_breakpoint_msg, None, move |window, cx| { - weak_editor - .update(cx, |this, cx| { - this.add_edit_breakpoint_block( - anchor, - breakpoint.as_ref(), - BreakpointPromptEditAction::HitCondition, - window, - cx, - ); - }) - .log_err(); - }) - }) - } - - fn render_breakpoint( - &self, - position: Anchor, - row: DisplayRow, - breakpoint: &Breakpoint, - cx: &mut Context, - ) -> IconButton { - // Is it a breakpoint that shows up when hovering over gutter? - let (is_phantom, collides_with_existing) = self.gutter_breakpoint_indicator.0.map_or( - (false, false), - |PhantomBreakpointIndicator { - is_active, - display_row, - collides_with_existing_breakpoint, - }| { - ( - is_active && display_row == row, - collides_with_existing_breakpoint, - ) - }, - ); - - let (color, icon) = { - let icon = match (&breakpoint.message.is_some(), breakpoint.is_disabled()) { - (false, false) => ui::IconName::DebugBreakpoint, - (true, false) => ui::IconName::DebugLogBreakpoint, - (false, true) => ui::IconName::DebugDisabledBreakpoint, - (true, true) => ui::IconName::DebugDisabledLogBreakpoint, - }; - - let color = if is_phantom { - Color::Hint - } else { - Color::Debugger - }; - - (color, icon) - }; - - let breakpoint = Arc::from(breakpoint.clone()); - - let alt_as_text = gpui::Keystroke { - modifiers: Modifiers::secondary_key(), - ..Default::default() - }; - let primary_action_text = if breakpoint.is_disabled() { - "enable" - } else if is_phantom && !collides_with_existing { - "set" - } else { - "unset" - }; - let mut primary_text = format!("Click to {primary_action_text}"); - if collides_with_existing && !breakpoint.is_disabled() { - use std::fmt::Write; - write!(primary_text, ", {alt_as_text}-click to disable").ok(); - } - let primary_text = SharedString::from(primary_text); - let focus_handle = self.focus_handle.clone(); - IconButton::new(("breakpoint_indicator", row.0 as usize), icon) - .icon_size(IconSize::XSmall) - .size(ui::ButtonSize::None) - .icon_color(color) - .style(ButtonStyle::Transparent) - .on_click(cx.listener({ - let breakpoint = breakpoint.clone(); - - move |editor, event: &ClickEvent, window, cx| { - let edit_action = if event.modifiers().platform || breakpoint.is_disabled() { - BreakpointEditAction::InvertState - } else { - BreakpointEditAction::Toggle - }; - - window.focus(&editor.focus_handle(cx)); - editor.edit_breakpoint_at_anchor( - position, - breakpoint.as_ref().clone(), - edit_action, - cx, - ); - } - })) - .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { - editor.set_breakpoint_context_menu( - row, - Some(position), - event.down.position, - window, - cx, - ); - })) - .tooltip(move |window, cx| { - Tooltip::with_meta_in( - primary_text.clone(), - None, - "Right-click for more options", - &focus_handle, - window, - cx, - ) - }) - } - - fn build_tasks_context( - project: &Entity, - buffer: &Entity, - buffer_row: u32, - tasks: &Arc, - cx: &mut Context, - ) -> Task> { - let position = Point::new(buffer_row, tasks.column); - let range_start = buffer.read(cx).anchor_at(position, Bias::Right); - let location = Location { - buffer: buffer.clone(), - range: range_start..range_start, - }; - // Fill in the environmental variables from the tree-sitter captures - let mut captured_task_variables = TaskVariables::default(); - for (capture_name, value) in tasks.extra_variables.clone() { - captured_task_variables.insert( - task::VariableName::Custom(capture_name.into()), - value.clone(), - ); - } - project.update(cx, |project, cx| { - project.task_store().update(cx, |task_store, cx| { - task_store.task_context_for_location(captured_task_variables, location, cx) - }) - }) - } - - pub fn spawn_nearest_task( - &mut self, - action: &SpawnNearestTask, - window: &mut Window, - cx: &mut Context, - ) { - let Some((workspace, _)) = self.workspace.clone() else { - return; - }; - let Some(project) = self.project.clone() else { - return; - }; - - // Try to find a closest, enclosing node using tree-sitter that has a - // task - let Some((buffer, buffer_row, tasks)) = self - .find_enclosing_node_task(cx) - // Or find the task that's closest in row-distance. - .or_else(|| self.find_closest_task(cx)) - else { - return; - }; - - let reveal_strategy = action.reveal; - let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx); - cx.spawn_in(window, async move |_, cx| { - let context = task_context.await?; - let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?; - - let resolved = &mut resolved_task.resolved; - resolved.reveal = reveal_strategy; - - workspace - .update_in(cx, |workspace, window, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - }) - .ok() - }) - .detach(); - } - - fn find_closest_task( - &mut self, - cx: &mut Context, - ) -> Option<(Entity, u32, Arc)> { - let cursor_row = self.selections.newest_adjusted(cx).head().row; - - let ((buffer_id, row), tasks) = self - .tasks - .iter() - .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?; - - let buffer = self.buffer.read(cx).buffer(*buffer_id)?; - let tasks = Arc::new(tasks.to_owned()); - Some((buffer, *row, tasks)) - } - - fn find_enclosing_node_task( - &mut self, - cx: &mut Context, - ) -> Option<(Entity, u32, Arc)> { - let snapshot = self.buffer.read(cx).snapshot(cx); - let offset = self.selections.newest::(cx).head(); - let excerpt = snapshot.excerpt_containing(offset..offset)?; - let buffer_id = excerpt.buffer().remote_id(); - - let layer = excerpt.buffer().syntax_layer_at(offset)?; - let mut cursor = layer.node().walk(); - - while cursor.goto_first_child_for_byte(offset).is_some() { - if cursor.node().end_byte() == offset { - cursor.goto_next_sibling(); - } - } - - // Ascend to the smallest ancestor that contains the range and has a task. - loop { - let node = cursor.node(); - let node_range = node.byte_range(); - let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row; - - // Check if this node contains our offset - if node_range.start <= offset && node_range.end >= offset { - // If it contains offset, check for task - if let Some(tasks) = self.tasks.get(&(buffer_id, symbol_start_row)) { - let buffer = self.buffer.read(cx).buffer(buffer_id)?; - return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned()))); - } - } - - if !cursor.goto_parent() { - break; - } - } - None - } - - fn render_run_indicator( - &self, - _style: &EditorStyle, - is_active: bool, - row: DisplayRow, - breakpoint: Option<(Anchor, Breakpoint)>, - cx: &mut Context, - ) -> IconButton { - let color = Color::Muted; - let position = breakpoint.as_ref().map(|(anchor, _)| *anchor); - - IconButton::new(("run_indicator", row.0 as usize), ui::IconName::Play) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(color) - .toggle_state(is_active) - .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| { - let quick_launch = e.down.button == MouseButton::Left; - window.focus(&editor.focus_handle(cx)); - editor.toggle_code_actions( - &ToggleCodeActions { - deployed_from_indicator: Some(row), - quick_launch, - }, - window, - cx, - ); - })) - .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { - editor.set_breakpoint_context_menu(row, position, event.down.position, window, cx); - })) - } - - pub fn context_menu_visible(&self) -> bool { - !self.edit_prediction_preview_is_active() - && self - .context_menu - .borrow() - .as_ref() - .map_or(false, |menu| menu.visible()) - } - - fn context_menu_origin(&self) -> Option { - self.context_menu - .borrow() - .as_ref() - .map(|menu| menu.origin()) - } - - pub fn set_context_menu_options(&mut self, options: ContextMenuOptions) { - self.context_menu_options = Some(options); - } - - const EDIT_PREDICTION_POPOVER_PADDING_X: Pixels = Pixels(24.); - const EDIT_PREDICTION_POPOVER_PADDING_Y: Pixels = Pixels(2.); - - fn render_edit_prediction_popover( - &mut self, - text_bounds: &Bounds, - content_origin: gpui::Point, - editor_snapshot: &EditorSnapshot, - visible_row_range: Range, - scroll_top: f32, - scroll_bottom: f32, - line_layouts: &[LineWithInvisibles], - line_height: Pixels, - scroll_pixel_position: gpui::Point, - newest_selection_head: Option, - editor_width: Pixels, - style: &EditorStyle, - window: &mut Window, - cx: &mut App, - ) -> Option<(AnyElement, gpui::Point)> { - let active_inline_completion = self.active_inline_completion.as_ref()?; - - if self.edit_prediction_visible_in_cursor_popover(true) { - return None; - } - - match &active_inline_completion.completion { - InlineCompletion::Move { target, .. } => { - let target_display_point = target.to_display_point(editor_snapshot); - - if self.edit_prediction_requires_modifier() { - if !self.edit_prediction_preview_is_active() { - return None; - } - - self.render_edit_prediction_modifier_jump_popover( - text_bounds, - content_origin, - visible_row_range, - line_layouts, - line_height, - scroll_pixel_position, - newest_selection_head, - target_display_point, - window, - cx, - ) - } else { - self.render_edit_prediction_eager_jump_popover( - text_bounds, - content_origin, - editor_snapshot, - visible_row_range, - scroll_top, - scroll_bottom, - line_height, - scroll_pixel_position, - target_display_point, - editor_width, - window, - cx, - ) - } - } - InlineCompletion::Edit { - display_mode: EditDisplayMode::Inline, - .. - } => None, - InlineCompletion::Edit { - display_mode: EditDisplayMode::TabAccept, - edits, - .. - } => { - let range = &edits.first()?.0; - let target_display_point = range.end.to_display_point(editor_snapshot); - - self.render_edit_prediction_end_of_line_popover( - "Accept", - editor_snapshot, - visible_row_range, - target_display_point, - line_height, - scroll_pixel_position, - content_origin, - editor_width, - window, - cx, - ) - } - InlineCompletion::Edit { - edits, - edit_preview, - display_mode: EditDisplayMode::DiffPopover, - snapshot, - } => self.render_edit_prediction_diff_popover( - text_bounds, - content_origin, - editor_snapshot, - visible_row_range, - line_layouts, - line_height, - scroll_pixel_position, - newest_selection_head, - editor_width, - style, - edits, - edit_preview, - snapshot, - window, - cx, - ), - } - } - - fn render_edit_prediction_modifier_jump_popover( - &mut self, - text_bounds: &Bounds, - content_origin: gpui::Point, - visible_row_range: Range, - line_layouts: &[LineWithInvisibles], - line_height: Pixels, - scroll_pixel_position: gpui::Point, - newest_selection_head: Option, - target_display_point: DisplayPoint, - window: &mut Window, - cx: &mut App, - ) -> Option<(AnyElement, gpui::Point)> { - let scrolled_content_origin = - content_origin - gpui::Point::new(scroll_pixel_position.x, Pixels(0.0)); - - const SCROLL_PADDING_Y: Pixels = px(12.); - - if target_display_point.row() < visible_row_range.start { - return self.render_edit_prediction_scroll_popover( - |_| SCROLL_PADDING_Y, - IconName::ArrowUp, - visible_row_range, - line_layouts, - newest_selection_head, - scrolled_content_origin, - window, - cx, - ); - } else if target_display_point.row() >= visible_row_range.end { - return self.render_edit_prediction_scroll_popover( - |size| text_bounds.size.height - size.height - SCROLL_PADDING_Y, - IconName::ArrowDown, - visible_row_range, - line_layouts, - newest_selection_head, - scrolled_content_origin, - window, - cx, - ); - } - - const POLE_WIDTH: Pixels = px(2.); - - let line_layout = - line_layouts.get(target_display_point.row().minus(visible_row_range.start) as usize)?; - let target_column = target_display_point.column() as usize; - - let target_x = line_layout.x_for_index(target_column); - let target_y = - (target_display_point.row().as_f32() * line_height) - scroll_pixel_position.y; - - let flag_on_right = target_x < text_bounds.size.width / 2.; - - let mut border_color = Self::edit_prediction_callout_popover_border_color(cx); - border_color.l += 0.001; - - let mut element = v_flex() - .items_end() - .when(flag_on_right, |el| el.items_start()) - .child(if flag_on_right { - self.render_edit_prediction_line_popover("Jump", None, window, cx)? - .rounded_bl(px(0.)) - .rounded_tl(px(0.)) - .border_l_2() - .border_color(border_color) - } else { - self.render_edit_prediction_line_popover("Jump", None, window, cx)? - .rounded_br(px(0.)) - .rounded_tr(px(0.)) - .border_r_2() - .border_color(border_color) - }) - .child(div().w(POLE_WIDTH).bg(border_color).h(line_height)) - .into_any(); - - let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); - - let mut origin = scrolled_content_origin + point(target_x, target_y) - - point( - if flag_on_right { - POLE_WIDTH - } else { - size.width - POLE_WIDTH - }, - size.height - line_height, - ); - - origin.x = origin.x.max(content_origin.x); - - element.prepaint_at(origin, window, cx); - - Some((element, origin)) - } - - fn render_edit_prediction_scroll_popover( - &mut self, - to_y: impl Fn(Size) -> Pixels, - scroll_icon: IconName, - visible_row_range: Range, - line_layouts: &[LineWithInvisibles], - newest_selection_head: Option, - scrolled_content_origin: gpui::Point, - window: &mut Window, - cx: &mut App, - ) -> Option<(AnyElement, gpui::Point)> { - let mut element = self - .render_edit_prediction_line_popover("Scroll", Some(scroll_icon), window, cx)? - .into_any(); - - let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); - - let cursor = newest_selection_head?; - let cursor_row_layout = - line_layouts.get(cursor.row().minus(visible_row_range.start) as usize)?; - let cursor_column = cursor.column() as usize; - - let cursor_character_x = cursor_row_layout.x_for_index(cursor_column); - - let origin = scrolled_content_origin + point(cursor_character_x, to_y(size)); - - element.prepaint_at(origin, window, cx); - Some((element, origin)) - } - - fn render_edit_prediction_eager_jump_popover( - &mut self, - text_bounds: &Bounds, - content_origin: gpui::Point, - editor_snapshot: &EditorSnapshot, - visible_row_range: Range, - scroll_top: f32, - scroll_bottom: f32, - line_height: Pixels, - scroll_pixel_position: gpui::Point, - target_display_point: DisplayPoint, - editor_width: Pixels, - window: &mut Window, - cx: &mut App, - ) -> Option<(AnyElement, gpui::Point)> { - if target_display_point.row().as_f32() < scroll_top { - let mut element = self - .render_edit_prediction_line_popover( - "Jump to Edit", - Some(IconName::ArrowUp), - window, - cx, - )? - .into_any(); - - let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); - let offset = point( - (text_bounds.size.width - size.width) / 2., - Self::EDIT_PREDICTION_POPOVER_PADDING_Y, - ); - - let origin = text_bounds.origin + offset; - element.prepaint_at(origin, window, cx); - Some((element, origin)) - } else if (target_display_point.row().as_f32() + 1.) > scroll_bottom { - let mut element = self - .render_edit_prediction_line_popover( - "Jump to Edit", - Some(IconName::ArrowDown), - window, - cx, - )? - .into_any(); - - let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); - let offset = point( - (text_bounds.size.width - size.width) / 2., - text_bounds.size.height - size.height - Self::EDIT_PREDICTION_POPOVER_PADDING_Y, - ); - - let origin = text_bounds.origin + offset; - element.prepaint_at(origin, window, cx); - Some((element, origin)) - } else { - self.render_edit_prediction_end_of_line_popover( - "Jump to Edit", - editor_snapshot, - visible_row_range, - target_display_point, - line_height, - scroll_pixel_position, - content_origin, - editor_width, - window, - cx, - ) - } - } - - fn render_edit_prediction_end_of_line_popover( - self: &mut Editor, - label: &'static str, - editor_snapshot: &EditorSnapshot, - visible_row_range: Range, - target_display_point: DisplayPoint, - line_height: Pixels, - scroll_pixel_position: gpui::Point, - content_origin: gpui::Point, - editor_width: Pixels, - window: &mut Window, - cx: &mut App, - ) -> Option<(AnyElement, gpui::Point)> { - let target_line_end = DisplayPoint::new( - target_display_point.row(), - editor_snapshot.line_len(target_display_point.row()), - ); - - let mut element = self - .render_edit_prediction_line_popover(label, None, window, cx)? - .into_any(); - - let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); - - let line_origin = self.display_to_pixel_point(target_line_end, editor_snapshot, window)?; - - let start_point = content_origin - point(scroll_pixel_position.x, Pixels::ZERO); - let mut origin = start_point - + line_origin - + point(Self::EDIT_PREDICTION_POPOVER_PADDING_X, Pixels::ZERO); - origin.x = origin.x.max(content_origin.x); - - let max_x = content_origin.x + editor_width - size.width; - - if origin.x > max_x { - let offset = line_height + Self::EDIT_PREDICTION_POPOVER_PADDING_Y; - - let icon = if visible_row_range.contains(&(target_display_point.row() + 2)) { - origin.y += offset; - IconName::ArrowUp - } else { - origin.y -= offset; - IconName::ArrowDown - }; - - element = self - .render_edit_prediction_line_popover(label, Some(icon), window, cx)? - .into_any(); - - let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); - - origin.x = content_origin.x + editor_width - size.width - px(2.); - } - - element.prepaint_at(origin, window, cx); - Some((element, origin)) - } - - fn render_edit_prediction_diff_popover( - self: &Editor, - text_bounds: &Bounds, - content_origin: gpui::Point, - editor_snapshot: &EditorSnapshot, - visible_row_range: Range, - line_layouts: &[LineWithInvisibles], - line_height: Pixels, - scroll_pixel_position: gpui::Point, - newest_selection_head: Option, - editor_width: Pixels, - style: &EditorStyle, - edits: &Vec<(Range, String)>, - edit_preview: &Option, - snapshot: &language::BufferSnapshot, - window: &mut Window, - cx: &mut App, - ) -> Option<(AnyElement, gpui::Point)> { - let edit_start = edits - .first() - .unwrap() - .0 - .start - .to_display_point(editor_snapshot); - let edit_end = edits - .last() - .unwrap() - .0 - .end - .to_display_point(editor_snapshot); - - let is_visible = visible_row_range.contains(&edit_start.row()) - || visible_row_range.contains(&edit_end.row()); - if !is_visible { - return None; - } - - let highlighted_edits = - crate::inline_completion_edit_text(&snapshot, edits, edit_preview.as_ref()?, false, cx); - - let styled_text = highlighted_edits.to_styled_text(&style.text); - let line_count = highlighted_edits.text.lines().count(); - - const BORDER_WIDTH: Pixels = px(1.); - - let keybind = self.render_edit_prediction_accept_keybind(window, cx); - let has_keybind = keybind.is_some(); - - let mut element = h_flex() - .items_start() - .child( - h_flex() - .bg(cx.theme().colors().editor_background) - .border(BORDER_WIDTH) - .shadow_sm() - .border_color(cx.theme().colors().border) - .rounded_l_lg() - .when(line_count > 1, |el| el.rounded_br_lg()) - .pr_1() - .child(styled_text), - ) - .child( - h_flex() - .h(line_height + BORDER_WIDTH * 2.) - .px_1p5() - .gap_1() - // Workaround: For some reason, there's a gap if we don't do this - .ml(-BORDER_WIDTH) - .shadow(vec![gpui::BoxShadow { - color: gpui::black().opacity(0.05), - offset: point(px(1.), px(1.)), - blur_radius: px(2.), - spread_radius: px(0.), - }]) - .bg(Editor::edit_prediction_line_popover_bg_color(cx)) - .border(BORDER_WIDTH) - .border_color(cx.theme().colors().border) - .rounded_r_lg() - .id("edit_prediction_diff_popover_keybind") - .when(!has_keybind, |el| { - let status_colors = cx.theme().status(); - - el.bg(status_colors.error_background) - .border_color(status_colors.error.opacity(0.6)) - .child(Icon::new(IconName::Info).color(Color::Error)) - .cursor_default() - .hoverable_tooltip(move |_window, cx| { - cx.new(|_| MissingEditPredictionKeybindingTooltip).into() - }) - }) - .children(keybind), - ) - .into_any(); - - let longest_row = - editor_snapshot.longest_row_in_range(edit_start.row()..edit_end.row() + 1); - let longest_line_width = if visible_row_range.contains(&longest_row) { - line_layouts[(longest_row.0 - visible_row_range.start.0) as usize].width - } else { - layout_line( - longest_row, - editor_snapshot, - style, - editor_width, - |_| false, - window, - cx, - ) - .width - }; - - let viewport_bounds = - Bounds::new(Default::default(), window.viewport_size()).extend(Edges { - right: -EditorElement::SCROLLBAR_WIDTH, - ..Default::default() - }); - - let x_after_longest = - text_bounds.origin.x + longest_line_width + Self::EDIT_PREDICTION_POPOVER_PADDING_X - - scroll_pixel_position.x; - - let element_bounds = element.layout_as_root(AvailableSpace::min_size(), window, cx); - - // Fully visible if it can be displayed within the window (allow overlapping other - // panes). However, this is only allowed if the popover starts within text_bounds. - let can_position_to_the_right = x_after_longest < text_bounds.right() - && x_after_longest + element_bounds.width < viewport_bounds.right(); - - let mut origin = if can_position_to_the_right { - point( - x_after_longest, - text_bounds.origin.y + edit_start.row().as_f32() * line_height - - scroll_pixel_position.y, - ) - } else { - let cursor_row = newest_selection_head.map(|head| head.row()); - let above_edit = edit_start - .row() - .0 - .checked_sub(line_count as u32) - .map(DisplayRow); - let below_edit = Some(edit_end.row() + 1); - let above_cursor = - cursor_row.and_then(|row| row.0.checked_sub(line_count as u32).map(DisplayRow)); - let below_cursor = cursor_row.map(|cursor_row| cursor_row + 1); - - // Place the edit popover adjacent to the edit if there is a location - // available that is onscreen and does not obscure the cursor. Otherwise, - // place it adjacent to the cursor. - let row_target = [above_edit, below_edit, above_cursor, below_cursor] - .into_iter() - .flatten() - .find(|&start_row| { - let end_row = start_row + line_count as u32; - visible_row_range.contains(&start_row) - && visible_row_range.contains(&end_row) - && cursor_row.map_or(true, |cursor_row| { - !((start_row..end_row).contains(&cursor_row)) - }) - })?; - - content_origin - + point( - -scroll_pixel_position.x, - row_target.as_f32() * line_height - scroll_pixel_position.y, - ) - }; - - origin.x -= BORDER_WIDTH; - - window.defer_draw(element, origin, 1); - - // Do not return an element, since it will already be drawn due to defer_draw. - None - } - - fn edit_prediction_cursor_popover_height(&self) -> Pixels { - px(30.) - } - - fn current_user_player_color(&self, cx: &mut App) -> PlayerColor { - if self.read_only(cx) { - cx.theme().players().read_only() - } else { - self.style.as_ref().unwrap().local_player - } - } - - fn render_edit_prediction_accept_keybind( - &self, - window: &mut Window, - cx: &App, - ) -> Option { - let accept_binding = self.accept_edit_prediction_keybind(window, cx); - let accept_keystroke = accept_binding.keystroke()?; - - let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; - - let modifiers_color = if accept_keystroke.modifiers == window.modifiers() { - Color::Accent - } else { - Color::Muted - }; - - h_flex() - .px_0p5() - .when(is_platform_style_mac, |parent| parent.gap_0p5()) - .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) - .text_size(TextSize::XSmall.rems(cx)) - .child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, - PlatformStyle::platform(), - Some(modifiers_color), - Some(IconSize::XSmall.rems().into()), - true, - ))) - .when(is_platform_style_mac, |parent| { - parent.child(accept_keystroke.key.clone()) - }) - .when(!is_platform_style_mac, |parent| { - parent.child( - Key::new( - util::capitalize(&accept_keystroke.key), - Some(Color::Default), - ) - .size(Some(IconSize::XSmall.rems().into())), - ) - }) - .into_any() - .into() - } - - fn render_edit_prediction_line_popover( - &self, - label: impl Into, - icon: Option, - window: &mut Window, - cx: &App, - ) -> Option> { - let padding_right = if icon.is_some() { px(4.) } else { px(8.) }; - - let keybind = self.render_edit_prediction_accept_keybind(window, cx); - let has_keybind = keybind.is_some(); - - let result = h_flex() - .id("ep-line-popover") - .py_0p5() - .pl_1() - .pr(padding_right) - .gap_1() - .rounded_md() - .border_1() - .bg(Self::edit_prediction_line_popover_bg_color(cx)) - .border_color(Self::edit_prediction_callout_popover_border_color(cx)) - .shadow_sm() - .when(!has_keybind, |el| { - let status_colors = cx.theme().status(); - - el.bg(status_colors.error_background) - .border_color(status_colors.error.opacity(0.6)) - .pl_2() - .child(Icon::new(IconName::ZedPredictError).color(Color::Error)) - .cursor_default() - .hoverable_tooltip(move |_window, cx| { - cx.new(|_| MissingEditPredictionKeybindingTooltip).into() - }) - }) - .children(keybind) - .child( - Label::new(label) - .size(LabelSize::Small) - .when(!has_keybind, |el| { - el.color(cx.theme().status().error.into()).strikethrough() - }), - ) - .when(!has_keybind, |el| { - el.child( - h_flex().ml_1().child( - Icon::new(IconName::Info) - .size(IconSize::Small) - .color(cx.theme().status().error.into()), - ), - ) - }) - .when_some(icon, |element, icon| { - element.child( - div() - .mt(px(1.5)) - .child(Icon::new(icon).size(IconSize::Small)), - ) - }); - - Some(result) - } - - fn edit_prediction_line_popover_bg_color(cx: &App) -> Hsla { - let accent_color = cx.theme().colors().text_accent; - let editor_bg_color = cx.theme().colors().editor_background; - editor_bg_color.blend(accent_color.opacity(0.1)) - } - - fn edit_prediction_callout_popover_border_color(cx: &App) -> Hsla { - let accent_color = cx.theme().colors().text_accent; - let editor_bg_color = cx.theme().colors().editor_background; - editor_bg_color.blend(accent_color.opacity(0.6)) - } - - fn render_edit_prediction_cursor_popover( - &self, - min_width: Pixels, - max_width: Pixels, - cursor_point: Point, - style: &EditorStyle, - accept_keystroke: Option<&gpui::Keystroke>, - _window: &Window, - cx: &mut Context, - ) -> Option { - let provider = self.edit_prediction_provider.as_ref()?; - - if provider.provider.needs_terms_acceptance(cx) { - return Some( - h_flex() - .min_w(min_width) - .flex_1() - .px_2() - .py_1() - .gap_3() - .elevation_2(cx) - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .id("accept-terms") - .cursor_pointer() - .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) - .on_click(cx.listener(|this, _event, window, cx| { - cx.stop_propagation(); - this.report_editor_event("Edit Prediction Provider ToS Clicked", None, cx); - window.dispatch_action( - zed_actions::OpenZedPredictOnboarding.boxed_clone(), - cx, - ); - })) - .child( - h_flex() - .flex_1() - .gap_2() - .child(Icon::new(IconName::ZedPredict)) - .child(Label::new("Accept Terms of Service")) - .child(div().w_full()) - .child( - Icon::new(IconName::ArrowUpRight) - .color(Color::Muted) - .size(IconSize::Small), - ) - .into_any_element(), - ) - .into_any(), - ); - } - - let is_refreshing = provider.provider.is_refreshing(cx); - - fn pending_completion_container() -> Div { - h_flex() - .h_full() - .flex_1() - .gap_2() - .child(Icon::new(IconName::ZedPredict)) - } - - let completion = match &self.active_inline_completion { - Some(prediction) => { - if !self.has_visible_completions_menu() { - const RADIUS: Pixels = px(6.); - const BORDER_WIDTH: Pixels = px(1.); - - return Some( - h_flex() - .elevation_2(cx) - .border(BORDER_WIDTH) - .border_color(cx.theme().colors().border) - .when(accept_keystroke.is_none(), |el| { - el.border_color(cx.theme().status().error) - }) - .rounded(RADIUS) - .rounded_tl(px(0.)) - .overflow_hidden() - .child(div().px_1p5().child(match &prediction.completion { - InlineCompletion::Move { target, snapshot } => { - use text::ToPoint as _; - if target.text_anchor.to_point(&snapshot).row > cursor_point.row - { - Icon::new(IconName::ZedPredictDown) - } else { - Icon::new(IconName::ZedPredictUp) - } - } - InlineCompletion::Edit { .. } => Icon::new(IconName::ZedPredict), - })) - .child( - h_flex() - .gap_1() - .py_1() - .px_2() - .rounded_r(RADIUS - BORDER_WIDTH) - .border_l_1() - .border_color(cx.theme().colors().border) - .bg(Self::edit_prediction_line_popover_bg_color(cx)) - .when(self.edit_prediction_preview.released_too_fast(), |el| { - el.child( - Label::new("Hold") - .size(LabelSize::Small) - .when(accept_keystroke.is_none(), |el| { - el.strikethrough() - }) - .line_height_style(LineHeightStyle::UiLabel), - ) - }) - .id("edit_prediction_cursor_popover_keybind") - .when(accept_keystroke.is_none(), |el| { - let status_colors = cx.theme().status(); - - el.bg(status_colors.error_background) - .border_color(status_colors.error.opacity(0.6)) - .child(Icon::new(IconName::Info).color(Color::Error)) - .cursor_default() - .hoverable_tooltip(move |_window, cx| { - cx.new(|_| MissingEditPredictionKeybindingTooltip) - .into() - }) - }) - .when_some( - accept_keystroke.as_ref(), - |el, accept_keystroke| { - el.child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, - PlatformStyle::platform(), - Some(Color::Default), - Some(IconSize::XSmall.rems().into()), - false, - ))) - }, - ), - ) - .into_any(), - ); - } - - self.render_edit_prediction_cursor_popover_preview( - prediction, - cursor_point, - style, - cx, - )? - } - - None if is_refreshing => match &self.stale_inline_completion_in_menu { - Some(stale_completion) => self.render_edit_prediction_cursor_popover_preview( - stale_completion, - cursor_point, - style, - cx, - )?, - - None => { - pending_completion_container().child(Label::new("...").size(LabelSize::Small)) - } - }, - - None => pending_completion_container().child(Label::new("No Prediction")), - }; - - let completion = if is_refreshing { - completion - .with_animation( - "loading-completion", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.opacity(delta), - ) - .into_any_element() - } else { - completion.into_any_element() - }; - - let has_completion = self.active_inline_completion.is_some(); - - let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; - Some( - h_flex() - .min_w(min_width) - .max_w(max_width) - .flex_1() - .elevation_2(cx) - .border_color(cx.theme().colors().border) - .child( - div() - .flex_1() - .py_1() - .px_2() - .overflow_hidden() - .child(completion), - ) - .when_some(accept_keystroke, |el, accept_keystroke| { - if !accept_keystroke.modifiers.modified() { - return el; - } - - el.child( - h_flex() - .h_full() - .border_l_1() - .rounded_r_lg() - .border_color(cx.theme().colors().border) - .bg(Self::edit_prediction_line_popover_bg_color(cx)) - .gap_1() - .py_1() - .px_2() - .child( - h_flex() - .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) - .when(is_platform_style_mac, |parent| parent.gap_1()) - .child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, - PlatformStyle::platform(), - Some(if !has_completion { - Color::Muted - } else { - Color::Default - }), - None, - false, - ))), - ) - .child(Label::new("Preview").into_any_element()) - .opacity(if has_completion { 1.0 } else { 0.4 }), - ) - }) - .into_any(), - ) - } - - fn render_edit_prediction_cursor_popover_preview( - &self, - completion: &InlineCompletionState, - cursor_point: Point, - style: &EditorStyle, - cx: &mut Context, - ) -> Option
{ - use text::ToPoint as _; - - fn render_relative_row_jump( - prefix: impl Into, - current_row: u32, - target_row: u32, - ) -> Div { - let (row_diff, arrow) = if target_row < current_row { - (current_row - target_row, IconName::ArrowUp) - } else { - (target_row - current_row, IconName::ArrowDown) - }; - - h_flex() - .child( - Label::new(format!("{}{}", prefix.into(), row_diff)) - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child(Icon::new(arrow).color(Color::Muted).size(IconSize::Small)) - } - - match &completion.completion { - InlineCompletion::Move { - target, snapshot, .. - } => Some( - h_flex() - .px_2() - .gap_2() - .flex_1() - .child( - if target.text_anchor.to_point(&snapshot).row > cursor_point.row { - Icon::new(IconName::ZedPredictDown) - } else { - Icon::new(IconName::ZedPredictUp) - }, - ) - .child(Label::new("Jump to Edit")), - ), - - InlineCompletion::Edit { - edits, - edit_preview, - snapshot, - display_mode: _, - } => { - let first_edit_row = edits.first()?.0.start.text_anchor.to_point(&snapshot).row; - - let (highlighted_edits, has_more_lines) = crate::inline_completion_edit_text( - &snapshot, - &edits, - edit_preview.as_ref()?, - true, - cx, - ) - .first_line_preview(); - - let styled_text = gpui::StyledText::new(highlighted_edits.text) - .with_default_highlights(&style.text, highlighted_edits.highlights); - - let preview = h_flex() - .gap_1() - .min_w_16() - .child(styled_text) - .when(has_more_lines, |parent| parent.child("…")); - - let left = if first_edit_row != cursor_point.row { - render_relative_row_jump("", cursor_point.row, first_edit_row) - .into_any_element() - } else { - Icon::new(IconName::ZedPredict).into_any_element() - }; - - Some( - h_flex() - .h_full() - .flex_1() - .gap_2() - .pr_1() - .overflow_x_hidden() - .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) - .child(left) - .child(preview), - ) - } - } - } - - fn render_context_menu( - &self, - style: &EditorStyle, - max_height_in_lines: u32, - window: &mut Window, - cx: &mut Context, - ) -> Option { - let menu = self.context_menu.borrow(); - let menu = menu.as_ref()?; - if !menu.visible() { - return None; - }; - Some(menu.render(style, max_height_in_lines, window, cx)) - } - - fn render_context_menu_aside( - &mut self, - max_size: Size, - window: &mut Window, - cx: &mut Context, - ) -> Option { - self.context_menu.borrow_mut().as_mut().and_then(|menu| { - if menu.visible() { - menu.render_aside(self, max_size, window, cx) - } else { - None - } - }) - } - - fn hide_context_menu( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Option { - cx.notify(); - self.completion_tasks.clear(); - let context_menu = self.context_menu.borrow_mut().take(); - self.stale_inline_completion_in_menu.take(); - self.update_visible_inline_completion(window, cx); - context_menu - } - - fn show_snippet_choices( - &mut self, - choices: &Vec, - selection: Range, - cx: &mut Context, - ) { - if selection.start.buffer_id.is_none() { - return; - } - let buffer_id = selection.start.buffer_id.unwrap(); - let buffer = self.buffer().read(cx).buffer(buffer_id); - let id = post_inc(&mut self.next_completion_id); - let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; - - if let Some(buffer) = buffer { - *self.context_menu.borrow_mut() = Some(CodeContextMenu::Completions( - CompletionsMenu::new_snippet_choices( - id, - true, - choices, - selection, - buffer, - snippet_sort_order, - ), - )); - } - } - - pub fn insert_snippet( - &mut self, - insertion_ranges: &[Range], - snippet: Snippet, - window: &mut Window, - cx: &mut Context, - ) -> Result<()> { - struct Tabstop { - is_end_tabstop: bool, - ranges: Vec>, - choices: Option>, - } - - let tabstops = self.buffer.update(cx, |buffer, cx| { - let snippet_text: Arc = snippet.text.clone().into(); - let edits = insertion_ranges - .iter() - .cloned() - .map(|range| (range, snippet_text.clone())); - buffer.edit(edits, Some(AutoindentMode::EachLine), cx); - - let snapshot = &*buffer.read(cx); - let snippet = &snippet; - snippet - .tabstops - .iter() - .map(|tabstop| { - let is_end_tabstop = tabstop.ranges.first().map_or(false, |tabstop| { - tabstop.is_empty() && tabstop.start == snippet.text.len() as isize - }); - let mut tabstop_ranges = tabstop - .ranges - .iter() - .flat_map(|tabstop_range| { - let mut delta = 0_isize; - insertion_ranges.iter().map(move |insertion_range| { - let insertion_start = insertion_range.start as isize + delta; - delta += - snippet.text.len() as isize - insertion_range.len() as isize; - - let start = ((insertion_start + tabstop_range.start) as usize) - .min(snapshot.len()); - let end = ((insertion_start + tabstop_range.end) as usize) - .min(snapshot.len()); - snapshot.anchor_before(start)..snapshot.anchor_after(end) - }) - }) - .collect::>(); - tabstop_ranges.sort_unstable_by(|a, b| a.start.cmp(&b.start, snapshot)); - - Tabstop { - is_end_tabstop, - ranges: tabstop_ranges, - choices: tabstop.choices.clone(), - } - }) - .collect::>() - }); - if let Some(tabstop) = tabstops.first() { - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_ranges(tabstop.ranges.iter().cloned()); - }); - - if let Some(choices) = &tabstop.choices { - if let Some(selection) = tabstop.ranges.first() { - self.show_snippet_choices(choices, selection.clone(), cx) - } - } - - // If we're already at the last tabstop and it's at the end of the snippet, - // we're done, we don't need to keep the state around. - if !tabstop.is_end_tabstop { - let choices = tabstops - .iter() - .map(|tabstop| tabstop.choices.clone()) - .collect(); - - let ranges = tabstops - .into_iter() - .map(|tabstop| tabstop.ranges) - .collect::>(); - - self.snippet_stack.push(SnippetState { - active_index: 0, - ranges, - choices, - }); - } - - // Check whether the just-entered snippet ends with an auto-closable bracket. - if self.autoclose_regions.is_empty() { - let snapshot = self.buffer.read(cx).snapshot(cx); - for selection in &mut self.selections.all::(cx) { - let selection_head = selection.head(); - let Some(scope) = snapshot.language_scope_at(selection_head) else { - continue; - }; - - let mut bracket_pair = None; - let next_chars = snapshot.chars_at(selection_head).collect::(); - let prev_chars = snapshot - .reversed_chars_at(selection_head) - .collect::(); - for (pair, enabled) in scope.brackets() { - if enabled - && pair.close - && prev_chars.starts_with(pair.start.as_str()) - && next_chars.starts_with(pair.end.as_str()) - { - bracket_pair = Some(pair.clone()); - break; - } - } - if let Some(pair) = bracket_pair { - let snapshot_settings = snapshot.language_settings_at(selection_head, cx); - let autoclose_enabled = - self.use_autoclose && snapshot_settings.use_autoclose; - if autoclose_enabled { - let start = snapshot.anchor_after(selection_head); - let end = snapshot.anchor_after(selection_head); - self.autoclose_regions.push(AutocloseRegion { - selection_id: selection.id, - range: start..end, - pair, - }); - } - } - } - } - } - Ok(()) - } - - pub fn move_to_next_snippet_tabstop( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> bool { - self.move_to_snippet_tabstop(Bias::Right, window, cx) - } - - pub fn move_to_prev_snippet_tabstop( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> bool { - self.move_to_snippet_tabstop(Bias::Left, window, cx) - } - - pub fn move_to_snippet_tabstop( - &mut self, - bias: Bias, - window: &mut Window, - cx: &mut Context, - ) -> bool { - if let Some(mut snippet) = self.snippet_stack.pop() { - match bias { - Bias::Left => { - if snippet.active_index > 0 { - snippet.active_index -= 1; - } else { - self.snippet_stack.push(snippet); - return false; - } - } - Bias::Right => { - if snippet.active_index + 1 < snippet.ranges.len() { - snippet.active_index += 1; - } else { - self.snippet_stack.push(snippet); - return false; - } - } - } - if let Some(current_ranges) = snippet.ranges.get(snippet.active_index) { - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_anchor_ranges(current_ranges.iter().cloned()) - }); - - if let Some(choices) = &snippet.choices[snippet.active_index] { - if let Some(selection) = current_ranges.first() { - self.show_snippet_choices(&choices, selection.clone(), cx); - } - } - - // If snippet state is not at the last tabstop, push it back on the stack - if snippet.active_index + 1 < snippet.ranges.len() { - self.snippet_stack.push(snippet); - } - return true; - } - } - - false - } - - pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { - self.transact(window, cx, |this, window, cx| { - this.select_all(&SelectAll, window, cx); - this.insert("", window, cx); - }); - } - - pub fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - this.select_autoclose_pair(window, cx); - let mut linked_ranges = HashMap::<_, Vec<_>>::default(); - if !this.linked_edit_ranges.is_empty() { - let selections = this.selections.all::(cx); - let snapshot = this.buffer.read(cx).snapshot(cx); - - for selection in selections.iter() { - let selection_start = snapshot.anchor_before(selection.start).text_anchor; - let selection_end = snapshot.anchor_after(selection.end).text_anchor; - if selection_start.buffer_id != selection_end.buffer_id { - continue; - } - if let Some(ranges) = - this.linked_editing_ranges_for(selection_start..selection_end, cx) - { - for (buffer, entries) in ranges { - linked_ranges.entry(buffer).or_default().extend(entries); - } - } - } - } - - let mut selections = this.selections.all::(cx); - let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); - for selection in &mut selections { - if selection.is_empty() { - let old_head = selection.head(); - let mut new_head = - movement::left(&display_map, old_head.to_display_point(&display_map)) - .to_point(&display_map); - if let Some((buffer, line_buffer_range)) = display_map - .buffer_snapshot - .buffer_line_for_row(MultiBufferRow(old_head.row)) - { - let indent_size = buffer.indent_size_for_line(line_buffer_range.start.row); - let indent_len = match indent_size.kind { - IndentKind::Space => { - buffer.settings_at(line_buffer_range.start, cx).tab_size - } - IndentKind::Tab => NonZeroU32::new(1).unwrap(), - }; - if old_head.column <= indent_size.len && old_head.column > 0 { - let indent_len = indent_len.get(); - new_head = cmp::min( - new_head, - MultiBufferPoint::new( - old_head.row, - ((old_head.column - 1) / indent_len) * indent_len, - ), - ); - } - } - - selection.set_head(new_head, SelectionGoal::None); - } - } - - this.signature_help_state.set_backspace_pressed(true); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections) - }); - this.insert("", window, cx); - let empty_str: Arc = Arc::from(""); - for (buffer, edits) in linked_ranges { - let snapshot = buffer.read(cx).snapshot(); - use text::ToPoint as TP; - - let edits = edits - .into_iter() - .map(|range| { - let end_point = TP::to_point(&range.end, &snapshot); - let mut start_point = TP::to_point(&range.start, &snapshot); - - if end_point == start_point { - let offset = text::ToOffset::to_offset(&range.start, &snapshot) - .saturating_sub(1); - start_point = - snapshot.clip_point(TP::to_point(&offset, &snapshot), Bias::Left); - }; - - (start_point..end_point, empty_str.clone()) - }) - .sorted_by_key(|(range, _)| range.start) - .collect::>(); - buffer.update(cx, |this, cx| { - this.edit(edits, None, cx); - }) - } - this.refresh_inline_completion(true, false, window, cx); - linked_editing_ranges::refresh_linked_ranges(this, window, cx); - }); - } - - pub fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - if selection.is_empty() { - let cursor = movement::right(map, selection.head()); - selection.end = cursor; - selection.reversed = true; - selection.goal = SelectionGoal::None; - } - }) - }); - this.insert("", window, cx); - this.refresh_inline_completion(true, false, window, cx); - }); - } - - pub fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - if self.move_to_prev_snippet_tabstop(window, cx) { - return; - } - self.outdent(&Outdent, window, cx); - } - - pub fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { - if self.move_to_next_snippet_tabstop(window, cx) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - return; - } - if self.read_only(cx) { - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let mut selections = self.selections.all_adjusted(cx); - let buffer = self.buffer.read(cx); - let snapshot = buffer.snapshot(cx); - let rows_iter = selections.iter().map(|s| s.head().row); - let suggested_indents = snapshot.suggested_indents(rows_iter, cx); - - let has_some_cursor_in_whitespace = selections - .iter() - .filter(|selection| selection.is_empty()) - .any(|selection| { - let cursor = selection.head(); - let current_indent = snapshot.indent_size_for_line(MultiBufferRow(cursor.row)); - cursor.column < current_indent.len - }); - - let mut edits = Vec::new(); - let mut prev_edited_row = 0; - let mut row_delta = 0; - for selection in &mut selections { - if selection.start.row != prev_edited_row { - row_delta = 0; - } - prev_edited_row = selection.end.row; - - // If the selection is non-empty, then increase the indentation of the selected lines. - if !selection.is_empty() { - row_delta = - Self::indent_selection(buffer, &snapshot, selection, &mut edits, row_delta, cx); - continue; - } - - // If the selection is empty and the cursor is in the leading whitespace before the - // suggested indentation, then auto-indent the line. - let cursor = selection.head(); - let current_indent = snapshot.indent_size_for_line(MultiBufferRow(cursor.row)); - if let Some(suggested_indent) = - suggested_indents.get(&MultiBufferRow(cursor.row)).copied() - { - // If there exist any empty selection in the leading whitespace, then skip - // indent for selections at the boundary. - if has_some_cursor_in_whitespace - && cursor.column == current_indent.len - && current_indent.len == suggested_indent.len - { - continue; - } - - if cursor.column < suggested_indent.len - && cursor.column <= current_indent.len - && current_indent.len <= suggested_indent.len - { - selection.start = Point::new(cursor.row, suggested_indent.len); - selection.end = selection.start; - if row_delta == 0 { - edits.extend(Buffer::edit_for_indent_size_adjustment( - cursor.row, - current_indent, - suggested_indent, - )); - row_delta = suggested_indent.len - current_indent.len; - } - continue; - } - } - - // Otherwise, insert a hard or soft tab. - let settings = buffer.language_settings_at(cursor, cx); - let tab_size = if settings.hard_tabs { - IndentSize::tab() - } else { - let tab_size = settings.tab_size.get(); - let indent_remainder = snapshot - .text_for_range(Point::new(cursor.row, 0)..cursor) - .flat_map(str::chars) - .fold(row_delta % tab_size, |counter: u32, c| { - if c == '\t' { - 0 - } else { - (counter + 1) % tab_size - } - }); - - let chars_to_next_tab_stop = tab_size - indent_remainder; - IndentSize::spaces(chars_to_next_tab_stop) - }; - selection.start = Point::new(cursor.row, cursor.column + row_delta + tab_size.len); - selection.end = selection.start; - edits.push((cursor..cursor, tab_size.chars().collect::())); - row_delta += tab_size.len; - } - - self.transact(window, cx, |this, window, cx| { - this.buffer.update(cx, |b, cx| b.edit(edits, None, cx)); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections) - }); - this.refresh_inline_completion(true, false, window, cx); - }); - } - - pub fn indent(&mut self, _: &Indent, window: &mut Window, cx: &mut Context) { - if self.read_only(cx) { - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let mut selections = self.selections.all::(cx); - let mut prev_edited_row = 0; - let mut row_delta = 0; - let mut edits = Vec::new(); - let buffer = self.buffer.read(cx); - let snapshot = buffer.snapshot(cx); - for selection in &mut selections { - if selection.start.row != prev_edited_row { - row_delta = 0; - } - prev_edited_row = selection.end.row; - - row_delta = - Self::indent_selection(buffer, &snapshot, selection, &mut edits, row_delta, cx); - } - - self.transact(window, cx, |this, window, cx| { - this.buffer.update(cx, |b, cx| b.edit(edits, None, cx)); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections) - }); - }); - } - - fn indent_selection( - buffer: &MultiBuffer, - snapshot: &MultiBufferSnapshot, - selection: &mut Selection, - edits: &mut Vec<(Range, String)>, - delta_for_start_row: u32, - cx: &App, - ) -> u32 { - let settings = buffer.language_settings_at(selection.start, cx); - let tab_size = settings.tab_size.get(); - let indent_kind = if settings.hard_tabs { - IndentKind::Tab - } else { - IndentKind::Space - }; - let mut start_row = selection.start.row; - let mut end_row = selection.end.row + 1; - - // If a selection ends at the beginning of a line, don't indent - // that last line. - if selection.end.column == 0 && selection.end.row > selection.start.row { - end_row -= 1; - } - - // Avoid re-indenting a row that has already been indented by a - // previous selection, but still update this selection's column - // to reflect that indentation. - if delta_for_start_row > 0 { - start_row += 1; - selection.start.column += delta_for_start_row; - if selection.end.row == selection.start.row { - selection.end.column += delta_for_start_row; - } - } - - let mut delta_for_end_row = 0; - let has_multiple_rows = start_row + 1 != end_row; - for row in start_row..end_row { - let current_indent = snapshot.indent_size_for_line(MultiBufferRow(row)); - let indent_delta = match (current_indent.kind, indent_kind) { - (IndentKind::Space, IndentKind::Space) => { - let columns_to_next_tab_stop = tab_size - (current_indent.len % tab_size); - IndentSize::spaces(columns_to_next_tab_stop) - } - (IndentKind::Tab, IndentKind::Space) => IndentSize::spaces(tab_size), - (_, IndentKind::Tab) => IndentSize::tab(), - }; - - let start = if has_multiple_rows || current_indent.len < selection.start.column { - 0 - } else { - selection.start.column - }; - let row_start = Point::new(row, start); - edits.push(( - row_start..row_start, - indent_delta.chars().collect::(), - )); - - // Update this selection's endpoints to reflect the indentation. - if row == selection.start.row { - selection.start.column += indent_delta.len; - } - if row == selection.end.row { - selection.end.column += indent_delta.len; - delta_for_end_row = indent_delta.len; - } - } - - if selection.start.row == selection.end.row { - delta_for_start_row + delta_for_end_row - } else { - delta_for_end_row - } - } - - pub fn outdent(&mut self, _: &Outdent, window: &mut Window, cx: &mut Context) { - if self.read_only(cx) { - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); - let mut deletion_ranges = Vec::new(); - let mut last_outdent = None; - { - let buffer = self.buffer.read(cx); - let snapshot = buffer.snapshot(cx); - for selection in &selections { - let settings = buffer.language_settings_at(selection.start, cx); - let tab_size = settings.tab_size.get(); - let mut rows = selection.spanned_rows(false, &display_map); - - // Avoid re-outdenting a row that has already been outdented by a - // previous selection. - if let Some(last_row) = last_outdent { - if last_row == rows.start { - rows.start = rows.start.next_row(); - } - } - let has_multiple_rows = rows.len() > 1; - for row in rows.iter_rows() { - let indent_size = snapshot.indent_size_for_line(row); - if indent_size.len > 0 { - let deletion_len = match indent_size.kind { - IndentKind::Space => { - let columns_to_prev_tab_stop = indent_size.len % tab_size; - if columns_to_prev_tab_stop == 0 { - tab_size - } else { - columns_to_prev_tab_stop - } - } - IndentKind::Tab => 1, - }; - let start = if has_multiple_rows - || deletion_len > selection.start.column - || indent_size.len < selection.start.column - { - 0 - } else { - selection.start.column - deletion_len - }; - deletion_ranges.push( - Point::new(row.0, start)..Point::new(row.0, start + deletion_len), - ); - last_outdent = Some(row); - } - } - } - } - - self.transact(window, cx, |this, window, cx| { - this.buffer.update(cx, |buffer, cx| { - let empty_str: Arc = Arc::default(); - buffer.edit( - deletion_ranges - .into_iter() - .map(|range| (range, empty_str.clone())), - None, - cx, - ); - }); - let selections = this.selections.all::(cx); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections) - }); - }); - } - - pub fn autoindent(&mut self, _: &AutoIndent, window: &mut Window, cx: &mut Context) { - if self.read_only(cx) { - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let selections = self - .selections - .all::(cx) - .into_iter() - .map(|s| s.range()); - - self.transact(window, cx, |this, window, cx| { - this.buffer.update(cx, |buffer, cx| { - buffer.autoindent_ranges(selections, cx); - }); - let selections = this.selections.all::(cx); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections) - }); - }); - } - - pub fn delete_line(&mut self, _: &DeleteLine, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); - - let mut new_cursors = Vec::new(); - let mut edit_ranges = Vec::new(); - let mut selections = selections.iter().peekable(); - while let Some(selection) = selections.next() { - let mut rows = selection.spanned_rows(false, &display_map); - let goal_display_column = selection.head().to_display_point(&display_map).column(); - - // Accumulate contiguous regions of rows that we want to delete. - while let Some(next_selection) = selections.peek() { - let next_rows = next_selection.spanned_rows(false, &display_map); - if next_rows.start <= rows.end { - rows.end = next_rows.end; - selections.next().unwrap(); - } else { - break; - } - } - - let buffer = &display_map.buffer_snapshot; - let mut edit_start = Point::new(rows.start.0, 0).to_offset(buffer); - let edit_end; - let cursor_buffer_row; - if buffer.max_point().row >= rows.end.0 { - // If there's a line after the range, delete the \n from the end of the row range - // and position the cursor on the next line. - edit_end = Point::new(rows.end.0, 0).to_offset(buffer); - cursor_buffer_row = rows.end; - } else { - // If there isn't a line after the range, delete the \n from the line before the - // start of the row range and position the cursor there. - edit_start = edit_start.saturating_sub(1); - edit_end = buffer.len(); - cursor_buffer_row = rows.start.previous_row(); - } - - let mut cursor = Point::new(cursor_buffer_row.0, 0).to_display_point(&display_map); - *cursor.column_mut() = - cmp::min(goal_display_column, display_map.line_len(cursor.row())); - - new_cursors.push(( - selection.id, - buffer.anchor_after(cursor.to_point(&display_map)), - )); - edit_ranges.push(edit_start..edit_end); - } - - self.transact(window, cx, |this, window, cx| { - let buffer = this.buffer.update(cx, |buffer, cx| { - let empty_str: Arc = Arc::default(); - buffer.edit( - edit_ranges - .into_iter() - .map(|range| (range, empty_str.clone())), - None, - cx, - ); - buffer.snapshot(cx) - }); - let new_selections = new_cursors - .into_iter() - .map(|(id, cursor)| { - let cursor = cursor.to_point(&buffer); - Selection { - id, - start: cursor, - end: cursor, - reversed: false, - goal: SelectionGoal::None, - } - }) - .collect(); - - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(new_selections); - }); - }); - } - - pub fn join_lines_impl( - &mut self, - insert_whitespace: bool, - window: &mut Window, - cx: &mut Context, - ) { - if self.read_only(cx) { - return; - } - let mut row_ranges = Vec::>::new(); - for selection in self.selections.all::(cx) { - let start = MultiBufferRow(selection.start.row); - // Treat single line selections as if they include the next line. Otherwise this action - // would do nothing for single line selections individual cursors. - let end = if selection.start.row == selection.end.row { - MultiBufferRow(selection.start.row + 1) - } else { - MultiBufferRow(selection.end.row) - }; - - if let Some(last_row_range) = row_ranges.last_mut() { - if start <= last_row_range.end { - last_row_range.end = end; - continue; - } - } - row_ranges.push(start..end); - } - - let snapshot = self.buffer.read(cx).snapshot(cx); - let mut cursor_positions = Vec::new(); - for row_range in &row_ranges { - let anchor = snapshot.anchor_before(Point::new( - row_range.end.previous_row().0, - snapshot.line_len(row_range.end.previous_row()), - )); - cursor_positions.push(anchor..anchor); - } - - self.transact(window, cx, |this, window, cx| { - for row_range in row_ranges.into_iter().rev() { - for row in row_range.iter_rows().rev() { - let end_of_line = Point::new(row.0, snapshot.line_len(row)); - let next_line_row = row.next_row(); - let indent = snapshot.indent_size_for_line(next_line_row); - let start_of_next_line = Point::new(next_line_row.0, indent.len); - - let replace = - if snapshot.line_len(next_line_row) > indent.len && insert_whitespace { - " " - } else { - "" - }; - - this.buffer.update(cx, |buffer, cx| { - buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx) - }); - } - } - - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_anchor_ranges(cursor_positions) - }); - }); - } - - pub fn join_lines(&mut self, _: &JoinLines, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.join_lines_impl(true, window, cx); - } - - pub fn sort_lines_case_sensitive( - &mut self, - _: &SortLinesCaseSensitive, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_lines(window, cx, |lines| lines.sort()) - } - - pub fn sort_lines_case_insensitive( - &mut self, - _: &SortLinesCaseInsensitive, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_lines(window, cx, |lines| { - lines.sort_by_key(|line| line.to_lowercase()) - }) - } - - pub fn unique_lines_case_insensitive( - &mut self, - _: &UniqueLinesCaseInsensitive, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_lines(window, cx, |lines| { - let mut seen = HashSet::default(); - lines.retain(|line| seen.insert(line.to_lowercase())); - }) - } - - pub fn unique_lines_case_sensitive( - &mut self, - _: &UniqueLinesCaseSensitive, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_lines(window, cx, |lines| { - let mut seen = HashSet::default(); - lines.retain(|line| seen.insert(*line)); - }) - } - - pub fn reload_file(&mut self, _: &ReloadFile, window: &mut Window, cx: &mut Context) { - let Some(project) = self.project.clone() else { - return; - }; - self.reload(project, window, cx) - .detach_and_notify_err(window, cx); - } - - pub fn restore_file( - &mut self, - _: &::git::RestoreFile, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let mut buffer_ids = HashSet::default(); - let snapshot = self.buffer().read(cx).snapshot(cx); - for selection in self.selections.all::(cx) { - buffer_ids.extend(snapshot.buffer_ids_for_range(selection.range())) - } - - let buffer = self.buffer().read(cx); - let ranges = buffer_ids - .into_iter() - .flat_map(|buffer_id| buffer.excerpt_ranges_for_buffer(buffer_id, cx)) - .collect::>(); - - self.restore_hunks_in_ranges(ranges, window, cx); - } - - pub fn git_restore(&mut self, _: &Restore, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let selections = self - .selections - .all(cx) - .into_iter() - .map(|s| s.range()) - .collect(); - self.restore_hunks_in_ranges(selections, window, cx); - } - - pub fn restore_hunks_in_ranges( - &mut self, - ranges: Vec>, - window: &mut Window, - cx: &mut Context, - ) { - let mut revert_changes = HashMap::default(); - let chunk_by = self - .snapshot(window, cx) - .hunks_for_ranges(ranges) - .into_iter() - .chunk_by(|hunk| hunk.buffer_id); - for (buffer_id, hunks) in &chunk_by { - let hunks = hunks.collect::>(); - for hunk in &hunks { - self.prepare_restore_change(&mut revert_changes, hunk, cx); - } - self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx); - } - drop(chunk_by); - if !revert_changes.is_empty() { - self.transact(window, cx, |editor, window, cx| { - editor.restore(revert_changes, window, cx); - }); - } - } - - pub fn open_active_item_in_terminal( - &mut self, - _: &OpenInTerminal, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(working_directory) = self.active_excerpt(cx).and_then(|(_, buffer, _)| { - let project_path = buffer.read(cx).project_path(cx)?; - let project = self.project.as_ref()?.read(cx); - let entry = project.entry_for_path(&project_path, cx)?; - let parent = match &entry.canonical_path { - Some(canonical_path) => canonical_path.to_path_buf(), - None => project.absolute_path(&project_path, cx)?, - } - .parent()? - .to_path_buf(); - Some(parent) - }) { - window.dispatch_action(OpenTerminal { working_directory }.boxed_clone(), cx); - } - } - - fn set_breakpoint_context_menu( - &mut self, - display_row: DisplayRow, - position: Option, - clicked_point: gpui::Point, - window: &mut Window, - cx: &mut Context, - ) { - if !cx.has_flag::() { - return; - } - let source = self - .buffer - .read(cx) - .snapshot(cx) - .anchor_before(Point::new(display_row.0, 0u32)); - - let context_menu = self.breakpoint_context_menu(position.unwrap_or(source), window, cx); - - self.mouse_context_menu = MouseContextMenu::pinned_to_editor( - self, - source, - clicked_point, - context_menu, - window, - cx, - ); - } - - fn add_edit_breakpoint_block( - &mut self, - anchor: Anchor, - breakpoint: &Breakpoint, - edit_action: BreakpointPromptEditAction, - window: &mut Window, - cx: &mut Context, - ) { - let weak_editor = cx.weak_entity(); - let bp_prompt = cx.new(|cx| { - BreakpointPromptEditor::new( - weak_editor, - anchor, - breakpoint.clone(), - edit_action, - window, - cx, - ) - }); - - let height = bp_prompt.update(cx, |this, cx| { - this.prompt - .update(cx, |prompt, cx| prompt.max_point(cx).row().0 + 1 + 2) - }); - let cloned_prompt = bp_prompt.clone(); - let blocks = vec![BlockProperties { - style: BlockStyle::Sticky, - placement: BlockPlacement::Above(anchor), - height: Some(height), - render: Arc::new(move |cx| { - *cloned_prompt.read(cx).gutter_dimensions.lock() = *cx.gutter_dimensions; - cloned_prompt.clone().into_any_element() - }), - priority: 0, - }]; - - let focus_handle = bp_prompt.focus_handle(cx); - window.focus(&focus_handle); - - let block_ids = self.insert_blocks(blocks, None, cx); - bp_prompt.update(cx, |prompt, _| { - prompt.add_block_ids(block_ids); - }); - } - - pub(crate) fn breakpoint_at_row( - &self, - row: u32, - window: &mut Window, - cx: &mut Context, - ) -> Option<(Anchor, Breakpoint)> { - let snapshot = self.snapshot(window, cx); - let breakpoint_position = snapshot.buffer_snapshot.anchor_before(Point::new(row, 0)); - - self.breakpoint_at_anchor(breakpoint_position, &snapshot, cx) - } - - pub(crate) fn breakpoint_at_anchor( - &self, - breakpoint_position: Anchor, - snapshot: &EditorSnapshot, - cx: &mut Context, - ) -> Option<(Anchor, Breakpoint)> { - let project = self.project.clone()?; - - let buffer_id = breakpoint_position.buffer_id.or_else(|| { - snapshot - .buffer_snapshot - .buffer_id_for_excerpt(breakpoint_position.excerpt_id) - })?; - - let enclosing_excerpt = breakpoint_position.excerpt_id; - let buffer = project.read_with(cx, |project, cx| project.buffer_for_id(buffer_id, cx))?; - let buffer_snapshot = buffer.read(cx).snapshot(); - - let row = buffer_snapshot - .summary_for_anchor::(&breakpoint_position.text_anchor) - .row; - - let line_len = snapshot.buffer_snapshot.line_len(MultiBufferRow(row)); - let anchor_end = snapshot - .buffer_snapshot - .anchor_after(Point::new(row, line_len)); - - let bp = self - .breakpoint_store - .as_ref()? - .read_with(cx, |breakpoint_store, cx| { - breakpoint_store - .breakpoints( - &buffer, - Some(breakpoint_position.text_anchor..anchor_end.text_anchor), - &buffer_snapshot, - cx, - ) - .next() - .and_then(|(anchor, bp)| { - let breakpoint_row = buffer_snapshot - .summary_for_anchor::(anchor) - .row; - - if breakpoint_row == row { - snapshot - .buffer_snapshot - .anchor_in_excerpt(enclosing_excerpt, *anchor) - .map(|anchor| (anchor, bp.clone())) - } else { - None - } - }) - }); - bp - } - - pub fn edit_log_breakpoint( - &mut self, - _: &EditLogBreakpoint, - window: &mut Window, - cx: &mut Context, - ) { - for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { - let breakpoint = breakpoint.unwrap_or_else(|| Breakpoint { - message: None, - state: BreakpointState::Enabled, - condition: None, - hit_condition: None, - }); - - self.add_edit_breakpoint_block( - anchor, - &breakpoint, - BreakpointPromptEditAction::Log, - window, - cx, - ); - } - } - - fn breakpoints_at_cursors( - &self, - window: &mut Window, - cx: &mut Context, - ) -> Vec<(Anchor, Option)> { - let snapshot = self.snapshot(window, cx); - let cursors = self - .selections - .disjoint_anchors() - .into_iter() - .map(|selection| { - let cursor_position: Point = selection.head().to_point(&snapshot.buffer_snapshot); - - let breakpoint_position = self - .breakpoint_at_row(cursor_position.row, window, cx) - .map(|bp| bp.0) - .unwrap_or_else(|| { - snapshot - .display_snapshot - .buffer_snapshot - .anchor_after(Point::new(cursor_position.row, 0)) - }); - - let breakpoint = self - .breakpoint_at_anchor(breakpoint_position, &snapshot, cx) - .map(|(anchor, breakpoint)| (anchor, Some(breakpoint))); - - breakpoint.unwrap_or_else(|| (breakpoint_position, None)) - }) - // There might be multiple cursors on the same line; all of them should have the same anchors though as their breakpoints positions, which makes it possible to sort and dedup the list. - .collect::>(); - - cursors.into_iter().collect() - } - - pub fn enable_breakpoint( - &mut self, - _: &crate::actions::EnableBreakpoint, - window: &mut Window, - cx: &mut Context, - ) { - for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { - let Some(breakpoint) = breakpoint.filter(|breakpoint| breakpoint.is_disabled()) else { - continue; - }; - self.edit_breakpoint_at_anchor( - anchor, - breakpoint, - BreakpointEditAction::InvertState, - cx, - ); - } - } - - pub fn disable_breakpoint( - &mut self, - _: &crate::actions::DisableBreakpoint, - window: &mut Window, - cx: &mut Context, - ) { - for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { - let Some(breakpoint) = breakpoint.filter(|breakpoint| breakpoint.is_enabled()) else { - continue; - }; - self.edit_breakpoint_at_anchor( - anchor, - breakpoint, - BreakpointEditAction::InvertState, - cx, - ); - } - } - - pub fn toggle_breakpoint( - &mut self, - _: &crate::actions::ToggleBreakpoint, - window: &mut Window, - cx: &mut Context, - ) { - for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { - if let Some(breakpoint) = breakpoint { - self.edit_breakpoint_at_anchor( - anchor, - breakpoint, - BreakpointEditAction::Toggle, - cx, - ); - } else { - self.edit_breakpoint_at_anchor( - anchor, - Breakpoint::new_standard(), - BreakpointEditAction::Toggle, - cx, - ); - } - } - } - - pub fn edit_breakpoint_at_anchor( - &mut self, - breakpoint_position: Anchor, - breakpoint: Breakpoint, - edit_action: BreakpointEditAction, - cx: &mut Context, - ) { - let Some(breakpoint_store) = &self.breakpoint_store else { - return; - }; - - let Some(buffer_id) = breakpoint_position.buffer_id.or_else(|| { - if breakpoint_position == Anchor::min() { - self.buffer() - .read(cx) - .excerpt_buffer_ids() - .into_iter() - .next() - } else { - None - } - }) else { - return; - }; - - let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else { - return; - }; - - breakpoint_store.update(cx, |breakpoint_store, cx| { - breakpoint_store.toggle_breakpoint( - buffer, - (breakpoint_position.text_anchor, breakpoint), - edit_action, - cx, - ); - }); - - cx.notify(); - } - - #[cfg(any(test, feature = "test-support"))] - pub fn breakpoint_store(&self) -> Option> { - self.breakpoint_store.clone() - } - - pub fn prepare_restore_change( - &self, - revert_changes: &mut HashMap, Rope)>>, - hunk: &MultiBufferDiffHunk, - cx: &mut App, - ) -> Option<()> { - if hunk.is_created_file() { - return None; - } - let buffer = self.buffer.read(cx); - let diff = buffer.diff_for(hunk.buffer_id)?; - let buffer = buffer.buffer(hunk.buffer_id)?; - let buffer = buffer.read(cx); - let original_text = diff - .read(cx) - .base_text() - .as_rope() - .slice(hunk.diff_base_byte_range.clone()); - let buffer_snapshot = buffer.snapshot(); - let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); - if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { - probe - .0 - .start - .cmp(&hunk.buffer_range.start, &buffer_snapshot) - .then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot)) - }) { - buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), original_text)); - Some(()) - } else { - None - } - } - - pub fn reverse_lines(&mut self, _: &ReverseLines, window: &mut Window, cx: &mut Context) { - self.manipulate_lines(window, cx, |lines| lines.reverse()) - } - - pub fn shuffle_lines(&mut self, _: &ShuffleLines, window: &mut Window, cx: &mut Context) { - self.manipulate_lines(window, cx, |lines| lines.shuffle(&mut thread_rng())) - } - - fn manipulate_lines( - &mut self, - window: &mut Window, - cx: &mut Context, - mut callback: Fn, - ) where - Fn: FnMut(&mut Vec<&str>), - { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = self.buffer.read(cx).snapshot(cx); - - let mut edits = Vec::new(); - - let selections = self.selections.all::(cx); - let mut selections = selections.iter().peekable(); - let mut contiguous_row_selections = Vec::new(); - let mut new_selections = Vec::new(); - let mut added_lines = 0; - let mut removed_lines = 0; - - while let Some(selection) = selections.next() { - let (start_row, end_row) = consume_contiguous_rows( - &mut contiguous_row_selections, - selection, - &display_map, - &mut selections, - ); - - let start_point = Point::new(start_row.0, 0); - let end_point = Point::new( - end_row.previous_row().0, - buffer.line_len(end_row.previous_row()), - ); - let text = buffer - .text_for_range(start_point..end_point) - .collect::(); - - let mut lines = text.split('\n').collect_vec(); - - let lines_before = lines.len(); - callback(&mut lines); - let lines_after = lines.len(); - - edits.push((start_point..end_point, lines.join("\n"))); - - // Selections must change based on added and removed line count - let start_row = - MultiBufferRow(start_point.row + added_lines as u32 - removed_lines as u32); - let end_row = MultiBufferRow(start_row.0 + lines_after.saturating_sub(1) as u32); - new_selections.push(Selection { - id: selection.id, - start: start_row, - end: end_row, - goal: SelectionGoal::None, - reversed: selection.reversed, - }); - - if lines_after > lines_before { - added_lines += lines_after - lines_before; - } else if lines_before > lines_after { - removed_lines += lines_before - lines_after; - } - } - - self.transact(window, cx, |this, window, cx| { - let buffer = this.buffer.update(cx, |buffer, cx| { - buffer.edit(edits, None, cx); - buffer.snapshot(cx) - }); - - // Recalculate offsets on newly edited buffer - let new_selections = new_selections - .iter() - .map(|s| { - let start_point = Point::new(s.start.0, 0); - let end_point = Point::new(s.end.0, buffer.line_len(s.end)); - Selection { - id: s.id, - start: buffer.point_to_offset(start_point), - end: buffer.point_to_offset(end_point), - goal: s.goal, - reversed: s.reversed, - } - }) - .collect(); - - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(new_selections); - }); - - this.request_autoscroll(Autoscroll::fit(), cx); - }); - } - - pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context) { - self.manipulate_text(window, cx, |text| { - let has_upper_case_characters = text.chars().any(|c| c.is_uppercase()); - if has_upper_case_characters { - text.to_lowercase() - } else { - text.to_uppercase() - } - }) - } - - pub fn convert_to_upper_case( - &mut self, - _: &ConvertToUpperCase, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_text(window, cx, |text| text.to_uppercase()) - } - - pub fn convert_to_lower_case( - &mut self, - _: &ConvertToLowerCase, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_text(window, cx, |text| text.to_lowercase()) - } - - pub fn convert_to_title_case( - &mut self, - _: &ConvertToTitleCase, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_text(window, cx, |text| { - text.split('\n') - .map(|line| line.to_case(Case::Title)) - .join("\n") - }) - } - - pub fn convert_to_snake_case( - &mut self, - _: &ConvertToSnakeCase, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Snake)) - } - - pub fn convert_to_kebab_case( - &mut self, - _: &ConvertToKebabCase, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Kebab)) - } - - pub fn convert_to_upper_camel_case( - &mut self, - _: &ConvertToUpperCamelCase, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_text(window, cx, |text| { - text.split('\n') - .map(|line| line.to_case(Case::UpperCamel)) - .join("\n") - }) - } - - pub fn convert_to_lower_camel_case( - &mut self, - _: &ConvertToLowerCamelCase, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Camel)) - } - - pub fn convert_to_opposite_case( - &mut self, - _: &ConvertToOppositeCase, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_text(window, cx, |text| { - text.chars() - .fold(String::with_capacity(text.len()), |mut t, c| { - if c.is_uppercase() { - t.extend(c.to_lowercase()); - } else { - t.extend(c.to_uppercase()); - } - t - }) - }) - } - - pub fn convert_to_rot13( - &mut self, - _: &ConvertToRot13, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_text(window, cx, |text| { - text.chars() - .map(|c| match c { - 'A'..='M' | 'a'..='m' => ((c as u8) + 13) as char, - 'N'..='Z' | 'n'..='z' => ((c as u8) - 13) as char, - _ => c, - }) - .collect() - }) - } - - pub fn convert_to_rot47( - &mut self, - _: &ConvertToRot47, - window: &mut Window, - cx: &mut Context, - ) { - self.manipulate_text(window, cx, |text| { - text.chars() - .map(|c| { - let code_point = c as u32; - if code_point >= 33 && code_point <= 126 { - return char::from_u32(33 + ((code_point + 14) % 94)).unwrap(); - } - c - }) - .collect() - }) - } - - fn manipulate_text(&mut self, window: &mut Window, cx: &mut Context, mut callback: Fn) - where - Fn: FnMut(&str) -> String, - { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = self.buffer.read(cx).snapshot(cx); - - let mut new_selections = Vec::new(); - let mut edits = Vec::new(); - let mut selection_adjustment = 0i32; - - for selection in self.selections.all::(cx) { - let selection_is_empty = selection.is_empty(); - - let (start, end) = if selection_is_empty { - let word_range = movement::surrounding_word( - &display_map, - selection.start.to_display_point(&display_map), - ); - let start = word_range.start.to_offset(&display_map, Bias::Left); - let end = word_range.end.to_offset(&display_map, Bias::Left); - (start, end) - } else { - (selection.start, selection.end) - }; - - let text = buffer.text_for_range(start..end).collect::(); - let old_length = text.len() as i32; - let text = callback(&text); - - new_selections.push(Selection { - start: (start as i32 - selection_adjustment) as usize, - end: ((start + text.len()) as i32 - selection_adjustment) as usize, - goal: SelectionGoal::None, - ..selection - }); - - selection_adjustment += old_length - text.len() as i32; - - edits.push((start..end, text)); - } - - self.transact(window, cx, |this, window, cx| { - this.buffer.update(cx, |buffer, cx| { - buffer.edit(edits, None, cx); - }); - - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(new_selections); - }); - - this.request_autoscroll(Autoscroll::fit(), cx); - }); - } - - pub fn duplicate( - &mut self, - upwards: bool, - whole_lines: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; - let selections = self.selections.all::(cx); - - let mut edits = Vec::new(); - let mut selections_iter = selections.iter().peekable(); - while let Some(selection) = selections_iter.next() { - let mut rows = selection.spanned_rows(false, &display_map); - // duplicate line-wise - if whole_lines || selection.start == selection.end { - // Avoid duplicating the same lines twice. - while let Some(next_selection) = selections_iter.peek() { - let next_rows = next_selection.spanned_rows(false, &display_map); - if next_rows.start < rows.end { - rows.end = next_rows.end; - selections_iter.next().unwrap(); - } else { - break; - } - } - - // Copy the text from the selected row region and splice it either at the start - // or end of the region. - let start = Point::new(rows.start.0, 0); - let end = Point::new( - rows.end.previous_row().0, - buffer.line_len(rows.end.previous_row()), - ); - let text = buffer - .text_for_range(start..end) - .chain(Some("\n")) - .collect::(); - let insert_location = if upwards { - Point::new(rows.end.0, 0) - } else { - start - }; - edits.push((insert_location..insert_location, text)); - } else { - // duplicate character-wise - let start = selection.start; - let end = selection.end; - let text = buffer.text_for_range(start..end).collect::(); - edits.push((selection.end..selection.end, text)); - } - } - - self.transact(window, cx, |this, _, cx| { - this.buffer.update(cx, |buffer, cx| { - buffer.edit(edits, None, cx); - }); - - this.request_autoscroll(Autoscroll::fit(), cx); - }); - } - - pub fn duplicate_line_up( - &mut self, - _: &DuplicateLineUp, - window: &mut Window, - cx: &mut Context, - ) { - self.duplicate(true, true, window, cx); - } - - pub fn duplicate_line_down( - &mut self, - _: &DuplicateLineDown, - window: &mut Window, - cx: &mut Context, - ) { - self.duplicate(false, true, window, cx); - } - - pub fn duplicate_selection( - &mut self, - _: &DuplicateSelection, - window: &mut Window, - cx: &mut Context, - ) { - self.duplicate(false, false, window, cx); - } - - pub fn move_line_up(&mut self, _: &MoveLineUp, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = self.buffer.read(cx).snapshot(cx); - - let mut edits = Vec::new(); - let mut unfold_ranges = Vec::new(); - let mut refold_creases = Vec::new(); - - let selections = self.selections.all::(cx); - let mut selections = selections.iter().peekable(); - let mut contiguous_row_selections = Vec::new(); - let mut new_selections = Vec::new(); - - while let Some(selection) = selections.next() { - // Find all the selections that span a contiguous row range - let (start_row, end_row) = consume_contiguous_rows( - &mut contiguous_row_selections, - selection, - &display_map, - &mut selections, - ); - - // Move the text spanned by the row range to be before the line preceding the row range - if start_row.0 > 0 { - let range_to_move = Point::new( - start_row.previous_row().0, - buffer.line_len(start_row.previous_row()), - ) - ..Point::new( - end_row.previous_row().0, - buffer.line_len(end_row.previous_row()), - ); - let insertion_point = display_map - .prev_line_boundary(Point::new(start_row.previous_row().0, 0)) - .0; - - // Don't move lines across excerpts - if buffer - .excerpt_containing(insertion_point..range_to_move.end) - .is_some() - { - let text = buffer - .text_for_range(range_to_move.clone()) - .flat_map(|s| s.chars()) - .skip(1) - .chain(['\n']) - .collect::(); - - edits.push(( - buffer.anchor_after(range_to_move.start) - ..buffer.anchor_before(range_to_move.end), - String::new(), - )); - let insertion_anchor = buffer.anchor_after(insertion_point); - edits.push((insertion_anchor..insertion_anchor, text)); - - let row_delta = range_to_move.start.row - insertion_point.row + 1; - - // Move selections up - new_selections.extend(contiguous_row_selections.drain(..).map( - |mut selection| { - selection.start.row -= row_delta; - selection.end.row -= row_delta; - selection - }, - )); - - // Move folds up - unfold_ranges.push(range_to_move.clone()); - for fold in display_map.folds_in_range( - buffer.anchor_before(range_to_move.start) - ..buffer.anchor_after(range_to_move.end), - ) { - let mut start = fold.range.start.to_point(&buffer); - let mut end = fold.range.end.to_point(&buffer); - start.row -= row_delta; - end.row -= row_delta; - refold_creases.push(Crease::simple(start..end, fold.placeholder.clone())); - } - } - } - - // If we didn't move line(s), preserve the existing selections - new_selections.append(&mut contiguous_row_selections); - } - - self.transact(window, cx, |this, window, cx| { - this.unfold_ranges(&unfold_ranges, true, true, cx); - this.buffer.update(cx, |buffer, cx| { - for (range, text) in edits { - buffer.edit([(range, text)], None, cx); - } - }); - this.fold_creases(refold_creases, true, window, cx); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(new_selections); - }) - }); - } - - pub fn move_line_down( - &mut self, - _: &MoveLineDown, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = self.buffer.read(cx).snapshot(cx); - - let mut edits = Vec::new(); - let mut unfold_ranges = Vec::new(); - let mut refold_creases = Vec::new(); - - let selections = self.selections.all::(cx); - let mut selections = selections.iter().peekable(); - let mut contiguous_row_selections = Vec::new(); - let mut new_selections = Vec::new(); - - while let Some(selection) = selections.next() { - // Find all the selections that span a contiguous row range - let (start_row, end_row) = consume_contiguous_rows( - &mut contiguous_row_selections, - selection, - &display_map, - &mut selections, - ); - - // Move the text spanned by the row range to be after the last line of the row range - if end_row.0 <= buffer.max_point().row { - let range_to_move = - MultiBufferPoint::new(start_row.0, 0)..MultiBufferPoint::new(end_row.0, 0); - let insertion_point = display_map - .next_line_boundary(MultiBufferPoint::new(end_row.0, 0)) - .0; - - // Don't move lines across excerpt boundaries - if buffer - .excerpt_containing(range_to_move.start..insertion_point) - .is_some() - { - let mut text = String::from("\n"); - text.extend(buffer.text_for_range(range_to_move.clone())); - text.pop(); // Drop trailing newline - edits.push(( - buffer.anchor_after(range_to_move.start) - ..buffer.anchor_before(range_to_move.end), - String::new(), - )); - let insertion_anchor = buffer.anchor_after(insertion_point); - edits.push((insertion_anchor..insertion_anchor, text)); - - let row_delta = insertion_point.row - range_to_move.end.row + 1; - - // Move selections down - new_selections.extend(contiguous_row_selections.drain(..).map( - |mut selection| { - selection.start.row += row_delta; - selection.end.row += row_delta; - selection - }, - )); - - // Move folds down - unfold_ranges.push(range_to_move.clone()); - for fold in display_map.folds_in_range( - buffer.anchor_before(range_to_move.start) - ..buffer.anchor_after(range_to_move.end), - ) { - let mut start = fold.range.start.to_point(&buffer); - let mut end = fold.range.end.to_point(&buffer); - start.row += row_delta; - end.row += row_delta; - refold_creases.push(Crease::simple(start..end, fold.placeholder.clone())); - } - } - } - - // If we didn't move line(s), preserve the existing selections - new_selections.append(&mut contiguous_row_selections); - } - - self.transact(window, cx, |this, window, cx| { - this.unfold_ranges(&unfold_ranges, true, true, cx); - this.buffer.update(cx, |buffer, cx| { - for (range, text) in edits { - buffer.edit([(range, text)], None, cx); - } - }); - this.fold_creases(refold_creases, true, window, cx); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(new_selections) - }); - }); - } - - pub fn transpose(&mut self, _: &Transpose, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let text_layout_details = &self.text_layout_details(window); - self.transact(window, cx, |this, window, cx| { - let edits = this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - let mut edits: Vec<(Range, String)> = Default::default(); - s.move_with(|display_map, selection| { - if !selection.is_empty() { - return; - } - - let mut head = selection.head(); - let mut transpose_offset = head.to_offset(display_map, Bias::Right); - if head.column() == display_map.line_len(head.row()) { - transpose_offset = display_map - .buffer_snapshot - .clip_offset(transpose_offset.saturating_sub(1), Bias::Left); - } - - if transpose_offset == 0 { - return; - } - - *head.column_mut() += 1; - head = display_map.clip_point(head, Bias::Right); - let goal = SelectionGoal::HorizontalPosition( - display_map - .x_for_display_point(head, text_layout_details) - .into(), - ); - selection.collapse_to(head, goal); - - let transpose_start = display_map - .buffer_snapshot - .clip_offset(transpose_offset.saturating_sub(1), Bias::Left); - if edits.last().map_or(true, |e| e.0.end <= transpose_start) { - let transpose_end = display_map - .buffer_snapshot - .clip_offset(transpose_offset + 1, Bias::Right); - if let Some(ch) = - display_map.buffer_snapshot.chars_at(transpose_start).next() - { - edits.push((transpose_start..transpose_offset, String::new())); - edits.push((transpose_end..transpose_end, ch.to_string())); - } - } - }); - edits - }); - this.buffer - .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); - let selections = this.selections.all::(cx); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections); - }); - }); - } - - pub fn rewrap(&mut self, _: &Rewrap, _: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.rewrap_impl(RewrapOptions::default(), cx) - } - - pub fn rewrap_impl(&mut self, options: RewrapOptions, cx: &mut Context) { - let buffer = self.buffer.read(cx).snapshot(cx); - let selections = self.selections.all::(cx); - let mut selections = selections.iter().peekable(); - - let mut edits = Vec::new(); - let mut rewrapped_row_ranges = Vec::>::new(); - - while let Some(selection) = selections.next() { - let mut start_row = selection.start.row; - let mut end_row = selection.end.row; - - // Skip selections that overlap with a range that has already been rewrapped. - let selection_range = start_row..end_row; - if rewrapped_row_ranges - .iter() - .any(|range| range.overlaps(&selection_range)) - { - continue; - } - - let tab_size = buffer.language_settings_at(selection.head(), cx).tab_size; - - // Since not all lines in the selection may be at the same indent - // level, choose the indent size that is the most common between all - // of the lines. - // - // If there is a tie, we use the deepest indent. - let (indent_size, indent_end) = { - let mut indent_size_occurrences = HashMap::default(); - let mut rows_by_indent_size = HashMap::>::default(); - - for row in start_row..=end_row { - let indent = buffer.indent_size_for_line(MultiBufferRow(row)); - rows_by_indent_size.entry(indent).or_default().push(row); - *indent_size_occurrences.entry(indent).or_insert(0) += 1; - } - - let indent_size = indent_size_occurrences - .into_iter() - .max_by_key(|(indent, count)| (*count, indent.len_with_expanded_tabs(tab_size))) - .map(|(indent, _)| indent) - .unwrap_or_default(); - let row = rows_by_indent_size[&indent_size][0]; - let indent_end = Point::new(row, indent_size.len); - - (indent_size, indent_end) - }; - - let mut line_prefix = indent_size.chars().collect::(); - - let mut inside_comment = false; - if let Some(comment_prefix) = - buffer - .language_scope_at(selection.head()) - .and_then(|language| { - language - .line_comment_prefixes() - .iter() - .find(|prefix| buffer.contains_str_at(indent_end, prefix)) - .cloned() - }) - { - line_prefix.push_str(&comment_prefix); - inside_comment = true; - } - - let language_settings = buffer.language_settings_at(selection.head(), cx); - let allow_rewrap_based_on_language = match language_settings.allow_rewrap { - RewrapBehavior::InComments => inside_comment, - RewrapBehavior::InSelections => !selection.is_empty(), - RewrapBehavior::Anywhere => true, - }; - - let should_rewrap = options.override_language_settings - || allow_rewrap_based_on_language - || self.hard_wrap.is_some(); - if !should_rewrap { - continue; - } - - if selection.is_empty() { - 'expand_upwards: while start_row > 0 { - let prev_row = start_row - 1; - if buffer.contains_str_at(Point::new(prev_row, 0), &line_prefix) - && buffer.line_len(MultiBufferRow(prev_row)) as usize > line_prefix.len() - { - start_row = prev_row; - } else { - break 'expand_upwards; - } - } - - 'expand_downwards: while end_row < buffer.max_point().row { - let next_row = end_row + 1; - if buffer.contains_str_at(Point::new(next_row, 0), &line_prefix) - && buffer.line_len(MultiBufferRow(next_row)) as usize > line_prefix.len() - { - end_row = next_row; - } else { - break 'expand_downwards; - } - } - } - - let start = Point::new(start_row, 0); - let start_offset = start.to_offset(&buffer); - let end = Point::new(end_row, buffer.line_len(MultiBufferRow(end_row))); - let selection_text = buffer.text_for_range(start..end).collect::(); - let Some(lines_without_prefixes) = selection_text - .lines() - .map(|line| { - line.strip_prefix(&line_prefix) - .or_else(|| line.trim_start().strip_prefix(&line_prefix.trim_start())) - .with_context(|| { - format!("line did not start with prefix {line_prefix:?}: {line:?}") - }) - }) - .collect::, _>>() - .log_err() - else { - continue; - }; - - let wrap_column = self.hard_wrap.unwrap_or_else(|| { - buffer - .language_settings_at(Point::new(start_row, 0), cx) - .preferred_line_length as usize - }); - let wrapped_text = wrap_with_prefix( - line_prefix, - lines_without_prefixes.join("\n"), - wrap_column, - tab_size, - options.preserve_existing_whitespace, - ); - - // TODO: should always use char-based diff while still supporting cursor behavior that - // matches vim. - let mut diff_options = DiffOptions::default(); - if options.override_language_settings { - diff_options.max_word_diff_len = 0; - diff_options.max_word_diff_line_count = 0; - } else { - diff_options.max_word_diff_len = usize::MAX; - diff_options.max_word_diff_line_count = usize::MAX; - } - - for (old_range, new_text) in - text_diff_with_options(&selection_text, &wrapped_text, diff_options) - { - let edit_start = buffer.anchor_after(start_offset + old_range.start); - let edit_end = buffer.anchor_after(start_offset + old_range.end); - edits.push((edit_start..edit_end, new_text)); - } - - rewrapped_row_ranges.push(start_row..=end_row); - } - - self.buffer - .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); - } - - pub fn cut_common(&mut self, window: &mut Window, cx: &mut Context) -> ClipboardItem { - let mut text = String::new(); - let buffer = self.buffer.read(cx).snapshot(cx); - let mut selections = self.selections.all::(cx); - let mut clipboard_selections = Vec::with_capacity(selections.len()); - { - let max_point = buffer.max_point(); - let mut is_first = true; - for selection in &mut selections { - let is_entire_line = selection.is_empty() || self.selections.line_mode; - if is_entire_line { - selection.start = Point::new(selection.start.row, 0); - if !selection.is_empty() && selection.end.column == 0 { - selection.end = cmp::min(max_point, selection.end); - } else { - selection.end = cmp::min(max_point, Point::new(selection.end.row + 1, 0)); - } - selection.goal = SelectionGoal::None; - } - if is_first { - is_first = false; - } else { - text += "\n"; - } - let mut len = 0; - for chunk in buffer.text_for_range(selection.start..selection.end) { - text.push_str(chunk); - len += chunk.len(); - } - clipboard_selections.push(ClipboardSelection { - len, - is_entire_line, - first_line_indent: buffer - .indent_size_for_line(MultiBufferRow(selection.start.row)) - .len, - }); - } - } - - self.transact(window, cx, |this, window, cx| { - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections); - }); - this.insert("", window, cx); - }); - ClipboardItem::new_string_with_json_metadata(text, clipboard_selections) - } - - pub fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let item = self.cut_common(window, cx); - cx.write_to_clipboard(item); - } - - pub fn kill_ring_cut(&mut self, _: &KillRingCut, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.change_selections(None, window, cx, |s| { - s.move_with(|snapshot, sel| { - if sel.is_empty() { - sel.end = DisplayPoint::new(sel.end.row(), snapshot.line_len(sel.end.row())) - } - }); - }); - let item = self.cut_common(window, cx); - cx.set_global(KillRing(item)) - } - - pub fn kill_ring_yank( - &mut self, - _: &KillRingYank, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let (text, metadata) = if let Some(KillRing(item)) = cx.try_global() { - if let Some(ClipboardEntry::String(kill_ring)) = item.entries().first() { - (kill_ring.text().to_string(), kill_ring.metadata_json()) - } else { - return; - } - } else { - return; - }; - self.do_paste(&text, metadata, false, window, cx); - } - - pub fn copy_and_trim(&mut self, _: &CopyAndTrim, _: &mut Window, cx: &mut Context) { - self.do_copy(true, cx); - } - - pub fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - self.do_copy(false, cx); - } - - fn do_copy(&self, strip_leading_indents: bool, cx: &mut Context) { - let selections = self.selections.all::(cx); - let buffer = self.buffer.read(cx).read(cx); - let mut text = String::new(); - - let mut clipboard_selections = Vec::with_capacity(selections.len()); - { - let max_point = buffer.max_point(); - let mut is_first = true; - for selection in &selections { - let mut start = selection.start; - let mut end = selection.end; - let is_entire_line = selection.is_empty() || self.selections.line_mode; - if is_entire_line { - start = Point::new(start.row, 0); - end = cmp::min(max_point, Point::new(end.row + 1, 0)); - } - - let mut trimmed_selections = Vec::new(); - if strip_leading_indents && end.row.saturating_sub(start.row) > 0 { - let row = MultiBufferRow(start.row); - let first_indent = buffer.indent_size_for_line(row); - if first_indent.len == 0 || start.column > first_indent.len { - trimmed_selections.push(start..end); - } else { - trimmed_selections.push( - Point::new(row.0, first_indent.len) - ..Point::new(row.0, buffer.line_len(row)), - ); - for row in start.row + 1..=end.row { - let mut line_len = buffer.line_len(MultiBufferRow(row)); - if row == end.row { - line_len = end.column; - } - if line_len == 0 { - trimmed_selections - .push(Point::new(row, 0)..Point::new(row, line_len)); - continue; - } - let row_indent_size = buffer.indent_size_for_line(MultiBufferRow(row)); - if row_indent_size.len >= first_indent.len { - trimmed_selections.push( - Point::new(row, first_indent.len)..Point::new(row, line_len), - ); - } else { - trimmed_selections.clear(); - trimmed_selections.push(start..end); - break; - } - } - } - } else { - trimmed_selections.push(start..end); - } - - for trimmed_range in trimmed_selections { - if is_first { - is_first = false; - } else { - text += "\n"; - } - let mut len = 0; - for chunk in buffer.text_for_range(trimmed_range.start..trimmed_range.end) { - text.push_str(chunk); - len += chunk.len(); - } - clipboard_selections.push(ClipboardSelection { - len, - is_entire_line, - first_line_indent: buffer - .indent_size_for_line(MultiBufferRow(trimmed_range.start.row)) - .len, - }); - } - } - } - - cx.write_to_clipboard(ClipboardItem::new_string_with_json_metadata( - text, - clipboard_selections, - )); - } - - pub fn do_paste( - &mut self, - text: &String, - clipboard_selections: Option>, - handle_entire_lines: bool, - window: &mut Window, - cx: &mut Context, - ) { - if self.read_only(cx) { - return; - } - - let clipboard_text = Cow::Borrowed(text); - - self.transact(window, cx, |this, window, cx| { - if let Some(mut clipboard_selections) = clipboard_selections { - let old_selections = this.selections.all::(cx); - let all_selections_were_entire_line = - clipboard_selections.iter().all(|s| s.is_entire_line); - let first_selection_indent_column = - clipboard_selections.first().map(|s| s.first_line_indent); - if clipboard_selections.len() != old_selections.len() { - clipboard_selections.drain(..); - } - let cursor_offset = this.selections.last::(cx).head(); - let mut auto_indent_on_paste = true; - - this.buffer.update(cx, |buffer, cx| { - let snapshot = buffer.read(cx); - auto_indent_on_paste = snapshot - .language_settings_at(cursor_offset, cx) - .auto_indent_on_paste; - - let mut start_offset = 0; - let mut edits = Vec::new(); - let mut original_indent_columns = Vec::new(); - for (ix, selection) in old_selections.iter().enumerate() { - let to_insert; - let entire_line; - let original_indent_column; - if let Some(clipboard_selection) = clipboard_selections.get(ix) { - let end_offset = start_offset + clipboard_selection.len; - to_insert = &clipboard_text[start_offset..end_offset]; - entire_line = clipboard_selection.is_entire_line; - start_offset = end_offset + 1; - original_indent_column = Some(clipboard_selection.first_line_indent); - } else { - to_insert = clipboard_text.as_str(); - entire_line = all_selections_were_entire_line; - original_indent_column = first_selection_indent_column - } - - // If the corresponding selection was empty when this slice of the - // clipboard text was written, then the entire line containing the - // selection was copied. If this selection is also currently empty, - // then paste the line before the current line of the buffer. - let range = if selection.is_empty() && handle_entire_lines && entire_line { - let column = selection.start.to_point(&snapshot).column as usize; - let line_start = selection.start - column; - line_start..line_start - } else { - selection.range() - }; - - edits.push((range, to_insert)); - original_indent_columns.push(original_indent_column); - } - drop(snapshot); - - buffer.edit( - edits, - if auto_indent_on_paste { - Some(AutoindentMode::Block { - original_indent_columns, - }) - } else { - None - }, - cx, - ); - }); - - let selections = this.selections.all::(cx); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections) - }); - } else { - this.insert(&clipboard_text, window, cx); - } - }); - } - - pub fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - if let Some(item) = cx.read_from_clipboard() { - let entries = item.entries(); - - match entries.first() { - // For now, we only support applying metadata if there's one string. In the future, we can incorporate all the selections - // of all the pasted entries. - Some(ClipboardEntry::String(clipboard_string)) if entries.len() == 1 => self - .do_paste( - clipboard_string.text(), - clipboard_string.metadata_json::>(), - true, - window, - cx, - ), - _ => self.do_paste(&item.text().unwrap_or_default(), None, true, window, cx), - } - } - } - - pub fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { - if self.read_only(cx) { - return; - } - - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.undo(cx)) { - if let Some((selections, _)) = - self.selection_history.transaction(transaction_id).cloned() - { - self.change_selections(None, window, cx, |s| { - s.select_anchors(selections.to_vec()); - }); - } else { - log::error!( - "No entry in selection_history found for undo. \ - This may correspond to a bug where undo does not update the selection. \ - If this is occurring, please add details to \ - https://github.com/zed-industries/zed/issues/22692" - ); - } - self.request_autoscroll(Autoscroll::fit(), cx); - self.unmark_text(window, cx); - self.refresh_inline_completion(true, false, window, cx); - cx.emit(EditorEvent::Edited { transaction_id }); - cx.emit(EditorEvent::TransactionUndone { transaction_id }); - } - } - - pub fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context) { - if self.read_only(cx) { - return; - } - - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) { - if let Some((_, Some(selections))) = - self.selection_history.transaction(transaction_id).cloned() - { - self.change_selections(None, window, cx, |s| { - s.select_anchors(selections.to_vec()); - }); - } else { - log::error!( - "No entry in selection_history found for redo. \ - This may correspond to a bug where undo does not update the selection. \ - If this is occurring, please add details to \ - https://github.com/zed-industries/zed/issues/22692" - ); - } - self.request_autoscroll(Autoscroll::fit(), cx); - self.unmark_text(window, cx); - self.refresh_inline_completion(true, false, window, cx); - cx.emit(EditorEvent::Edited { transaction_id }); - } - } - - pub fn finalize_last_transaction(&mut self, cx: &mut Context) { - self.buffer - .update(cx, |buffer, cx| buffer.finalize_last_transaction(cx)); - } - - pub fn group_until_transaction(&mut self, tx_id: TransactionId, cx: &mut Context) { - self.buffer - .update(cx, |buffer, cx| buffer.group_until_transaction(tx_id, cx)); - } - - pub fn move_left(&mut self, _: &MoveLeft, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - let cursor = if selection.is_empty() { - movement::left(map, selection.start) - } else { - selection.start - }; - selection.collapse_to(cursor, SelectionGoal::None); - }); - }) - } - - pub fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| (movement::left(map, head), SelectionGoal::None)); - }) - } - - pub fn move_right(&mut self, _: &MoveRight, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - let cursor = if selection.is_empty() { - movement::right(map, selection.end) - } else { - selection.end - }; - selection.collapse_to(cursor, SelectionGoal::None) - }); - }) - } - - pub fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| (movement::right(map, head), SelectionGoal::None)); - }) - } - - pub fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { - if self.take_rename(true, window, cx).is_some() { - return; - } - - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let text_layout_details = &self.text_layout_details(window); - let selection_count = self.selections.count(); - let first_selection = self.selections.first_anchor(); - - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - if !selection.is_empty() { - selection.goal = SelectionGoal::None; - } - let (cursor, goal) = movement::up( - map, - selection.start, - selection.goal, - false, - text_layout_details, - ); - selection.collapse_to(cursor, goal); - }); - }); - - if selection_count == 1 && first_selection.range() == self.selections.first_anchor().range() - { - cx.propagate(); - } - } - - pub fn move_up_by_lines( - &mut self, - action: &MoveUpByLines, - window: &mut Window, - cx: &mut Context, - ) { - if self.take_rename(true, window, cx).is_some() { - return; - } - - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let text_layout_details = &self.text_layout_details(window); - - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - if !selection.is_empty() { - selection.goal = SelectionGoal::None; - } - let (cursor, goal) = movement::up_by_rows( - map, - selection.start, - action.lines, - selection.goal, - false, - text_layout_details, - ); - selection.collapse_to(cursor, goal); - }); - }) - } - - pub fn move_down_by_lines( - &mut self, - action: &MoveDownByLines, - window: &mut Window, - cx: &mut Context, - ) { - if self.take_rename(true, window, cx).is_some() { - return; - } - - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let text_layout_details = &self.text_layout_details(window); - - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - if !selection.is_empty() { - selection.goal = SelectionGoal::None; - } - let (cursor, goal) = movement::down_by_rows( - map, - selection.start, - action.lines, - selection.goal, - false, - text_layout_details, - ); - selection.collapse_to(cursor, goal); - }); - }) - } - - pub fn select_down_by_lines( - &mut self, - action: &SelectDownByLines, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - let text_layout_details = &self.text_layout_details(window); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, goal| { - movement::down_by_rows(map, head, action.lines, goal, false, text_layout_details) - }) - }) - } - - pub fn select_up_by_lines( - &mut self, - action: &SelectUpByLines, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - let text_layout_details = &self.text_layout_details(window); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, goal| { - movement::up_by_rows(map, head, action.lines, goal, false, text_layout_details) - }) - }) - } - - pub fn select_page_up( - &mut self, - _: &SelectPageUp, - window: &mut Window, - cx: &mut Context, - ) { - let Some(row_count) = self.visible_row_count() else { - return; - }; - - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let text_layout_details = &self.text_layout_details(window); - - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, goal| { - movement::up_by_rows(map, head, row_count, goal, false, text_layout_details) - }) - }) - } - - pub fn move_page_up( - &mut self, - action: &MovePageUp, - window: &mut Window, - cx: &mut Context, - ) { - if self.take_rename(true, window, cx).is_some() { - return; - } - - if self - .context_menu - .borrow_mut() - .as_mut() - .map(|menu| menu.select_first(self.completion_provider.as_deref(), cx)) - .unwrap_or(false) - { - return; - } - - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - - let Some(row_count) = self.visible_row_count() else { - return; - }; - - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let autoscroll = if action.center_cursor { - Autoscroll::center() - } else { - Autoscroll::fit() - }; - - let text_layout_details = &self.text_layout_details(window); - - self.change_selections(Some(autoscroll), window, cx, |s| { - s.move_with(|map, selection| { - if !selection.is_empty() { - selection.goal = SelectionGoal::None; - } - let (cursor, goal) = movement::up_by_rows( - map, - selection.end, - row_count, - selection.goal, - false, - text_layout_details, - ); - selection.collapse_to(cursor, goal); - }); - }); - } - - pub fn select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - let text_layout_details = &self.text_layout_details(window); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, goal| { - movement::up(map, head, goal, false, text_layout_details) - }) - }) - } - - pub fn move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { - self.take_rename(true, window, cx); - - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let text_layout_details = &self.text_layout_details(window); - let selection_count = self.selections.count(); - let first_selection = self.selections.first_anchor(); - - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - if !selection.is_empty() { - selection.goal = SelectionGoal::None; - } - let (cursor, goal) = movement::down( - map, - selection.end, - selection.goal, - false, - text_layout_details, - ); - selection.collapse_to(cursor, goal); - }); - }); - - if selection_count == 1 && first_selection.range() == self.selections.first_anchor().range() - { - cx.propagate(); - } - } - - pub fn select_page_down( - &mut self, - _: &SelectPageDown, - window: &mut Window, - cx: &mut Context, - ) { - let Some(row_count) = self.visible_row_count() else { - return; - }; - - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let text_layout_details = &self.text_layout_details(window); - - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, goal| { - movement::down_by_rows(map, head, row_count, goal, false, text_layout_details) - }) - }) - } - - pub fn move_page_down( - &mut self, - action: &MovePageDown, - window: &mut Window, - cx: &mut Context, - ) { - if self.take_rename(true, window, cx).is_some() { - return; - } - - if self - .context_menu - .borrow_mut() - .as_mut() - .map(|menu| menu.select_last(self.completion_provider.as_deref(), cx)) - .unwrap_or(false) - { - return; - } - - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - - let Some(row_count) = self.visible_row_count() else { - return; - }; - - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let autoscroll = if action.center_cursor { - Autoscroll::center() - } else { - Autoscroll::fit() - }; - - let text_layout_details = &self.text_layout_details(window); - self.change_selections(Some(autoscroll), window, cx, |s| { - s.move_with(|map, selection| { - if !selection.is_empty() { - selection.goal = SelectionGoal::None; - } - let (cursor, goal) = movement::down_by_rows( - map, - selection.end, - row_count, - selection.goal, - false, - text_layout_details, - ); - selection.collapse_to(cursor, goal); - }); - }); - } - - pub fn select_down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - let text_layout_details = &self.text_layout_details(window); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, goal| { - movement::down(map, head, goal, false, text_layout_details) - }) - }); - } - - pub fn context_menu_first( - &mut self, - _: &ContextMenuFirst, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() { - context_menu.select_first(self.completion_provider.as_deref(), cx); - } - } - - pub fn context_menu_prev( - &mut self, - _: &ContextMenuPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() { - context_menu.select_prev(self.completion_provider.as_deref(), cx); - } - } - - pub fn context_menu_next( - &mut self, - _: &ContextMenuNext, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() { - context_menu.select_next(self.completion_provider.as_deref(), cx); - } - } - - pub fn context_menu_last( - &mut self, - _: &ContextMenuLast, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() { - context_menu.select_last(self.completion_provider.as_deref(), cx); - } - } - - pub fn move_to_previous_word_start( - &mut self, - _: &MoveToPreviousWordStart, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_cursors_with(|map, head, _| { - ( - movement::previous_word_start(map, head), - SelectionGoal::None, - ) - }); - }) - } - - pub fn move_to_previous_subword_start( - &mut self, - _: &MoveToPreviousSubwordStart, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_cursors_with(|map, head, _| { - ( - movement::previous_subword_start(map, head), - SelectionGoal::None, - ) - }); - }) - } - - pub fn select_to_previous_word_start( - &mut self, - _: &SelectToPreviousWordStart, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - ( - movement::previous_word_start(map, head), - SelectionGoal::None, - ) - }); - }) - } - - pub fn select_to_previous_subword_start( - &mut self, - _: &SelectToPreviousSubwordStart, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - ( - movement::previous_subword_start(map, head), - SelectionGoal::None, - ) - }); - }) - } - - pub fn delete_to_previous_word_start( - &mut self, - action: &DeleteToPreviousWordStart, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - this.select_autoclose_pair(window, cx); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - if selection.is_empty() { - let cursor = if action.ignore_newlines { - movement::previous_word_start(map, selection.head()) - } else { - movement::previous_word_start_or_newline(map, selection.head()) - }; - selection.set_head(cursor, SelectionGoal::None); - } - }); - }); - this.insert("", window, cx); - }); - } - - pub fn delete_to_previous_subword_start( - &mut self, - _: &DeleteToPreviousSubwordStart, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - this.select_autoclose_pair(window, cx); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - if selection.is_empty() { - let cursor = movement::previous_subword_start(map, selection.head()); - selection.set_head(cursor, SelectionGoal::None); - } - }); - }); - this.insert("", window, cx); - }); - } - - pub fn move_to_next_word_end( - &mut self, - _: &MoveToNextWordEnd, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_cursors_with(|map, head, _| { - (movement::next_word_end(map, head), SelectionGoal::None) - }); - }) - } - - pub fn move_to_next_subword_end( - &mut self, - _: &MoveToNextSubwordEnd, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_cursors_with(|map, head, _| { - (movement::next_subword_end(map, head), SelectionGoal::None) - }); - }) - } - - pub fn select_to_next_word_end( - &mut self, - _: &SelectToNextWordEnd, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - (movement::next_word_end(map, head), SelectionGoal::None) - }); - }) - } - - pub fn select_to_next_subword_end( - &mut self, - _: &SelectToNextSubwordEnd, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - (movement::next_subword_end(map, head), SelectionGoal::None) - }); - }) - } - - pub fn delete_to_next_word_end( - &mut self, - action: &DeleteToNextWordEnd, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - if selection.is_empty() { - let cursor = if action.ignore_newlines { - movement::next_word_end(map, selection.head()) - } else { - movement::next_word_end_or_newline(map, selection.head()) - }; - selection.set_head(cursor, SelectionGoal::None); - } - }); - }); - this.insert("", window, cx); - }); - } - - pub fn delete_to_next_subword_end( - &mut self, - _: &DeleteToNextSubwordEnd, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - if selection.is_empty() { - let cursor = movement::next_subword_end(map, selection.head()); - selection.set_head(cursor, SelectionGoal::None); - } - }); - }); - this.insert("", window, cx); - }); - } - - pub fn move_to_beginning_of_line( - &mut self, - action: &MoveToBeginningOfLine, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_cursors_with(|map, head, _| { - ( - movement::indented_line_beginning( - map, - head, - action.stop_at_soft_wraps, - action.stop_at_indent, - ), - SelectionGoal::None, - ) - }); - }) - } - - pub fn select_to_beginning_of_line( - &mut self, - action: &SelectToBeginningOfLine, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - ( - movement::indented_line_beginning( - map, - head, - action.stop_at_soft_wraps, - action.stop_at_indent, - ), - SelectionGoal::None, - ) - }); - }); - } - - pub fn delete_to_beginning_of_line( - &mut self, - action: &DeleteToBeginningOfLine, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|_, selection| { - selection.reversed = true; - }); - }); - - this.select_to_beginning_of_line( - &SelectToBeginningOfLine { - stop_at_soft_wraps: false, - stop_at_indent: action.stop_at_indent, - }, - window, - cx, - ); - this.backspace(&Backspace, window, cx); - }); - } - - pub fn move_to_end_of_line( - &mut self, - action: &MoveToEndOfLine, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_cursors_with(|map, head, _| { - ( - movement::line_end(map, head, action.stop_at_soft_wraps), - SelectionGoal::None, - ) - }); - }) - } - - pub fn select_to_end_of_line( - &mut self, - action: &SelectToEndOfLine, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - ( - movement::line_end(map, head, action.stop_at_soft_wraps), - SelectionGoal::None, - ) - }); - }) - } - - pub fn delete_to_end_of_line( - &mut self, - _: &DeleteToEndOfLine, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - this.select_to_end_of_line( - &SelectToEndOfLine { - stop_at_soft_wraps: false, - }, - window, - cx, - ); - this.delete(&Delete, window, cx); - }); - } - - pub fn cut_to_end_of_line( - &mut self, - _: &CutToEndOfLine, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - this.select_to_end_of_line( - &SelectToEndOfLine { - stop_at_soft_wraps: false, - }, - window, - cx, - ); - this.cut(&Cut, window, cx); - }); - } - - pub fn move_to_start_of_paragraph( - &mut self, - _: &MoveToStartOfParagraph, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - selection.collapse_to( - movement::start_of_paragraph(map, selection.head(), 1), - SelectionGoal::None, - ) - }); - }) - } - - pub fn move_to_end_of_paragraph( - &mut self, - _: &MoveToEndOfParagraph, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - selection.collapse_to( - movement::end_of_paragraph(map, selection.head(), 1), - SelectionGoal::None, - ) - }); - }) - } - - pub fn select_to_start_of_paragraph( - &mut self, - _: &SelectToStartOfParagraph, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - ( - movement::start_of_paragraph(map, head, 1), - SelectionGoal::None, - ) - }); - }) - } - - pub fn select_to_end_of_paragraph( - &mut self, - _: &SelectToEndOfParagraph, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - ( - movement::end_of_paragraph(map, head, 1), - SelectionGoal::None, - ) - }); - }) - } - - pub fn move_to_start_of_excerpt( - &mut self, - _: &MoveToStartOfExcerpt, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - selection.collapse_to( - movement::start_of_excerpt( - map, - selection.head(), - workspace::searchable::Direction::Prev, - ), - SelectionGoal::None, - ) - }); - }) - } - - pub fn move_to_start_of_next_excerpt( - &mut self, - _: &MoveToStartOfNextExcerpt, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - selection.collapse_to( - movement::start_of_excerpt( - map, - selection.head(), - workspace::searchable::Direction::Next, - ), - SelectionGoal::None, - ) - }); - }) - } - - pub fn move_to_end_of_excerpt( - &mut self, - _: &MoveToEndOfExcerpt, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - selection.collapse_to( - movement::end_of_excerpt( - map, - selection.head(), - workspace::searchable::Direction::Next, - ), - SelectionGoal::None, - ) - }); - }) - } - - pub fn move_to_end_of_previous_excerpt( - &mut self, - _: &MoveToEndOfPreviousExcerpt, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_with(|map, selection| { - selection.collapse_to( - movement::end_of_excerpt( - map, - selection.head(), - workspace::searchable::Direction::Prev, - ), - SelectionGoal::None, - ) - }); - }) - } - - pub fn select_to_start_of_excerpt( - &mut self, - _: &SelectToStartOfExcerpt, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - ( - movement::start_of_excerpt(map, head, workspace::searchable::Direction::Prev), - SelectionGoal::None, - ) - }); - }) - } - - pub fn select_to_start_of_next_excerpt( - &mut self, - _: &SelectToStartOfNextExcerpt, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - ( - movement::start_of_excerpt(map, head, workspace::searchable::Direction::Next), - SelectionGoal::None, - ) - }); - }) - } - - pub fn select_to_end_of_excerpt( - &mut self, - _: &SelectToEndOfExcerpt, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - ( - movement::end_of_excerpt(map, head, workspace::searchable::Direction::Next), - SelectionGoal::None, - ) - }); - }) - } - - pub fn select_to_end_of_previous_excerpt( - &mut self, - _: &SelectToEndOfPreviousExcerpt, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_heads_with(|map, head, _| { - ( - movement::end_of_excerpt(map, head, workspace::searchable::Direction::Prev), - SelectionGoal::None, - ) - }); - }) - } - - pub fn move_to_beginning( - &mut self, - _: &MoveToBeginning, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_ranges(vec![0..0]); - }); - } - - pub fn select_to_beginning( - &mut self, - _: &SelectToBeginning, - window: &mut Window, - cx: &mut Context, - ) { - let mut selection = self.selections.last::(cx); - selection.set_head(Point::zero(), SelectionGoal::None); - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(vec![selection]); - }); - } - - pub fn move_to_end(&mut self, _: &MoveToEnd, window: &mut Window, cx: &mut Context) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { - cx.propagate(); - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - let cursor = self.buffer.read(cx).read(cx).len(); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_ranges(vec![cursor..cursor]) - }); - } - - pub fn set_nav_history(&mut self, nav_history: Option) { - self.nav_history = nav_history; - } - - pub fn nav_history(&self) -> Option<&ItemNavHistory> { - self.nav_history.as_ref() - } - - pub fn create_nav_history_entry(&mut self, cx: &mut Context) { - self.push_to_nav_history(self.selections.newest_anchor().head(), None, false, cx); - } - - fn push_to_nav_history( - &mut self, - cursor_anchor: Anchor, - new_position: Option, - is_deactivate: bool, - cx: &mut Context, - ) { - if let Some(nav_history) = self.nav_history.as_mut() { - let buffer = self.buffer.read(cx).read(cx); - let cursor_position = cursor_anchor.to_point(&buffer); - let scroll_state = self.scroll_manager.anchor(); - let scroll_top_row = scroll_state.top_row(&buffer); - drop(buffer); - - if let Some(new_position) = new_position { - let row_delta = (new_position.row as i64 - cursor_position.row as i64).abs(); - if row_delta < MIN_NAVIGATION_HISTORY_ROW_DELTA { - return; - } - } - - nav_history.push( - Some(NavigationData { - cursor_anchor, - cursor_position, - scroll_anchor: scroll_state, - scroll_top_row, - }), - Some(cursor_position.row), - cx, - ); - cx.emit(EditorEvent::PushedToNavHistory { - anchor: cursor_anchor, - is_deactivate, - }) - } - } - - pub fn select_to_end(&mut self, _: &SelectToEnd, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - let buffer = self.buffer.read(cx).snapshot(cx); - let mut selection = self.selections.first::(cx); - selection.set_head(buffer.len(), SelectionGoal::None); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(vec![selection]); - }); - } - - pub fn select_all(&mut self, _: &SelectAll, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - let end = self.buffer.read(cx).read(cx).len(); - self.change_selections(None, window, cx, |s| { - s.select_ranges(vec![0..end]); - }); - } - - pub fn select_line(&mut self, _: &SelectLine, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let mut selections = self.selections.all::(cx); - let max_point = display_map.buffer_snapshot.max_point(); - for selection in &mut selections { - let rows = selection.spanned_rows(true, &display_map); - selection.start = Point::new(rows.start.0, 0); - selection.end = cmp::min(max_point, Point::new(rows.end.0, 0)); - selection.reversed = false; - } - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections); - }); - } - - pub fn split_selection_into_lines( - &mut self, - _: &SplitSelectionIntoLines, - window: &mut Window, - cx: &mut Context, - ) { - let selections = self - .selections - .all::(cx) - .into_iter() - .map(|selection| selection.start..selection.end) - .collect::>(); - self.unfold_ranges(&selections, true, true, cx); - - let mut new_selection_ranges = Vec::new(); - { - let buffer = self.buffer.read(cx).read(cx); - for selection in selections { - for row in selection.start.row..selection.end.row { - let cursor = Point::new(row, buffer.line_len(MultiBufferRow(row))); - new_selection_ranges.push(cursor..cursor); - } - - let is_multiline_selection = selection.start.row != selection.end.row; - // Don't insert last one if it's a multi-line selection ending at the start of a line, - // so this action feels more ergonomic when paired with other selection operations - let should_skip_last = is_multiline_selection && selection.end.column == 0; - if !should_skip_last { - new_selection_ranges.push(selection.end..selection.end); - } - } - } - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_ranges(new_selection_ranges); - }); - } - - pub fn add_selection_above( - &mut self, - _: &AddSelectionAbove, - window: &mut Window, - cx: &mut Context, - ) { - self.add_selection(true, window, cx); - } - - pub fn add_selection_below( - &mut self, - _: &AddSelectionBelow, - window: &mut Window, - cx: &mut Context, - ) { - self.add_selection(false, window, cx); - } - - fn add_selection(&mut self, above: bool, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let mut selections = self.selections.all::(cx); - let text_layout_details = self.text_layout_details(window); - let mut state = self.add_selections_state.take().unwrap_or_else(|| { - let oldest_selection = selections.iter().min_by_key(|s| s.id).unwrap().clone(); - let range = oldest_selection.display_range(&display_map).sorted(); - - let start_x = display_map.x_for_display_point(range.start, &text_layout_details); - let end_x = display_map.x_for_display_point(range.end, &text_layout_details); - let positions = start_x.min(end_x)..start_x.max(end_x); - - selections.clear(); - let mut stack = Vec::new(); - for row in range.start.row().0..=range.end.row().0 { - if let Some(selection) = self.selections.build_columnar_selection( - &display_map, - DisplayRow(row), - &positions, - oldest_selection.reversed, - &text_layout_details, - ) { - stack.push(selection.id); - selections.push(selection); - } - } - - if above { - stack.reverse(); - } - - AddSelectionsState { above, stack } - }); - - let last_added_selection = *state.stack.last().unwrap(); - let mut new_selections = Vec::new(); - if above == state.above { - let end_row = if above { - DisplayRow(0) - } else { - display_map.max_point().row() - }; - - 'outer: for selection in selections { - if selection.id == last_added_selection { - let range = selection.display_range(&display_map).sorted(); - debug_assert_eq!(range.start.row(), range.end.row()); - let mut row = range.start.row(); - let positions = - if let SelectionGoal::HorizontalRange { start, end } = selection.goal { - px(start)..px(end) - } else { - let start_x = - display_map.x_for_display_point(range.start, &text_layout_details); - let end_x = - display_map.x_for_display_point(range.end, &text_layout_details); - start_x.min(end_x)..start_x.max(end_x) - }; - - while row != end_row { - if above { - row.0 -= 1; - } else { - row.0 += 1; - } - - if let Some(new_selection) = self.selections.build_columnar_selection( - &display_map, - row, - &positions, - selection.reversed, - &text_layout_details, - ) { - state.stack.push(new_selection.id); - if above { - new_selections.push(new_selection); - new_selections.push(selection); - } else { - new_selections.push(selection); - new_selections.push(new_selection); - } - - continue 'outer; - } - } - } - - new_selections.push(selection); - } - } else { - new_selections = selections; - new_selections.retain(|s| s.id != last_added_selection); - state.stack.pop(); - } - - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(new_selections); - }); - if state.stack.len() > 1 { - self.add_selections_state = Some(state); - } - } - - pub fn select_next_match_internal( - &mut self, - display_map: &DisplaySnapshot, - replace_newest: bool, - autoscroll: Option, - window: &mut Window, - cx: &mut Context, - ) -> Result<()> { - fn select_next_match_ranges( - this: &mut Editor, - range: Range, - reversed: bool, - replace_newest: bool, - auto_scroll: Option, - window: &mut Window, - cx: &mut Context, - ) { - this.unfold_ranges(&[range.clone()], false, auto_scroll.is_some(), cx); - this.change_selections(auto_scroll, window, cx, |s| { - if replace_newest { - s.delete(s.newest_anchor().id); - } - if reversed { - s.insert_range(range.end..range.start); - } else { - s.insert_range(range); - } - }); - } - - let buffer = &display_map.buffer_snapshot; - let mut selections = self.selections.all::(cx); - if let Some(mut select_next_state) = self.select_next_state.take() { - let query = &select_next_state.query; - if !select_next_state.done { - let first_selection = selections.iter().min_by_key(|s| s.id).unwrap(); - let last_selection = selections.iter().max_by_key(|s| s.id).unwrap(); - let mut next_selected_range = None; - - let bytes_after_last_selection = - buffer.bytes_in_range(last_selection.end..buffer.len()); - let bytes_before_first_selection = buffer.bytes_in_range(0..first_selection.start); - let query_matches = query - .stream_find_iter(bytes_after_last_selection) - .map(|result| (last_selection.end, result)) - .chain( - query - .stream_find_iter(bytes_before_first_selection) - .map(|result| (0, result)), - ); - - for (start_offset, query_match) in query_matches { - let query_match = query_match.unwrap(); // can only fail due to I/O - let offset_range = - start_offset + query_match.start()..start_offset + query_match.end(); - let display_range = offset_range.start.to_display_point(display_map) - ..offset_range.end.to_display_point(display_map); - - if !select_next_state.wordwise - || (!movement::is_inside_word(display_map, display_range.start) - && !movement::is_inside_word(display_map, display_range.end)) - { - // TODO: This is n^2, because we might check all the selections - if !selections - .iter() - .any(|selection| selection.range().overlaps(&offset_range)) - { - next_selected_range = Some(offset_range); - break; - } - } - } - - if let Some(next_selected_range) = next_selected_range { - select_next_match_ranges( - self, - next_selected_range, - last_selection.reversed, - replace_newest, - autoscroll, - window, - cx, - ); - } else { - select_next_state.done = true; - } - } - - self.select_next_state = Some(select_next_state); - } else { - let mut only_carets = true; - let mut same_text_selected = true; - let mut selected_text = None; - - let mut selections_iter = selections.iter().peekable(); - while let Some(selection) = selections_iter.next() { - if selection.start != selection.end { - only_carets = false; - } - - if same_text_selected { - if selected_text.is_none() { - selected_text = - Some(buffer.text_for_range(selection.range()).collect::()); - } - - if let Some(next_selection) = selections_iter.peek() { - if next_selection.range().len() == selection.range().len() { - let next_selected_text = buffer - .text_for_range(next_selection.range()) - .collect::(); - if Some(next_selected_text) != selected_text { - same_text_selected = false; - selected_text = None; - } - } else { - same_text_selected = false; - selected_text = None; - } - } - } - } - - if only_carets { - for selection in &mut selections { - let word_range = movement::surrounding_word( - display_map, - selection.start.to_display_point(display_map), - ); - selection.start = word_range.start.to_offset(display_map, Bias::Left); - selection.end = word_range.end.to_offset(display_map, Bias::Left); - selection.goal = SelectionGoal::None; - selection.reversed = false; - select_next_match_ranges( - self, - selection.start..selection.end, - selection.reversed, - replace_newest, - autoscroll, - window, - cx, - ); - } - - if selections.len() == 1 { - let selection = selections - .last() - .expect("ensured that there's only one selection"); - let query = buffer - .text_for_range(selection.start..selection.end) - .collect::(); - let is_empty = query.is_empty(); - let select_state = SelectNextState { - query: AhoCorasick::new(&[query])?, - wordwise: true, - done: is_empty, - }; - self.select_next_state = Some(select_state); - } else { - self.select_next_state = None; - } - } else if let Some(selected_text) = selected_text { - self.select_next_state = Some(SelectNextState { - query: AhoCorasick::new(&[selected_text])?, - wordwise: false, - done: false, - }); - self.select_next_match_internal( - display_map, - replace_newest, - autoscroll, - window, - cx, - )?; - } - } - Ok(()) - } - - pub fn select_all_matches( - &mut self, - _action: &SelectAllMatches, - window: &mut Window, - cx: &mut Context, - ) -> Result<()> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - self.push_to_selection_history(); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - self.select_next_match_internal(&display_map, false, None, window, cx)?; - let Some(select_next_state) = self.select_next_state.as_mut() else { - return Ok(()); - }; - if select_next_state.done { - return Ok(()); - } - - let mut new_selections = Vec::new(); - - let reversed = self.selections.oldest::(cx).reversed; - let buffer = &display_map.buffer_snapshot; - let query_matches = select_next_state - .query - .stream_find_iter(buffer.bytes_in_range(0..buffer.len())); - - for query_match in query_matches.into_iter() { - let query_match = query_match.context("query match for select all action")?; // can only fail due to I/O - let offset_range = if reversed { - query_match.end()..query_match.start() - } else { - query_match.start()..query_match.end() - }; - let display_range = offset_range.start.to_display_point(&display_map) - ..offset_range.end.to_display_point(&display_map); - - if !select_next_state.wordwise - || (!movement::is_inside_word(&display_map, display_range.start) - && !movement::is_inside_word(&display_map, display_range.end)) - { - new_selections.push(offset_range.start..offset_range.end); - } - } - - select_next_state.done = true; - self.unfold_ranges(&new_selections.clone(), false, false, cx); - self.change_selections(None, window, cx, |selections| { - selections.select_ranges(new_selections) - }); - - Ok(()) - } - - pub fn select_next( - &mut self, - action: &SelectNext, - window: &mut Window, - cx: &mut Context, - ) -> Result<()> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.push_to_selection_history(); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - self.select_next_match_internal( - &display_map, - action.replace_newest, - Some(Autoscroll::newest()), - window, - cx, - )?; - Ok(()) - } - - pub fn select_previous( - &mut self, - action: &SelectPrevious, - window: &mut Window, - cx: &mut Context, - ) -> Result<()> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.push_to_selection_history(); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; - let mut selections = self.selections.all::(cx); - if let Some(mut select_prev_state) = self.select_prev_state.take() { - let query = &select_prev_state.query; - if !select_prev_state.done { - let first_selection = selections.iter().min_by_key(|s| s.id).unwrap(); - let last_selection = selections.iter().max_by_key(|s| s.id).unwrap(); - let mut next_selected_range = None; - // When we're iterating matches backwards, the oldest match will actually be the furthest one in the buffer. - let bytes_before_last_selection = - buffer.reversed_bytes_in_range(0..last_selection.start); - let bytes_after_first_selection = - buffer.reversed_bytes_in_range(first_selection.end..buffer.len()); - let query_matches = query - .stream_find_iter(bytes_before_last_selection) - .map(|result| (last_selection.start, result)) - .chain( - query - .stream_find_iter(bytes_after_first_selection) - .map(|result| (buffer.len(), result)), - ); - for (end_offset, query_match) in query_matches { - let query_match = query_match.unwrap(); // can only fail due to I/O - let offset_range = - end_offset - query_match.end()..end_offset - query_match.start(); - let display_range = offset_range.start.to_display_point(&display_map) - ..offset_range.end.to_display_point(&display_map); - - if !select_prev_state.wordwise - || (!movement::is_inside_word(&display_map, display_range.start) - && !movement::is_inside_word(&display_map, display_range.end)) - { - next_selected_range = Some(offset_range); - break; - } - } - - if let Some(next_selected_range) = next_selected_range { - self.unfold_ranges(&[next_selected_range.clone()], false, true, cx); - self.change_selections(Some(Autoscroll::newest()), window, cx, |s| { - if action.replace_newest { - s.delete(s.newest_anchor().id); - } - if last_selection.reversed { - s.insert_range(next_selected_range.end..next_selected_range.start); - } else { - s.insert_range(next_selected_range); - } - }); - } else { - select_prev_state.done = true; - } - } - - self.select_prev_state = Some(select_prev_state); - } else { - let mut only_carets = true; - let mut same_text_selected = true; - let mut selected_text = None; - - let mut selections_iter = selections.iter().peekable(); - while let Some(selection) = selections_iter.next() { - if selection.start != selection.end { - only_carets = false; - } - - if same_text_selected { - if selected_text.is_none() { - selected_text = - Some(buffer.text_for_range(selection.range()).collect::()); - } - - if let Some(next_selection) = selections_iter.peek() { - if next_selection.range().len() == selection.range().len() { - let next_selected_text = buffer - .text_for_range(next_selection.range()) - .collect::(); - if Some(next_selected_text) != selected_text { - same_text_selected = false; - selected_text = None; - } - } else { - same_text_selected = false; - selected_text = None; - } - } - } - } - - if only_carets { - for selection in &mut selections { - let word_range = movement::surrounding_word( - &display_map, - selection.start.to_display_point(&display_map), - ); - selection.start = word_range.start.to_offset(&display_map, Bias::Left); - selection.end = word_range.end.to_offset(&display_map, Bias::Left); - selection.goal = SelectionGoal::None; - selection.reversed = false; - } - if selections.len() == 1 { - let selection = selections - .last() - .expect("ensured that there's only one selection"); - let query = buffer - .text_for_range(selection.start..selection.end) - .collect::(); - let is_empty = query.is_empty(); - let select_state = SelectNextState { - query: AhoCorasick::new(&[query.chars().rev().collect::()])?, - wordwise: true, - done: is_empty, - }; - self.select_prev_state = Some(select_state); - } else { - self.select_prev_state = None; - } - - self.unfold_ranges( - &selections.iter().map(|s| s.range()).collect::>(), - false, - true, - cx, - ); - self.change_selections(Some(Autoscroll::newest()), window, cx, |s| { - s.select(selections); - }); - } else if let Some(selected_text) = selected_text { - self.select_prev_state = Some(SelectNextState { - query: AhoCorasick::new(&[selected_text.chars().rev().collect::()])?, - wordwise: false, - done: false, - }); - self.select_previous(action, window, cx)?; - } - } - Ok(()) - } - - pub fn find_next_match( - &mut self, - _: &FindNextMatch, - window: &mut Window, - cx: &mut Context, - ) -> Result<()> { - let selections = self.selections.disjoint_anchors(); - match selections.first() { - Some(first) if selections.len() >= 2 => { - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_ranges([first.range()]); - }); - } - _ => self.select_next( - &SelectNext { - replace_newest: true, - }, - window, - cx, - )?, - } - Ok(()) - } - - pub fn find_previous_match( - &mut self, - _: &FindPreviousMatch, - window: &mut Window, - cx: &mut Context, - ) -> Result<()> { - let selections = self.selections.disjoint_anchors(); - match selections.last() { - Some(last) if selections.len() >= 2 => { - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_ranges([last.range()]); - }); - } - _ => self.select_previous( - &SelectPrevious { - replace_newest: true, - }, - window, - cx, - )?, - } - Ok(()) - } - - pub fn toggle_comments( - &mut self, - action: &ToggleComments, - window: &mut Window, - cx: &mut Context, - ) { - if self.read_only(cx) { - return; - } - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let text_layout_details = &self.text_layout_details(window); - self.transact(window, cx, |this, window, cx| { - let mut selections = this.selections.all::(cx); - let mut edits = Vec::new(); - let mut selection_edit_ranges = Vec::new(); - let mut last_toggled_row = None; - let snapshot = this.buffer.read(cx).read(cx); - let empty_str: Arc = Arc::default(); - let mut suffixes_inserted = Vec::new(); - let ignore_indent = action.ignore_indent; - - fn comment_prefix_range( - snapshot: &MultiBufferSnapshot, - row: MultiBufferRow, - comment_prefix: &str, - comment_prefix_whitespace: &str, - ignore_indent: bool, - ) -> Range { - let indent_size = if ignore_indent { - 0 - } else { - snapshot.indent_size_for_line(row).len - }; - - let start = Point::new(row.0, indent_size); - - let mut line_bytes = snapshot - .bytes_in_range(start..snapshot.max_point()) - .flatten() - .copied(); - - // If this line currently begins with the line comment prefix, then record - // the range containing the prefix. - if line_bytes - .by_ref() - .take(comment_prefix.len()) - .eq(comment_prefix.bytes()) - { - // Include any whitespace that matches the comment prefix. - let matching_whitespace_len = line_bytes - .zip(comment_prefix_whitespace.bytes()) - .take_while(|(a, b)| a == b) - .count() as u32; - let end = Point::new( - start.row, - start.column + comment_prefix.len() as u32 + matching_whitespace_len, - ); - start..end - } else { - start..start - } - } - - fn comment_suffix_range( - snapshot: &MultiBufferSnapshot, - row: MultiBufferRow, - comment_suffix: &str, - comment_suffix_has_leading_space: bool, - ) -> Range { - let end = Point::new(row.0, snapshot.line_len(row)); - let suffix_start_column = end.column.saturating_sub(comment_suffix.len() as u32); - - let mut line_end_bytes = snapshot - .bytes_in_range(Point::new(end.row, suffix_start_column.saturating_sub(1))..end) - .flatten() - .copied(); - - let leading_space_len = if suffix_start_column > 0 - && line_end_bytes.next() == Some(b' ') - && comment_suffix_has_leading_space - { - 1 - } else { - 0 - }; - - // If this line currently begins with the line comment prefix, then record - // the range containing the prefix. - if line_end_bytes.by_ref().eq(comment_suffix.bytes()) { - let start = Point::new(end.row, suffix_start_column - leading_space_len); - start..end - } else { - end..end - } - } - - // TODO: Handle selections that cross excerpts - for selection in &mut selections { - let start_column = snapshot - .indent_size_for_line(MultiBufferRow(selection.start.row)) - .len; - let language = if let Some(language) = - snapshot.language_scope_at(Point::new(selection.start.row, start_column)) - { - language - } else { - continue; - }; - - selection_edit_ranges.clear(); - - // If multiple selections contain a given row, avoid processing that - // row more than once. - let mut start_row = MultiBufferRow(selection.start.row); - if last_toggled_row == Some(start_row) { - start_row = start_row.next_row(); - } - let end_row = - if selection.end.row > selection.start.row && selection.end.column == 0 { - MultiBufferRow(selection.end.row - 1) - } else { - MultiBufferRow(selection.end.row) - }; - last_toggled_row = Some(end_row); - - if start_row > end_row { - continue; - } - - // If the language has line comments, toggle those. - let mut full_comment_prefixes = language.line_comment_prefixes().to_vec(); - - // If ignore_indent is set, trim spaces from the right side of all full_comment_prefixes - if ignore_indent { - full_comment_prefixes = full_comment_prefixes - .into_iter() - .map(|s| Arc::from(s.trim_end())) - .collect(); - } - - if !full_comment_prefixes.is_empty() { - let first_prefix = full_comment_prefixes - .first() - .expect("prefixes is non-empty"); - let prefix_trimmed_lengths = full_comment_prefixes - .iter() - .map(|p| p.trim_end_matches(' ').len()) - .collect::>(); - - let mut all_selection_lines_are_comments = true; - - for row in start_row.0..=end_row.0 { - let row = MultiBufferRow(row); - if start_row < end_row && snapshot.is_line_blank(row) { - continue; - } - - let prefix_range = full_comment_prefixes - .iter() - .zip(prefix_trimmed_lengths.iter().copied()) - .map(|(prefix, trimmed_prefix_len)| { - comment_prefix_range( - snapshot.deref(), - row, - &prefix[..trimmed_prefix_len], - &prefix[trimmed_prefix_len..], - ignore_indent, - ) - }) - .max_by_key(|range| range.end.column - range.start.column) - .expect("prefixes is non-empty"); - - if prefix_range.is_empty() { - all_selection_lines_are_comments = false; - } - - selection_edit_ranges.push(prefix_range); - } - - if all_selection_lines_are_comments { - edits.extend( - selection_edit_ranges - .iter() - .cloned() - .map(|range| (range, empty_str.clone())), - ); - } else { - let min_column = selection_edit_ranges - .iter() - .map(|range| range.start.column) - .min() - .unwrap_or(0); - edits.extend(selection_edit_ranges.iter().map(|range| { - let position = Point::new(range.start.row, min_column); - (position..position, first_prefix.clone()) - })); - } - } else if let Some((full_comment_prefix, comment_suffix)) = - language.block_comment_delimiters() - { - let comment_prefix = full_comment_prefix.trim_end_matches(' '); - let comment_prefix_whitespace = &full_comment_prefix[comment_prefix.len()..]; - let prefix_range = comment_prefix_range( - snapshot.deref(), - start_row, - comment_prefix, - comment_prefix_whitespace, - ignore_indent, - ); - let suffix_range = comment_suffix_range( - snapshot.deref(), - end_row, - comment_suffix.trim_start_matches(' '), - comment_suffix.starts_with(' '), - ); - - if prefix_range.is_empty() || suffix_range.is_empty() { - edits.push(( - prefix_range.start..prefix_range.start, - full_comment_prefix.clone(), - )); - edits.push((suffix_range.end..suffix_range.end, comment_suffix.clone())); - suffixes_inserted.push((end_row, comment_suffix.len())); - } else { - edits.push((prefix_range, empty_str.clone())); - edits.push((suffix_range, empty_str.clone())); - } - } else { - continue; - } - } - - drop(snapshot); - this.buffer.update(cx, |buffer, cx| { - buffer.edit(edits, None, cx); - }); - - // Adjust selections so that they end before any comment suffixes that - // were inserted. - let mut suffixes_inserted = suffixes_inserted.into_iter().peekable(); - let mut selections = this.selections.all::(cx); - let snapshot = this.buffer.read(cx).read(cx); - for selection in &mut selections { - while let Some((row, suffix_len)) = suffixes_inserted.peek().copied() { - match row.cmp(&MultiBufferRow(selection.end.row)) { - Ordering::Less => { - suffixes_inserted.next(); - continue; - } - Ordering::Greater => break, - Ordering::Equal => { - if selection.end.column == snapshot.line_len(row) { - if selection.is_empty() { - selection.start.column -= suffix_len as u32; - } - selection.end.column -= suffix_len as u32; - } - break; - } - } - } - } - - drop(snapshot); - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections) - }); - - let selections = this.selections.all::(cx); - let selections_on_single_row = selections.windows(2).all(|selections| { - selections[0].start.row == selections[1].start.row - && selections[0].end.row == selections[1].end.row - && selections[0].start.row == selections[0].end.row - }); - let selections_selecting = selections - .iter() - .any(|selection| selection.start != selection.end); - let advance_downwards = action.advance_downwards - && selections_on_single_row - && !selections_selecting - && !matches!(this.mode, EditorMode::SingleLine { .. }); - - if advance_downwards { - let snapshot = this.buffer.read(cx).snapshot(cx); - - this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_cursors_with(|display_snapshot, display_point, _| { - let mut point = display_point.to_point(display_snapshot); - point.row += 1; - point = snapshot.clip_point(point, Bias::Left); - let display_point = point.to_display_point(display_snapshot); - let goal = SelectionGoal::HorizontalPosition( - display_snapshot - .x_for_display_point(display_point, text_layout_details) - .into(), - ); - (display_point, goal) - }) - }); - } - }); - } - - pub fn select_enclosing_symbol( - &mut self, - _: &SelectEnclosingSymbol, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let buffer = self.buffer.read(cx).snapshot(cx); - let old_selections = self.selections.all::(cx).into_boxed_slice(); - - fn update_selection( - selection: &Selection, - buffer_snap: &MultiBufferSnapshot, - ) -> Option> { - let cursor = selection.head(); - let (_buffer_id, symbols) = buffer_snap.symbols_containing(cursor, None)?; - for symbol in symbols.iter().rev() { - let start = symbol.range.start.to_offset(buffer_snap); - let end = symbol.range.end.to_offset(buffer_snap); - let new_range = start..end; - if start < selection.start || end > selection.end { - return Some(Selection { - id: selection.id, - start: new_range.start, - end: new_range.end, - goal: SelectionGoal::None, - reversed: selection.reversed, - }); - } - } - None - } - - let mut selected_larger_symbol = false; - let new_selections = old_selections - .iter() - .map(|selection| match update_selection(selection, &buffer) { - Some(new_selection) => { - if new_selection.range() != selection.range() { - selected_larger_symbol = true; - } - new_selection - } - None => selection.clone(), - }) - .collect::>(); - - if selected_larger_symbol { - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(new_selections); - }); - } - } - - pub fn select_larger_syntax_node( - &mut self, - _: &SelectLargerSyntaxNode, - window: &mut Window, - cx: &mut Context, - ) { - let Some(visible_row_count) = self.visible_row_count() else { - return; - }; - let old_selections: Box<[_]> = self.selections.all::(cx).into(); - if old_selections.is_empty() { - return; - } - - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = self.buffer.read(cx).snapshot(cx); - - let mut selected_larger_node = false; - let mut new_selections = old_selections - .iter() - .map(|selection| { - let old_range = selection.start..selection.end; - - if let Some((node, _)) = buffer.syntax_ancestor(old_range.clone()) { - // manually select word at selection - if ["string_content", "inline"].contains(&node.kind()) { - let word_range = { - let display_point = buffer - .offset_to_point(old_range.start) - .to_display_point(&display_map); - let Range { start, end } = - movement::surrounding_word(&display_map, display_point); - start.to_point(&display_map).to_offset(&buffer) - ..end.to_point(&display_map).to_offset(&buffer) - }; - // ignore if word is already selected - if !word_range.is_empty() && old_range != word_range { - let last_word_range = { - let display_point = buffer - .offset_to_point(old_range.end) - .to_display_point(&display_map); - let Range { start, end } = - movement::surrounding_word(&display_map, display_point); - start.to_point(&display_map).to_offset(&buffer) - ..end.to_point(&display_map).to_offset(&buffer) - }; - // only select word if start and end point belongs to same word - if word_range == last_word_range { - selected_larger_node = true; - return Selection { - id: selection.id, - start: word_range.start, - end: word_range.end, - goal: SelectionGoal::None, - reversed: selection.reversed, - }; - } - } - } - } - - let mut new_range = old_range.clone(); - let mut new_node = None; - while let Some((node, containing_range)) = buffer.syntax_ancestor(new_range.clone()) - { - new_node = Some(node); - new_range = match containing_range { - MultiOrSingleBufferOffsetRange::Single(_) => break, - MultiOrSingleBufferOffsetRange::Multi(range) => range, - }; - if !display_map.intersects_fold(new_range.start) - && !display_map.intersects_fold(new_range.end) - { - break; - } - } - - if let Some(node) = new_node { - // Log the ancestor, to support using this action as a way to explore TreeSitter - // nodes. Parent and grandparent are also logged because this operation will not - // visit nodes that have the same range as their parent. - log::info!("Node: {node:?}"); - let parent = node.parent(); - log::info!("Parent: {parent:?}"); - let grandparent = parent.and_then(|x| x.parent()); - log::info!("Grandparent: {grandparent:?}"); - } - - selected_larger_node |= new_range != old_range; - Selection { - id: selection.id, - start: new_range.start, - end: new_range.end, - goal: SelectionGoal::None, - reversed: selection.reversed, - } - }) - .collect::>(); - - if !selected_larger_node { - return; // don't put this call in the history - } - - // scroll based on transformation done to the last selection created by the user - let (last_old, last_new) = old_selections - .last() - .zip(new_selections.last().cloned()) - .expect("old_selections isn't empty"); - - // revert selection - let is_selection_reversed = { - let should_newest_selection_be_reversed = last_old.start != last_new.start; - new_selections.last_mut().expect("checked above").reversed = - should_newest_selection_be_reversed; - should_newest_selection_be_reversed - }; - - if selected_larger_node { - self.select_syntax_node_history.disable_clearing = true; - self.change_selections(None, window, cx, |s| { - s.select(new_selections.clone()); - }); - self.select_syntax_node_history.disable_clearing = false; - } - - let start_row = last_new.start.to_display_point(&display_map).row().0; - let end_row = last_new.end.to_display_point(&display_map).row().0; - let selection_height = end_row - start_row + 1; - let scroll_margin_rows = self.vertical_scroll_margin() as u32; - - let fits_on_the_screen = visible_row_count >= selection_height + scroll_margin_rows * 2; - let scroll_behavior = if fits_on_the_screen { - self.request_autoscroll(Autoscroll::fit(), cx); - SelectSyntaxNodeScrollBehavior::FitSelection - } else if is_selection_reversed { - self.scroll_cursor_top(&ScrollCursorTop, window, cx); - SelectSyntaxNodeScrollBehavior::CursorTop - } else { - self.scroll_cursor_bottom(&ScrollCursorBottom, window, cx); - SelectSyntaxNodeScrollBehavior::CursorBottom - }; - - self.select_syntax_node_history.push(( - old_selections, - scroll_behavior, - is_selection_reversed, - )); - } - - pub fn select_smaller_syntax_node( - &mut self, - _: &SelectSmallerSyntaxNode, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - - if let Some((mut selections, scroll_behavior, is_selection_reversed)) = - self.select_syntax_node_history.pop() - { - if let Some(selection) = selections.last_mut() { - selection.reversed = is_selection_reversed; - } - - self.select_syntax_node_history.disable_clearing = true; - self.change_selections(None, window, cx, |s| { - s.select(selections.to_vec()); - }); - self.select_syntax_node_history.disable_clearing = false; - - match scroll_behavior { - SelectSyntaxNodeScrollBehavior::CursorTop => { - self.scroll_cursor_top(&ScrollCursorTop, window, cx); - } - SelectSyntaxNodeScrollBehavior::FitSelection => { - self.request_autoscroll(Autoscroll::fit(), cx); - } - SelectSyntaxNodeScrollBehavior::CursorBottom => { - self.scroll_cursor_bottom(&ScrollCursorBottom, window, cx); - } - } - } - } - - fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { - if !EditorSettings::get_global(cx).gutter.runnables { - self.clear_tasks(); - return Task::ready(()); - } - let project = self.project.as_ref().map(Entity::downgrade); - let task_sources = self.lsp_task_sources(cx); - cx.spawn_in(window, async move |editor, cx| { - cx.background_executor().timer(UPDATE_DEBOUNCE).await; - let Some(project) = project.and_then(|p| p.upgrade()) else { - return; - }; - let Ok(display_snapshot) = editor.update(cx, |this, cx| { - this.display_map.update(cx, |map, cx| map.snapshot(cx)) - }) else { - return; - }; - - let hide_runnables = project - .update(cx, |project, cx| { - // Do not display any test indicators in non-dev server remote projects. - project.is_via_collab() && project.ssh_connection_string(cx).is_none() - }) - .unwrap_or(true); - if hide_runnables { - return; - } - let new_rows = - cx.background_spawn({ - let snapshot = display_snapshot.clone(); - async move { - Self::fetch_runnable_ranges(&snapshot, Anchor::min()..Anchor::max()) - } - }) - .await; - let Ok(lsp_tasks) = - cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx)) - else { - return; - }; - let lsp_tasks = lsp_tasks.await; - - let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| { - lsp_tasks - .into_iter() - .flat_map(|(kind, tasks)| { - tasks.into_iter().filter_map(move |(location, task)| { - Some((kind.clone(), location?, task)) - }) - }) - .fold(HashMap::default(), |mut acc, (kind, location, task)| { - let buffer = location.target.buffer; - let buffer_snapshot = buffer.read(cx).snapshot(); - let offset = display_snapshot.buffer_snapshot.excerpts().find_map( - |(excerpt_id, snapshot, _)| { - if snapshot.remote_id() == buffer_snapshot.remote_id() { - display_snapshot - .buffer_snapshot - .anchor_in_excerpt(excerpt_id, location.target.range.start) - } else { - None - } - }, - ); - if let Some(offset) = offset { - let task_buffer_range = - location.target.range.to_point(&buffer_snapshot); - let context_buffer_range = - task_buffer_range.to_offset(&buffer_snapshot); - let context_range = BufferOffset(context_buffer_range.start) - ..BufferOffset(context_buffer_range.end); - - acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row)) - .or_insert_with(|| RunnableTasks { - templates: Vec::new(), - offset, - column: task_buffer_range.start.column, - extra_variables: HashMap::default(), - context_range, - }) - .templates - .push((kind, task.original_task().clone())); - } - - acc - }) - }) else { - return; - }; - - let rows = Self::runnable_rows(project, display_snapshot, new_rows, cx.clone()); - editor - .update(cx, |editor, _| { - editor.clear_tasks(); - for (key, mut value) in rows { - if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&key) { - value.templates.extend(lsp_tasks.templates); - } - - editor.insert_tasks(key, value); - } - for (key, value) in lsp_tasks_by_rows { - editor.insert_tasks(key, value); - } - }) - .ok(); - }) - } - fn fetch_runnable_ranges( - snapshot: &DisplaySnapshot, - range: Range, - ) -> Vec { - snapshot.buffer_snapshot.runnable_ranges(range).collect() - } - - fn runnable_rows( - project: Entity, - snapshot: DisplaySnapshot, - runnable_ranges: Vec, - mut cx: AsyncWindowContext, - ) -> Vec<((BufferId, BufferRow), RunnableTasks)> { - runnable_ranges - .into_iter() - .filter_map(|mut runnable| { - let tasks = cx - .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx)) - .ok()?; - if tasks.is_empty() { - return None; - } - - let point = runnable.run_range.start.to_point(&snapshot.buffer_snapshot); - - let row = snapshot - .buffer_snapshot - .buffer_line_for_row(MultiBufferRow(point.row))? - .1 - .start - .row; - - let context_range = - BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end); - Some(( - (runnable.buffer_id, row), - RunnableTasks { - templates: tasks, - offset: snapshot - .buffer_snapshot - .anchor_before(runnable.run_range.start), - context_range, - column: point.column, - extra_variables: runnable.extra_captures, - }, - )) - }) - .collect() - } - - fn templates_with_tags( - project: &Entity, - runnable: &mut Runnable, - cx: &mut App, - ) -> Vec<(TaskSourceKind, TaskTemplate)> { - let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| { - let (worktree_id, file) = project - .buffer_for_id(runnable.buffer, cx) - .and_then(|buffer| buffer.read(cx).file()) - .map(|file| (file.worktree_id(cx), file.clone())) - .unzip(); - - ( - project.task_store().read(cx).task_inventory().cloned(), - worktree_id, - file, - ) - }); - - let mut templates_with_tags = mem::take(&mut runnable.tags) - .into_iter() - .flat_map(|RunnableTag(tag)| { - inventory - .as_ref() - .into_iter() - .flat_map(|inventory| { - inventory.read(cx).list_tasks( - file.clone(), - Some(runnable.language.clone()), - worktree_id, - cx, - ) - }) - .filter(move |(_, template)| { - template.tags.iter().any(|source_tag| source_tag == &tag) - }) - }) - .sorted_by_key(|(kind, _)| kind.to_owned()) - .collect::>(); - if let Some((leading_tag_source, _)) = templates_with_tags.first() { - // Strongest source wins; if we have worktree tag binding, prefer that to - // global and language bindings; - // if we have a global binding, prefer that to language binding. - let first_mismatch = templates_with_tags - .iter() - .position(|(tag_source, _)| tag_source != leading_tag_source); - if let Some(index) = first_mismatch { - templates_with_tags.truncate(index); - } - } - - templates_with_tags - } - - pub fn move_to_enclosing_bracket( - &mut self, - _: &MoveToEnclosingBracket, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.move_offsets_with(|snapshot, selection| { - let Some(enclosing_bracket_ranges) = - snapshot.enclosing_bracket_ranges(selection.start..selection.end) - else { - return; - }; - - let mut best_length = usize::MAX; - let mut best_inside = false; - let mut best_in_bracket_range = false; - let mut best_destination = None; - for (open, close) in enclosing_bracket_ranges { - let close = close.to_inclusive(); - let length = close.end() - open.start; - let inside = selection.start >= open.end && selection.end <= *close.start(); - let in_bracket_range = open.to_inclusive().contains(&selection.head()) - || close.contains(&selection.head()); - - // If best is next to a bracket and current isn't, skip - if !in_bracket_range && best_in_bracket_range { - continue; - } - - // Prefer smaller lengths unless best is inside and current isn't - if length > best_length && (best_inside || !inside) { - continue; - } - - best_length = length; - best_inside = inside; - best_in_bracket_range = in_bracket_range; - best_destination = Some( - if close.contains(&selection.start) && close.contains(&selection.end) { - if inside { open.end } else { open.start } - } else if inside { - *close.start() - } else { - *close.end() - }, - ); - } - - if let Some(destination) = best_destination { - selection.collapse_to(destination, SelectionGoal::None); - } - }) - }); - } - - pub fn undo_selection( - &mut self, - _: &UndoSelection, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.end_selection(window, cx); - self.selection_history.mode = SelectionHistoryMode::Undoing; - if let Some(entry) = self.selection_history.undo_stack.pop_back() { - self.change_selections(None, window, cx, |s| { - s.select_anchors(entry.selections.to_vec()) - }); - self.select_next_state = entry.select_next_state; - self.select_prev_state = entry.select_prev_state; - self.add_selections_state = entry.add_selections_state; - self.request_autoscroll(Autoscroll::newest(), cx); - } - self.selection_history.mode = SelectionHistoryMode::Normal; - } - - pub fn redo_selection( - &mut self, - _: &RedoSelection, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.end_selection(window, cx); - self.selection_history.mode = SelectionHistoryMode::Redoing; - if let Some(entry) = self.selection_history.redo_stack.pop_back() { - self.change_selections(None, window, cx, |s| { - s.select_anchors(entry.selections.to_vec()) - }); - self.select_next_state = entry.select_next_state; - self.select_prev_state = entry.select_prev_state; - self.add_selections_state = entry.add_selections_state; - self.request_autoscroll(Autoscroll::newest(), cx); - } - self.selection_history.mode = SelectionHistoryMode::Normal; - } - - pub fn expand_excerpts( - &mut self, - action: &ExpandExcerpts, - _: &mut Window, - cx: &mut Context, - ) { - self.expand_excerpts_for_direction(action.lines, ExpandExcerptDirection::UpAndDown, cx) - } - - pub fn expand_excerpts_down( - &mut self, - action: &ExpandExcerptsDown, - _: &mut Window, - cx: &mut Context, - ) { - self.expand_excerpts_for_direction(action.lines, ExpandExcerptDirection::Down, cx) - } - - pub fn expand_excerpts_up( - &mut self, - action: &ExpandExcerptsUp, - _: &mut Window, - cx: &mut Context, - ) { - self.expand_excerpts_for_direction(action.lines, ExpandExcerptDirection::Up, cx) - } - - pub fn expand_excerpts_for_direction( - &mut self, - lines: u32, - direction: ExpandExcerptDirection, - - cx: &mut Context, - ) { - let selections = self.selections.disjoint_anchors(); - - let lines = if lines == 0 { - EditorSettings::get_global(cx).expand_excerpt_lines - } else { - lines - }; - - self.buffer.update(cx, |buffer, cx| { - let snapshot = buffer.snapshot(cx); - let mut excerpt_ids = selections - .iter() - .flat_map(|selection| snapshot.excerpt_ids_for_range(selection.range())) - .collect::>(); - excerpt_ids.sort(); - excerpt_ids.dedup(); - buffer.expand_excerpts(excerpt_ids, lines, direction, cx) - }) - } - - pub fn expand_excerpt( - &mut self, - excerpt: ExcerptId, - direction: ExpandExcerptDirection, - window: &mut Window, - cx: &mut Context, - ) { - let current_scroll_position = self.scroll_position(cx); - let lines_to_expand = EditorSettings::get_global(cx).expand_excerpt_lines; - let mut should_scroll_up = false; - - if direction == ExpandExcerptDirection::Down { - let multi_buffer = self.buffer.read(cx); - let snapshot = multi_buffer.snapshot(cx); - if let Some(buffer_id) = snapshot.buffer_id_for_excerpt(excerpt) { - if let Some(buffer) = multi_buffer.buffer(buffer_id) { - if let Some(excerpt_range) = snapshot.buffer_range_for_excerpt(excerpt) { - let buffer_snapshot = buffer.read(cx).snapshot(); - let excerpt_end_row = - Point::from_anchor(&excerpt_range.end, &buffer_snapshot).row; - let last_row = buffer_snapshot.max_point().row; - let lines_below = last_row.saturating_sub(excerpt_end_row); - should_scroll_up = lines_below >= lines_to_expand; - } - } - } - } - - self.buffer.update(cx, |buffer, cx| { - buffer.expand_excerpts([excerpt], lines_to_expand, direction, cx) - }); - - if should_scroll_up { - let new_scroll_position = - current_scroll_position + gpui::Point::new(0.0, lines_to_expand as f32); - self.set_scroll_position(new_scroll_position, window, cx); - } - } - - pub fn go_to_singleton_buffer_point( - &mut self, - point: Point, - window: &mut Window, - cx: &mut Context, - ) { - self.go_to_singleton_buffer_range(point..point, window, cx); - } - - pub fn go_to_singleton_buffer_range( - &mut self, - range: Range, - window: &mut Window, - cx: &mut Context, - ) { - let multibuffer = self.buffer().read(cx); - let Some(buffer) = multibuffer.as_singleton() else { - return; - }; - let Some(start) = multibuffer.buffer_point_to_anchor(&buffer, range.start, cx) else { - return; - }; - let Some(end) = multibuffer.buffer_point_to_anchor(&buffer, range.end, cx) else { - return; - }; - self.change_selections(Some(Autoscroll::center()), window, cx, |s| { - s.select_anchor_ranges([start..end]) - }); - } - - pub fn go_to_diagnostic( - &mut self, - _: &GoToDiagnostic, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.go_to_diagnostic_impl(Direction::Next, window, cx) - } - - pub fn go_to_prev_diagnostic( - &mut self, - _: &GoToPreviousDiagnostic, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - self.go_to_diagnostic_impl(Direction::Prev, window, cx) - } - - pub fn go_to_diagnostic_impl( - &mut self, - direction: Direction, - window: &mut Window, - cx: &mut Context, - ) { - let buffer = self.buffer.read(cx).snapshot(cx); - let selection = self.selections.newest::(cx); - - let mut active_group_id = None; - if let ActiveDiagnostic::Group(active_group) = &self.active_diagnostics { - if active_group.active_range.start.to_offset(&buffer) == selection.start { - active_group_id = Some(active_group.group_id); - } - } - - fn filtered( - snapshot: EditorSnapshot, - diagnostics: impl Iterator>, - ) -> impl Iterator> { - diagnostics - .filter(|entry| entry.range.start != entry.range.end) - .filter(|entry| !entry.diagnostic.is_unnecessary) - .filter(move |entry| !snapshot.intersects_fold(entry.range.start)) - } - - let snapshot = self.snapshot(window, cx); - let before = filtered( - snapshot.clone(), - buffer - .diagnostics_in_range(0..selection.start) - .filter(|entry| entry.range.start <= selection.start), - ); - let after = filtered( - snapshot, - buffer - .diagnostics_in_range(selection.start..buffer.len()) - .filter(|entry| entry.range.start >= selection.start), - ); - - let mut found: Option> = None; - if direction == Direction::Prev { - 'outer: for prev_diagnostics in [before.collect::>(), after.collect::>()] - { - for diagnostic in prev_diagnostics.into_iter().rev() { - if diagnostic.range.start != selection.start - || active_group_id - .is_some_and(|active| diagnostic.diagnostic.group_id < active) - { - found = Some(diagnostic); - break 'outer; - } - } - } - } else { - for diagnostic in after.chain(before) { - if diagnostic.range.start != selection.start - || active_group_id.is_some_and(|active| diagnostic.diagnostic.group_id > active) - { - found = Some(diagnostic); - break; - } - } - } - let Some(next_diagnostic) = found else { - return; - }; - - let Some(buffer_id) = buffer.anchor_after(next_diagnostic.range.start).buffer_id else { - return; - }; - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_ranges(vec![ - next_diagnostic.range.start..next_diagnostic.range.start, - ]) - }); - self.activate_diagnostics(buffer_id, next_diagnostic, window, cx); - self.refresh_inline_completion(false, true, window, cx); - } - - fn go_to_next_hunk(&mut self, _: &GoToHunk, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - let snapshot = self.snapshot(window, cx); - let selection = self.selections.newest::(cx); - self.go_to_hunk_before_or_after_position( - &snapshot, - selection.head(), - Direction::Next, - window, - cx, - ); - } - - pub fn go_to_hunk_before_or_after_position( - &mut self, - snapshot: &EditorSnapshot, - position: Point, - direction: Direction, - window: &mut Window, - cx: &mut Context, - ) { - let row = if direction == Direction::Next { - self.hunk_after_position(snapshot, position) - .map(|hunk| hunk.row_range.start) - } else { - self.hunk_before_position(snapshot, position) - }; - - if let Some(row) = row { - let destination = Point::new(row.0, 0); - let autoscroll = Autoscroll::center(); - - self.unfold_ranges(&[destination..destination], false, false, cx); - self.change_selections(Some(autoscroll), window, cx, |s| { - s.select_ranges([destination..destination]); - }); - } - } - - fn hunk_after_position( - &mut self, - snapshot: &EditorSnapshot, - position: Point, - ) -> Option { - snapshot - .buffer_snapshot - .diff_hunks_in_range(position..snapshot.buffer_snapshot.max_point()) - .find(|hunk| hunk.row_range.start.0 > position.row) - .or_else(|| { - snapshot - .buffer_snapshot - .diff_hunks_in_range(Point::zero()..position) - .find(|hunk| hunk.row_range.end.0 < position.row) - }) - } - - fn go_to_prev_hunk( - &mut self, - _: &GoToPreviousHunk, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); - let snapshot = self.snapshot(window, cx); - let selection = self.selections.newest::(cx); - self.go_to_hunk_before_or_after_position( - &snapshot, - selection.head(), - Direction::Prev, - window, - cx, - ); - } - - fn hunk_before_position( - &mut self, - snapshot: &EditorSnapshot, - position: Point, - ) -> Option { - snapshot - .buffer_snapshot - .diff_hunk_before(position) - .or_else(|| snapshot.buffer_snapshot.diff_hunk_before(Point::MAX)) - } - - fn go_to_next_change( - &mut self, - _: &GoToNextChange, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(selections) = self - .change_list - .next_change(1, Direction::Next) - .map(|s| s.to_vec()) - { - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - let map = s.display_map(); - s.select_display_ranges(selections.iter().map(|a| { - let point = a.to_display_point(&map); - point..point - })) - }) - } - } - - fn go_to_previous_change( - &mut self, - _: &GoToPreviousChange, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(selections) = self - .change_list - .next_change(1, Direction::Prev) - .map(|s| s.to_vec()) - { - self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - let map = s.display_map(); - s.select_display_ranges(selections.iter().map(|a| { - let point = a.to_display_point(&map); - point..point - })) - }) - } - } - - fn go_to_line( - &mut self, - position: Anchor, - highlight_color: Option, - window: &mut Window, - cx: &mut Context, - ) { - let snapshot = self.snapshot(window, cx).display_snapshot; - let position = position.to_point(&snapshot.buffer_snapshot); - let start = snapshot - .buffer_snapshot - .clip_point(Point::new(position.row, 0), Bias::Left); - let end = start + Point::new(1, 0); - let start = snapshot.buffer_snapshot.anchor_before(start); - let end = snapshot.buffer_snapshot.anchor_before(end); - - self.highlight_rows::( - start..end, - highlight_color - .unwrap_or_else(|| cx.theme().colors().editor_highlighted_line_background), - Default::default(), - cx, - ); - self.request_autoscroll(Autoscroll::center().for_anchor(start), cx); - } - - pub fn go_to_definition( - &mut self, - _: &GoToDefinition, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - let definition = - self.go_to_definition_of_kind(GotoDefinitionKind::Symbol, false, window, cx); - let fallback_strategy = EditorSettings::get_global(cx).go_to_definition_fallback; - cx.spawn_in(window, async move |editor, cx| { - if definition.await? == Navigated::Yes { - return Ok(Navigated::Yes); - } - match fallback_strategy { - GoToDefinitionFallback::None => Ok(Navigated::No), - GoToDefinitionFallback::FindAllReferences => { - match editor.update_in(cx, |editor, window, cx| { - editor.find_all_references(&FindAllReferences, window, cx) - })? { - Some(references) => references.await, - None => Ok(Navigated::No), - } - } - } - }) - } - - pub fn go_to_declaration( - &mut self, - _: &GoToDeclaration, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - self.go_to_definition_of_kind(GotoDefinitionKind::Declaration, false, window, cx) - } - - pub fn go_to_declaration_split( - &mut self, - _: &GoToDeclaration, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - self.go_to_definition_of_kind(GotoDefinitionKind::Declaration, true, window, cx) - } - - pub fn go_to_implementation( - &mut self, - _: &GoToImplementation, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - self.go_to_definition_of_kind(GotoDefinitionKind::Implementation, false, window, cx) - } - - pub fn go_to_implementation_split( - &mut self, - _: &GoToImplementationSplit, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - self.go_to_definition_of_kind(GotoDefinitionKind::Implementation, true, window, cx) - } - - pub fn go_to_type_definition( - &mut self, - _: &GoToTypeDefinition, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - self.go_to_definition_of_kind(GotoDefinitionKind::Type, false, window, cx) - } - - pub fn go_to_definition_split( - &mut self, - _: &GoToDefinitionSplit, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - self.go_to_definition_of_kind(GotoDefinitionKind::Symbol, true, window, cx) - } - - pub fn go_to_type_definition_split( - &mut self, - _: &GoToTypeDefinitionSplit, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - self.go_to_definition_of_kind(GotoDefinitionKind::Type, true, window, cx) - } - - fn go_to_definition_of_kind( - &mut self, - kind: GotoDefinitionKind, - split: bool, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - let Some(provider) = self.semantics_provider.clone() else { - return Task::ready(Ok(Navigated::No)); - }; - let head = self.selections.newest::(cx).head(); - let buffer = self.buffer.read(cx); - let (buffer, head) = if let Some(text_anchor) = buffer.text_anchor_for_position(head, cx) { - text_anchor - } else { - return Task::ready(Ok(Navigated::No)); - }; - - let Some(definitions) = provider.definitions(&buffer, head, kind, cx) else { - return Task::ready(Ok(Navigated::No)); - }; - - cx.spawn_in(window, async move |editor, cx| { - let definitions = definitions.await?; - let navigated = editor - .update_in(cx, |editor, window, cx| { - editor.navigate_to_hover_links( - Some(kind), - definitions - .into_iter() - .filter(|location| { - hover_links::exclude_link_to_position(&buffer, &head, location, cx) - }) - .map(HoverLink::Text) - .collect::>(), - split, - window, - cx, - ) - })? - .await?; - anyhow::Ok(navigated) - }) - } - - pub fn open_url(&mut self, _: &OpenUrl, window: &mut Window, cx: &mut Context) { - let selection = self.selections.newest_anchor(); - let head = selection.head(); - let tail = selection.tail(); - - let Some((buffer, start_position)) = - self.buffer.read(cx).text_anchor_for_position(head, cx) - else { - return; - }; - - let end_position = if head != tail { - let Some((_, pos)) = self.buffer.read(cx).text_anchor_for_position(tail, cx) else { - return; - }; - Some(pos) - } else { - None - }; - - let url_finder = cx.spawn_in(window, async move |editor, cx| { - let url = if let Some(end_pos) = end_position { - find_url_from_range(&buffer, start_position..end_pos, cx.clone()) - } else { - find_url(&buffer, start_position, cx.clone()).map(|(_, url)| url) - }; - - if let Some(url) = url { - editor.update(cx, |_, cx| { - cx.open_url(&url); - }) - } else { - Ok(()) - } - }); - - url_finder.detach(); - } - - pub fn open_selected_filename( - &mut self, - _: &OpenSelectedFilename, - window: &mut Window, - cx: &mut Context, - ) { - let Some(workspace) = self.workspace() else { - return; - }; - - let position = self.selections.newest_anchor().head(); - - let Some((buffer, buffer_position)) = - self.buffer.read(cx).text_anchor_for_position(position, cx) - else { - return; - }; - - let project = self.project.clone(); - - cx.spawn_in(window, async move |_, cx| { - let result = find_file(&buffer, project, buffer_position, cx).await; - - if let Some((_, path)) = result { - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_resolved_path(path, window, cx) - })? - .await?; - } - anyhow::Ok(()) - }) - .detach(); - } - - pub(crate) fn navigate_to_hover_links( - &mut self, - kind: Option, - mut definitions: Vec, - split: bool, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - // If there is one definition, just open it directly - if definitions.len() == 1 { - let definition = definitions.pop().unwrap(); - - enum TargetTaskResult { - Location(Option), - AlreadyNavigated, - } - - let target_task = match definition { - HoverLink::Text(link) => { - Task::ready(anyhow::Ok(TargetTaskResult::Location(Some(link.target)))) - } - HoverLink::InlayHint(lsp_location, server_id) => { - let computation = - self.compute_target_location(lsp_location, server_id, window, cx); - cx.background_spawn(async move { - let location = computation.await?; - Ok(TargetTaskResult::Location(location)) - }) - } - HoverLink::Url(url) => { - cx.open_url(&url); - Task::ready(Ok(TargetTaskResult::AlreadyNavigated)) - } - HoverLink::File(path) => { - if let Some(workspace) = self.workspace() { - cx.spawn_in(window, async move |_, cx| { - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_resolved_path(path, window, cx) - })? - .await - .map(|_| TargetTaskResult::AlreadyNavigated) - }) - } else { - Task::ready(Ok(TargetTaskResult::Location(None))) - } - } - }; - cx.spawn_in(window, async move |editor, cx| { - let target = match target_task.await.context("target resolution task")? { - TargetTaskResult::AlreadyNavigated => return Ok(Navigated::Yes), - TargetTaskResult::Location(None) => return Ok(Navigated::No), - TargetTaskResult::Location(Some(target)) => target, - }; - - editor.update_in(cx, |editor, window, cx| { - let Some(workspace) = editor.workspace() else { - return Navigated::No; - }; - let pane = workspace.read(cx).active_pane().clone(); - - let range = target.range.to_point(target.buffer.read(cx)); - let range = editor.range_for_match(&range); - let range = collapse_multiline_range(range); - - if !split - && Some(&target.buffer) == editor.buffer.read(cx).as_singleton().as_ref() - { - editor.go_to_singleton_buffer_range(range.clone(), window, cx); - } else { - window.defer(cx, move |window, cx| { - let target_editor: Entity = - workspace.update(cx, |workspace, cx| { - let pane = if split { - workspace.adjacent_pane(window, cx) - } else { - workspace.active_pane().clone() - }; - - workspace.open_project_item( - pane, - target.buffer.clone(), - true, - true, - window, - cx, - ) - }); - target_editor.update(cx, |target_editor, cx| { - // When selecting a definition in a different buffer, disable the nav history - // to avoid creating a history entry at the previous cursor location. - pane.update(cx, |pane, _| pane.disable_history()); - target_editor.go_to_singleton_buffer_range(range, window, cx); - pane.update(cx, |pane, _| pane.enable_history()); - }); - }); - } - Navigated::Yes - }) - }) - } else if !definitions.is_empty() { - cx.spawn_in(window, async move |editor, cx| { - let (title, location_tasks, workspace) = editor - .update_in(cx, |editor, window, cx| { - let tab_kind = match kind { - Some(GotoDefinitionKind::Implementation) => "Implementations", - _ => "Definitions", - }; - let title = definitions - .iter() - .find_map(|definition| match definition { - HoverLink::Text(link) => link.origin.as_ref().map(|origin| { - let buffer = origin.buffer.read(cx); - format!( - "{} for {}", - tab_kind, - buffer - .text_for_range(origin.range.clone()) - .collect::() - ) - }), - HoverLink::InlayHint(_, _) => None, - HoverLink::Url(_) => None, - HoverLink::File(_) => None, - }) - .unwrap_or(tab_kind.to_string()); - let location_tasks = definitions - .into_iter() - .map(|definition| match definition { - HoverLink::Text(link) => Task::ready(Ok(Some(link.target))), - HoverLink::InlayHint(lsp_location, server_id) => editor - .compute_target_location(lsp_location, server_id, window, cx), - HoverLink::Url(_) => Task::ready(Ok(None)), - HoverLink::File(_) => Task::ready(Ok(None)), - }) - .collect::>(); - (title, location_tasks, editor.workspace().clone()) - }) - .context("location tasks preparation")?; - - let locations = future::join_all(location_tasks) - .await - .into_iter() - .filter_map(|location| location.transpose()) - .collect::>() - .context("location tasks")?; - - let Some(workspace) = workspace else { - return Ok(Navigated::No); - }; - let opened = workspace - .update_in(cx, |workspace, window, cx| { - Self::open_locations_in_multibuffer( - workspace, - locations, - title, - split, - MultibufferSelectionMode::First, - window, - cx, - ) - }) - .ok(); - - anyhow::Ok(Navigated::from_bool(opened.is_some())) - }) - } else { - Task::ready(Ok(Navigated::No)) - } - } - - fn compute_target_location( - &self, - lsp_location: lsp::Location, - server_id: LanguageServerId, - window: &mut Window, - cx: &mut Context, - ) -> Task>> { - let Some(project) = self.project.clone() else { - return Task::ready(Ok(None)); - }; - - cx.spawn_in(window, async move |editor, cx| { - let location_task = editor.update(cx, |_, cx| { - project.update(cx, |project, cx| { - let language_server_name = project - .language_server_statuses(cx) - .find(|(id, _)| server_id == *id) - .map(|(_, status)| LanguageServerName::from(status.name.as_str())); - language_server_name.map(|language_server_name| { - project.open_local_buffer_via_lsp( - lsp_location.uri.clone(), - server_id, - language_server_name, - cx, - ) - }) - }) - })?; - let location = match location_task { - Some(task) => Some({ - let target_buffer_handle = task.await.context("open local buffer")?; - let range = target_buffer_handle.update(cx, |target_buffer, _| { - let target_start = target_buffer - .clip_point_utf16(point_from_lsp(lsp_location.range.start), Bias::Left); - let target_end = target_buffer - .clip_point_utf16(point_from_lsp(lsp_location.range.end), Bias::Left); - target_buffer.anchor_after(target_start) - ..target_buffer.anchor_before(target_end) - })?; - Location { - buffer: target_buffer_handle, - range, - } - }), - None => None, - }; - Ok(location) - }) - } - - pub fn find_all_references( - &mut self, - _: &FindAllReferences, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - let selection = self.selections.newest::(cx); - let multi_buffer = self.buffer.read(cx); - let head = selection.head(); - - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let head_anchor = multi_buffer_snapshot.anchor_at( - head, - if head < selection.tail() { - Bias::Right - } else { - Bias::Left - }, - ); - - match self - .find_all_references_task_sources - .binary_search_by(|anchor| anchor.cmp(&head_anchor, &multi_buffer_snapshot)) - { - Ok(_) => { - log::info!( - "Ignoring repeated FindAllReferences invocation with the position of already running task" - ); - return None; - } - Err(i) => { - self.find_all_references_task_sources.insert(i, head_anchor); - } - } - - let (buffer, head) = multi_buffer.text_anchor_for_position(head, cx)?; - let workspace = self.workspace()?; - let project = workspace.read(cx).project().clone(); - let references = project.update(cx, |project, cx| project.references(&buffer, head, cx)); - Some(cx.spawn_in(window, async move |editor, cx| { - let _cleanup = cx.on_drop(&editor, move |editor, _| { - if let Ok(i) = editor - .find_all_references_task_sources - .binary_search_by(|anchor| anchor.cmp(&head_anchor, &multi_buffer_snapshot)) - { - editor.find_all_references_task_sources.remove(i); - } - }); - - let locations = references.await?; - if locations.is_empty() { - return anyhow::Ok(Navigated::No); - } - - workspace.update_in(cx, |workspace, window, cx| { - let title = locations - .first() - .as_ref() - .map(|location| { - let buffer = location.buffer.read(cx); - format!( - "References to `{}`", - buffer - .text_for_range(location.range.clone()) - .collect::() - ) - }) - .unwrap(); - Self::open_locations_in_multibuffer( - workspace, - locations, - title, - false, - MultibufferSelectionMode::First, - window, - cx, - ); - Navigated::Yes - }) - })) - } - - /// Opens a multibuffer with the given project locations in it - pub fn open_locations_in_multibuffer( - workspace: &mut Workspace, - mut locations: Vec, - title: String, - split: bool, - multibuffer_selection_mode: MultibufferSelectionMode, - window: &mut Window, - cx: &mut Context, - ) { - // If there are multiple definitions, open them in a multibuffer - locations.sort_by_key(|location| location.buffer.read(cx).remote_id()); - let mut locations = locations.into_iter().peekable(); - let mut ranges: Vec> = Vec::new(); - let capability = workspace.project().read(cx).capability(); - - let excerpt_buffer = cx.new(|cx| { - let mut multibuffer = MultiBuffer::new(capability); - while let Some(location) = locations.next() { - let buffer = location.buffer.read(cx); - let mut ranges_for_buffer = Vec::new(); - let range = location.range.to_point(buffer); - ranges_for_buffer.push(range.clone()); - - while let Some(next_location) = locations.peek() { - if next_location.buffer == location.buffer { - ranges_for_buffer.push(next_location.range.to_point(buffer)); - locations.next(); - } else { - break; - } - } - - ranges_for_buffer.sort_by_key(|range| (range.start, Reverse(range.end))); - let (new_ranges, _) = multibuffer.set_excerpts_for_path( - PathKey::for_buffer(&location.buffer, cx), - location.buffer.clone(), - ranges_for_buffer, - DEFAULT_MULTIBUFFER_CONTEXT, - cx, - ); - ranges.extend(new_ranges) - } - - multibuffer.with_title(title) - }); - - let editor = cx.new(|cx| { - Editor::for_multibuffer( - excerpt_buffer, - Some(workspace.project().clone()), - window, - cx, - ) - }); - editor.update(cx, |editor, cx| { - match multibuffer_selection_mode { - MultibufferSelectionMode::First => { - if let Some(first_range) = ranges.first() { - editor.change_selections(None, window, cx, |selections| { - selections.clear_disjoint(); - selections.select_anchor_ranges(std::iter::once(first_range.clone())); - }); - } - editor.highlight_background::( - &ranges, - |theme| theme.editor_highlighted_line_background, - cx, - ); - } - MultibufferSelectionMode::All => { - editor.change_selections(None, window, cx, |selections| { - selections.clear_disjoint(); - selections.select_anchor_ranges(ranges); - }); - } - } - editor.register_buffers_with_language_servers(cx); - }); - - let item = Box::new(editor); - let item_id = item.item_id(); - - if split { - workspace.split_item(SplitDirection::Right, item.clone(), window, cx); - } else { - if PreviewTabsSettings::get_global(cx).enable_preview_from_code_navigation { - let (preview_item_id, preview_item_idx) = - workspace.active_pane().update(cx, |pane, _| { - (pane.preview_item_id(), pane.preview_item_idx()) - }); - - workspace.add_item_to_active_pane(item.clone(), preview_item_idx, true, window, cx); - - if let Some(preview_item_id) = preview_item_id { - workspace.active_pane().update(cx, |pane, cx| { - pane.remove_item(preview_item_id, false, false, window, cx); - }); - } - } else { - workspace.add_item_to_active_pane(item.clone(), None, true, window, cx); - } - } - workspace.active_pane().update(cx, |pane, cx| { - pane.set_preview_item_id(Some(item_id), cx); - }); - } - - pub fn rename( - &mut self, - _: &Rename, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - use language::ToOffset as _; - - let provider = self.semantics_provider.clone()?; - let selection = self.selections.newest_anchor().clone(); - let (cursor_buffer, cursor_buffer_position) = self - .buffer - .read(cx) - .text_anchor_for_position(selection.head(), cx)?; - let (tail_buffer, cursor_buffer_position_end) = self - .buffer - .read(cx) - .text_anchor_for_position(selection.tail(), cx)?; - if tail_buffer != cursor_buffer { - return None; - } - - let snapshot = cursor_buffer.read(cx).snapshot(); - let cursor_buffer_offset = cursor_buffer_position.to_offset(&snapshot); - let cursor_buffer_offset_end = cursor_buffer_position_end.to_offset(&snapshot); - let prepare_rename = provider - .range_for_rename(&cursor_buffer, cursor_buffer_position, cx) - .unwrap_or_else(|| Task::ready(Ok(None))); - drop(snapshot); - - Some(cx.spawn_in(window, async move |this, cx| { - let rename_range = if let Some(range) = prepare_rename.await? { - Some(range) - } else { - this.update(cx, |this, cx| { - let buffer = this.buffer.read(cx).snapshot(cx); - let mut buffer_highlights = this - .document_highlights_for_position(selection.head(), &buffer) - .filter(|highlight| { - highlight.start.excerpt_id == selection.head().excerpt_id - && highlight.end.excerpt_id == selection.head().excerpt_id - }); - buffer_highlights - .next() - .map(|highlight| highlight.start.text_anchor..highlight.end.text_anchor) - })? - }; - if let Some(rename_range) = rename_range { - this.update_in(cx, |this, window, cx| { - let snapshot = cursor_buffer.read(cx).snapshot(); - let rename_buffer_range = rename_range.to_offset(&snapshot); - let cursor_offset_in_rename_range = - cursor_buffer_offset.saturating_sub(rename_buffer_range.start); - let cursor_offset_in_rename_range_end = - cursor_buffer_offset_end.saturating_sub(rename_buffer_range.start); - - this.take_rename(false, window, cx); - let buffer = this.buffer.read(cx).read(cx); - let cursor_offset = selection.head().to_offset(&buffer); - let rename_start = cursor_offset.saturating_sub(cursor_offset_in_rename_range); - let rename_end = rename_start + rename_buffer_range.len(); - let range = buffer.anchor_before(rename_start)..buffer.anchor_after(rename_end); - let mut old_highlight_id = None; - let old_name: Arc = buffer - .chunks(rename_start..rename_end, true) - .map(|chunk| { - if old_highlight_id.is_none() { - old_highlight_id = chunk.syntax_highlight_id; - } - chunk.text - }) - .collect::() - .into(); - - drop(buffer); - - // Position the selection in the rename editor so that it matches the current selection. - this.show_local_selections = false; - let rename_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.buffer.update(cx, |buffer, cx| { - buffer.edit([(0..0, old_name.clone())], None, cx) - }); - let rename_selection_range = match cursor_offset_in_rename_range - .cmp(&cursor_offset_in_rename_range_end) - { - Ordering::Equal => { - editor.select_all(&SelectAll, window, cx); - return editor; - } - Ordering::Less => { - cursor_offset_in_rename_range..cursor_offset_in_rename_range_end - } - Ordering::Greater => { - cursor_offset_in_rename_range_end..cursor_offset_in_rename_range - } - }; - if rename_selection_range.end > old_name.len() { - editor.select_all(&SelectAll, window, cx); - } else { - editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select_ranges([rename_selection_range]); - }); - } - editor - }); - cx.subscribe(&rename_editor, |_, _, e: &EditorEvent, cx| { - if e == &EditorEvent::Focused { - cx.emit(EditorEvent::FocusedIn) - } - }) - .detach(); - - let write_highlights = - this.clear_background_highlights::(cx); - let read_highlights = - this.clear_background_highlights::(cx); - let ranges = write_highlights - .iter() - .flat_map(|(_, ranges)| ranges.iter()) - .chain(read_highlights.iter().flat_map(|(_, ranges)| ranges.iter())) - .cloned() - .collect(); - - this.highlight_text::( - ranges, - HighlightStyle { - fade_out: Some(0.6), - ..Default::default() - }, - cx, - ); - let rename_focus_handle = rename_editor.focus_handle(cx); - window.focus(&rename_focus_handle); - let block_id = this.insert_blocks( - [BlockProperties { - style: BlockStyle::Flex, - placement: BlockPlacement::Below(range.start), - height: Some(1), - render: Arc::new({ - let rename_editor = rename_editor.clone(); - move |cx: &mut BlockContext| { - let mut text_style = cx.editor_style.text.clone(); - if let Some(highlight_style) = old_highlight_id - .and_then(|h| h.style(&cx.editor_style.syntax)) - { - text_style = text_style.highlight(highlight_style); - } - div() - .block_mouse_down() - .pl(cx.anchor_x) - .child(EditorElement::new( - &rename_editor, - EditorStyle { - background: cx.theme().system().transparent, - local_player: cx.editor_style.local_player, - text: text_style, - scrollbar_width: cx.editor_style.scrollbar_width, - syntax: cx.editor_style.syntax.clone(), - status: cx.editor_style.status.clone(), - inlay_hints_style: HighlightStyle { - font_weight: Some(FontWeight::BOLD), - ..make_inlay_hints_style(cx.app) - }, - inline_completion_styles: make_suggestion_styles( - cx.app, - ), - ..EditorStyle::default() - }, - )) - .into_any_element() - } - }), - priority: 0, - }], - Some(Autoscroll::fit()), - cx, - )[0]; - this.pending_rename = Some(RenameState { - range, - old_name, - editor: rename_editor, - block_id, - }); - })?; - } - - Ok(()) - })) - } - - pub fn confirm_rename( - &mut self, - _: &ConfirmRename, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - let rename = self.take_rename(false, window, cx)?; - let workspace = self.workspace()?.downgrade(); - let (buffer, start) = self - .buffer - .read(cx) - .text_anchor_for_position(rename.range.start, cx)?; - let (end_buffer, _) = self - .buffer - .read(cx) - .text_anchor_for_position(rename.range.end, cx)?; - if buffer != end_buffer { - return None; - } - - let old_name = rename.old_name; - let new_name = rename.editor.read(cx).text(cx); - - let rename = self.semantics_provider.as_ref()?.perform_rename( - &buffer, - start, - new_name.clone(), - cx, - )?; - - Some(cx.spawn_in(window, async move |editor, cx| { - let project_transaction = rename.await?; - Self::open_project_transaction( - &editor, - workspace, - project_transaction, - format!("Rename: {} → {}", old_name, new_name), - cx, - ) - .await?; - - editor.update(cx, |editor, cx| { - editor.refresh_document_highlights(cx); - })?; - Ok(()) - })) - } - - fn take_rename( - &mut self, - moving_cursor: bool, - window: &mut Window, - cx: &mut Context, - ) -> Option { - let rename = self.pending_rename.take()?; - if rename.editor.focus_handle(cx).is_focused(window) { - window.focus(&self.focus_handle); - } - - self.remove_blocks( - [rename.block_id].into_iter().collect(), - Some(Autoscroll::fit()), - cx, - ); - self.clear_highlights::(cx); - self.show_local_selections = true; - - if moving_cursor { - let cursor_in_rename_editor = rename.editor.update(cx, |editor, cx| { - editor.selections.newest::(cx).head() - }); - - // Update the selection to match the position of the selection inside - // the rename editor. - let snapshot = self.buffer.read(cx).read(cx); - let rename_range = rename.range.to_offset(&snapshot); - let cursor_in_editor = snapshot - .clip_offset(rename_range.start + cursor_in_rename_editor, Bias::Left) - .min(rename_range.end); - drop(snapshot); - - self.change_selections(None, window, cx, |s| { - s.select_ranges(vec![cursor_in_editor..cursor_in_editor]) - }); - } else { - self.refresh_document_highlights(cx); - } - - Some(rename) - } - - pub fn pending_rename(&self) -> Option<&RenameState> { - self.pending_rename.as_ref() - } - - fn format( - &mut self, - _: &Format, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let project = match &self.project { - Some(project) => project.clone(), - None => return None, - }; - - Some(self.perform_format( - project, - FormatTrigger::Manual, - FormatTarget::Buffers, - window, - cx, - )) - } - - fn format_selections( - &mut self, - _: &FormatSelections, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let project = match &self.project { - Some(project) => project.clone(), - None => return None, - }; - - let ranges = self - .selections - .all_adjusted(cx) - .into_iter() - .map(|selection| selection.range()) - .collect_vec(); - - Some(self.perform_format( - project, - FormatTrigger::Manual, - FormatTarget::Ranges(ranges), - window, - cx, - )) - } - - fn perform_format( - &mut self, - project: Entity, - trigger: FormatTrigger, - target: FormatTarget, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - let buffer = self.buffer.clone(); - let (buffers, target) = match target { - FormatTarget::Buffers => { - let mut buffers = buffer.read(cx).all_buffers(); - if trigger == FormatTrigger::Save { - buffers.retain(|buffer| buffer.read(cx).is_dirty()); - } - (buffers, LspFormatTarget::Buffers) - } - FormatTarget::Ranges(selection_ranges) => { - let multi_buffer = buffer.read(cx); - let snapshot = multi_buffer.read(cx); - let mut buffers = HashSet::default(); - let mut buffer_id_to_ranges: BTreeMap>> = - BTreeMap::new(); - for selection_range in selection_ranges { - for (buffer, buffer_range, _) in - snapshot.range_to_buffer_ranges(selection_range) - { - let buffer_id = buffer.remote_id(); - let start = buffer.anchor_before(buffer_range.start); - let end = buffer.anchor_after(buffer_range.end); - buffers.insert(multi_buffer.buffer(buffer_id).unwrap()); - buffer_id_to_ranges - .entry(buffer_id) - .and_modify(|buffer_ranges| buffer_ranges.push(start..end)) - .or_insert_with(|| vec![start..end]); - } - } - (buffers, LspFormatTarget::Ranges(buffer_id_to_ranges)) - } - }; - - let transaction_id_prev = buffer.read_with(cx, |b, cx| b.last_transaction_id(cx)); - let selections_prev = transaction_id_prev - .and_then(|transaction_id_prev| { - // default to selections as they were after the last edit, if we have them, - // instead of how they are now. - // This will make it so that editing, moving somewhere else, formatting, then undoing the format - // will take you back to where you made the last edit, instead of staying where you scrolled - self.selection_history - .transaction(transaction_id_prev) - .map(|t| t.0.clone()) - }) - .unwrap_or_else(|| { - log::info!("Failed to determine selections from before format. Falling back to selections when format was initiated"); - self.selections.disjoint_anchors() - }); - - let mut timeout = cx.background_executor().timer(FORMAT_TIMEOUT).fuse(); - let format = project.update(cx, |project, cx| { - project.format(buffers, target, true, trigger, cx) - }); - - cx.spawn_in(window, async move |editor, cx| { - let transaction = futures::select_biased! { - transaction = format.log_err().fuse() => transaction, - () = timeout => { - log::warn!("timed out waiting for formatting"); - None - } - }; - - buffer - .update(cx, |buffer, cx| { - if let Some(transaction) = transaction { - if !buffer.is_singleton() { - buffer.push_transaction(&transaction.0, cx); - } - } - cx.notify(); - }) - .ok(); - - if let Some(transaction_id_now) = - buffer.read_with(cx, |b, cx| b.last_transaction_id(cx))? - { - let has_new_transaction = transaction_id_prev != Some(transaction_id_now); - if has_new_transaction { - _ = editor.update(cx, |editor, _| { - editor - .selection_history - .insert_transaction(transaction_id_now, selections_prev); - }); - } - } - - Ok(()) - }) - } - - fn organize_imports( - &mut self, - _: &OrganizeImports, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let project = match &self.project { - Some(project) => project.clone(), - None => return None, - }; - Some(self.perform_code_action_kind( - project, - CodeActionKind::SOURCE_ORGANIZE_IMPORTS, - window, - cx, - )) - } - - fn perform_code_action_kind( - &mut self, - project: Entity, - kind: CodeActionKind, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - let buffer = self.buffer.clone(); - let buffers = buffer.read(cx).all_buffers(); - let mut timeout = cx.background_executor().timer(CODE_ACTION_TIMEOUT).fuse(); - let apply_action = project.update(cx, |project, cx| { - project.apply_code_action_kind(buffers, kind, true, cx) - }); - cx.spawn_in(window, async move |_, cx| { - let transaction = futures::select_biased! { - () = timeout => { - log::warn!("timed out waiting for executing code action"); - None - } - transaction = apply_action.log_err().fuse() => transaction, - }; - buffer - .update(cx, |buffer, cx| { - // check if we need this - if let Some(transaction) = transaction { - if !buffer.is_singleton() { - buffer.push_transaction(&transaction.0, cx); - } - } - cx.notify(); - }) - .ok(); - Ok(()) - }) - } - - fn restart_language_server( - &mut self, - _: &RestartLanguageServer, - _: &mut Window, - cx: &mut Context, - ) { - if let Some(project) = self.project.clone() { - self.buffer.update(cx, |multi_buffer, cx| { - project.update(cx, |project, cx| { - project.restart_language_servers_for_buffers( - multi_buffer.all_buffers().into_iter().collect(), - cx, - ); - }); - }) - } - } - - fn stop_language_server( - &mut self, - _: &StopLanguageServer, - _: &mut Window, - cx: &mut Context, - ) { - if let Some(project) = self.project.clone() { - self.buffer.update(cx, |multi_buffer, cx| { - project.update(cx, |project, cx| { - project.stop_language_servers_for_buffers( - multi_buffer.all_buffers().into_iter().collect(), - cx, - ); - cx.emit(project::Event::RefreshInlayHints); - }); - }); - } - } - - fn cancel_language_server_work( - workspace: &mut Workspace, - _: &actions::CancelLanguageServerWork, - _: &mut Window, - cx: &mut Context, - ) { - let project = workspace.project(); - let buffers = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - .map_or(HashSet::default(), |editor| { - editor.read(cx).buffer.read(cx).all_buffers() - }); - project.update(cx, |project, cx| { - project.cancel_language_server_work_for_buffers(buffers, cx); - }); - } - - fn show_character_palette( - &mut self, - _: &ShowCharacterPalette, - window: &mut Window, - _: &mut Context, - ) { - window.show_character_palette(); - } - - fn refresh_active_diagnostics(&mut self, cx: &mut Context) { - if let ActiveDiagnostic::Group(active_diagnostics) = &mut self.active_diagnostics { - let buffer = self.buffer.read(cx).snapshot(cx); - let primary_range_start = active_diagnostics.active_range.start.to_offset(&buffer); - let primary_range_end = active_diagnostics.active_range.end.to_offset(&buffer); - let is_valid = buffer - .diagnostics_in_range::(primary_range_start..primary_range_end) - .any(|entry| { - entry.diagnostic.is_primary - && !entry.range.is_empty() - && entry.range.start == primary_range_start - && entry.diagnostic.message == active_diagnostics.active_message - }); - - if !is_valid { - self.dismiss_diagnostics(cx); - } - } - } - - pub fn active_diagnostic_group(&self) -> Option<&ActiveDiagnosticGroup> { - match &self.active_diagnostics { - ActiveDiagnostic::Group(group) => Some(group), - _ => None, - } - } - - pub fn set_all_diagnostics_active(&mut self, cx: &mut Context) { - self.dismiss_diagnostics(cx); - self.active_diagnostics = ActiveDiagnostic::All; - } - - fn activate_diagnostics( - &mut self, - buffer_id: BufferId, - diagnostic: DiagnosticEntry, - window: &mut Window, - cx: &mut Context, - ) { - if matches!(self.active_diagnostics, ActiveDiagnostic::All) { - return; - } - self.dismiss_diagnostics(cx); - let snapshot = self.snapshot(window, cx); - let buffer = self.buffer.read(cx).snapshot(cx); - let Some(renderer) = GlobalDiagnosticRenderer::global(cx) else { - return; - }; - - let diagnostic_group = buffer - .diagnostic_group(buffer_id, diagnostic.diagnostic.group_id) - .collect::>(); - - let blocks = - renderer.render_group(diagnostic_group, buffer_id, snapshot, cx.weak_entity(), cx); - - let blocks = self.display_map.update(cx, |display_map, cx| { - display_map.insert_blocks(blocks, cx).into_iter().collect() - }); - self.active_diagnostics = ActiveDiagnostic::Group(ActiveDiagnosticGroup { - active_range: buffer.anchor_before(diagnostic.range.start) - ..buffer.anchor_after(diagnostic.range.end), - active_message: diagnostic.diagnostic.message.clone(), - group_id: diagnostic.diagnostic.group_id, - blocks, - }); - cx.notify(); - } - - fn dismiss_diagnostics(&mut self, cx: &mut Context) { - if matches!(self.active_diagnostics, ActiveDiagnostic::All) { - return; - }; - - let prev = mem::replace(&mut self.active_diagnostics, ActiveDiagnostic::None); - if let ActiveDiagnostic::Group(group) = prev { - self.display_map.update(cx, |display_map, cx| { - display_map.remove_blocks(group.blocks, cx); - }); - cx.notify(); - } - } - - /// Disable inline diagnostics rendering for this editor. - pub fn disable_inline_diagnostics(&mut self) { - self.inline_diagnostics_enabled = false; - self.inline_diagnostics_update = Task::ready(()); - self.inline_diagnostics.clear(); - } - - pub fn inline_diagnostics_enabled(&self) -> bool { - self.inline_diagnostics_enabled - } - - pub fn show_inline_diagnostics(&self) -> bool { - self.show_inline_diagnostics - } - - pub fn toggle_inline_diagnostics( - &mut self, - _: &ToggleInlineDiagnostics, - window: &mut Window, - cx: &mut Context, - ) { - self.show_inline_diagnostics = !self.show_inline_diagnostics; - self.refresh_inline_diagnostics(false, window, cx); - } - - fn refresh_inline_diagnostics( - &mut self, - debounce: bool, - window: &mut Window, - cx: &mut Context, - ) { - if !self.inline_diagnostics_enabled || !self.show_inline_diagnostics { - self.inline_diagnostics_update = Task::ready(()); - self.inline_diagnostics.clear(); - return; - } - - let debounce_ms = ProjectSettings::get_global(cx) - .diagnostics - .inline - .update_debounce_ms; - let debounce = if debounce && debounce_ms > 0 { - Some(Duration::from_millis(debounce_ms)) - } else { - None - }; - self.inline_diagnostics_update = cx.spawn_in(window, async move |editor, cx| { - let editor = editor.upgrade().unwrap(); - - if let Some(debounce) = debounce { - cx.background_executor().timer(debounce).await; - } - let Some(snapshot) = editor - .update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)) - .ok() - else { - return; - }; - - let new_inline_diagnostics = cx - .background_spawn(async move { - let mut inline_diagnostics = Vec::<(Anchor, InlineDiagnostic)>::new(); - for diagnostic_entry in snapshot.diagnostics_in_range(0..snapshot.len()) { - let message = diagnostic_entry - .diagnostic - .message - .split_once('\n') - .map(|(line, _)| line) - .map(SharedString::new) - .unwrap_or_else(|| { - SharedString::from(diagnostic_entry.diagnostic.message) - }); - let start_anchor = snapshot.anchor_before(diagnostic_entry.range.start); - let (Ok(i) | Err(i)) = inline_diagnostics - .binary_search_by(|(probe, _)| probe.cmp(&start_anchor, &snapshot)); - inline_diagnostics.insert( - i, - ( - start_anchor, - InlineDiagnostic { - message, - group_id: diagnostic_entry.diagnostic.group_id, - start: diagnostic_entry.range.start.to_point(&snapshot), - is_primary: diagnostic_entry.diagnostic.is_primary, - severity: diagnostic_entry.diagnostic.severity, - }, - ), - ); - } - inline_diagnostics - }) - .await; - - editor - .update(cx, |editor, cx| { - editor.inline_diagnostics = new_inline_diagnostics; - cx.notify(); - }) - .ok(); - }); - } - - pub fn set_selections_from_remote( - &mut self, - selections: Vec>, - pending_selection: Option>, - window: &mut Window, - cx: &mut Context, - ) { - let old_cursor_position = self.selections.newest_anchor().head(); - self.selections.change_with(cx, |s| { - s.select_anchors(selections); - if let Some(pending_selection) = pending_selection { - s.set_pending(pending_selection, SelectMode::Character); - } else { - s.clear_pending(); - } - }); - self.selections_did_change(false, &old_cursor_position, true, window, cx); - } - - fn push_to_selection_history(&mut self) { - self.selection_history.push(SelectionHistoryEntry { - selections: self.selections.disjoint_anchors(), - select_next_state: self.select_next_state.clone(), - select_prev_state: self.select_prev_state.clone(), - add_selections_state: self.add_selections_state.clone(), - }); - } - - pub fn transact( - &mut self, - window: &mut Window, - cx: &mut Context, - update: impl FnOnce(&mut Self, &mut Window, &mut Context), - ) -> Option { - self.start_transaction_at(Instant::now(), window, cx); - update(self, window, cx); - self.end_transaction_at(Instant::now(), cx) - } - - pub fn start_transaction_at( - &mut self, - now: Instant, - window: &mut Window, - cx: &mut Context, - ) { - self.end_selection(window, cx); - if let Some(tx_id) = self - .buffer - .update(cx, |buffer, cx| buffer.start_transaction_at(now, cx)) - { - self.selection_history - .insert_transaction(tx_id, self.selections.disjoint_anchors()); - cx.emit(EditorEvent::TransactionBegun { - transaction_id: tx_id, - }) - } - } - - pub fn end_transaction_at( - &mut self, - now: Instant, - cx: &mut Context, - ) -> Option { - if let Some(transaction_id) = self - .buffer - .update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)) - { - if let Some((_, end_selections)) = - self.selection_history.transaction_mut(transaction_id) - { - *end_selections = Some(self.selections.disjoint_anchors()); - } else { - log::error!("unexpectedly ended a transaction that wasn't started by this editor"); - } - - cx.emit(EditorEvent::Edited { transaction_id }); - Some(transaction_id) - } else { - None - } - } - - pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context) { - if self.selection_mark_mode { - self.change_selections(None, window, cx, |s| { - s.move_with(|_, sel| { - sel.collapse_to(sel.head(), SelectionGoal::None); - }); - }) - } - self.selection_mark_mode = true; - cx.notify(); - } - - pub fn swap_selection_ends( - &mut self, - _: &actions::SwapSelectionEnds, - window: &mut Window, - cx: &mut Context, - ) { - self.change_selections(None, window, cx, |s| { - s.move_with(|_, sel| { - if sel.start != sel.end { - sel.reversed = !sel.reversed - } - }); - }); - self.request_autoscroll(Autoscroll::newest(), cx); - cx.notify(); - } - - pub fn toggle_fold( - &mut self, - _: &actions::ToggleFold, - window: &mut Window, - cx: &mut Context, - ) { - if self.is_singleton(cx) { - let selection = self.selections.newest::(cx); - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let range = if selection.is_empty() { - let point = selection.head().to_display_point(&display_map); - let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); - let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) - .to_point(&display_map); - start..end - } else { - selection.range() - }; - if display_map.folds_in_range(range).next().is_some() { - self.unfold_lines(&Default::default(), window, cx) - } else { - self.fold(&Default::default(), window, cx) - } - } else { - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids: HashSet<_> = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) - .collect(); - - let should_unfold = buffer_ids - .iter() - .any(|buffer_id| self.is_buffer_folded(*buffer_id, cx)); - - for buffer_id in buffer_ids { - if should_unfold { - self.unfold_buffer(buffer_id, cx); - } else { - self.fold_buffer(buffer_id, cx); - } - } - } - } - - pub fn toggle_fold_recursive( - &mut self, - _: &actions::ToggleFoldRecursive, - window: &mut Window, - cx: &mut Context, - ) { - let selection = self.selections.newest::(cx); - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let range = if selection.is_empty() { - let point = selection.head().to_display_point(&display_map); - let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); - let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) - .to_point(&display_map); - start..end - } else { - selection.range() - }; - if display_map.folds_in_range(range).next().is_some() { - self.unfold_recursive(&Default::default(), window, cx) - } else { - self.fold_recursive(&Default::default(), window, cx) - } - } - - pub fn fold(&mut self, _: &actions::Fold, window: &mut Window, cx: &mut Context) { - if self.is_singleton(cx) { - let mut to_fold = Vec::new(); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(cx); - - for selection in selections { - let range = selection.range().sorted(); - let buffer_start_row = range.start.row; - - if range.start.row != range.end.row { - let mut found = false; - let mut row = range.start.row; - while row <= range.end.row { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) - { - found = true; - row = crease.range().end.row + 1; - to_fold.push(crease); - } else { - row += 1 - } - } - if found { - continue; - } - } - - for row in (0..=range.start.row).rev() { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { - if crease.range().end.row >= buffer_start_row { - to_fold.push(crease); - if row <= range.start.row { - break; - } - } - } - } - } - - self.fold_creases(to_fold, true, window, cx); - } else { - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) - .collect::>(); - for buffer_id in buffer_ids { - self.fold_buffer(buffer_id, cx); - } - } - } - - fn fold_at_level( - &mut self, - fold_at: &FoldAtLevel, - window: &mut Window, - cx: &mut Context, - ) { - if !self.buffer.read(cx).is_singleton() { - return; - } - - let fold_at_level = fold_at.0; - let snapshot = self.buffer.read(cx).snapshot(cx); - let mut to_fold = Vec::new(); - let mut stack = vec![(0, snapshot.max_row().0, 1)]; - - while let Some((mut start_row, end_row, current_level)) = stack.pop() { - while start_row < end_row { - match self - .snapshot(window, cx) - .crease_for_buffer_row(MultiBufferRow(start_row)) - { - Some(crease) => { - let nested_start_row = crease.range().start.row + 1; - let nested_end_row = crease.range().end.row; - - if current_level < fold_at_level { - stack.push((nested_start_row, nested_end_row, current_level + 1)); - } else if current_level == fold_at_level { - to_fold.push(crease); - } - - start_row = nested_end_row + 1; - } - None => start_row += 1, - } - } - } - - self.fold_creases(to_fold, true, window, cx); - } - - pub fn fold_all(&mut self, _: &actions::FoldAll, window: &mut Window, cx: &mut Context) { - if self.buffer.read(cx).is_singleton() { - let mut fold_ranges = Vec::new(); - let snapshot = self.buffer.read(cx).snapshot(cx); - - for row in 0..snapshot.max_row().0 { - if let Some(foldable_range) = self - .snapshot(window, cx) - .crease_for_buffer_row(MultiBufferRow(row)) - { - fold_ranges.push(foldable_range); - } - } - - self.fold_creases(fold_ranges, true, window, cx); - } else { - self.toggle_fold_multiple_buffers = cx.spawn_in(window, async move |editor, cx| { - editor - .update_in(cx, |editor, _, cx| { - for buffer_id in editor.buffer.read(cx).excerpt_buffer_ids() { - editor.fold_buffer(buffer_id, cx); - } - }) - .ok(); - }); - } - } - - pub fn fold_function_bodies( - &mut self, - _: &actions::FoldFunctionBodies, - window: &mut Window, - cx: &mut Context, - ) { - let snapshot = self.buffer.read(cx).snapshot(cx); - - let ranges = snapshot - .text_object_ranges(0..snapshot.len(), TreeSitterOptions::default()) - .filter_map(|(range, obj)| (obj == TextObject::InsideFunction).then_some(range)) - .collect::>(); - - let creases = ranges - .into_iter() - .map(|range| Crease::simple(range, self.display_map.read(cx).fold_placeholder.clone())) - .collect(); - - self.fold_creases(creases, true, window, cx); - } - - pub fn fold_recursive( - &mut self, - _: &actions::FoldRecursive, - window: &mut Window, - cx: &mut Context, - ) { - let mut to_fold = Vec::new(); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(cx); - - for selection in selections { - let range = selection.range().sorted(); - let buffer_start_row = range.start.row; - - if range.start.row != range.end.row { - let mut found = false; - for row in range.start.row..=range.end.row { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { - found = true; - to_fold.push(crease); - } - } - if found { - continue; - } - } - - for row in (0..=range.start.row).rev() { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { - if crease.range().end.row >= buffer_start_row { - to_fold.push(crease); - } else { - break; - } - } - } - } - - self.fold_creases(to_fold, true, window, cx); - } - - pub fn fold_at( - &mut self, - buffer_row: MultiBufferRow, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - if let Some(crease) = display_map.crease_for_buffer_row(buffer_row) { - let autoscroll = self - .selections - .all::(cx) - .iter() - .any(|selection| crease.range().overlaps(&selection.range())); - - self.fold_creases(vec![crease], autoscroll, window, cx); - } - } - - pub fn unfold_lines(&mut self, _: &UnfoldLines, _window: &mut Window, cx: &mut Context) { - if self.is_singleton(cx) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; - let selections = self.selections.all::(cx); - let ranges = selections - .iter() - .map(|s| { - let range = s.display_range(&display_map).sorted(); - let mut start = range.start.to_point(&display_map); - let mut end = range.end.to_point(&display_map); - start.column = 0; - end.column = buffer.line_len(MultiBufferRow(end.row)); - start..end - }) - .collect::>(); - - self.unfold_ranges(&ranges, true, true, cx); - } else { - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) - .collect::>(); - for buffer_id in buffer_ids { - self.unfold_buffer(buffer_id, cx); - } - } - } - - pub fn unfold_recursive( - &mut self, - _: &UnfoldRecursive, - _window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); - let ranges = selections - .iter() - .map(|s| { - let mut range = s.display_range(&display_map).sorted(); - *range.start.column_mut() = 0; - *range.end.column_mut() = display_map.line_len(range.end.row()); - let start = range.start.to_point(&display_map); - let end = range.end.to_point(&display_map); - start..end - }) - .collect::>(); - - self.unfold_ranges(&ranges, true, true, cx); - } - - pub fn unfold_at( - &mut self, - buffer_row: MultiBufferRow, - _window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - let intersection_range = Point::new(buffer_row.0, 0) - ..Point::new( - buffer_row.0, - display_map.buffer_snapshot.line_len(buffer_row), - ); - - let autoscroll = self - .selections - .all::(cx) - .iter() - .any(|selection| RangeExt::overlaps(&selection.range(), &intersection_range)); - - self.unfold_ranges(&[intersection_range], true, autoscroll, cx); - } - - pub fn unfold_all( - &mut self, - _: &actions::UnfoldAll, - _window: &mut Window, - cx: &mut Context, - ) { - if self.buffer.read(cx).is_singleton() { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - self.unfold_ranges(&[0..display_map.buffer_snapshot.len()], true, true, cx); - } else { - self.toggle_fold_multiple_buffers = cx.spawn(async move |editor, cx| { - editor - .update(cx, |editor, cx| { - for buffer_id in editor.buffer.read(cx).excerpt_buffer_ids() { - editor.unfold_buffer(buffer_id, cx); - } - }) - .ok(); - }); - } - } - - pub fn fold_selected_ranges( - &mut self, - _: &FoldSelectedRanges, - window: &mut Window, - cx: &mut Context, - ) { - let selections = self.selections.all_adjusted(cx); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let ranges = selections - .into_iter() - .map(|s| Crease::simple(s.range(), display_map.fold_placeholder.clone())) - .collect::>(); - self.fold_creases(ranges, true, window, cx); - } - - pub fn fold_ranges( - &mut self, - ranges: Vec>, - auto_scroll: bool, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let ranges = ranges - .into_iter() - .map(|r| Crease::simple(r, display_map.fold_placeholder.clone())) - .collect::>(); - self.fold_creases(ranges, auto_scroll, window, cx); - } - - pub fn fold_creases( - &mut self, - creases: Vec>, - auto_scroll: bool, - _window: &mut Window, - cx: &mut Context, - ) { - if creases.is_empty() { - return; - } - - let mut buffers_affected = HashSet::default(); - let multi_buffer = self.buffer().read(cx); - for crease in &creases { - if let Some((_, buffer, _)) = - multi_buffer.excerpt_containing(crease.range().start.clone(), cx) - { - buffers_affected.insert(buffer.read(cx).remote_id()); - }; - } - - self.display_map.update(cx, |map, cx| map.fold(creases, cx)); - - if auto_scroll { - self.request_autoscroll(Autoscroll::fit(), cx); - } - - cx.notify(); - - self.scrollbar_marker_state.dirty = true; - self.folds_did_change(cx); - } - - /// Removes any folds whose ranges intersect any of the given ranges. - pub fn unfold_ranges( - &mut self, - ranges: &[Range], - inclusive: bool, - auto_scroll: bool, - cx: &mut Context, - ) { - self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { - map.unfold_intersecting(ranges.iter().cloned(), inclusive, cx) - }); - self.folds_did_change(cx); - } - - pub fn fold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - if self.buffer().read(cx).is_singleton() || self.is_buffer_folded(buffer_id, cx) { - return; - } - let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx); - self.display_map.update(cx, |display_map, cx| { - display_map.fold_buffers([buffer_id], cx) - }); - cx.emit(EditorEvent::BufferFoldToggled { - ids: folded_excerpts.iter().map(|&(id, _)| id).collect(), - folded: true, - }); - cx.notify(); - } - - pub fn unfold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - if self.buffer().read(cx).is_singleton() || !self.is_buffer_folded(buffer_id, cx) { - return; - } - let unfolded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx); - self.display_map.update(cx, |display_map, cx| { - display_map.unfold_buffers([buffer_id], cx); - }); - cx.emit(EditorEvent::BufferFoldToggled { - ids: unfolded_excerpts.iter().map(|&(id, _)| id).collect(), - folded: false, - }); - cx.notify(); - } - - pub fn is_buffer_folded(&self, buffer: BufferId, cx: &App) -> bool { - self.display_map.read(cx).is_buffer_folded(buffer) - } - - pub fn folded_buffers<'a>(&self, cx: &'a App) -> &'a HashSet { - self.display_map.read(cx).folded_buffers() - } - - pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - self.display_map.update(cx, |display_map, cx| { - display_map.disable_header_for_buffer(buffer_id, cx); - }); - cx.notify(); - } - - /// Removes any folds with the given ranges. - pub fn remove_folds_with_type( - &mut self, - ranges: &[Range], - type_id: TypeId, - auto_scroll: bool, - cx: &mut Context, - ) { - self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { - map.remove_folds_with_type(ranges.iter().cloned(), type_id, cx) - }); - self.folds_did_change(cx); - } - - fn remove_folds_with( - &mut self, - ranges: &[Range], - auto_scroll: bool, - cx: &mut Context, - update: impl FnOnce(&mut DisplayMap, &mut Context), - ) { - if ranges.is_empty() { - return; - } - - let mut buffers_affected = HashSet::default(); - let multi_buffer = self.buffer().read(cx); - for range in ranges { - if let Some((_, buffer, _)) = multi_buffer.excerpt_containing(range.start.clone(), cx) { - buffers_affected.insert(buffer.read(cx).remote_id()); - }; - } - - self.display_map.update(cx, update); - - if auto_scroll { - self.request_autoscroll(Autoscroll::fit(), cx); - } - - cx.notify(); - self.scrollbar_marker_state.dirty = true; - self.active_indent_guides_state.dirty = true; - } - - pub fn update_fold_widths( - &mut self, - widths: impl IntoIterator, - cx: &mut Context, - ) -> bool { - self.display_map - .update(cx, |map, cx| map.update_fold_widths(widths, cx)) - } - - pub fn default_fold_placeholder(&self, cx: &App) -> FoldPlaceholder { - self.display_map.read(cx).fold_placeholder.clone() - } - - pub fn set_expand_all_diff_hunks(&mut self, cx: &mut App) { - self.buffer.update(cx, |buffer, cx| { - buffer.set_all_diff_hunks_expanded(cx); - }); - } - - pub fn expand_all_diff_hunks( - &mut self, - _: &ExpandAllDiffHunks, - _window: &mut Window, - cx: &mut Context, - ) { - self.buffer.update(cx, |buffer, cx| { - buffer.expand_diff_hunks(vec![Anchor::min()..Anchor::max()], cx) - }); - } - - pub fn toggle_selected_diff_hunks( - &mut self, - _: &ToggleSelectedDiffHunks, - _window: &mut Window, - cx: &mut Context, - ) { - let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); - self.toggle_diff_hunks_in_ranges(ranges, cx); - } - - pub fn diff_hunks_in_ranges<'a>( - &'a self, - ranges: &'a [Range], - buffer: &'a MultiBufferSnapshot, - ) -> impl 'a + Iterator { - ranges.iter().flat_map(move |range| { - let end_excerpt_id = range.end.excerpt_id; - let range = range.to_point(buffer); - let mut peek_end = range.end; - if range.end.row < buffer.max_row().0 { - peek_end = Point::new(range.end.row + 1, 0); - } - buffer - .diff_hunks_in_range(range.start..peek_end) - .filter(move |hunk| hunk.excerpt_id.cmp(&end_excerpt_id, buffer).is_le()) - }) - } - - pub fn has_stageable_diff_hunks_in_ranges( - &self, - ranges: &[Range], - snapshot: &MultiBufferSnapshot, - ) -> bool { - let mut hunks = self.diff_hunks_in_ranges(ranges, &snapshot); - hunks.any(|hunk| hunk.status().has_secondary_hunk()) - } - - pub fn toggle_staged_selected_diff_hunks( - &mut self, - _: &::git::ToggleStaged, - _: &mut Window, - cx: &mut Context, - ) { - let snapshot = self.buffer.read(cx).snapshot(cx); - let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); - let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot); - self.stage_or_unstage_diff_hunks(stage, ranges, cx); - } - - pub fn set_render_diff_hunk_controls( - &mut self, - render_diff_hunk_controls: RenderDiffHunkControlsFn, - cx: &mut Context, - ) { - self.render_diff_hunk_controls = render_diff_hunk_controls; - cx.notify(); - } - - pub fn stage_and_next( - &mut self, - _: &::git::StageAndNext, - window: &mut Window, - cx: &mut Context, - ) { - self.do_stage_or_unstage_and_next(true, window, cx); - } - - pub fn unstage_and_next( - &mut self, - _: &::git::UnstageAndNext, - window: &mut Window, - cx: &mut Context, - ) { - self.do_stage_or_unstage_and_next(false, window, cx); - } - - pub fn stage_or_unstage_diff_hunks( - &mut self, - stage: bool, - ranges: Vec>, - cx: &mut Context, - ) { - let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); - cx.spawn(async move |this, cx| { - task.await?; - this.update(cx, |this, cx| { - let snapshot = this.buffer.read(cx).snapshot(cx); - let chunk_by = this - .diff_hunks_in_ranges(&ranges, &snapshot) - .chunk_by(|hunk| hunk.buffer_id); - for (buffer_id, hunks) in &chunk_by { - this.do_stage_or_unstage(stage, buffer_id, hunks, cx); - } - }) - }) - .detach_and_log_err(cx); - } - - fn save_buffers_for_ranges_if_needed( - &mut self, - ranges: &[Range], - cx: &mut Context, - ) -> Task> { - let multibuffer = self.buffer.read(cx); - let snapshot = multibuffer.read(cx); - let buffer_ids: HashSet<_> = ranges - .iter() - .flat_map(|range| snapshot.buffer_ids_for_range(range.clone())) - .collect(); - drop(snapshot); - - let mut buffers = HashSet::default(); - for buffer_id in buffer_ids { - if let Some(buffer_entity) = multibuffer.buffer(buffer_id) { - let buffer = buffer_entity.read(cx); - if buffer.file().is_some_and(|file| file.disk_state().exists()) && buffer.is_dirty() - { - buffers.insert(buffer_entity); - } - } - } - - if let Some(project) = &self.project { - project.update(cx, |project, cx| project.save_buffers(buffers, cx)) - } else { - Task::ready(Ok(())) - } - } - - fn do_stage_or_unstage_and_next( - &mut self, - stage: bool, - window: &mut Window, - cx: &mut Context, - ) { - let ranges = self.selections.disjoint_anchor_ranges().collect::>(); - - if ranges.iter().any(|range| range.start != range.end) { - self.stage_or_unstage_diff_hunks(stage, ranges, cx); - return; - } - - self.stage_or_unstage_diff_hunks(stage, ranges, cx); - let snapshot = self.snapshot(window, cx); - let position = self.selections.newest::(cx).head(); - let mut row = snapshot - .buffer_snapshot - .diff_hunks_in_range(position..snapshot.buffer_snapshot.max_point()) - .find(|hunk| hunk.row_range.start.0 > position.row) - .map(|hunk| hunk.row_range.start); - - let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); - // Outside of the project diff editor, wrap around to the beginning. - if !all_diff_hunks_expanded { - row = row.or_else(|| { - snapshot - .buffer_snapshot - .diff_hunks_in_range(Point::zero()..position) - .find(|hunk| hunk.row_range.end.0 < position.row) - .map(|hunk| hunk.row_range.start) - }); - } - - if let Some(row) = row { - let destination = Point::new(row.0, 0); - let autoscroll = Autoscroll::center(); - - self.unfold_ranges(&[destination..destination], false, false, cx); - self.change_selections(Some(autoscroll), window, cx, |s| { - s.select_ranges([destination..destination]); - }); - } - } - - fn do_stage_or_unstage( - &self, - stage: bool, - buffer_id: BufferId, - hunks: impl Iterator, - cx: &mut App, - ) -> Option<()> { - let project = self.project.as_ref()?; - let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?; - let diff = self.buffer.read(cx).diff_for(buffer_id)?; - let buffer_snapshot = buffer.read(cx).snapshot(); - let file_exists = buffer_snapshot - .file() - .is_some_and(|file| file.disk_state().exists()); - diff.update(cx, |diff, cx| { - diff.stage_or_unstage_hunks( - stage, - &hunks - .map(|hunk| buffer_diff::DiffHunk { - buffer_range: hunk.buffer_range, - diff_base_byte_range: hunk.diff_base_byte_range, - secondary_status: hunk.secondary_status, - range: Point::zero()..Point::zero(), // unused - }) - .collect::>(), - &buffer_snapshot, - file_exists, - cx, - ) - }); - None - } - - pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context) { - let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); - self.buffer - .update(cx, |buffer, cx| buffer.expand_diff_hunks(ranges, cx)) - } - - pub fn clear_expanded_diff_hunks(&mut self, cx: &mut Context) -> bool { - self.buffer.update(cx, |buffer, cx| { - let ranges = vec![Anchor::min()..Anchor::max()]; - if !buffer.all_diff_hunks_expanded() - && buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx) - { - buffer.collapse_diff_hunks(ranges, cx); - true - } else { - false - } - }) - } - - fn toggle_diff_hunks_in_ranges( - &mut self, - ranges: Vec>, - cx: &mut Context, - ) { - self.buffer.update(cx, |buffer, cx| { - let expand = !buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx); - buffer.expand_or_collapse_diff_hunks(ranges, expand, cx); - }) - } - - fn toggle_single_diff_hunk(&mut self, range: Range, cx: &mut Context) { - self.buffer.update(cx, |buffer, cx| { - let snapshot = buffer.snapshot(cx); - let excerpt_id = range.end.excerpt_id; - let point_range = range.to_point(&snapshot); - let expand = !buffer.single_hunk_is_expanded(range, cx); - buffer.expand_or_collapse_diff_hunks_inner([(point_range, excerpt_id)], expand, cx); - }) - } - - pub(crate) fn apply_all_diff_hunks( - &mut self, - _: &ApplyAllDiffHunks, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - - let buffers = self.buffer.read(cx).all_buffers(); - for branch_buffer in buffers { - branch_buffer.update(cx, |branch_buffer, cx| { - branch_buffer.merge_into_base(Vec::new(), cx); - }); - } - - if let Some(project) = self.project.clone() { - self.save(true, project, window, cx).detach_and_log_err(cx); - } - } - - pub(crate) fn apply_selected_diff_hunks( - &mut self, - _: &ApplyDiffHunk, - window: &mut Window, - cx: &mut Context, - ) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let snapshot = self.snapshot(window, cx); - let hunks = snapshot.hunks_for_ranges(self.selections.ranges(cx)); - let mut ranges_by_buffer = HashMap::default(); - self.transact(window, cx, |editor, _window, cx| { - for hunk in hunks { - if let Some(buffer) = editor.buffer.read(cx).buffer(hunk.buffer_id) { - ranges_by_buffer - .entry(buffer.clone()) - .or_insert_with(Vec::new) - .push(hunk.buffer_range.to_offset(buffer.read(cx))); - } - } - - for (buffer, ranges) in ranges_by_buffer { - buffer.update(cx, |buffer, cx| { - buffer.merge_into_base(ranges, cx); - }); - } - }); - - if let Some(project) = self.project.clone() { - self.save(true, project, window, cx).detach_and_log_err(cx); - } - } - - pub fn set_gutter_hovered(&mut self, hovered: bool, cx: &mut Context) { - if hovered != self.gutter_hovered { - self.gutter_hovered = hovered; - cx.notify(); - } - } - - pub fn insert_blocks( - &mut self, - blocks: impl IntoIterator>, - autoscroll: Option, - cx: &mut Context, - ) -> Vec { - let blocks = self - .display_map - .update(cx, |display_map, cx| display_map.insert_blocks(blocks, cx)); - if let Some(autoscroll) = autoscroll { - self.request_autoscroll(autoscroll, cx); - } - cx.notify(); - blocks - } - - pub fn resize_blocks( - &mut self, - heights: HashMap, - autoscroll: Option, - cx: &mut Context, - ) { - self.display_map - .update(cx, |display_map, cx| display_map.resize_blocks(heights, cx)); - if let Some(autoscroll) = autoscroll { - self.request_autoscroll(autoscroll, cx); - } - cx.notify(); - } - - pub fn replace_blocks( - &mut self, - renderers: HashMap, - autoscroll: Option, - cx: &mut Context, - ) { - self.display_map - .update(cx, |display_map, _cx| display_map.replace_blocks(renderers)); - if let Some(autoscroll) = autoscroll { - self.request_autoscroll(autoscroll, cx); - } - cx.notify(); - } - - pub fn remove_blocks( - &mut self, - block_ids: HashSet, - autoscroll: Option, - cx: &mut Context, - ) { - self.display_map.update(cx, |display_map, cx| { - display_map.remove_blocks(block_ids, cx) - }); - if let Some(autoscroll) = autoscroll { - self.request_autoscroll(autoscroll, cx); - } - cx.notify(); - } - - pub fn row_for_block( - &self, - block_id: CustomBlockId, - cx: &mut Context, - ) -> Option { - self.display_map - .update(cx, |map, cx| map.row_for_block(block_id, cx)) - } - - pub(crate) fn set_focused_block(&mut self, focused_block: FocusedBlock) { - self.focused_block = Some(focused_block); - } - - pub(crate) fn take_focused_block(&mut self) -> Option { - self.focused_block.take() - } - - pub fn insert_creases( - &mut self, - creases: impl IntoIterator>, - cx: &mut Context, - ) -> Vec { - self.display_map - .update(cx, |map, cx| map.insert_creases(creases, cx)) - } - - pub fn remove_creases( - &mut self, - ids: impl IntoIterator, - cx: &mut Context, - ) { - self.display_map - .update(cx, |map, cx| map.remove_creases(ids, cx)); - } - - pub fn longest_row(&self, cx: &mut App) -> DisplayRow { - self.display_map - .update(cx, |map, cx| map.snapshot(cx)) - .longest_row() - } - - pub fn max_point(&self, cx: &mut App) -> DisplayPoint { - self.display_map - .update(cx, |map, cx| map.snapshot(cx)) - .max_point() - } - - pub fn text(&self, cx: &App) -> String { - self.buffer.read(cx).read(cx).text() - } - - pub fn is_empty(&self, cx: &App) -> bool { - self.buffer.read(cx).read(cx).is_empty() - } - - pub fn text_option(&self, cx: &App) -> Option { - let text = self.text(cx); - let text = text.trim(); - - if text.is_empty() { - return None; - } - - Some(text.to_string()) - } - - pub fn set_text( - &mut self, - text: impl Into>, - window: &mut Window, - cx: &mut Context, - ) { - self.transact(window, cx, |this, _, cx| { - this.buffer - .read(cx) - .as_singleton() - .expect("you can only call set_text on editors for singleton buffers") - .update(cx, |buffer, cx| buffer.set_text(text, cx)); - }); - } - - pub fn display_text(&self, cx: &mut App) -> String { - self.display_map - .update(cx, |map, cx| map.snapshot(cx)) - .text() - } - - pub fn wrap_guides(&self, cx: &App) -> SmallVec<[(usize, bool); 2]> { - let mut wrap_guides = smallvec::smallvec![]; - - if self.show_wrap_guides == Some(false) { - return wrap_guides; - } - - let settings = self.buffer.read(cx).language_settings(cx); - if settings.show_wrap_guides { - match self.soft_wrap_mode(cx) { - SoftWrap::Column(soft_wrap) => { - wrap_guides.push((soft_wrap as usize, true)); - } - SoftWrap::Bounded(soft_wrap) => { - wrap_guides.push((soft_wrap as usize, true)); - } - SoftWrap::GitDiff | SoftWrap::None | SoftWrap::EditorWidth => {} - } - wrap_guides.extend(settings.wrap_guides.iter().map(|guide| (*guide, false))) - } - - wrap_guides - } - - pub fn soft_wrap_mode(&self, cx: &App) -> SoftWrap { - let settings = self.buffer.read(cx).language_settings(cx); - let mode = self.soft_wrap_mode_override.unwrap_or(settings.soft_wrap); - match mode { - language_settings::SoftWrap::PreferLine | language_settings::SoftWrap::None => { - SoftWrap::None - } - language_settings::SoftWrap::EditorWidth => SoftWrap::EditorWidth, - language_settings::SoftWrap::PreferredLineLength => { - SoftWrap::Column(settings.preferred_line_length) - } - language_settings::SoftWrap::Bounded => { - SoftWrap::Bounded(settings.preferred_line_length) - } - } - } - - pub fn set_soft_wrap_mode( - &mut self, - mode: language_settings::SoftWrap, - - cx: &mut Context, - ) { - self.soft_wrap_mode_override = Some(mode); - cx.notify(); - } - - pub fn set_hard_wrap(&mut self, hard_wrap: Option, cx: &mut Context) { - self.hard_wrap = hard_wrap; - cx.notify(); - } - - pub fn set_text_style_refinement(&mut self, style: TextStyleRefinement) { - self.text_style_refinement = Some(style); - } - - /// called by the Element so we know what style we were most recently rendered with. - pub(crate) fn set_style( - &mut self, - style: EditorStyle, - window: &mut Window, - cx: &mut Context, - ) { - let rem_size = window.rem_size(); - self.display_map.update(cx, |map, cx| { - map.set_font( - style.text.font(), - style.text.font_size.to_pixels(rem_size), - cx, - ) - }); - self.style = Some(style); - } - - pub fn style(&self) -> Option<&EditorStyle> { - self.style.as_ref() - } - - // Called by the element. This method is not designed to be called outside of the editor - // element's layout code because it does not notify when rewrapping is computed synchronously. - pub(crate) fn set_wrap_width(&self, width: Option, cx: &mut App) -> bool { - self.display_map - .update(cx, |map, cx| map.set_wrap_width(width, cx)) - } - - pub fn set_soft_wrap(&mut self) { - self.soft_wrap_mode_override = Some(language_settings::SoftWrap::EditorWidth) - } - - pub fn toggle_soft_wrap(&mut self, _: &ToggleSoftWrap, _: &mut Window, cx: &mut Context) { - if self.soft_wrap_mode_override.is_some() { - self.soft_wrap_mode_override.take(); - } else { - let soft_wrap = match self.soft_wrap_mode(cx) { - SoftWrap::GitDiff => return, - SoftWrap::None => language_settings::SoftWrap::EditorWidth, - SoftWrap::EditorWidth | SoftWrap::Column(_) | SoftWrap::Bounded(_) => { - language_settings::SoftWrap::None - } - }; - self.soft_wrap_mode_override = Some(soft_wrap); - } - cx.notify(); - } - - pub fn toggle_tab_bar(&mut self, _: &ToggleTabBar, _: &mut Window, cx: &mut Context) { - let Some(workspace) = self.workspace() else { - return; - }; - let fs = workspace.read(cx).app_state().fs.clone(); - let current_show = TabBarSettings::get_global(cx).show; - update_settings_file::(fs, cx, move |setting, _| { - setting.show = Some(!current_show); - }); - } - - pub fn toggle_indent_guides( - &mut self, - _: &ToggleIndentGuides, - _: &mut Window, - cx: &mut Context, - ) { - let currently_enabled = self.should_show_indent_guides().unwrap_or_else(|| { - self.buffer - .read(cx) - .language_settings(cx) - .indent_guides - .enabled - }); - self.show_indent_guides = Some(!currently_enabled); - cx.notify(); - } - - fn should_show_indent_guides(&self) -> Option { - self.show_indent_guides - } - - pub fn toggle_line_numbers( - &mut self, - _: &ToggleLineNumbers, - _: &mut Window, - cx: &mut Context, - ) { - let mut editor_settings = EditorSettings::get_global(cx).clone(); - editor_settings.gutter.line_numbers = !editor_settings.gutter.line_numbers; - EditorSettings::override_global(editor_settings, cx); - } - - pub fn line_numbers_enabled(&self, cx: &App) -> bool { - if let Some(show_line_numbers) = self.show_line_numbers { - return show_line_numbers; - } - EditorSettings::get_global(cx).gutter.line_numbers - } - - pub fn should_use_relative_line_numbers(&self, cx: &mut App) -> bool { - self.use_relative_line_numbers - .unwrap_or(EditorSettings::get_global(cx).relative_line_numbers) - } - - pub fn toggle_relative_line_numbers( - &mut self, - _: &ToggleRelativeLineNumbers, - _: &mut Window, - cx: &mut Context, - ) { - let is_relative = self.should_use_relative_line_numbers(cx); - self.set_relative_line_number(Some(!is_relative), cx) - } - - pub fn set_relative_line_number(&mut self, is_relative: Option, cx: &mut Context) { - self.use_relative_line_numbers = is_relative; - cx.notify(); - } - - pub fn set_show_gutter(&mut self, show_gutter: bool, cx: &mut Context) { - self.show_gutter = show_gutter; - cx.notify(); - } - - pub fn set_show_scrollbars(&mut self, show_scrollbars: bool, cx: &mut Context) { - self.show_scrollbars = show_scrollbars; - cx.notify(); - } - - pub fn disable_scrolling(&mut self, cx: &mut Context) { - self.disable_scrolling = true; - cx.notify(); - } - - pub fn set_show_line_numbers(&mut self, show_line_numbers: bool, cx: &mut Context) { - self.show_line_numbers = Some(show_line_numbers); - cx.notify(); - } - - pub fn disable_expand_excerpt_buttons(&mut self, cx: &mut Context) { - self.disable_expand_excerpt_buttons = true; - cx.notify(); - } - - pub fn set_show_git_diff_gutter(&mut self, show_git_diff_gutter: bool, cx: &mut Context) { - self.show_git_diff_gutter = Some(show_git_diff_gutter); - cx.notify(); - } - - pub fn set_show_code_actions(&mut self, show_code_actions: bool, cx: &mut Context) { - self.show_code_actions = Some(show_code_actions); - cx.notify(); - } - - pub fn set_show_runnables(&mut self, show_runnables: bool, cx: &mut Context) { - self.show_runnables = Some(show_runnables); - cx.notify(); - } - - pub fn set_show_breakpoints(&mut self, show_breakpoints: bool, cx: &mut Context) { - self.show_breakpoints = Some(show_breakpoints); - cx.notify(); - } - - pub fn set_masked(&mut self, masked: bool, cx: &mut Context) { - if self.display_map.read(cx).masked != masked { - self.display_map.update(cx, |map, _| map.masked = masked); - } - cx.notify() - } - - pub fn set_show_wrap_guides(&mut self, show_wrap_guides: bool, cx: &mut Context) { - self.show_wrap_guides = Some(show_wrap_guides); - cx.notify(); - } - - pub fn set_show_indent_guides(&mut self, show_indent_guides: bool, cx: &mut Context) { - self.show_indent_guides = Some(show_indent_guides); - cx.notify(); - } - - pub fn working_directory(&self, cx: &App) -> Option { - if let Some(buffer) = self.buffer().read(cx).as_singleton() { - if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) { - if let Some(dir) = file.abs_path(cx).parent() { - return Some(dir.to_owned()); - } - } - - if let Some(project_path) = buffer.read(cx).project_path(cx) { - return Some(project_path.path.to_path_buf()); - } - } - - None - } - - fn target_file<'a>(&self, cx: &'a App) -> Option<&'a dyn language::LocalFile> { - self.active_excerpt(cx)? - .1 - .read(cx) - .file() - .and_then(|f| f.as_local()) - } - - pub fn target_file_abs_path(&self, cx: &mut Context) -> Option { - self.active_excerpt(cx).and_then(|(_, buffer, _)| { - let buffer = buffer.read(cx); - if let Some(project_path) = buffer.project_path(cx) { - let project = self.project.as_ref()?.read(cx); - project.absolute_path(&project_path, cx) - } else { - buffer - .file() - .and_then(|file| file.as_local().map(|file| file.abs_path(cx))) - } - }) - } - - fn target_file_path(&self, cx: &mut Context) -> Option { - self.active_excerpt(cx).and_then(|(_, buffer, _)| { - let project_path = buffer.read(cx).project_path(cx)?; - let project = self.project.as_ref()?.read(cx); - let entry = project.entry_for_path(&project_path, cx)?; - let path = entry.path.to_path_buf(); - Some(path) - }) - } - - pub fn reveal_in_finder( - &mut self, - _: &RevealInFileManager, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(target) = self.target_file(cx) { - cx.reveal_path(&target.abs_path(cx)); - } - } - - pub fn copy_path( - &mut self, - _: &zed_actions::workspace::CopyPath, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(path) = self.target_file_abs_path(cx) { - if let Some(path) = path.to_str() { - cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); - } - } - } - - pub fn copy_relative_path( - &mut self, - _: &zed_actions::workspace::CopyRelativePath, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(path) = self.target_file_path(cx) { - if let Some(path) = path.to_str() { - cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); - } - } - } - - pub fn project_path(&self, cx: &App) -> Option { - if let Some(buffer) = self.buffer.read(cx).as_singleton() { - buffer.read(cx).project_path(cx) - } else { - None - } - } - - // Returns true if the editor handled a go-to-line request - pub fn go_to_active_debug_line(&mut self, window: &mut Window, cx: &mut Context) -> bool { - maybe!({ - let breakpoint_store = self.breakpoint_store.as_ref()?; - - let Some(active_stack_frame) = breakpoint_store.read(cx).active_position().cloned() - else { - self.clear_row_highlights::(); - return None; - }; - - let position = active_stack_frame.position; - let buffer_id = position.buffer_id?; - let snapshot = self - .project - .as_ref()? - .read(cx) - .buffer_for_id(buffer_id, cx)? - .read(cx) - .snapshot(); - - let mut handled = false; - for (id, ExcerptRange { context, .. }) in - self.buffer.read(cx).excerpts_for_buffer(buffer_id, cx) - { - if context.start.cmp(&position, &snapshot).is_ge() - || context.end.cmp(&position, &snapshot).is_lt() - { - continue; - } - let snapshot = self.buffer.read(cx).snapshot(cx); - let multibuffer_anchor = snapshot.anchor_in_excerpt(id, position)?; - - handled = true; - self.clear_row_highlights::(); - self.go_to_line::( - multibuffer_anchor, - Some(cx.theme().colors().editor_debugger_active_line_background), - window, - cx, - ); - - cx.notify(); - } - - handled.then_some(()) - }) - .is_some() - } - - pub fn copy_file_name_without_extension( - &mut self, - _: &CopyFileNameWithoutExtension, - _: &mut Window, - cx: &mut Context, - ) { - if let Some(file) = self.target_file(cx) { - if let Some(file_stem) = file.path().file_stem() { - if let Some(name) = file_stem.to_str() { - cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); - } - } - } - } - - pub fn copy_file_name(&mut self, _: &CopyFileName, _: &mut Window, cx: &mut Context) { - if let Some(file) = self.target_file(cx) { - if let Some(file_name) = file.path().file_name() { - if let Some(name) = file_name.to_str() { - cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); - } - } - } - } - - pub fn toggle_git_blame( - &mut self, - _: &::git::Blame, - window: &mut Window, - cx: &mut Context, - ) { - self.show_git_blame_gutter = !self.show_git_blame_gutter; - - if self.show_git_blame_gutter && !self.has_blame_entries(cx) { - self.start_git_blame(true, window, cx); - } - - cx.notify(); - } - - pub fn toggle_git_blame_inline( - &mut self, - _: &ToggleGitBlameInline, - window: &mut Window, - cx: &mut Context, - ) { - self.toggle_git_blame_inline_internal(true, window, cx); - cx.notify(); - } - - pub fn open_git_blame_commit( - &mut self, - _: &OpenGitBlameCommit, - window: &mut Window, - cx: &mut Context, - ) { - self.open_git_blame_commit_internal(window, cx); - } - - fn open_git_blame_commit_internal( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let blame = self.blame.as_ref()?; - let snapshot = self.snapshot(window, cx); - let cursor = self.selections.newest::(cx).head(); - let (buffer, point, _) = snapshot.buffer_snapshot.point_to_buffer_point(cursor)?; - let blame_entry = blame - .update(cx, |blame, cx| { - blame - .blame_for_rows( - &[RowInfo { - buffer_id: Some(buffer.remote_id()), - buffer_row: Some(point.row), - ..Default::default() - }], - cx, - ) - .next() - }) - .flatten()?; - let renderer = cx.global::().0.clone(); - let repo = blame.read(cx).repository(cx)?; - let workspace = self.workspace()?.downgrade(); - renderer.open_blame_commit(blame_entry, repo, workspace, window, cx); - None - } - - pub fn git_blame_inline_enabled(&self) -> bool { - self.git_blame_inline_enabled - } - - pub fn toggle_selection_menu( - &mut self, - _: &ToggleSelectionMenu, - _: &mut Window, - cx: &mut Context, - ) { - self.show_selection_menu = self - .show_selection_menu - .map(|show_selections_menu| !show_selections_menu) - .or_else(|| Some(!EditorSettings::get_global(cx).toolbar.selections_menu)); - - cx.notify(); - } - - pub fn selection_menu_enabled(&self, cx: &App) -> bool { - self.show_selection_menu - .unwrap_or_else(|| EditorSettings::get_global(cx).toolbar.selections_menu) - } - - fn start_git_blame( - &mut self, - user_triggered: bool, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(project) = self.project.as_ref() { - let Some(buffer) = self.buffer().read(cx).as_singleton() else { - return; - }; - - if buffer.read(cx).file().is_none() { - return; - } - - let focused = self.focus_handle(cx).contains_focused(window, cx); - - let project = project.clone(); - let blame = cx.new(|cx| GitBlame::new(buffer, project, user_triggered, focused, cx)); - self.blame_subscription = - Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify())); - self.blame = Some(blame); - } - } - - fn toggle_git_blame_inline_internal( - &mut self, - user_triggered: bool, - window: &mut Window, - cx: &mut Context, - ) { - if self.git_blame_inline_enabled { - self.git_blame_inline_enabled = false; - self.show_git_blame_inline = false; - self.show_git_blame_inline_delay_task.take(); - } else { - self.git_blame_inline_enabled = true; - self.start_git_blame_inline(user_triggered, window, cx); - } - - cx.notify(); - } - - fn start_git_blame_inline( - &mut self, - user_triggered: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.start_git_blame(user_triggered, window, cx); - - if ProjectSettings::get_global(cx) - .git - .inline_blame_delay() - .is_some() - { - self.start_inline_blame_timer(window, cx); - } else { - self.show_git_blame_inline = true - } - } - - pub fn blame(&self) -> Option<&Entity> { - self.blame.as_ref() - } - - pub fn show_git_blame_gutter(&self) -> bool { - self.show_git_blame_gutter - } - - pub fn render_git_blame_gutter(&self, cx: &App) -> bool { - self.show_git_blame_gutter && self.has_blame_entries(cx) - } - - pub fn render_git_blame_inline(&self, window: &Window, cx: &App) -> bool { - self.show_git_blame_inline - && (self.focus_handle.is_focused(window) || self.inline_blame_popover.is_some()) - && !self.newest_selection_head_on_empty_line(cx) - && self.has_blame_entries(cx) - } - - fn has_blame_entries(&self, cx: &App) -> bool { - self.blame() - .map_or(false, |blame| blame.read(cx).has_generated_entries()) - } - - fn newest_selection_head_on_empty_line(&self, cx: &App) -> bool { - let cursor_anchor = self.selections.newest_anchor().head(); - - let snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_row = MultiBufferRow(cursor_anchor.to_point(&snapshot).row); - - snapshot.line_len(buffer_row) == 0 - } - - fn get_permalink_to_line(&self, cx: &mut Context) -> Task> { - let buffer_and_selection = maybe!({ - let selection = self.selections.newest::(cx); - let selection_range = selection.range(); - - let multi_buffer = self.buffer().read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let buffer_ranges = multi_buffer_snapshot.range_to_buffer_ranges(selection_range); - - let (buffer, range, _) = if selection.reversed { - buffer_ranges.first() - } else { - buffer_ranges.last() - }?; - - let selection = text::ToPoint::to_point(&range.start, &buffer).row - ..text::ToPoint::to_point(&range.end, &buffer).row; - Some(( - multi_buffer.buffer(buffer.remote_id()).unwrap().clone(), - selection, - )) - }); - - let Some((buffer, selection)) = buffer_and_selection else { - return Task::ready(Err(anyhow!("failed to determine buffer and selection"))); - }; - - let Some(project) = self.project.as_ref() else { - return Task::ready(Err(anyhow!("editor does not have project"))); - }; - - project.update(cx, |project, cx| { - project.get_permalink_to_line(&buffer, selection, cx) - }) - } - - pub fn copy_permalink_to_line( - &mut self, - _: &CopyPermalinkToLine, - window: &mut Window, - cx: &mut Context, - ) { - let permalink_task = self.get_permalink_to_line(cx); - let workspace = self.workspace(); - - cx.spawn_in(window, async move |_, cx| match permalink_task.await { - Ok(permalink) => { - cx.update(|_, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string())); - }) - .ok(); - } - Err(err) => { - let message = format!("Failed to copy permalink: {err}"); - - anyhow::Result::<()>::Err(err).log_err(); - - if let Some(workspace) = workspace { - workspace - .update_in(cx, |workspace, _, cx| { - struct CopyPermalinkToLine; - - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - message, - ), - cx, - ) - }) - .ok(); - } - } - }) - .detach(); - } - - pub fn copy_file_location( - &mut self, - _: &CopyFileLocation, - _: &mut Window, - cx: &mut Context, - ) { - let selection = self.selections.newest::(cx).start.row + 1; - if let Some(file) = self.target_file(cx) { - if let Some(path) = file.path().to_str() { - cx.write_to_clipboard(ClipboardItem::new_string(format!("{path}:{selection}"))); - } - } - } - - pub fn open_permalink_to_line( - &mut self, - _: &OpenPermalinkToLine, - window: &mut Window, - cx: &mut Context, - ) { - let permalink_task = self.get_permalink_to_line(cx); - let workspace = self.workspace(); - - cx.spawn_in(window, async move |_, cx| match permalink_task.await { - Ok(permalink) => { - cx.update(|_, cx| { - cx.open_url(permalink.as_ref()); - }) - .ok(); - } - Err(err) => { - let message = format!("Failed to open permalink: {err}"); - - anyhow::Result::<()>::Err(err).log_err(); - - if let Some(workspace) = workspace { - workspace - .update(cx, |workspace, cx| { - struct OpenPermalinkToLine; - - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - message, - ), - cx, - ) - }) - .ok(); - } - } - }) - .detach(); - } - - pub fn insert_uuid_v4( - &mut self, - _: &InsertUuidV4, - window: &mut Window, - cx: &mut Context, - ) { - self.insert_uuid(UuidVersion::V4, window, cx); - } - - pub fn insert_uuid_v7( - &mut self, - _: &InsertUuidV7, - window: &mut Window, - cx: &mut Context, - ) { - self.insert_uuid(UuidVersion::V7, window, cx); - } - - fn insert_uuid(&mut self, version: UuidVersion, window: &mut Window, cx: &mut Context) { - self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - self.transact(window, cx, |this, window, cx| { - let edits = this - .selections - .all::(cx) - .into_iter() - .map(|selection| { - let uuid = match version { - UuidVersion::V4 => uuid::Uuid::new_v4(), - UuidVersion::V7 => uuid::Uuid::now_v7(), - }; - - (selection.range(), uuid.to_string()) - }); - this.edit(edits, cx); - this.refresh_inline_completion(true, false, window, cx); - }); - } - - pub fn open_selections_in_multibuffer( - &mut self, - _: &OpenSelectionsInMultibuffer, - window: &mut Window, - cx: &mut Context, - ) { - let multibuffer = self.buffer.read(cx); - - let Some(buffer) = multibuffer.as_singleton() else { - return; - }; - - let Some(workspace) = self.workspace() else { - return; - }; - - let locations = self - .selections - .disjoint_anchors() - .iter() - .map(|range| Location { - buffer: buffer.clone(), - range: range.start.text_anchor..range.end.text_anchor, - }) - .collect::>(); - - let title = multibuffer.title(cx).to_string(); - - cx.spawn_in(window, async move |_, cx| { - workspace.update_in(cx, |workspace, window, cx| { - Self::open_locations_in_multibuffer( - workspace, - locations, - format!("Selections for '{title}'"), - false, - MultibufferSelectionMode::All, - window, - cx, - ); - }) - }) - .detach(); - } - - /// Adds a row highlight for the given range. If a row has multiple highlights, the - /// last highlight added will be used. - /// - /// If the range ends at the beginning of a line, then that line will not be highlighted. - pub fn highlight_rows( - &mut self, - range: Range, - color: Hsla, - options: RowHighlightOptions, - cx: &mut Context, - ) { - let snapshot = self.buffer().read(cx).snapshot(cx); - let row_highlights = self.highlighted_rows.entry(TypeId::of::()).or_default(); - let ix = row_highlights.binary_search_by(|highlight| { - Ordering::Equal - .then_with(|| highlight.range.start.cmp(&range.start, &snapshot)) - .then_with(|| highlight.range.end.cmp(&range.end, &snapshot)) - }); - - if let Err(mut ix) = ix { - let index = post_inc(&mut self.highlight_order); - - // If this range intersects with the preceding highlight, then merge it with - // the preceding highlight. Otherwise insert a new highlight. - let mut merged = false; - if ix > 0 { - let prev_highlight = &mut row_highlights[ix - 1]; - if prev_highlight - .range - .end - .cmp(&range.start, &snapshot) - .is_ge() - { - ix -= 1; - if prev_highlight.range.end.cmp(&range.end, &snapshot).is_lt() { - prev_highlight.range.end = range.end; - } - merged = true; - prev_highlight.index = index; - prev_highlight.color = color; - prev_highlight.options = options; - } - } - - if !merged { - row_highlights.insert( - ix, - RowHighlight { - range: range.clone(), - index, - color, - options, - type_id: TypeId::of::(), - }, - ); - } - - // If any of the following highlights intersect with this one, merge them. - while let Some(next_highlight) = row_highlights.get(ix + 1) { - let highlight = &row_highlights[ix]; - if next_highlight - .range - .start - .cmp(&highlight.range.end, &snapshot) - .is_le() - { - if next_highlight - .range - .end - .cmp(&highlight.range.end, &snapshot) - .is_gt() - { - row_highlights[ix].range.end = next_highlight.range.end; - } - row_highlights.remove(ix + 1); - } else { - break; - } - } - } - } - - /// Remove any highlighted row ranges of the given type that intersect the - /// given ranges. - pub fn remove_highlighted_rows( - &mut self, - ranges_to_remove: Vec>, - cx: &mut Context, - ) { - let snapshot = self.buffer().read(cx).snapshot(cx); - let row_highlights = self.highlighted_rows.entry(TypeId::of::()).or_default(); - let mut ranges_to_remove = ranges_to_remove.iter().peekable(); - row_highlights.retain(|highlight| { - while let Some(range_to_remove) = ranges_to_remove.peek() { - match range_to_remove.end.cmp(&highlight.range.start, &snapshot) { - Ordering::Less | Ordering::Equal => { - ranges_to_remove.next(); - } - Ordering::Greater => { - match range_to_remove.start.cmp(&highlight.range.end, &snapshot) { - Ordering::Less | Ordering::Equal => { - return false; - } - Ordering::Greater => break, - } - } - } - } - - true - }) - } - - /// Clear all anchor ranges for a certain highlight context type, so no corresponding rows will be highlighted. - pub fn clear_row_highlights(&mut self) { - self.highlighted_rows.remove(&TypeId::of::()); - } - - /// For a highlight given context type, gets all anchor ranges that will be used for row highlighting. - pub fn highlighted_rows(&self) -> impl '_ + Iterator, Hsla)> { - self.highlighted_rows - .get(&TypeId::of::()) - .map_or(&[] as &[_], |vec| vec.as_slice()) - .iter() - .map(|highlight| (highlight.range.clone(), highlight.color)) - } - - /// Merges all anchor ranges for all context types ever set, picking the last highlight added in case of a row conflict. - /// Returns a map of display rows that are highlighted and their corresponding highlight color. - /// Allows to ignore certain kinds of highlights. - pub fn highlighted_display_rows( - &self, - window: &mut Window, - cx: &mut App, - ) -> BTreeMap { - let snapshot = self.snapshot(window, cx); - let mut used_highlight_orders = HashMap::default(); - self.highlighted_rows - .iter() - .flat_map(|(_, highlighted_rows)| highlighted_rows.iter()) - .fold( - BTreeMap::::new(), - |mut unique_rows, highlight| { - let start = highlight.range.start.to_display_point(&snapshot); - let end = highlight.range.end.to_display_point(&snapshot); - let start_row = start.row().0; - let end_row = if highlight.range.end.text_anchor != text::Anchor::MAX - && end.column() == 0 - { - end.row().0.saturating_sub(1) - } else { - end.row().0 - }; - for row in start_row..=end_row { - let used_index = - used_highlight_orders.entry(row).or_insert(highlight.index); - if highlight.index >= *used_index { - *used_index = highlight.index; - unique_rows.insert( - DisplayRow(row), - LineHighlight { - include_gutter: highlight.options.include_gutter, - border: None, - background: highlight.color.into(), - type_id: Some(highlight.type_id), - }, - ); - } - } - unique_rows - }, - ) - } - - pub fn highlighted_display_row_for_autoscroll( - &self, - snapshot: &DisplaySnapshot, - ) -> Option { - self.highlighted_rows - .values() - .flat_map(|highlighted_rows| highlighted_rows.iter()) - .filter_map(|highlight| { - if highlight.options.autoscroll { - Some(highlight.range.start.to_display_point(snapshot).row()) - } else { - None - } - }) - .min() - } - - pub fn set_search_within_ranges(&mut self, ranges: &[Range], cx: &mut Context) { - self.highlight_background::( - ranges, - |colors| colors.editor_document_highlight_read_background, - cx, - ) - } - - pub fn set_breadcrumb_header(&mut self, new_header: String) { - self.breadcrumb_header = Some(new_header); - } - - pub fn clear_search_within_ranges(&mut self, cx: &mut Context) { - self.clear_background_highlights::(cx); - } - - pub fn highlight_background( - &mut self, - ranges: &[Range], - color_fetcher: fn(&ThemeColors) -> Hsla, - cx: &mut Context, - ) { - self.background_highlights - .insert(TypeId::of::(), (color_fetcher, Arc::from(ranges))); - self.scrollbar_marker_state.dirty = true; - cx.notify(); - } - - pub fn clear_background_highlights( - &mut self, - cx: &mut Context, - ) -> Option { - let text_highlights = self.background_highlights.remove(&TypeId::of::())?; - if !text_highlights.1.is_empty() { - self.scrollbar_marker_state.dirty = true; - cx.notify(); - } - Some(text_highlights) - } - - pub fn highlight_gutter( - &mut self, - ranges: &[Range], - color_fetcher: fn(&App) -> Hsla, - cx: &mut Context, - ) { - self.gutter_highlights - .insert(TypeId::of::(), (color_fetcher, Arc::from(ranges))); - cx.notify(); - } - - pub fn clear_gutter_highlights( - &mut self, - cx: &mut Context, - ) -> Option { - cx.notify(); - self.gutter_highlights.remove(&TypeId::of::()) - } - - #[cfg(feature = "test-support")] - pub fn all_text_background_highlights( - &self, - window: &mut Window, - cx: &mut Context, - ) -> Vec<(Range, Hsla)> { - let snapshot = self.snapshot(window, cx); - let buffer = &snapshot.buffer_snapshot; - let start = buffer.anchor_before(0); - let end = buffer.anchor_after(buffer.len()); - let theme = cx.theme().colors(); - self.background_highlights_in_range(start..end, &snapshot, theme) - } - - #[cfg(feature = "test-support")] - pub fn search_background_highlights(&mut self, cx: &mut Context) -> Vec> { - let snapshot = self.buffer().read(cx).snapshot(cx); - - let highlights = self - .background_highlights - .get(&TypeId::of::()); - - if let Some((_color, ranges)) = highlights { - ranges - .iter() - .map(|range| range.start.to_point(&snapshot)..range.end.to_point(&snapshot)) - .collect_vec() - } else { - vec![] - } - } - - fn document_highlights_for_position<'a>( - &'a self, - position: Anchor, - buffer: &'a MultiBufferSnapshot, - ) -> impl 'a + Iterator> { - let read_highlights = self - .background_highlights - .get(&TypeId::of::()) - .map(|h| &h.1); - let write_highlights = self - .background_highlights - .get(&TypeId::of::()) - .map(|h| &h.1); - let left_position = position.bias_left(buffer); - let right_position = position.bias_right(buffer); - read_highlights - .into_iter() - .chain(write_highlights) - .flat_map(move |ranges| { - let start_ix = match ranges.binary_search_by(|probe| { - let cmp = probe.end.cmp(&left_position, buffer); - if cmp.is_ge() { - Ordering::Greater - } else { - Ordering::Less - } - }) { - Ok(i) | Err(i) => i, - }; - - ranges[start_ix..] - .iter() - .take_while(move |range| range.start.cmp(&right_position, buffer).is_le()) - }) - } - - pub fn has_background_highlights(&self) -> bool { - self.background_highlights - .get(&TypeId::of::()) - .map_or(false, |(_, highlights)| !highlights.is_empty()) - } - - pub fn background_highlights_in_range( - &self, - search_range: Range, - display_snapshot: &DisplaySnapshot, - theme: &ThemeColors, - ) -> Vec<(Range, Hsla)> { - let mut results = Vec::new(); - for (color_fetcher, ranges) in self.background_highlights.values() { - let color = color_fetcher(theme); - let start_ix = match ranges.binary_search_by(|probe| { - let cmp = probe - .end - .cmp(&search_range.start, &display_snapshot.buffer_snapshot); - if cmp.is_gt() { - Ordering::Greater - } else { - Ordering::Less - } - }) { - Ok(i) | Err(i) => i, - }; - for range in &ranges[start_ix..] { - if range - .start - .cmp(&search_range.end, &display_snapshot.buffer_snapshot) - .is_ge() - { - break; - } - - let start = range.start.to_display_point(display_snapshot); - let end = range.end.to_display_point(display_snapshot); - results.push((start..end, color)) - } - } - results - } - - pub fn background_highlight_row_ranges( - &self, - search_range: Range, - display_snapshot: &DisplaySnapshot, - count: usize, - ) -> Vec> { - let mut results = Vec::new(); - let Some((_, ranges)) = self.background_highlights.get(&TypeId::of::()) else { - return vec![]; - }; - - let start_ix = match ranges.binary_search_by(|probe| { - let cmp = probe - .end - .cmp(&search_range.start, &display_snapshot.buffer_snapshot); - if cmp.is_gt() { - Ordering::Greater - } else { - Ordering::Less - } - }) { - Ok(i) | Err(i) => i, - }; - let mut push_region = |start: Option, end: Option| { - if let (Some(start_display), Some(end_display)) = (start, end) { - results.push( - start_display.to_display_point(display_snapshot) - ..=end_display.to_display_point(display_snapshot), - ); - } - }; - let mut start_row: Option = None; - let mut end_row: Option = None; - if ranges.len() > count { - return Vec::new(); - } - for range in &ranges[start_ix..] { - if range - .start - .cmp(&search_range.end, &display_snapshot.buffer_snapshot) - .is_ge() - { - break; - } - let end = range.end.to_point(&display_snapshot.buffer_snapshot); - if let Some(current_row) = &end_row { - if end.row == current_row.row { - continue; - } - } - let start = range.start.to_point(&display_snapshot.buffer_snapshot); - if start_row.is_none() { - assert_eq!(end_row, None); - start_row = Some(start); - end_row = Some(end); - continue; - } - if let Some(current_end) = end_row.as_mut() { - if start.row > current_end.row + 1 { - push_region(start_row, end_row); - start_row = Some(start); - end_row = Some(end); - } else { - // Merge two hunks. - *current_end = end; - } - } else { - unreachable!(); - } - } - // We might still have a hunk that was not rendered (if there was a search hit on the last line) - push_region(start_row, end_row); - results - } - - pub fn gutter_highlights_in_range( - &self, - search_range: Range, - display_snapshot: &DisplaySnapshot, - cx: &App, - ) -> Vec<(Range, Hsla)> { - let mut results = Vec::new(); - for (color_fetcher, ranges) in self.gutter_highlights.values() { - let color = color_fetcher(cx); - let start_ix = match ranges.binary_search_by(|probe| { - let cmp = probe - .end - .cmp(&search_range.start, &display_snapshot.buffer_snapshot); - if cmp.is_gt() { - Ordering::Greater - } else { - Ordering::Less - } - }) { - Ok(i) | Err(i) => i, - }; - for range in &ranges[start_ix..] { - if range - .start - .cmp(&search_range.end, &display_snapshot.buffer_snapshot) - .is_ge() - { - break; - } - - let start = range.start.to_display_point(display_snapshot); - let end = range.end.to_display_point(display_snapshot); - results.push((start..end, color)) - } - } - results - } - - /// Get the text ranges corresponding to the redaction query - pub fn redacted_ranges( - &self, - search_range: Range, - display_snapshot: &DisplaySnapshot, - cx: &App, - ) -> Vec> { - display_snapshot - .buffer_snapshot - .redacted_ranges(search_range, |file| { - if let Some(file) = file { - file.is_private() - && EditorSettings::get( - Some(SettingsLocation { - worktree_id: file.worktree_id(cx), - path: file.path().as_ref(), - }), - cx, - ) - .redact_private_values - } else { - false - } - }) - .map(|range| { - range.start.to_display_point(display_snapshot) - ..range.end.to_display_point(display_snapshot) - }) - .collect() - } - - pub fn highlight_text( - &mut self, - ranges: Vec>, - style: HighlightStyle, - cx: &mut Context, - ) { - self.display_map.update(cx, |map, _| { - map.highlight_text(TypeId::of::(), ranges, style) - }); - cx.notify(); - } - - pub(crate) fn highlight_inlays( - &mut self, - highlights: Vec, - style: HighlightStyle, - cx: &mut Context, - ) { - self.display_map.update(cx, |map, _| { - map.highlight_inlays(TypeId::of::(), highlights, style) - }); - cx.notify(); - } - - pub fn text_highlights<'a, T: 'static>( - &'a self, - cx: &'a App, - ) -> Option<(HighlightStyle, &'a [Range])> { - self.display_map.read(cx).text_highlights(TypeId::of::()) - } - - pub fn clear_highlights(&mut self, cx: &mut Context) { - let cleared = self - .display_map - .update(cx, |map, _| map.clear_highlights(TypeId::of::())); - if cleared { - cx.notify(); - } - } - - pub fn show_local_cursors(&self, window: &mut Window, cx: &mut App) -> bool { - (self.read_only(cx) || self.blink_manager.read(cx).visible()) - && self.focus_handle.is_focused(window) - } - - pub fn set_show_cursor_when_unfocused(&mut self, is_enabled: bool, cx: &mut Context) { - self.show_cursor_when_unfocused = is_enabled; - cx.notify(); - } - - fn on_buffer_changed(&mut self, _: Entity, cx: &mut Context) { - cx.notify(); - } - - fn on_debug_session_event( - &mut self, - _session: Entity, - event: &SessionEvent, - cx: &mut Context, - ) { - match event { - SessionEvent::InvalidateInlineValue => { - self.refresh_inline_values(cx); - } - _ => {} - } - } - - fn refresh_inline_values(&mut self, cx: &mut Context) { - let Some(project) = self.project.clone() else { - return; - }; - let Some(buffer) = self.buffer.read(cx).as_singleton() else { - return; - }; - if !self.inline_value_cache.enabled { - let inlays = std::mem::take(&mut self.inline_value_cache.inlays); - self.splice_inlays(&inlays, Vec::new(), cx); - return; - } - - let current_execution_position = self - .highlighted_rows - .get(&TypeId::of::()) - .and_then(|lines| lines.last().map(|line| line.range.start)); - - self.inline_value_cache.refresh_task = cx.spawn(async move |editor, cx| { - let snapshot = editor - .update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)) - .ok()?; - - let inline_values = editor - .update(cx, |_, cx| { - let Some(current_execution_position) = current_execution_position else { - return Some(Task::ready(Ok(Vec::new()))); - }; - - // todo(debugger) when introducing multi buffer inline values check execution position's buffer id to make sure the text - // anchor is in the same buffer - let range = - buffer.read(cx).anchor_before(0)..current_execution_position.text_anchor; - project.inline_values(buffer, range, cx) - }) - .ok() - .flatten()? - .await - .context("refreshing debugger inlays") - .log_err()?; - - let (excerpt_id, buffer_id) = snapshot - .excerpts() - .next() - .map(|excerpt| (excerpt.0, excerpt.1.remote_id()))?; - editor - .update(cx, |editor, cx| { - let new_inlays = inline_values - .into_iter() - .map(|debugger_value| { - Inlay::debugger_hint( - post_inc(&mut editor.next_inlay_id), - Anchor::in_buffer(excerpt_id, buffer_id, debugger_value.position), - debugger_value.text(), - ) - }) - .collect::>(); - let mut inlay_ids = new_inlays.iter().map(|inlay| inlay.id).collect(); - std::mem::swap(&mut editor.inline_value_cache.inlays, &mut inlay_ids); - - editor.splice_inlays(&inlay_ids, new_inlays, cx); - }) - .ok()?; - Some(()) - }); - } - - fn on_buffer_event( - &mut self, - multibuffer: &Entity, - event: &multi_buffer::Event, - window: &mut Window, - cx: &mut Context, - ) { - match event { - multi_buffer::Event::Edited { - singleton_buffer_edited, - edited_buffer: buffer_edited, - } => { - self.scrollbar_marker_state.dirty = true; - self.active_indent_guides_state.dirty = true; - self.refresh_active_diagnostics(cx); - self.refresh_code_actions(window, cx); - self.refresh_selected_text_highlights(true, window, cx); - refresh_matching_bracket_highlights(self, window, cx); - if self.has_active_inline_completion() { - self.update_visible_inline_completion(window, cx); - } - if let Some(buffer) = buffer_edited { - let buffer_id = buffer.read(cx).remote_id(); - if !self.registered_buffers.contains_key(&buffer_id) { - if let Some(project) = self.project.as_ref() { - project.update(cx, |project, cx| { - self.registered_buffers.insert( - buffer_id, - project.register_buffer_with_language_servers(&buffer, cx), - ); - }) - } - } - } - cx.emit(EditorEvent::BufferEdited); - cx.emit(SearchEvent::MatchesInvalidated); - if *singleton_buffer_edited { - if let Some(project) = &self.project { - #[allow(clippy::mutable_key_type)] - let languages_affected = multibuffer.update(cx, |multibuffer, cx| { - multibuffer - .all_buffers() - .into_iter() - .filter_map(|buffer| { - buffer.update(cx, |buffer, cx| { - let language = buffer.language()?; - let should_discard = project.update(cx, |project, cx| { - project.is_local() - && !project.has_language_servers_for(buffer, cx) - }); - should_discard.not().then_some(language.clone()) - }) - }) - .collect::>() - }); - if !languages_affected.is_empty() { - self.refresh_inlay_hints( - InlayHintRefreshReason::BufferEdited(languages_affected), - cx, - ); - } - } - } - - let Some(project) = &self.project else { return }; - let (telemetry, is_via_ssh) = { - let project = project.read(cx); - let telemetry = project.client().telemetry().clone(); - let is_via_ssh = project.is_via_ssh(); - (telemetry, is_via_ssh) - }; - refresh_linked_ranges(self, window, cx); - telemetry.log_edit_event("editor", is_via_ssh); - } - multi_buffer::Event::ExcerptsAdded { - buffer, - predecessor, - excerpts, - } => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); - let buffer_id = buffer.read(cx).remote_id(); - if self.buffer.read(cx).diff_for(buffer_id).is_none() { - if let Some(project) = &self.project { - get_uncommitted_diff_for_buffer( - project, - [buffer.clone()], - self.buffer.clone(), - cx, - ) - .detach(); - } - } - cx.emit(EditorEvent::ExcerptsAdded { - buffer: buffer.clone(), - predecessor: *predecessor, - excerpts: excerpts.clone(), - }); - self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); - } - multi_buffer::Event::ExcerptsRemoved { - ids, - removed_buffer_ids, - } => { - self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); - let buffer = self.buffer.read(cx); - self.registered_buffers - .retain(|buffer_id, _| buffer.buffer(*buffer_id).is_some()); - jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); - cx.emit(EditorEvent::ExcerptsRemoved { - ids: ids.clone(), - removed_buffer_ids: removed_buffer_ids.clone(), - }) - } - multi_buffer::Event::ExcerptsEdited { - excerpt_ids, - buffer_ids, - } => { - self.display_map.update(cx, |map, cx| { - map.unfold_buffers(buffer_ids.iter().copied(), cx) - }); - cx.emit(EditorEvent::ExcerptsEdited { - ids: excerpt_ids.clone(), - }) - } - multi_buffer::Event::ExcerptsExpanded { ids } => { - self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); - cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() }) - } - multi_buffer::Event::Reparsed(buffer_id) => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); - jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); - - cx.emit(EditorEvent::Reparsed(*buffer_id)); - } - multi_buffer::Event::DiffHunksToggled => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); - } - multi_buffer::Event::LanguageChanged(buffer_id) => { - linked_editing_ranges::refresh_linked_ranges(self, window, cx); - jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); - cx.emit(EditorEvent::Reparsed(*buffer_id)); - cx.notify(); - } - multi_buffer::Event::DirtyChanged => cx.emit(EditorEvent::DirtyChanged), - multi_buffer::Event::Saved => cx.emit(EditorEvent::Saved), - multi_buffer::Event::FileHandleChanged - | multi_buffer::Event::Reloaded - | multi_buffer::Event::BufferDiffChanged => cx.emit(EditorEvent::TitleChanged), - multi_buffer::Event::Closed => cx.emit(EditorEvent::Closed), - multi_buffer::Event::DiagnosticsUpdated => { - self.refresh_active_diagnostics(cx); - self.refresh_inline_diagnostics(true, window, cx); - self.scrollbar_marker_state.dirty = true; - cx.notify(); - } - _ => {} - }; - } - - fn on_display_map_changed( - &mut self, - _: Entity, - _: &mut Window, - cx: &mut Context, - ) { - cx.notify(); - } - - fn settings_changed(&mut self, window: &mut Window, cx: &mut Context) { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); - self.update_edit_prediction_settings(cx); - self.refresh_inline_completion(true, false, window, cx); - self.refresh_inlay_hints( - InlayHintRefreshReason::SettingsChange(inlay_hint_settings( - self.selections.newest_anchor().head(), - &self.buffer.read(cx).snapshot(cx), - cx, - )), - cx, - ); - - let old_cursor_shape = self.cursor_shape; - - { - let editor_settings = EditorSettings::get_global(cx); - self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin; - self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs; - self.cursor_shape = editor_settings.cursor_shape.unwrap_or_default(); - self.hide_mouse_mode = editor_settings.hide_mouse.unwrap_or_default(); - } - - if old_cursor_shape != self.cursor_shape { - cx.emit(EditorEvent::CursorShapeChanged); - } - - let project_settings = ProjectSettings::get_global(cx); - self.serialize_dirty_buffers = project_settings.session.restore_unsaved_buffers; - - if self.mode.is_full() { - let show_inline_diagnostics = project_settings.diagnostics.inline.enabled; - let inline_blame_enabled = project_settings.git.inline_blame_enabled(); - if self.show_inline_diagnostics != show_inline_diagnostics { - self.show_inline_diagnostics = show_inline_diagnostics; - self.refresh_inline_diagnostics(false, window, cx); - } - - if self.git_blame_inline_enabled != inline_blame_enabled { - self.toggle_git_blame_inline_internal(false, window, cx); - } - } - - cx.notify(); - } - - pub fn set_searchable(&mut self, searchable: bool) { - self.searchable = searchable; - } - - pub fn searchable(&self) -> bool { - self.searchable - } - - fn open_proposed_changes_editor( - &mut self, - _: &OpenProposedChangesEditor, - window: &mut Window, - cx: &mut Context, - ) { - let Some(workspace) = self.workspace() else { - cx.propagate(); - return; - }; - - let selections = self.selections.all::(cx); - let multi_buffer = self.buffer.read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let mut new_selections_by_buffer = HashMap::default(); - for selection in selections { - for (buffer, range, _) in - multi_buffer_snapshot.range_to_buffer_ranges(selection.start..selection.end) - { - let mut range = range.to_point(buffer); - range.start.column = 0; - range.end.column = buffer.line_len(range.end.row); - new_selections_by_buffer - .entry(multi_buffer.buffer(buffer.remote_id()).unwrap()) - .or_insert(Vec::new()) - .push(range) - } - } - - let proposed_changes_buffers = new_selections_by_buffer - .into_iter() - .map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges }) - .collect::>(); - let proposed_changes_editor = cx.new(|cx| { - ProposedChangesEditor::new( - "Proposed changes", - proposed_changes_buffers, - self.project.clone(), - window, - cx, - ) - }); - - window.defer(cx, move |window, cx| { - workspace.update(cx, |workspace, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item( - Box::new(proposed_changes_editor), - true, - true, - None, - window, - cx, - ); - }); - }); - }); - } - - pub fn open_excerpts_in_split( - &mut self, - _: &OpenExcerptsSplit, - window: &mut Window, - cx: &mut Context, - ) { - self.open_excerpts_common(None, true, window, cx) - } - - pub fn open_excerpts(&mut self, _: &OpenExcerpts, window: &mut Window, cx: &mut Context) { - self.open_excerpts_common(None, false, window, cx) - } - - fn open_excerpts_common( - &mut self, - jump_data: Option, - split: bool, - window: &mut Window, - cx: &mut Context, - ) { - let Some(workspace) = self.workspace() else { - cx.propagate(); - return; - }; - - if self.buffer.read(cx).is_singleton() { - cx.propagate(); - return; - } - - let mut new_selections_by_buffer = HashMap::default(); - match &jump_data { - Some(JumpData::MultiBufferPoint { - excerpt_id, - position, - anchor, - line_offset_from_top, - }) => { - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - if let Some(buffer) = multi_buffer_snapshot - .buffer_id_for_excerpt(*excerpt_id) - .and_then(|buffer_id| self.buffer.read(cx).buffer(buffer_id)) - { - let buffer_snapshot = buffer.read(cx).snapshot(); - let jump_to_point = if buffer_snapshot.can_resolve(anchor) { - language::ToPoint::to_point(anchor, &buffer_snapshot) - } else { - buffer_snapshot.clip_point(*position, Bias::Left) - }; - let jump_to_offset = buffer_snapshot.point_to_offset(jump_to_point); - new_selections_by_buffer.insert( - buffer, - ( - vec![jump_to_offset..jump_to_offset], - Some(*line_offset_from_top), - ), - ); - } - } - Some(JumpData::MultiBufferRow { - row, - line_offset_from_top, - }) => { - let point = MultiBufferPoint::new(row.0, 0); - if let Some((buffer, buffer_point, _)) = - self.buffer.read(cx).point_to_buffer_point(point, cx) - { - let buffer_offset = buffer.read(cx).point_to_offset(buffer_point); - new_selections_by_buffer - .entry(buffer) - .or_insert((Vec::new(), Some(*line_offset_from_top))) - .0 - .push(buffer_offset..buffer_offset) - } - } - None => { - let selections = self.selections.all::(cx); - let multi_buffer = self.buffer.read(cx); - for selection in selections { - for (snapshot, range, _, anchor) in multi_buffer - .snapshot(cx) - .range_to_buffer_ranges_with_deleted_hunks(selection.range()) - { - if let Some(anchor) = anchor { - // selection is in a deleted hunk - let Some(buffer_id) = anchor.buffer_id else { - continue; - }; - let Some(buffer_handle) = multi_buffer.buffer(buffer_id) else { - continue; - }; - let offset = text::ToOffset::to_offset( - &anchor.text_anchor, - &buffer_handle.read(cx).snapshot(), - ); - let range = offset..offset; - new_selections_by_buffer - .entry(buffer_handle) - .or_insert((Vec::new(), None)) - .0 - .push(range) - } else { - let Some(buffer_handle) = multi_buffer.buffer(snapshot.remote_id()) - else { - continue; - }; - new_selections_by_buffer - .entry(buffer_handle) - .or_insert((Vec::new(), None)) - .0 - .push(range) - } - } - } - } - } - - new_selections_by_buffer - .retain(|buffer, _| Self::can_open_excerpts_in_file(buffer.read(cx).file())); - - if new_selections_by_buffer.is_empty() { - return; - } - - // We defer the pane interaction because we ourselves are a workspace item - // and activating a new item causes the pane to call a method on us reentrantly, - // which panics if we're on the stack. - window.defer(cx, move |window, cx| { - workspace.update(cx, |workspace, cx| { - let pane = if split { - workspace.adjacent_pane(window, cx) - } else { - workspace.active_pane().clone() - }; - - for (buffer, (ranges, scroll_offset)) in new_selections_by_buffer { - let editor = buffer - .read(cx) - .file() - .is_none() - .then(|| { - // Handle file-less buffers separately: those are not really the project items, so won't have a project path or entity id, - // so `workspace.open_project_item` will never find them, always opening a new editor. - // Instead, we try to activate the existing editor in the pane first. - let (editor, pane_item_index) = - pane.read(cx).items().enumerate().find_map(|(i, item)| { - let editor = item.downcast::()?; - let singleton_buffer = - editor.read(cx).buffer().read(cx).as_singleton()?; - if singleton_buffer == buffer { - Some((editor, i)) - } else { - None - } - })?; - pane.update(cx, |pane, cx| { - pane.activate_item(pane_item_index, true, true, window, cx) - }); - Some(editor) - }) - .flatten() - .unwrap_or_else(|| { - workspace.open_project_item::( - pane.clone(), - buffer, - true, - true, - window, - cx, - ) - }); - - editor.update(cx, |editor, cx| { - let autoscroll = match scroll_offset { - Some(scroll_offset) => Autoscroll::top_relative(scroll_offset as usize), - None => Autoscroll::newest(), - }; - let nav_history = editor.nav_history.take(); - editor.change_selections(Some(autoscroll), window, cx, |s| { - s.select_ranges(ranges); - }); - editor.nav_history = nav_history; - }); - } - }) - }); - } - - // For now, don't allow opening excerpts in buffers that aren't backed by - // regular project files. - fn can_open_excerpts_in_file(file: Option<&Arc>) -> bool { - file.map_or(true, |file| project::File::from_dyn(Some(file)).is_some()) - } - - fn marked_text_ranges(&self, cx: &App) -> Option>> { - let snapshot = self.buffer.read(cx).read(cx); - let (_, ranges) = self.text_highlights::(cx)?; - Some( - ranges - .iter() - .map(move |range| { - range.start.to_offset_utf16(&snapshot)..range.end.to_offset_utf16(&snapshot) - }) - .collect(), - ) - } - - fn selection_replacement_ranges( - &self, - range: Range, - cx: &mut App, - ) -> Vec> { - let selections = self.selections.all::(cx); - let newest_selection = selections - .iter() - .max_by_key(|selection| selection.id) - .unwrap(); - let start_delta = range.start.0 as isize - newest_selection.start.0 as isize; - let end_delta = range.end.0 as isize - newest_selection.end.0 as isize; - let snapshot = self.buffer.read(cx).read(cx); - selections - .into_iter() - .map(|mut selection| { - selection.start.0 = - (selection.start.0 as isize).saturating_add(start_delta) as usize; - selection.end.0 = (selection.end.0 as isize).saturating_add(end_delta) as usize; - snapshot.clip_offset_utf16(selection.start, Bias::Left) - ..snapshot.clip_offset_utf16(selection.end, Bias::Right) - }) - .collect() - } - - fn report_editor_event( - &self, - event_type: &'static str, - file_extension: Option, - cx: &App, - ) { - if cfg!(any(test, feature = "test-support")) { - return; - } - - let Some(project) = &self.project else { return }; - - // If None, we are in a file without an extension - let file = self - .buffer - .read(cx) - .as_singleton() - .and_then(|b| b.read(cx).file()); - let file_extension = file_extension.or(file - .as_ref() - .and_then(|file| Path::new(file.file_name(cx)).extension()) - .and_then(|e| e.to_str()) - .map(|a| a.to_string())); - - let vim_mode = vim_enabled(cx); - - let edit_predictions_provider = all_language_settings(file, cx).edit_predictions.provider; - let copilot_enabled = edit_predictions_provider - == language::language_settings::EditPredictionProvider::Copilot; - let copilot_enabled_for_language = self - .buffer - .read(cx) - .language_settings(cx) - .show_edit_predictions; - - let project = project.read(cx); - telemetry::event!( - event_type, - file_extension, - vim_mode, - copilot_enabled, - copilot_enabled_for_language, - edit_predictions_provider, - is_via_ssh = project.is_via_ssh(), - ); - } - - /// Copy the highlighted chunks to the clipboard as JSON. The format is an array of lines, - /// with each line being an array of {text, highlight} objects. - fn copy_highlight_json( - &mut self, - _: &CopyHighlightJson, - window: &mut Window, - cx: &mut Context, - ) { - #[derive(Serialize)] - struct Chunk<'a> { - text: String, - highlight: Option<&'a str>, - } - - let snapshot = self.buffer.read(cx).snapshot(cx); - let range = self - .selected_text_range(false, window, cx) - .and_then(|selection| { - if selection.range.is_empty() { - None - } else { - Some(selection.range) - } - }) - .unwrap_or_else(|| 0..snapshot.len()); - - let chunks = snapshot.chunks(range, true); - let mut lines = Vec::new(); - let mut line: VecDeque = VecDeque::new(); - - let Some(style) = self.style.as_ref() else { - return; - }; - - for chunk in chunks { - let highlight = chunk - .syntax_highlight_id - .and_then(|id| id.name(&style.syntax)); - let mut chunk_lines = chunk.text.split('\n').peekable(); - while let Some(text) = chunk_lines.next() { - let mut merged_with_last_token = false; - if let Some(last_token) = line.back_mut() { - if last_token.highlight == highlight { - last_token.text.push_str(text); - merged_with_last_token = true; - } - } - - if !merged_with_last_token { - line.push_back(Chunk { - text: text.into(), - highlight, - }); - } - - if chunk_lines.peek().is_some() { - if line.len() > 1 && line.front().unwrap().text.is_empty() { - line.pop_front(); - } - if line.len() > 1 && line.back().unwrap().text.is_empty() { - line.pop_back(); - } - - lines.push(mem::take(&mut line)); - } - } - } - - let Some(lines) = serde_json::to_string_pretty(&lines).log_err() else { - return; - }; - cx.write_to_clipboard(ClipboardItem::new_string(lines)); - } - - pub fn open_context_menu( - &mut self, - _: &OpenContextMenu, - window: &mut Window, - cx: &mut Context, - ) { - self.request_autoscroll(Autoscroll::newest(), cx); - let position = self.selections.newest_display(cx).start; - mouse_context_menu::deploy_context_menu(self, None, position, window, cx); - } - - pub fn inlay_hint_cache(&self) -> &InlayHintCache { - &self.inlay_hint_cache - } - - pub fn replay_insert_event( - &mut self, - text: &str, - relative_utf16_range: Option>, - window: &mut Window, - cx: &mut Context, - ) { - if !self.input_enabled { - cx.emit(EditorEvent::InputIgnored { text: text.into() }); - return; - } - if let Some(relative_utf16_range) = relative_utf16_range { - let selections = self.selections.all::(cx); - self.change_selections(None, window, cx, |s| { - let new_ranges = selections.into_iter().map(|range| { - let start = OffsetUtf16( - range - .head() - .0 - .saturating_add_signed(relative_utf16_range.start), - ); - let end = OffsetUtf16( - range - .head() - .0 - .saturating_add_signed(relative_utf16_range.end), - ); - start..end - }); - s.select_ranges(new_ranges); - }); - } - - self.handle_input(text, window, cx); - } - - pub fn supports_inlay_hints(&self, cx: &mut App) -> bool { - let Some(provider) = self.semantics_provider.as_ref() else { - return false; - }; - - let mut supports = false; - self.buffer().update(cx, |this, cx| { - this.for_each_buffer(|buffer| { - supports |= provider.supports_inlay_hints(buffer, cx); - }); - }); - - supports - } - - pub fn is_focused(&self, window: &Window) -> bool { - self.focus_handle.is_focused(window) - } - - fn handle_focus(&mut self, window: &mut Window, cx: &mut Context) { - cx.emit(EditorEvent::Focused); - - if let Some(descendant) = self - .last_focused_descendant - .take() - .and_then(|descendant| descendant.upgrade()) - { - window.focus(&descendant); - } else { - if let Some(blame) = self.blame.as_ref() { - blame.update(cx, GitBlame::focus) - } - - self.blink_manager.update(cx, |blink_manager, cx| { - blink_manager.enable(cx); - }); - self.show_cursor_names(window, cx); - self.buffer.update(cx, |buffer, cx| { - buffer.finalize_last_transaction(cx); - if self.leader_peer_id.is_none() { - buffer.set_active_selections( - &self.selections.disjoint_anchors(), - self.selections.line_mode, - self.cursor_shape, - cx, - ); - } - }); - } - } - - fn handle_focus_in(&mut self, _: &mut Window, cx: &mut Context) { - cx.emit(EditorEvent::FocusedIn) - } - - fn handle_focus_out( - &mut self, - event: FocusOutEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.blurred != self.focus_handle { - self.last_focused_descendant = Some(event.blurred); - } - self.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); - } - - pub fn handle_blur(&mut self, window: &mut Window, cx: &mut Context) { - self.blink_manager.update(cx, BlinkManager::disable); - self.buffer - .update(cx, |buffer, cx| buffer.remove_active_selections(cx)); - - if let Some(blame) = self.blame.as_ref() { - blame.update(cx, GitBlame::blur) - } - if !self.hover_state.focused(window, cx) { - hide_hover(self, cx); - } - if !self - .context_menu - .borrow() - .as_ref() - .is_some_and(|context_menu| context_menu.focused(window, cx)) - { - self.hide_context_menu(window, cx); - } - self.discard_inline_completion(false, cx); - cx.emit(EditorEvent::Blurred); - cx.notify(); - } - - pub fn register_action( - &mut self, - listener: impl Fn(&A, &mut Window, &mut App) + 'static, - ) -> Subscription { - let id = self.next_editor_action_id.post_inc(); - let listener = Arc::new(listener); - self.editor_actions.borrow_mut().insert( - id, - Box::new(move |window, _| { - let listener = listener.clone(); - window.on_action(TypeId::of::(), move |action, phase, window, cx| { - let action = action.downcast_ref().unwrap(); - if phase == DispatchPhase::Bubble { - listener(action, window, cx) - } - }) - }), - ); - - let editor_actions = self.editor_actions.clone(); - Subscription::new(move || { - editor_actions.borrow_mut().remove(&id); - }) - } - - pub fn file_header_size(&self) -> u32 { - FILE_HEADER_HEIGHT - } - - pub fn restore( - &mut self, - revert_changes: HashMap, Rope)>>, - window: &mut Window, - cx: &mut Context, - ) { - let workspace = self.workspace(); - let project = self.project.as_ref(); - let save_tasks = self.buffer().update(cx, |multi_buffer, cx| { - let mut tasks = Vec::new(); - for (buffer_id, changes) in revert_changes { - if let Some(buffer) = multi_buffer.buffer(buffer_id) { - buffer.update(cx, |buffer, cx| { - buffer.edit( - changes - .into_iter() - .map(|(range, text)| (range, text.to_string())), - None, - cx, - ); - }); - - if let Some(project) = - project.filter(|_| multi_buffer.all_diff_hunks_expanded()) - { - project.update(cx, |project, cx| { - tasks.push((buffer.clone(), project.save_buffer(buffer, cx))); - }) - } - } - } - tasks - }); - cx.spawn_in(window, async move |_, cx| { - for (buffer, task) in save_tasks { - let result = task.await; - if result.is_err() { - let Some(path) = buffer - .read_with(cx, |buffer, cx| buffer.project_path(cx)) - .ok() - else { - continue; - }; - if let Some((workspace, path)) = workspace.as_ref().zip(path) { - let Some(task) = cx - .update_window_entity(&workspace, |workspace, window, cx| { - workspace - .open_path_preview(path, None, false, false, false, window, cx) - }) - .ok() - else { - continue; - }; - task.await.log_err(); - } - } - } - }) - .detach(); - self.change_selections(None, window, cx, |selections| selections.refresh()); - } - - pub fn to_pixel_point( - &self, - source: multi_buffer::Anchor, - editor_snapshot: &EditorSnapshot, - window: &mut Window, - ) -> Option> { - let source_point = source.to_display_point(editor_snapshot); - self.display_to_pixel_point(source_point, editor_snapshot, window) - } - - pub fn display_to_pixel_point( - &self, - source: DisplayPoint, - editor_snapshot: &EditorSnapshot, - window: &mut Window, - ) -> Option> { - let line_height = self.style()?.text.line_height_in_pixels(window.rem_size()); - let text_layout_details = self.text_layout_details(window); - let scroll_top = text_layout_details - .scroll_anchor - .scroll_position(editor_snapshot) - .y; - - if source.row().as_f32() < scroll_top.floor() { - return None; - } - let source_x = editor_snapshot.x_for_display_point(source, &text_layout_details); - let source_y = line_height * (source.row().as_f32() - scroll_top); - Some(gpui::Point::new(source_x, source_y)) - } - - pub fn has_visible_completions_menu(&self) -> bool { - !self.edit_prediction_preview_is_active() - && self.context_menu.borrow().as_ref().map_or(false, |menu| { - menu.visible() && matches!(menu, CodeContextMenu::Completions(_)) - }) - } - - pub fn register_addon(&mut self, instance: T) { - self.addons - .insert(std::any::TypeId::of::(), Box::new(instance)); - } - - pub fn unregister_addon(&mut self) { - self.addons.remove(&std::any::TypeId::of::()); - } - - pub fn addon(&self) -> Option<&T> { - let type_id = std::any::TypeId::of::(); - self.addons - .get(&type_id) - .and_then(|item| item.to_any().downcast_ref::()) - } - - pub fn addon_mut(&mut self) -> Option<&mut T> { - let type_id = std::any::TypeId::of::(); - self.addons - .get_mut(&type_id) - .and_then(|item| item.to_any_mut()?.downcast_mut::()) - } - - fn character_size(&self, window: &mut Window) -> gpui::Size { - let text_layout_details = self.text_layout_details(window); - let style = &text_layout_details.editor_style; - let font_id = window.text_system().resolve_font(&style.text.font()); - let font_size = style.text.font_size.to_pixels(window.rem_size()); - let line_height = style.text.line_height_in_pixels(window.rem_size()); - let em_width = window.text_system().em_width(font_id, font_size).unwrap(); - - gpui::Size::new(em_width, line_height) - } - - pub fn wait_for_diff_to_load(&self) -> Option>> { - self.load_diff_task.clone() - } - - fn read_metadata_from_db( - &mut self, - item_id: u64, - workspace_id: WorkspaceId, - window: &mut Window, - cx: &mut Context, - ) { - if self.is_singleton(cx) - && WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None - { - let buffer_snapshot = OnceCell::new(); - - if let Some(folds) = DB.get_editor_folds(item_id, workspace_id).log_err() { - if !folds.is_empty() { - let snapshot = - buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx)); - self.fold_ranges( - folds - .into_iter() - .map(|(start, end)| { - snapshot.clip_offset(start, Bias::Left) - ..snapshot.clip_offset(end, Bias::Right) - }) - .collect(), - false, - window, - cx, - ); - } - } - - if let Some(selections) = DB.get_editor_selections(item_id, workspace_id).log_err() { - if !selections.is_empty() { - let snapshot = - buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx)); - self.change_selections(None, window, cx, |s| { - s.select_ranges(selections.into_iter().map(|(start, end)| { - snapshot.clip_offset(start, Bias::Left) - ..snapshot.clip_offset(end, Bias::Right) - })); - }); - } - }; - } - - self.read_scroll_position_from_db(item_id, workspace_id, window, cx); - } -} - -fn vim_enabled(cx: &App) -> bool { - cx.global::() - .raw_user_settings() - .get("vim_mode") - == Some(&serde_json::Value::Bool(true)) -} - -// Consider user intent and default settings -fn choose_completion_range( - completion: &Completion, - intent: CompletionIntent, - buffer: &Entity, - cx: &mut Context, -) -> Range { - fn should_replace( - completion: &Completion, - insert_range: &Range, - intent: CompletionIntent, - completion_mode_setting: LspInsertMode, - buffer: &Buffer, - ) -> bool { - // specific actions take precedence over settings - match intent { - CompletionIntent::CompleteWithInsert => return false, - CompletionIntent::CompleteWithReplace => return true, - CompletionIntent::Complete | CompletionIntent::Compose => {} - } - - match completion_mode_setting { - LspInsertMode::Insert => false, - LspInsertMode::Replace => true, - LspInsertMode::ReplaceSubsequence => { - let mut text_to_replace = buffer.chars_for_range( - buffer.anchor_before(completion.replace_range.start) - ..buffer.anchor_after(completion.replace_range.end), - ); - let mut completion_text = completion.new_text.chars(); - - // is `text_to_replace` a subsequence of `completion_text` - text_to_replace - .all(|needle_ch| completion_text.any(|haystack_ch| haystack_ch == needle_ch)) - } - LspInsertMode::ReplaceSuffix => { - let range_after_cursor = insert_range.end..completion.replace_range.end; - - let text_after_cursor = buffer - .text_for_range( - buffer.anchor_before(range_after_cursor.start) - ..buffer.anchor_after(range_after_cursor.end), - ) - .collect::(); - completion.new_text.ends_with(&text_after_cursor) - } - } - } - - let buffer = buffer.read(cx); - - if let CompletionSource::Lsp { - insert_range: Some(insert_range), - .. - } = &completion.source - { - let completion_mode_setting = - language_settings(cx).buffer(buffer).get() - .completions - .lsp_insert_mode; - - if !should_replace( - completion, - &insert_range, - intent, - completion_mode_setting, - buffer, - ) { - return insert_range.to_offset(buffer); - } - } - - completion.replace_range.to_offset(buffer) -} - -fn insert_extra_newline_brackets( - buffer: &MultiBufferSnapshot, - range: Range, - language: &language::LanguageScope, -) -> bool { - let leading_whitespace_len = buffer - .reversed_chars_at(range.start) - .take_while(|c| c.is_whitespace() && *c != '\n') - .map(|c| c.len_utf8()) - .sum::(); - let trailing_whitespace_len = buffer - .chars_at(range.end) - .take_while(|c| c.is_whitespace() && *c != '\n') - .map(|c| c.len_utf8()) - .sum::(); - let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len; - - language.brackets().any(|(pair, enabled)| { - let pair_start = pair.start.trim_end(); - let pair_end = pair.end.trim_start(); - - enabled - && pair.newline - && buffer.contains_str_at(range.end, pair_end) - && buffer.contains_str_at(range.start.saturating_sub(pair_start.len()), pair_start) - }) -} - -fn insert_extra_newline_tree_sitter(buffer: &MultiBufferSnapshot, range: Range) -> bool { - let (buffer, range) = match buffer.range_to_buffer_ranges(range).as_slice() { - [(buffer, range, _)] => (*buffer, range.clone()), - _ => return false, - }; - let pair = { - let mut result: Option = None; - - for pair in buffer - .all_bracket_ranges(range.clone()) - .filter(move |pair| { - pair.open_range.start <= range.start && pair.close_range.end >= range.end - }) - { - let len = pair.close_range.end - pair.open_range.start; - - if let Some(existing) = &result { - let existing_len = existing.close_range.end - existing.open_range.start; - if len > existing_len { - continue; - } - } - - result = Some(pair); - } - - result - }; - let Some(pair) = pair else { - return false; - }; - pair.newline_only - && buffer - .chars_for_range(pair.open_range.end..range.start) - .chain(buffer.chars_for_range(range.end..pair.close_range.start)) - .all(|c| c.is_whitespace() && c != '\n') -} - -fn get_uncommitted_diff_for_buffer( - project: &Entity, - buffers: impl IntoIterator>, - buffer: Entity, - cx: &mut App, -) -> Task<()> { - let mut tasks = Vec::new(); - project.update(cx, |project, cx| { - for buffer in buffers { - if project::File::from_dyn(buffer.read(cx).file()).is_some() { - tasks.push(project.open_uncommitted_diff(buffer.clone(), cx)) - } - } - }); - cx.spawn(async move |cx| { - let diffs = future::join_all(tasks).await; - buffer - .update(cx, |buffer, cx| { - for diff in diffs.into_iter().flatten() { - buffer.add_diff(diff, cx); - } - }) - .ok(); - }) -} - -fn char_len_with_expanded_tabs(offset: usize, text: &str, tab_size: NonZeroU32) -> usize { - let tab_size = tab_size.get() as usize; - let mut width = offset; - - for ch in text.chars() { - width += if ch == '\t' { - tab_size - (width % tab_size) - } else { - 1 - }; - } - - width - offset -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_string_size_with_expanded_tabs() { - let nz = |val| NonZeroU32::new(val).unwrap(); - assert_eq!(char_len_with_expanded_tabs(0, "", nz(4)), 0); - assert_eq!(char_len_with_expanded_tabs(0, "hello", nz(4)), 5); - assert_eq!(char_len_with_expanded_tabs(0, "\thello", nz(4)), 9); - assert_eq!(char_len_with_expanded_tabs(0, "abc\tab", nz(4)), 6); - assert_eq!(char_len_with_expanded_tabs(0, "hello\t", nz(4)), 8); - assert_eq!(char_len_with_expanded_tabs(0, "\t\t", nz(8)), 16); - assert_eq!(char_len_with_expanded_tabs(0, "x\t", nz(8)), 8); - assert_eq!(char_len_with_expanded_tabs(7, "x\t", nz(8)), 9); - } -} - -/// Tokenizes a string into runs of text that should stick together, or that is whitespace. -struct WordBreakingTokenizer<'a> { - input: &'a str, -} - -impl<'a> WordBreakingTokenizer<'a> { - fn new(input: &'a str) -> Self { - Self { input } - } -} - -fn is_char_ideographic(ch: char) -> bool { - use unicode_script::Script::*; - use unicode_script::UnicodeScript; - matches!(ch.script(), Han | Tangut | Yi) -} - -fn is_grapheme_ideographic(text: &str) -> bool { - text.chars().any(is_char_ideographic) -} - -fn is_grapheme_whitespace(text: &str) -> bool { - text.chars().any(|x| x.is_whitespace()) -} - -fn should_stay_with_preceding_ideograph(text: &str) -> bool { - text.chars().next().map_or(false, |ch| { - matches!(ch, '。' | '、' | ',' | '?' | '!' | ':' | ';' | '…') - }) -} - -#[derive(PartialEq, Eq, Debug, Clone, Copy)] -enum WordBreakToken<'a> { - Word { token: &'a str, grapheme_len: usize }, - InlineWhitespace { token: &'a str, grapheme_len: usize }, - Newline, -} - -impl<'a> Iterator for WordBreakingTokenizer<'a> { - /// Yields a span, the count of graphemes in the token, and whether it was - /// whitespace. Note that it also breaks at word boundaries. - type Item = WordBreakToken<'a>; - - fn next(&mut self) -> Option { - use unicode_segmentation::UnicodeSegmentation; - if self.input.is_empty() { - return None; - } - - let mut iter = self.input.graphemes(true).peekable(); - let mut offset = 0; - let mut grapheme_len = 0; - if let Some(first_grapheme) = iter.next() { - let is_newline = first_grapheme == "\n"; - let is_whitespace = is_grapheme_whitespace(first_grapheme); - offset += first_grapheme.len(); - grapheme_len += 1; - if is_grapheme_ideographic(first_grapheme) && !is_whitespace { - if let Some(grapheme) = iter.peek().copied() { - if should_stay_with_preceding_ideograph(grapheme) { - offset += grapheme.len(); - grapheme_len += 1; - } - } - } else { - let mut words = self.input[offset..].split_word_bound_indices().peekable(); - let mut next_word_bound = words.peek().copied(); - if next_word_bound.map_or(false, |(i, _)| i == 0) { - next_word_bound = words.next(); - } - while let Some(grapheme) = iter.peek().copied() { - if next_word_bound.map_or(false, |(i, _)| i == offset) { - break; - }; - if is_grapheme_whitespace(grapheme) != is_whitespace - || (grapheme == "\n") != is_newline - { - break; - }; - offset += grapheme.len(); - grapheme_len += 1; - iter.next(); - } - } - let token = &self.input[..offset]; - self.input = &self.input[offset..]; - if token == "\n" { - Some(WordBreakToken::Newline) - } else if is_whitespace { - Some(WordBreakToken::InlineWhitespace { - token, - grapheme_len, - }) - } else { - Some(WordBreakToken::Word { - token, - grapheme_len, - }) - } - } else { - None - } - } -} - -#[test] -fn test_word_breaking_tokenizer() { - let tests: &[(&str, &[WordBreakToken<'static>])] = &[ - ("", &[]), - (" ", &[whitespace(" ", 2)]), - ("Ʒ", &[word("Ʒ", 1)]), - ("Ǽ", &[word("Ǽ", 1)]), - ("⋑", &[word("⋑", 1)]), - ("⋑⋑", &[word("⋑⋑", 2)]), - ( - "原理,进而", - &[word("原", 1), word("理,", 2), word("进", 1), word("而", 1)], - ), - ( - "hello world", - &[word("hello", 5), whitespace(" ", 1), word("world", 5)], - ), - ( - "hello, world", - &[word("hello,", 6), whitespace(" ", 1), word("world", 5)], - ), - ( - " hello world", - &[ - whitespace(" ", 2), - word("hello", 5), - whitespace(" ", 1), - word("world", 5), - ], - ), - ( - "这是什么 \n 钢笔", - &[ - word("这", 1), - word("是", 1), - word("什", 1), - word("么", 1), - whitespace(" ", 1), - newline(), - whitespace(" ", 1), - word("钢", 1), - word("笔", 1), - ], - ), - (" mutton", &[whitespace(" ", 1), word("mutton", 6)]), - ]; - - fn word(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> { - WordBreakToken::Word { - token, - grapheme_len, - } - } - - fn whitespace(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> { - WordBreakToken::InlineWhitespace { - token, - grapheme_len, - } - } - - fn newline() -> WordBreakToken<'static> { - WordBreakToken::Newline - } - - for (input, result) in tests { - assert_eq!( - WordBreakingTokenizer::new(input) - .collect::>() - .as_slice(), - *result, - ); - } -} - -fn wrap_with_prefix( - line_prefix: String, - unwrapped_text: String, - wrap_column: usize, - tab_size: NonZeroU32, - preserve_existing_whitespace: bool, -) -> String { - let line_prefix_len = char_len_with_expanded_tabs(0, &line_prefix, tab_size); - let mut wrapped_text = String::new(); - let mut current_line = line_prefix.clone(); - - let tokenizer = WordBreakingTokenizer::new(&unwrapped_text); - let mut current_line_len = line_prefix_len; - let mut in_whitespace = false; - for token in tokenizer { - let have_preceding_whitespace = in_whitespace; - match token { - WordBreakToken::Word { - token, - grapheme_len, - } => { - in_whitespace = false; - if current_line_len + grapheme_len > wrap_column - && current_line_len != line_prefix_len - { - wrapped_text.push_str(current_line.trim_end()); - wrapped_text.push('\n'); - current_line.truncate(line_prefix.len()); - current_line_len = line_prefix_len; - } - current_line.push_str(token); - current_line_len += grapheme_len; - } - WordBreakToken::InlineWhitespace { - mut token, - mut grapheme_len, - } => { - in_whitespace = true; - if have_preceding_whitespace && !preserve_existing_whitespace { - continue; - } - if !preserve_existing_whitespace { - token = " "; - grapheme_len = 1; - } - if current_line_len + grapheme_len > wrap_column { - wrapped_text.push_str(current_line.trim_end()); - wrapped_text.push('\n'); - current_line.truncate(line_prefix.len()); - current_line_len = line_prefix_len; - } else if current_line_len != line_prefix_len || preserve_existing_whitespace { - current_line.push_str(token); - current_line_len += grapheme_len; - } - } - WordBreakToken::Newline => { - in_whitespace = true; - if preserve_existing_whitespace { - wrapped_text.push_str(current_line.trim_end()); - wrapped_text.push('\n'); - current_line.truncate(line_prefix.len()); - current_line_len = line_prefix_len; - } else if have_preceding_whitespace { - continue; - } else if current_line_len + 1 > wrap_column && current_line_len != line_prefix_len - { - wrapped_text.push_str(current_line.trim_end()); - wrapped_text.push('\n'); - current_line.truncate(line_prefix.len()); - current_line_len = line_prefix_len; - } else if current_line_len != line_prefix_len { - current_line.push(' '); - current_line_len += 1; - } - } - } - } - - if !current_line.is_empty() { - wrapped_text.push_str(¤t_line); - } - wrapped_text -} - -#[test] -fn test_wrap_with_prefix() { - assert_eq!( - wrap_with_prefix( - "# ".to_string(), - "abcdefg".to_string(), - 4, - NonZeroU32::new(4).unwrap(), - false, - ), - "# abcdefg" - ); - assert_eq!( - wrap_with_prefix( - "".to_string(), - "\thello world".to_string(), - 8, - NonZeroU32::new(4).unwrap(), - false, - ), - "hello\nworld" - ); - assert_eq!( - wrap_with_prefix( - "// ".to_string(), - "xx \nyy zz aa bb cc".to_string(), - 12, - NonZeroU32::new(4).unwrap(), - false, - ), - "// xx yy zz\n// aa bb cc" - ); - assert_eq!( - wrap_with_prefix( - String::new(), - "这是什么 \n 钢笔".to_string(), - 3, - NonZeroU32::new(4).unwrap(), - false, - ), - "这是什\n么 钢\n笔" - ); -} - -pub trait CollaborationHub { - fn collaborators<'a>(&self, cx: &'a App) -> &'a HashMap; - fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap; - fn user_names(&self, cx: &App) -> HashMap; -} - -impl CollaborationHub for Entity { - fn collaborators<'a>(&self, cx: &'a App) -> &'a HashMap { - self.read(cx).collaborators() - } - - fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap { - self.read(cx).user_store().read(cx).participant_indices() - } - - fn user_names(&self, cx: &App) -> HashMap { - let this = self.read(cx); - let user_ids = this.collaborators().values().map(|c| c.user_id); - this.user_store().read_with(cx, |user_store, cx| { - user_store.participant_names(user_ids, cx) - }) - } -} - -pub trait SemanticsProvider { - fn hover( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>>; - - fn inline_values( - &self, - buffer_handle: Entity, - range: Range, - cx: &mut App, - ) -> Option>>>; - - fn inlay_hints( - &self, - buffer_handle: Entity, - range: Range, - cx: &mut App, - ) -> Option>>>; - - fn resolve_inlay_hint( - &self, - hint: InlayHint, - buffer_handle: Entity, - server_id: LanguageServerId, - cx: &mut App, - ) -> Option>>; - - fn supports_inlay_hints(&self, buffer: &Entity, cx: &mut App) -> bool; - - fn document_highlights( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>>>; - - fn definitions( - &self, - buffer: &Entity, - position: text::Anchor, - kind: GotoDefinitionKind, - cx: &mut App, - ) -> Option>>>; - - fn range_for_rename( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>>>>; - - fn perform_rename( - &self, - buffer: &Entity, - position: text::Anchor, - new_name: String, - cx: &mut App, - ) -> Option>>; -} - -pub trait CompletionProvider { - fn completions( - &self, - excerpt_id: ExcerptId, - buffer: &Entity, - buffer_position: text::Anchor, - trigger: CompletionContext, - window: &mut Window, - cx: &mut Context, - ) -> Task>>>; - - fn resolve_completions( - &self, - buffer: Entity, - completion_indices: Vec, - completions: Rc>>, - cx: &mut Context, - ) -> Task>; - - fn apply_additional_edits_for_completion( - &self, - _buffer: Entity, - _completions: Rc>>, - _completion_index: usize, - _push_to_history: bool, - _cx: &mut Context, - ) -> Task>> { - Task::ready(Ok(None)) - } - - fn is_completion_trigger( - &self, - buffer: &Entity, - position: language::Anchor, - text: &str, - trigger_in_words: bool, - cx: &mut Context, - ) -> bool; - - fn sort_completions(&self) -> bool { - true - } - - fn filter_completions(&self) -> bool { - true - } -} - -pub trait CodeActionProvider { - fn id(&self) -> Arc; - - fn code_actions( - &self, - buffer: &Entity, - range: Range, - window: &mut Window, - cx: &mut App, - ) -> Task>>; - - fn apply_code_action( - &self, - buffer_handle: Entity, - action: CodeAction, - excerpt_id: ExcerptId, - push_to_history: bool, - window: &mut Window, - cx: &mut App, - ) -> Task>; -} - -impl CodeActionProvider for Entity { - fn id(&self) -> Arc { - "project".into() - } - - fn code_actions( - &self, - buffer: &Entity, - range: Range, - _window: &mut Window, - cx: &mut App, - ) -> Task>> { - self.update(cx, |project, cx| { - let code_lens = project.code_lens(buffer, range.clone(), cx); - let code_actions = project.code_actions(buffer, range, None, cx); - cx.background_spawn(async move { - let (code_lens, code_actions) = join(code_lens, code_actions).await; - Ok(code_lens - .context("code lens fetch")? - .into_iter() - .chain(code_actions.context("code action fetch")?) - .collect()) - }) - }) - } - - fn apply_code_action( - &self, - buffer_handle: Entity, - action: CodeAction, - _excerpt_id: ExcerptId, - push_to_history: bool, - _window: &mut Window, - cx: &mut App, - ) -> Task> { - self.update(cx, |project, cx| { - project.apply_code_action(buffer_handle, action, push_to_history, cx) - }) - } -} - -fn snippet_completions( - project: &Project, - buffer: &Entity, - buffer_position: text::Anchor, - cx: &mut App, -) -> Task>> { - let languages = buffer.read(cx).languages_at(buffer_position); - let snippet_store = project.snippets().read(cx); - - let scopes: Vec<_> = languages - .iter() - .filter_map(|language| { - let language_name = language.lsp_id(); - let snippets = snippet_store.snippets_for(Some(language_name), cx); - - if snippets.is_empty() { - None - } else { - Some((language.default_scope(), snippets)) - } - }) - .collect(); - - if scopes.is_empty() { - return Task::ready(Ok(vec![])); - } - - let snapshot = buffer.read(cx).text_snapshot(); - let chars: String = snapshot - .reversed_chars_for_range(text::Anchor::MIN..buffer_position) - .collect(); - let executor = cx.background_executor().clone(); - - cx.background_spawn(async move { - let mut all_results: Vec = Vec::new(); - for (scope, snippets) in scopes.into_iter() { - let classifier = CharClassifier::new(Some(scope)).for_completion(true); - let mut last_word = chars - .chars() - .take_while(|c| classifier.is_word(*c)) - .collect::(); - last_word = last_word.chars().rev().collect(); - - if last_word.is_empty() { - return Ok(vec![]); - } - - let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); - let to_lsp = |point: &text::Anchor| { - let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); - point_to_lsp(end) - }; - let lsp_end = to_lsp(&buffer_position); - - let candidates = snippets - .iter() - .enumerate() - .flat_map(|(ix, snippet)| { - snippet - .prefix - .iter() - .map(move |prefix| StringMatchCandidate::new(ix, &prefix)) - }) - .collect::>(); - - let mut matches = fuzzy::match_strings( - &candidates, - &last_word, - last_word.chars().any(|c| c.is_uppercase()), - 100, - &Default::default(), - executor.clone(), - ) - .await; - - // Remove all candidates where the query's start does not match the start of any word in the candidate - if let Some(query_start) = last_word.chars().next() { - matches.retain(|string_match| { - split_words(&string_match.string).any(|word| { - // Check that the first codepoint of the word as lowercase matches the first - // codepoint of the query as lowercase - word.chars() - .flat_map(|codepoint| codepoint.to_lowercase()) - .zip(query_start.to_lowercase()) - .all(|(word_cp, query_cp)| word_cp == query_cp) - }) - }); - } - - let matched_strings = matches - .into_iter() - .map(|m| m.string) - .collect::>(); - - let mut result: Vec = snippets - .iter() - .filter_map(|snippet| { - let matching_prefix = snippet - .prefix - .iter() - .find(|prefix| matched_strings.contains(*prefix))?; - let start = as_offset - last_word.len(); - let start = snapshot.anchor_before(start); - let range = start..buffer_position; - let lsp_start = to_lsp(&start); - let lsp_range = lsp::Range { - start: lsp_start, - end: lsp_end, - }; - Some(Completion { - replace_range: range, - new_text: snippet.body.clone(), - source: CompletionSource::Lsp { - insert_range: None, - server_id: LanguageServerId(usize::MAX), - resolved: true, - lsp_completion: Box::new(lsp::CompletionItem { - label: snippet.prefix.first().unwrap().clone(), - kind: Some(CompletionItemKind::SNIPPET), - label_details: snippet.description.as_ref().map(|description| { - lsp::CompletionItemLabelDetails { - detail: Some(description.clone()), - description: None, - } - }), - insert_text_format: Some(InsertTextFormat::SNIPPET), - text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( - lsp::InsertReplaceEdit { - new_text: snippet.body.clone(), - insert: lsp_range, - replace: lsp_range, - }, - )), - filter_text: Some(snippet.body.clone()), - sort_text: Some(char::MAX.to_string()), - ..lsp::CompletionItem::default() - }), - lsp_defaults: None, - }, - label: CodeLabel { - text: matching_prefix.clone(), - runs: Vec::new(), - filter_range: 0..matching_prefix.len(), - }, - icon_path: None, - documentation: snippet.description.clone().map(|description| { - CompletionDocumentation::SingleLine(description.into()) - }), - insert_text_mode: None, - confirm: None, - }) - }) - .collect(); - - all_results.append(&mut result); - } - - Ok(all_results) - }) -} - -impl CompletionProvider for Entity { - fn completions( - &self, - _excerpt_id: ExcerptId, - buffer: &Entity, - buffer_position: text::Anchor, - options: CompletionContext, - _window: &mut Window, - cx: &mut Context, - ) -> Task>>> { - self.update(cx, |project, cx| { - let snippets = snippet_completions(project, buffer, buffer_position, cx); - let project_completions = project.completions(buffer, buffer_position, options, cx); - cx.background_spawn(async move { - let snippets_completions = snippets.await?; - match project_completions.await? { - Some(mut completions) => { - completions.extend(snippets_completions); - Ok(Some(completions)) - } - None => { - if snippets_completions.is_empty() { - Ok(None) - } else { - Ok(Some(snippets_completions)) - } - } - } - }) - }) - } - - fn resolve_completions( - &self, - buffer: Entity, - completion_indices: Vec, - completions: Rc>>, - cx: &mut Context, - ) -> Task> { - self.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store.resolve_completions(buffer, completion_indices, completions, cx) - }) - }) - } - - fn apply_additional_edits_for_completion( - &self, - buffer: Entity, - completions: Rc>>, - completion_index: usize, - push_to_history: bool, - cx: &mut Context, - ) -> Task>> { - self.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store.apply_additional_edits_for_completion( - buffer, - completions, - completion_index, - push_to_history, - cx, - ) - }) - }) - } - - fn is_completion_trigger( - &self, - buffer: &Entity, - position: language::Anchor, - text: &str, - trigger_in_words: bool, - cx: &mut Context, - ) -> bool { - let mut chars = text.chars(); - let char = if let Some(char) = chars.next() { - char - } else { - return false; - }; - if chars.next().is_some() { - return false; - } - - let buffer = buffer.read(cx); - let snapshot = buffer.snapshot(); - if !snapshot.settings_at(position, cx).show_completions_on_input { - return false; - } - let classifier = snapshot.char_classifier_at(position).for_completion(true); - if trigger_in_words && classifier.is_word(char) { - return true; - } - - buffer.completion_triggers().contains(text) - } -} - -impl SemanticsProvider for Entity { - fn hover( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>> { - Some(self.update(cx, |project, cx| project.hover(buffer, position, cx))) - } - - fn document_highlights( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>>> { - Some(self.update(cx, |project, cx| { - project.document_highlights(buffer, position, cx) - })) - } - - fn definitions( - &self, - buffer: &Entity, - position: text::Anchor, - kind: GotoDefinitionKind, - cx: &mut App, - ) -> Option>>> { - Some(self.update(cx, |project, cx| match kind { - GotoDefinitionKind::Symbol => project.definition(&buffer, position, cx), - GotoDefinitionKind::Declaration => project.declaration(&buffer, position, cx), - GotoDefinitionKind::Type => project.type_definition(&buffer, position, cx), - GotoDefinitionKind::Implementation => project.implementation(&buffer, position, cx), - })) - } - - fn supports_inlay_hints(&self, buffer: &Entity, cx: &mut App) -> bool { - // TODO: make this work for remote projects - self.update(cx, |project, cx| { - if project - .active_debug_session(cx) - .is_some_and(|(session, _)| session.read(cx).any_stopped_thread()) - { - return true; - } - - buffer.update(cx, |buffer, cx| { - project.any_language_server_supports_inlay_hints(buffer, cx) - }) - }) - } - - fn inline_values( - &self, - buffer_handle: Entity, - range: Range, - cx: &mut App, - ) -> Option>>> { - self.update(cx, |project, cx| { - let (session, active_stack_frame) = project.active_debug_session(cx)?; - - Some(project.inline_values(session, active_stack_frame, buffer_handle, range, cx)) - }) - } - - fn inlay_hints( - &self, - buffer_handle: Entity, - range: Range, - cx: &mut App, - ) -> Option>>> { - Some(self.update(cx, |project, cx| { - project.inlay_hints(buffer_handle, range, cx) - })) - } - - fn resolve_inlay_hint( - &self, - hint: InlayHint, - buffer_handle: Entity, - server_id: LanguageServerId, - cx: &mut App, - ) -> Option>> { - Some(self.update(cx, |project, cx| { - project.resolve_inlay_hint(hint, buffer_handle, server_id, cx) - })) - } - - fn range_for_rename( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>>>> { - Some(self.update(cx, |project, cx| { - let buffer = buffer.clone(); - let task = project.prepare_rename(buffer.clone(), position, cx); - cx.spawn(async move |_, cx| { - Ok(match task.await? { - PrepareRenameResponse::Success(range) => Some(range), - PrepareRenameResponse::InvalidPosition => None, - PrepareRenameResponse::OnlyUnpreparedRenameSupported => { - // Fallback on using TreeSitter info to determine identifier range - buffer.update(cx, |buffer, _| { - let snapshot = buffer.snapshot(); - let (range, kind) = snapshot.surrounding_word(position); - if kind != Some(CharKind::Word) { - return None; - } - Some( - snapshot.anchor_before(range.start) - ..snapshot.anchor_after(range.end), - ) - })? - } - }) - }) - })) - } - - fn perform_rename( - &self, - buffer: &Entity, - position: text::Anchor, - new_name: String, - cx: &mut App, - ) -> Option>> { - Some(self.update(cx, |project, cx| { - project.perform_rename(buffer.clone(), position, new_name, cx) - })) - } -} - -fn inlay_hint_settings( - location: Anchor, - snapshot: &MultiBufferSnapshot, - cx: &mut Context, -) -> InlayHintSettings { - let file = snapshot.file_at(location); - let language = snapshot.language_at(location).map(|l| l.name()); - language_settings(cx).language(language).file(file).get().inlay_hints -} - -fn consume_contiguous_rows( - contiguous_row_selections: &mut Vec>, - selection: &Selection, - display_map: &DisplaySnapshot, - selections: &mut Peekable>>, -) -> (MultiBufferRow, MultiBufferRow) { - contiguous_row_selections.push(selection.clone()); - let start_row = MultiBufferRow(selection.start.row); - let mut end_row = ending_row(selection, display_map); - - while let Some(next_selection) = selections.peek() { - if next_selection.start.row <= end_row.0 { - end_row = ending_row(next_selection, display_map); - contiguous_row_selections.push(selections.next().unwrap().clone()); - } else { - break; - } - } - (start_row, end_row) -} - -fn ending_row(next_selection: &Selection, display_map: &DisplaySnapshot) -> MultiBufferRow { - if next_selection.end.column > 0 || next_selection.is_empty() { - MultiBufferRow(display_map.next_line_boundary(next_selection.end).0.row + 1) - } else { - MultiBufferRow(next_selection.end.row) - } -} - -impl EditorSnapshot { - pub fn remote_selections_in_range<'a>( - &'a self, - range: &'a Range, - collaboration_hub: &dyn CollaborationHub, - cx: &'a App, - ) -> impl 'a + Iterator { - let participant_names = collaboration_hub.user_names(cx); - let participant_indices = collaboration_hub.user_participant_indices(cx); - let collaborators_by_peer_id = collaboration_hub.collaborators(cx); - let collaborators_by_replica_id = collaborators_by_peer_id - .iter() - .map(|(_, collaborator)| (collaborator.replica_id, collaborator)) - .collect::>(); - self.buffer_snapshot - .selections_in_range(range, false) - .filter_map(move |(replica_id, line_mode, cursor_shape, selection)| { - let collaborator = collaborators_by_replica_id.get(&replica_id)?; - let participant_index = participant_indices.get(&collaborator.user_id).copied(); - let user_name = participant_names.get(&collaborator.user_id).cloned(); - Some(RemoteSelection { - replica_id, - selection, - cursor_shape, - line_mode, - participant_index, - peer_id: collaborator.peer_id, - user_name, - }) - }) - } - - pub fn hunks_for_ranges( - &self, - ranges: impl IntoIterator>, - ) -> Vec { - let mut hunks = Vec::new(); - let mut processed_buffer_rows: HashMap>> = - HashMap::default(); - for query_range in ranges { - let query_rows = - MultiBufferRow(query_range.start.row)..MultiBufferRow(query_range.end.row + 1); - for hunk in self.buffer_snapshot.diff_hunks_in_range( - Point::new(query_rows.start.0, 0)..Point::new(query_rows.end.0, 0), - ) { - // Include deleted hunks that are adjacent to the query range, because - // otherwise they would be missed. - let mut intersects_range = hunk.row_range.overlaps(&query_rows); - if hunk.status().is_deleted() { - intersects_range |= hunk.row_range.start == query_rows.end; - intersects_range |= hunk.row_range.end == query_rows.start; - } - if intersects_range { - if !processed_buffer_rows - .entry(hunk.buffer_id) - .or_default() - .insert(hunk.buffer_range.start..hunk.buffer_range.end) - { - continue; - } - hunks.push(hunk); - } - } - } - - hunks - } - - fn display_diff_hunks_for_rows<'a>( - &'a self, - display_rows: Range, - folded_buffers: &'a HashSet, - ) -> impl 'a + Iterator { - let buffer_start = DisplayPoint::new(display_rows.start, 0).to_point(self); - let buffer_end = DisplayPoint::new(display_rows.end, 0).to_point(self); - - self.buffer_snapshot - .diff_hunks_in_range(buffer_start..buffer_end) - .filter_map(|hunk| { - if folded_buffers.contains(&hunk.buffer_id) { - return None; - } - - let hunk_start_point = Point::new(hunk.row_range.start.0, 0); - let hunk_end_point = Point::new(hunk.row_range.end.0, 0); - - let hunk_display_start = self.point_to_display_point(hunk_start_point, Bias::Left); - let hunk_display_end = self.point_to_display_point(hunk_end_point, Bias::Right); - - let display_hunk = if hunk_display_start.column() != 0 { - DisplayDiffHunk::Folded { - display_row: hunk_display_start.row(), - } - } else { - let mut end_row = hunk_display_end.row(); - if hunk_display_end.column() > 0 { - end_row.0 += 1; - } - let is_created_file = hunk.is_created_file(); - DisplayDiffHunk::Unfolded { - status: hunk.status(), - diff_base_byte_range: hunk.diff_base_byte_range, - display_row_range: hunk_display_start.row()..end_row, - multi_buffer_range: Anchor::range_in_buffer( - hunk.excerpt_id, - hunk.buffer_id, - hunk.buffer_range, - ), - is_created_file, - } - }; - - Some(display_hunk) - }) - } - - pub fn language_at(&self, position: T) -> Option<&Arc> { - self.display_snapshot.buffer_snapshot.language_at(position) - } - - pub fn is_focused(&self) -> bool { - self.is_focused - } - - pub fn placeholder_text(&self) -> Option<&Arc> { - self.placeholder_text.as_ref() - } - - pub fn scroll_position(&self) -> gpui::Point { - self.scroll_anchor.scroll_position(&self.display_snapshot) - } - - fn gutter_dimensions( - &self, - font_id: FontId, - font_size: Pixels, - max_line_number_width: Pixels, - cx: &App, - ) -> Option { - if !self.show_gutter { - return None; - } - - let descent = cx.text_system().descent(font_id, font_size); - let em_width = cx.text_system().em_width(font_id, font_size).log_err()?; - let em_advance = cx.text_system().em_advance(font_id, font_size).log_err()?; - - let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { - matches!( - ProjectSettings::get_global(cx).git.git_gutter, - Some(GitGutterSetting::TrackedFiles) - ) - }); - let gutter_settings = EditorSettings::get_global(cx).gutter; - let show_line_numbers = self - .show_line_numbers - .unwrap_or(gutter_settings.line_numbers); - let line_gutter_width = if show_line_numbers { - // Avoid flicker-like gutter resizes when the line number gains another digit and only resize the gutter on files with N*10^5 lines. - let min_width_for_number_on_gutter = em_advance * MIN_LINE_NUMBER_DIGITS as f32; - max_line_number_width.max(min_width_for_number_on_gutter) - } else { - 0.0.into() - }; - - let show_code_actions = self - .show_code_actions - .unwrap_or(gutter_settings.code_actions); - - let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); - let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); - - let git_blame_entries_width = - self.git_blame_gutter_max_author_length - .map(|max_author_length| { - let renderer = cx.global::().0.clone(); - const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; - - /// The number of characters to dedicate to gaps and margins. - const SPACING_WIDTH: usize = 4; - - let max_char_count = max_author_length.min(renderer.max_author_length()) - + ::git::SHORT_SHA_LENGTH - + MAX_RELATIVE_TIMESTAMP.len() - + SPACING_WIDTH; - - em_advance * max_char_count - }); - - let is_singleton = self.buffer_snapshot.is_singleton(); - - let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); - left_padding += if !is_singleton { - em_width * 4.0 - } else if show_code_actions || show_runnables || show_breakpoints { - em_width * 3.0 - } else if show_git_gutter && show_line_numbers { - em_width * 2.0 - } else if show_git_gutter || show_line_numbers { - em_width - } else { - px(0.) - }; - - let shows_folds = is_singleton && gutter_settings.folds; - - let right_padding = if shows_folds && show_line_numbers { - em_width * 4.0 - } else if shows_folds || (!is_singleton && show_line_numbers) { - em_width * 3.0 - } else if show_line_numbers { - em_width - } else { - px(0.) - }; - - Some(GutterDimensions { - left_padding, - right_padding, - width: line_gutter_width + left_padding + right_padding, - margin: -descent, - git_blame_entries_width, - }) - } - - pub fn render_crease_toggle( - &self, - buffer_row: MultiBufferRow, - row_contains_cursor: bool, - editor: Entity, - window: &mut Window, - cx: &mut App, - ) -> Option { - let folded = self.is_line_folded(buffer_row); - let mut is_foldable = false; - - if let Some(crease) = self - .crease_snapshot - .query_row(buffer_row, &self.buffer_snapshot) - { - is_foldable = true; - match crease { - Crease::Inline { render_toggle, .. } | Crease::Block { render_toggle, .. } => { - if let Some(render_toggle) = render_toggle { - let toggle_callback = - Arc::new(move |folded, window: &mut Window, cx: &mut App| { - if folded { - editor.update(cx, |editor, cx| { - editor.fold_at(buffer_row, window, cx) - }); - } else { - editor.update(cx, |editor, cx| { - editor.unfold_at(buffer_row, window, cx) - }); - } - }); - return Some((render_toggle)( - buffer_row, - folded, - toggle_callback, - window, - cx, - )); - } - } - } - } - - is_foldable |= self.starts_indent(buffer_row); - - if folded || (is_foldable && (row_contains_cursor || self.gutter_hovered)) { - Some( - Disclosure::new(("gutter_crease", buffer_row.0), !folded) - .toggle_state(folded) - .on_click(window.listener_for(&editor, move |this, _e, window, cx| { - if folded { - this.unfold_at(buffer_row, window, cx); - } else { - this.fold_at(buffer_row, window, cx); - } - })) - .into_any_element(), - ) - } else { - None - } - } - - pub fn render_crease_trailer( - &self, - buffer_row: MultiBufferRow, - window: &mut Window, - cx: &mut App, - ) -> Option { - let folded = self.is_line_folded(buffer_row); - if let Crease::Inline { render_trailer, .. } = self - .crease_snapshot - .query_row(buffer_row, &self.buffer_snapshot)? - { - let render_trailer = render_trailer.as_ref()?; - Some(render_trailer(buffer_row, folded, window, cx)) - } else { - None - } - } -} - -impl Deref for EditorSnapshot { - type Target = DisplaySnapshot; - - fn deref(&self) -> &Self::Target { - &self.display_snapshot - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum EditorEvent { - InputIgnored { - text: Arc, - }, - InputHandled { - utf16_range_to_replace: Option>, - text: Arc, - }, - ExcerptsAdded { - buffer: Entity, - predecessor: ExcerptId, - excerpts: Vec<(ExcerptId, ExcerptRange)>, - }, - ExcerptsRemoved { - ids: Vec, - removed_buffer_ids: Vec, - }, - BufferFoldToggled { - ids: Vec, - folded: bool, - }, - ExcerptsEdited { - ids: Vec, - }, - ExcerptsExpanded { - ids: Vec, - }, - BufferEdited, - Edited { - transaction_id: clock::Lamport, - }, - Reparsed(BufferId), - Focused, - FocusedIn, - Blurred, - DirtyChanged, - Saved, - TitleChanged, - DiffBaseChanged, - SelectionsChanged { - local: bool, - }, - ScrollPositionChanged { - local: bool, - autoscroll: bool, - }, - Closed, - TransactionUndone { - transaction_id: clock::Lamport, - }, - TransactionBegun { - transaction_id: clock::Lamport, - }, - Reloaded, - CursorShapeChanged, - PushedToNavHistory { - anchor: Anchor, - is_deactivate: bool, - }, -} - -impl EventEmitter for Editor {} - -impl Focusable for Editor { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for Editor { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - - let mut text_style = match self.mode { - EditorMode::SingleLine { .. } | EditorMode::AutoHeight { .. } => TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - EditorMode::Full { .. } => TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features.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() - }, - }; - if let Some(text_style_refinement) = &self.text_style_refinement { - text_style.refine(text_style_refinement) - } - - let background = match self.mode { - EditorMode::SingleLine { .. } => cx.theme().system().transparent, - EditorMode::AutoHeight { max_lines: _ } => cx.theme().system().transparent, - EditorMode::Full { .. } => cx.theme().colors().editor_background, - }; - - EditorElement::new( - &cx.entity(), - EditorStyle { - background, - local_player: cx.theme().players().local(), - text: text_style, - scrollbar_width: EditorElement::SCROLLBAR_WIDTH, - syntax: cx.theme().syntax().clone(), - status: cx.theme().status().clone(), - inlay_hints_style: make_inlay_hints_style(cx), - inline_completion_styles: make_suggestion_styles(cx), - unnecessary_code_fade: ThemeSettings::get_global(cx).unnecessary_code_fade, - }, - ) - } -} - -impl EntityInputHandler for Editor { - fn text_for_range( - &mut self, - range_utf16: Range, - adjusted_range: &mut Option>, - _: &mut Window, - cx: &mut Context, - ) -> Option { - let snapshot = self.buffer.read(cx).read(cx); - let start = snapshot.clip_offset_utf16(OffsetUtf16(range_utf16.start), Bias::Left); - let end = snapshot.clip_offset_utf16(OffsetUtf16(range_utf16.end), Bias::Right); - if (start.0..end.0) != range_utf16 { - adjusted_range.replace(start.0..end.0); - } - Some(snapshot.text_for_range(start..end).collect()) - } - - fn selected_text_range( - &mut self, - ignore_disabled_input: bool, - _: &mut Window, - cx: &mut Context, - ) -> Option { - // Prevent the IME menu from appearing when holding down an alphabetic key - // while input is disabled. - if !ignore_disabled_input && !self.input_enabled { - return None; - } - - let selection = self.selections.newest::(cx); - let range = selection.range(); - - Some(UTF16Selection { - range: range.start.0..range.end.0, - reversed: selection.reversed, - }) - } - - fn marked_text_range(&self, _: &mut Window, cx: &mut Context) -> Option> { - let snapshot = self.buffer.read(cx).read(cx); - let range = self.text_highlights::(cx)?.1.first()?; - Some(range.start.to_offset_utf16(&snapshot).0..range.end.to_offset_utf16(&snapshot).0) - } - - fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { - self.clear_highlights::(cx); - self.ime_transaction.take(); - } - - fn replace_text_in_range( - &mut self, - range_utf16: Option>, - text: &str, - window: &mut Window, - cx: &mut Context, - ) { - if !self.input_enabled { - cx.emit(EditorEvent::InputIgnored { text: text.into() }); - return; - } - - self.transact(window, cx, |this, window, cx| { - let new_selected_ranges = if let Some(range_utf16) = range_utf16 { - let range_utf16 = OffsetUtf16(range_utf16.start)..OffsetUtf16(range_utf16.end); - Some(this.selection_replacement_ranges(range_utf16, cx)) - } else { - this.marked_text_ranges(cx) - }; - - let range_to_replace = new_selected_ranges.as_ref().and_then(|ranges_to_replace| { - let newest_selection_id = this.selections.newest_anchor().id; - this.selections - .all::(cx) - .iter() - .zip(ranges_to_replace.iter()) - .find_map(|(selection, range)| { - if selection.id == newest_selection_id { - Some( - (range.start.0 as isize - selection.head().0 as isize) - ..(range.end.0 as isize - selection.head().0 as isize), - ) - } else { - None - } - }) - }); - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: range_to_replace, - text: text.into(), - }); - - if let Some(new_selected_ranges) = new_selected_ranges { - this.change_selections(None, window, cx, |selections| { - selections.select_ranges(new_selected_ranges) - }); - this.backspace(&Default::default(), window, cx); - } - - this.handle_input(text, window, cx); - }); - - if let Some(transaction) = self.ime_transaction { - self.buffer.update(cx, |buffer, cx| { - buffer.group_until_transaction(transaction, cx); - }); - } - - self.unmark_text(window, cx); - } - - fn replace_and_mark_text_in_range( - &mut self, - range_utf16: Option>, - text: &str, - new_selected_range_utf16: Option>, - window: &mut Window, - cx: &mut Context, - ) { - if !self.input_enabled { - return; - } - - let transaction = self.transact(window, cx, |this, window, cx| { - let ranges_to_replace = if let Some(mut marked_ranges) = this.marked_text_ranges(cx) { - let snapshot = this.buffer.read(cx).read(cx); - if let Some(relative_range_utf16) = range_utf16.as_ref() { - for marked_range in &mut marked_ranges { - marked_range.end.0 = marked_range.start.0 + relative_range_utf16.end; - marked_range.start.0 += relative_range_utf16.start; - marked_range.start = - snapshot.clip_offset_utf16(marked_range.start, Bias::Left); - marked_range.end = - snapshot.clip_offset_utf16(marked_range.end, Bias::Right); - } - } - Some(marked_ranges) - } else if let Some(range_utf16) = range_utf16 { - let range_utf16 = OffsetUtf16(range_utf16.start)..OffsetUtf16(range_utf16.end); - Some(this.selection_replacement_ranges(range_utf16, cx)) - } else { - None - }; - - let range_to_replace = ranges_to_replace.as_ref().and_then(|ranges_to_replace| { - let newest_selection_id = this.selections.newest_anchor().id; - this.selections - .all::(cx) - .iter() - .zip(ranges_to_replace.iter()) - .find_map(|(selection, range)| { - if selection.id == newest_selection_id { - Some( - (range.start.0 as isize - selection.head().0 as isize) - ..(range.end.0 as isize - selection.head().0 as isize), - ) - } else { - None - } - }) - }); - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: range_to_replace, - text: text.into(), - }); - - if let Some(ranges) = ranges_to_replace { - this.change_selections(None, window, cx, |s| s.select_ranges(ranges)); - } - - let marked_ranges = { - let snapshot = this.buffer.read(cx).read(cx); - this.selections - .disjoint_anchors() - .iter() - .map(|selection| { - selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot) - }) - .collect::>() - }; - - if text.is_empty() { - this.unmark_text(window, cx); - } else { - this.highlight_text::( - marked_ranges.clone(), - HighlightStyle { - underline: Some(UnderlineStyle { - thickness: px(1.), - color: None, - wavy: false, - }), - ..Default::default() - }, - cx, - ); - } - - // Disable auto-closing when composing text (i.e. typing a `"` on a Brazilian keyboard) - let use_autoclose = this.use_autoclose; - let use_auto_surround = this.use_auto_surround; - this.set_use_autoclose(false); - this.set_use_auto_surround(false); - this.handle_input(text, window, cx); - this.set_use_autoclose(use_autoclose); - this.set_use_auto_surround(use_auto_surround); - - if let Some(new_selected_range) = new_selected_range_utf16 { - let snapshot = this.buffer.read(cx).read(cx); - let new_selected_ranges = marked_ranges - .into_iter() - .map(|marked_range| { - let insertion_start = marked_range.start.to_offset_utf16(&snapshot).0; - let new_start = OffsetUtf16(new_selected_range.start + insertion_start); - let new_end = OffsetUtf16(new_selected_range.end + insertion_start); - snapshot.clip_offset_utf16(new_start, Bias::Left) - ..snapshot.clip_offset_utf16(new_end, Bias::Right) - }) - .collect::>(); - - drop(snapshot); - this.change_selections(None, window, cx, |selections| { - selections.select_ranges(new_selected_ranges) - }); - } - }); - - self.ime_transaction = self.ime_transaction.or(transaction); - if let Some(transaction) = self.ime_transaction { - self.buffer.update(cx, |buffer, cx| { - buffer.group_until_transaction(transaction, cx); - }); - } - - if self.text_highlights::(cx).is_none() { - self.ime_transaction.take(); - } - } - - fn bounds_for_range( - &mut self, - range_utf16: Range, - element_bounds: gpui::Bounds, - window: &mut Window, - cx: &mut Context, - ) -> Option> { - let text_layout_details = self.text_layout_details(window); - let gpui::Size { - width: em_width, - height: line_height, - } = self.character_size(window); - - let snapshot = self.snapshot(window, cx); - let scroll_position = snapshot.scroll_position(); - let scroll_left = scroll_position.x * em_width; - - let start = OffsetUtf16(range_utf16.start).to_display_point(&snapshot); - let x = snapshot.x_for_display_point(start, &text_layout_details) - scroll_left - + self.gutter_dimensions.width - + self.gutter_dimensions.margin; - let y = line_height * (start.row().as_f32() - scroll_position.y); - - Some(Bounds { - origin: element_bounds.origin + point(x, y), - size: size(em_width, line_height), - }) - } - - fn character_index_for_point( - &mut self, - point: gpui::Point, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let position_map = self.last_position_map.as_ref()?; - if !position_map.text_hitbox.contains(&point) { - return None; - } - let display_point = position_map.point_for_position(point).previous_valid; - let anchor = position_map - .snapshot - .display_point_to_anchor(display_point, Bias::Left); - let utf16_offset = anchor.to_offset_utf16(&position_map.snapshot.buffer_snapshot); - Some(utf16_offset.0) - } -} - -trait SelectionExt { - fn display_range(&self, map: &DisplaySnapshot) -> Range; - fn spanned_rows( - &self, - include_end_if_at_line_start: bool, - map: &DisplaySnapshot, - ) -> Range; -} - -impl SelectionExt for Selection { - fn display_range(&self, map: &DisplaySnapshot) -> Range { - let start = self - .start - .to_point(&map.buffer_snapshot) - .to_display_point(map); - let end = self - .end - .to_point(&map.buffer_snapshot) - .to_display_point(map); - if self.reversed { - end..start - } else { - start..end - } - } - - fn spanned_rows( - &self, - include_end_if_at_line_start: bool, - map: &DisplaySnapshot, - ) -> Range { - let start = self.start.to_point(&map.buffer_snapshot); - let mut end = self.end.to_point(&map.buffer_snapshot); - if !include_end_if_at_line_start && start.row != end.row && end.column == 0 { - end.row -= 1; - } - - let buffer_start = map.prev_line_boundary(start).0; - let buffer_end = map.next_line_boundary(end).0; - MultiBufferRow(buffer_start.row)..MultiBufferRow(buffer_end.row + 1) - } -} - -impl InvalidationStack { - fn invalidate(&mut self, selections: &[Selection], buffer: &MultiBufferSnapshot) - where - S: Clone + ToOffset, - { - while let Some(region) = self.last() { - let all_selections_inside_invalidation_ranges = - if selections.len() == region.ranges().len() { - selections - .iter() - .zip(region.ranges().iter().map(|r| r.to_offset(buffer))) - .all(|(selection, invalidation_range)| { - let head = selection.head().to_offset(buffer); - invalidation_range.start <= head && invalidation_range.end >= head - }) - } else { - false - }; - - if all_selections_inside_invalidation_ranges { - break; - } else { - self.pop(); - } - } - } -} - -impl Default for InvalidationStack { - fn default() -> Self { - Self(Default::default()) - } -} - -impl Deref for InvalidationStack { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for InvalidationStack { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl InvalidationRegion for SnippetState { - fn ranges(&self) -> &[Range] { - &self.ranges[self.active_index] - } -} - -fn inline_completion_edit_text( - current_snapshot: &BufferSnapshot, - edits: &[(Range, String)], - edit_preview: &EditPreview, - include_deletions: bool, - cx: &App, -) -> HighlightedText { - let edits = edits - .iter() - .map(|(anchor, text)| { - ( - anchor.start.text_anchor..anchor.end.text_anchor, - text.clone(), - ) - }) - .collect::>(); - - edit_preview.highlight_edits(current_snapshot, &edits, include_deletions, cx) -} - -pub fn diagnostic_style(severity: DiagnosticSeverity, colors: &StatusColors) -> Hsla { - match severity { - DiagnosticSeverity::ERROR => colors.error, - DiagnosticSeverity::WARNING => colors.warning, - DiagnosticSeverity::INFORMATION => colors.info, - DiagnosticSeverity::HINT => colors.info, - _ => colors.ignored, - } -} - -pub fn styled_runs_for_code_label<'a>( - label: &'a CodeLabel, - syntax_theme: &'a theme::SyntaxTheme, -) -> impl 'a + Iterator, HighlightStyle)> { - let fade_out = HighlightStyle { - fade_out: Some(0.35), - ..Default::default() - }; - - let mut prev_end = label.filter_range.end; - label - .runs - .iter() - .enumerate() - .flat_map(move |(ix, (range, highlight_id))| { - let style = if let Some(style) = highlight_id.style(syntax_theme) { - style - } else { - return Default::default(); - }; - let mut muted_style = style; - muted_style.highlight(fade_out); - - let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); - if range.start >= label.filter_range.end { - if range.start > prev_end { - runs.push((prev_end..range.start, fade_out)); - } - runs.push((range.clone(), muted_style)); - } else if range.end <= label.filter_range.end { - runs.push((range.clone(), style)); - } else { - runs.push((range.start..label.filter_range.end, style)); - runs.push((label.filter_range.end..range.end, muted_style)); - } - prev_end = cmp::max(prev_end, range.end); - - if ix + 1 == label.runs.len() && label.text.len() > prev_end { - runs.push((prev_end..label.text.len(), fade_out)); - } - - runs - }) -} - -pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + '_ { - let mut prev_index = 0; - let mut prev_codepoint: Option = None; - text.char_indices() - .chain([(text.len(), '\0')]) - .filter_map(move |(index, codepoint)| { - let prev_codepoint = prev_codepoint.replace(codepoint)?; - let is_boundary = index == text.len() - || !prev_codepoint.is_uppercase() && codepoint.is_uppercase() - || !prev_codepoint.is_alphanumeric() && codepoint.is_alphanumeric(); - if is_boundary { - let chunk = &text[prev_index..index]; - prev_index = index; - Some(chunk) - } else { - None - } - }) -} - -pub trait RangeToAnchorExt: Sized { - fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range; - - fn to_display_points(self, snapshot: &EditorSnapshot) -> Range { - let anchor_range = self.to_anchors(&snapshot.buffer_snapshot); - anchor_range.start.to_display_point(snapshot)..anchor_range.end.to_display_point(snapshot) - } -} - -impl RangeToAnchorExt for Range { - fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range { - let start_offset = self.start.to_offset(snapshot); - let end_offset = self.end.to_offset(snapshot); - if start_offset == end_offset { - snapshot.anchor_before(start_offset)..snapshot.anchor_before(end_offset) - } else { - snapshot.anchor_after(self.start)..snapshot.anchor_before(self.end) - } - } -} - -pub trait RowExt { - fn as_f32(&self) -> f32; - - fn next_row(&self) -> Self; - - fn previous_row(&self) -> Self; - - fn minus(&self, other: Self) -> u32; -} - -impl RowExt for DisplayRow { - fn as_f32(&self) -> f32 { - self.0 as f32 - } - - fn next_row(&self) -> Self { - Self(self.0 + 1) - } - - fn previous_row(&self) -> Self { - Self(self.0.saturating_sub(1)) - } - - fn minus(&self, other: Self) -> u32 { - self.0 - other.0 - } -} - -impl RowExt for MultiBufferRow { - fn as_f32(&self) -> f32 { - self.0 as f32 - } - - fn next_row(&self) -> Self { - Self(self.0 + 1) - } - - fn previous_row(&self) -> Self { - Self(self.0.saturating_sub(1)) - } - - fn minus(&self, other: Self) -> u32 { - self.0 - other.0 - } -} - -trait RowRangeExt { - type Row; - - fn len(&self) -> usize; - - fn iter_rows(&self) -> impl DoubleEndedIterator; -} - -impl RowRangeExt for Range { - type Row = MultiBufferRow; - - fn len(&self) -> usize { - (self.end.0 - self.start.0) as usize - } - - fn iter_rows(&self) -> impl DoubleEndedIterator { - (self.start.0..self.end.0).map(MultiBufferRow) - } -} - -impl RowRangeExt for Range { - type Row = DisplayRow; - - fn len(&self) -> usize { - (self.end.0 - self.start.0) as usize - } - - fn iter_rows(&self) -> impl DoubleEndedIterator { - (self.start.0..self.end.0).map(DisplayRow) - } -} - -/// If select range has more than one line, we -/// just point the cursor to range.start. -fn collapse_multiline_range(range: Range) -> Range { - if range.start.row == range.end.row { - range - } else { - range.start..range.start - } -} -pub struct KillRing(ClipboardItem); -impl Global for KillRing {} - -const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); - -enum BreakpointPromptEditAction { - Log, - Condition, - HitCondition, -} - -struct BreakpointPromptEditor { - pub(crate) prompt: Entity, - editor: WeakEntity, - breakpoint_anchor: Anchor, - breakpoint: Breakpoint, - edit_action: BreakpointPromptEditAction, - block_ids: HashSet, - gutter_dimensions: Arc>, - _subscriptions: Vec, -} - -impl BreakpointPromptEditor { - const MAX_LINES: u8 = 4; - - fn new( - editor: WeakEntity, - breakpoint_anchor: Anchor, - breakpoint: Breakpoint, - edit_action: BreakpointPromptEditAction, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let base_text = match edit_action { - BreakpointPromptEditAction::Log => breakpoint.message.as_ref(), - BreakpointPromptEditAction::Condition => breakpoint.condition.as_ref(), - BreakpointPromptEditAction::HitCondition => breakpoint.hit_condition.as_ref(), - } - .map(|msg| msg.to_string()) - .unwrap_or_default(); - - let buffer = cx.new(|cx| Buffer::local(base_text, cx)); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - - let prompt = cx.new(|cx| { - let mut prompt = Editor::new( - EditorMode::AutoHeight { - max_lines: Self::MAX_LINES as usize, - }, - buffer, - None, - window, - cx, - ); - prompt.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); - prompt.set_show_cursor_when_unfocused(false, cx); - prompt.set_placeholder_text( - match edit_action { - BreakpointPromptEditAction::Log => "Message to log when a breakpoint is hit. Expressions within {} are interpolated.", - BreakpointPromptEditAction::Condition => "Condition when a breakpoint is hit. Expressions within {} are interpolated.", - BreakpointPromptEditAction::HitCondition => "How many breakpoint hits to ignore", - }, - cx, - ); - - prompt - }); - - Self { - prompt, - editor, - breakpoint_anchor, - breakpoint, - edit_action, - gutter_dimensions: Arc::new(Mutex::new(GutterDimensions::default())), - block_ids: Default::default(), - _subscriptions: vec![], - } - } - - pub(crate) fn add_block_ids(&mut self, block_ids: Vec) { - self.block_ids.extend(block_ids) - } - - fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - if let Some(editor) = self.editor.upgrade() { - let message = self - .prompt - .read(cx) - .buffer - .read(cx) - .as_singleton() - .expect("A multi buffer in breakpoint prompt isn't possible") - .read(cx) - .as_rope() - .to_string(); - - editor.update(cx, |editor, cx| { - editor.edit_breakpoint_at_anchor( - self.breakpoint_anchor, - self.breakpoint.clone(), - match self.edit_action { - BreakpointPromptEditAction::Log => { - BreakpointEditAction::EditLogMessage(message.into()) - } - BreakpointPromptEditAction::Condition => { - BreakpointEditAction::EditCondition(message.into()) - } - BreakpointPromptEditAction::HitCondition => { - BreakpointEditAction::EditHitCondition(message.into()) - } - }, - cx, - ); - - editor.remove_blocks(self.block_ids.clone(), None, cx); - cx.focus_self(window); - }); - } - } - - fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { - self.editor - .update(cx, |editor, cx| { - editor.remove_blocks(self.block_ids.clone(), None, cx); - window.focus(&editor.focus_handle); - }) - .log_err(); - } - - fn render_prompt_editor(&self, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: if self.prompt.read(cx).read_only(cx) { - cx.theme().colors().text_disabled - } else { - 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( - &self.prompt, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) - } -} - -impl Render for BreakpointPromptEditor { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let gutter_dimensions = *self.gutter_dimensions.lock(); - h_flex() - .key_context("Editor") - .bg(cx.theme().colors().editor_background) - .border_y_1() - .border_color(cx.theme().status().info_border) - .size_full() - .py(window.line_height() / 2.5) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::cancel)) - .child(h_flex().w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0))) - .child(div().flex_1().child(self.render_prompt_editor(cx))) - } -} - -impl Focusable for BreakpointPromptEditor { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.prompt.focus_handle(cx) - } -} - -fn all_edits_insertions_or_deletions( - edits: &Vec<(Range, String)>, - snapshot: &MultiBufferSnapshot, -) -> bool { - let mut all_insertions = true; - let mut all_deletions = true; - - for (range, new_text) in edits.iter() { - let range_is_empty = range.to_offset(&snapshot).is_empty(); - let text_is_empty = new_text.is_empty(); - - if range_is_empty != text_is_empty { - if range_is_empty { - all_deletions = false; - } else { - all_insertions = false; - } - } else { - return false; - } - - if !all_insertions && !all_deletions { - return false; - } - } - all_insertions || all_deletions -} - -struct MissingEditPredictionKeybindingTooltip; - -impl Render for MissingEditPredictionKeybindingTooltip { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - ui::tooltip_container(window, cx, |container, _, cx| { - container - .flex_shrink_0() - .max_w_80() - .min_h(rems_from_px(124.)) - .justify_between() - .child( - v_flex() - .flex_1() - .text_ui_sm(cx) - .child(Label::new("Conflict with Accept Keybinding")) - .child("Your keymap currently overrides the default accept keybinding. To continue, assign one keybinding for the `editor::AcceptEditPrediction` action.") - ) - .child( - h_flex() - .pb_1() - .gap_1() - .items_end() - .w_full() - .child(Button::new("open-keymap", "Assign Keybinding").size(ButtonSize::Compact).on_click(|_ev, window, cx| { - window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx) - })) - .child(Button::new("see-docs", "See Docs").size(ButtonSize::Compact).on_click(|_ev, _window, cx| { - cx.open_url("https://zed.dev/docs/completions#edit-predictions-missing-keybinding"); - })), - ) - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct LineHighlight { - pub background: Background, - pub border: Option, - pub include_gutter: bool, - pub type_id: Option, -} - -fn render_diff_hunk_controls( - row: u32, - status: &DiffHunkStatus, - hunk_range: Range, - is_created_file: bool, - line_height: Pixels, - editor: &Entity, - _window: &mut Window, - cx: &mut App, -) -> AnyElement { - h_flex() - .h(line_height) - .mr_1() - .gap_1() - .px_0p5() - .pb_1() - .border_x_1() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .rounded_b_lg() - .bg(cx.theme().colors().editor_background) - .gap_1() - .occlude() - .shadow_md() - .child(if status.has_secondary_hunk() { - Button::new(("stage", row as u64), "Stage") - .alpha(if status.is_pending() { 0.66 } else { 1.0 }) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Stage Hunk", - &::git::ToggleStaged, - &focus_handle, - window, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - move |_event, _window, cx| { - editor.update(cx, |editor, cx| { - editor.stage_or_unstage_diff_hunks( - true, - vec![hunk_range.start..hunk_range.start], - cx, - ); - }); - } - }) - } else { - Button::new(("unstage", row as u64), "Unstage") - .alpha(if status.is_pending() { 0.66 } else { 1.0 }) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Unstage Hunk", - &::git::ToggleStaged, - &focus_handle, - window, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - move |_event, _window, cx| { - editor.update(cx, |editor, cx| { - editor.stage_or_unstage_diff_hunks( - false, - vec![hunk_range.start..hunk_range.start], - cx, - ); - }); - } - }) - }) - .child( - Button::new(("restore", row as u64), "Restore") - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Restore Hunk", - &::git::Restore, - &focus_handle, - window, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - move |_event, window, cx| { - editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - let point = hunk_range.start.to_point(&snapshot.buffer_snapshot); - editor.restore_hunks_in_ranges(vec![point..point], window, cx); - }); - } - }) - .disabled(is_created_file), - ) - .when( - !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(), - |el| { - el.child( - IconButton::new(("next-hunk", row as u64), IconName::ArrowDown) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - // .disabled(!has_multiple_hunks) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Next Hunk", - &GoToHunk, - &focus_handle, - window, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - move |_event, window, cx| { - editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - let position = - hunk_range.end.to_point(&snapshot.buffer_snapshot); - editor.go_to_hunk_before_or_after_position( - &snapshot, - position, - Direction::Next, - window, - cx, - ); - editor.expand_selected_diff_hunks(cx); - }); - } - }), - ) - .child( - IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - // .disabled(!has_multiple_hunks) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Previous Hunk", - &GoToPreviousHunk, - &focus_handle, - window, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - move |_event, window, cx| { - editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - let point = - hunk_range.start.to_point(&snapshot.buffer_snapshot); - editor.go_to_hunk_before_or_after_position( - &snapshot, - point, - Direction::Prev, - window, - cx, - ); - editor.expand_selected_diff_hunks(cx); - }); - } - }), - ) - }, - ) - .into_any_element() -} diff --git a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff deleted file mode 100644 index 1a38a1967f9..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff +++ /dev/null @@ -1,28 +0,0 @@ ---- before.rs 2025-07-07 11:37:48.434629001 +0300 -+++ expected.rs 2025-07-14 10:33:53.346906775 +0300 -@@ -1780,11 +1780,11 @@ - cx.observe_window_activation(window, |editor, window, cx| { - let active = window.is_window_active(); - editor.blink_manager.update(cx, |blink_manager, cx| { -- if active { -- blink_manager.enable(cx); -- } else { -- blink_manager.disable(cx); -- } -+ // if active { -+ // blink_manager.enable(cx); -+ // } else { -+ // blink_manager.disable(cx); -+ // } - }); - }), - ], -@@ -18463,7 +18463,7 @@ - } - - self.blink_manager.update(cx, |blink_manager, cx| { -- blink_manager.enable(cx); -+ // blink_manager.enable(cx); - }); - self.show_cursor_names(window, cx); - self.buffer.update(cx, |buffer, cx| { diff --git a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff deleted file mode 100644 index b484cce48f7..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff +++ /dev/null @@ -1,29 +0,0 @@ -@@ -1778,13 +1778,13 @@ - cx.observe_global_in::(window, Self::settings_changed), - observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), - cx.observe_window_activation(window, |editor, window, cx| { -- let active = window.is_window_active(); -+ // let active = window.is_window_active(); - editor.blink_manager.update(cx, |blink_manager, cx| { -- if active { -- blink_manager.enable(cx); -- } else { -- blink_manager.disable(cx); -- } -+ // if active { -+ // blink_manager.enable(cx); -+ // } else { -+ // blink_manager.disable(cx); -+ // } - }); - }), - ], -@@ -18463,7 +18463,7 @@ - } - - self.blink_manager.update(cx, |blink_manager, cx| { -- blink_manager.enable(cx); -+ // blink_manager.enable(cx); - }); - self.show_cursor_names(window, cx); - self.buffer.update(cx, |buffer, cx| { diff --git a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff deleted file mode 100644 index 431e34e48a2..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff +++ /dev/null @@ -1,34 +0,0 @@ -@@ -1774,17 +1774,17 @@ - cx.observe(&buffer, Self::on_buffer_changed), - cx.subscribe_in(&buffer, window, Self::on_buffer_event), - cx.observe_in(&display_map, window, Self::on_display_map_changed), -- cx.observe(&blink_manager, |_, _, cx| cx.notify()), -+ // cx.observe(&blink_manager, |_, _, cx| cx.notify()), - cx.observe_global_in::(window, Self::settings_changed), - observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), - cx.observe_window_activation(window, |editor, window, cx| { -- let active = window.is_window_active(); -+ // let active = window.is_window_active(); - editor.blink_manager.update(cx, |blink_manager, cx| { -- if active { -- blink_manager.enable(cx); -- } else { -- blink_manager.disable(cx); -- } -+ // if active { -+ // blink_manager.enable(cx); -+ // } else { -+ // blink_manager.disable(cx); -+ // } - }); - }), - ], -@@ -18463,7 +18463,7 @@ - } - - self.blink_manager.update(cx, |blink_manager, cx| { -- blink_manager.enable(cx); -+ // blink_manager.enable(cx); - }); - self.show_cursor_names(window, cx); - self.buffer.update(cx, |buffer, cx| { diff --git a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff deleted file mode 100644 index 64a6b85dd37..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff +++ /dev/null @@ -1,33 +0,0 @@ -@@ -1774,17 +1774,17 @@ - cx.observe(&buffer, Self::on_buffer_changed), - cx.subscribe_in(&buffer, window, Self::on_buffer_event), - cx.observe_in(&display_map, window, Self::on_display_map_changed), -- cx.observe(&blink_manager, |_, _, cx| cx.notify()), -+ // cx.observe(&blink_manager, |_, _, cx| cx.notify()), - cx.observe_global_in::(window, Self::settings_changed), - observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), - cx.observe_window_activation(window, |editor, window, cx| { - let active = window.is_window_active(); - editor.blink_manager.update(cx, |blink_manager, cx| { -- if active { -- blink_manager.enable(cx); -- } else { -- blink_manager.disable(cx); -- } -+ // if active { -+ // blink_manager.enable(cx); -+ // } else { -+ // blink_manager.disable(cx); -+ // } - }); - }), - ], -@@ -18463,7 +18463,7 @@ - } - - self.blink_manager.update(cx, |blink_manager, cx| { -- blink_manager.enable(cx); -+ // blink_manager.enable(cx); - }); - self.show_cursor_names(window, cx); - self.buffer.update(cx, |buffer, cx| { diff --git a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs deleted file mode 100644 index 36fccb51327..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs +++ /dev/null @@ -1,371 +0,0 @@ -use crate::commit::get_messages; -use crate::{GitRemote, Oid}; -use anyhow::{Context as _, Result, anyhow}; -use collections::{HashMap, HashSet}; -use futures::AsyncWriteExt; -use gpui::SharedString; -use serde::{Deserialize, Serialize}; -use std::process::Stdio; -use std::{ops::Range, path::Path}; -use text::Rope; -use time::OffsetDateTime; -use time::UtcOffset; -use time::macros::format_description; - -pub use git2 as libgit; - -#[derive(Debug, Clone, Default)] -pub struct Blame { - pub entries: Vec, - pub messages: HashMap, - pub remote_url: Option, -} - -#[derive(Clone, Debug, Default)] -pub struct ParsedCommitMessage { - pub message: SharedString, - pub permalink: Option, - pub pull_request: Option, - pub remote: Option, -} - -impl Blame { - pub async fn for_path( - git_binary: &Path, - working_directory: &Path, - path: &Path, - content: &Rope, - remote_url: Option, - ) -> Result { - let output = run_git_blame(git_binary, working_directory, path, content).await?; - let mut entries = parse_git_blame(&output)?; - entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); - - let mut unique_shas = HashSet::default(); - - for entry in entries.iter_mut() { - unique_shas.insert(entry.sha); - } - - let shas = unique_shas.into_iter().collect::>(); - let messages = get_messages(working_directory, &shas) - .await - .context("failed to get commit messages")?; - - Ok(Self { - entries, - messages, - remote_url, - }) - } -} - -const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD"; -const GIT_BLAME_NO_PATH: &str = "fatal: no such path"; - -async fn run_git_blame( - git_binary: &Path, - working_directory: &Path, - path: &Path, - contents: &Rope, -) -> Result { - let mut child = util::command::new_smol_command(git_binary) - .current_dir(working_directory) - .arg("blame") - .arg("--incremental") - .arg("--contents") - .arg("-") - .arg(path.as_os_str()) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("starting git blame process")?; - - let stdin = child - .stdin - .as_mut() - .context("failed to get pipe to stdin of git blame command")?; - - for chunk in contents.chunks() { - stdin.write_all(chunk.as_bytes()).await?; - } - stdin.flush().await?; - - let output = child.output().await.context("reading git blame output")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let trimmed = stderr.trim(); - if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { - return Ok(String::new()); - } - anyhow::bail!("git blame process failed: {stderr}"); - } - - Ok(String::from_utf8(output.stdout)?) -} - -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] -pub struct BlameEntry { - pub sha: Oid, - - pub range: Range, - - pub original_line_number: u32, - - pub author: Option, - pub author_mail: Option, - pub author_time: Option, - pub author_tz: Option, - - pub committer_name: Option, - pub committer_email: Option, - pub committer_time: Option, - pub committer_tz: Option, - - pub summary: Option, - - pub previous: Option, - pub filename: String, -} - -impl BlameEntry { - // Returns a BlameEntry by parsing the first line of a `git blame --incremental` - // entry. The line MUST have this format: - // - // <40-byte-hex-sha1> - fn new_from_blame_line(line: &str) -> Result { - let mut parts = line.split_whitespace(); - - let sha = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing sha from {line}"))?; - - let original_line_number = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing original line number from {line}"))?; - let final_line_number = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing final line number from {line}"))?; - - let line_count = parts - .next() - .and_then(|line| line.parse::().ok()) - .with_context(|| format!("parsing line count from {line}"))?; - - let start_line = final_line_number.saturating_sub(1); - let end_line = start_line + line_count; - let range = start_line..end_line; - - Ok(Self { - sha, - range, - original_line_number, - ..Default::default() - }) - } - - pub fn author_offset_date_time(&self) -> Result { - if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) { - let format = format_description!("[offset_hour][offset_minute]"); - let offset = UtcOffset::parse(author_tz, &format)?; - let date_time_utc = OffsetDateTime::from_unix_timestamp(author_time)?; - - Ok(date_time_utc.to_offset(offset)) - } else { - // Directly return current time in UTC if there's no committer time or timezone - Ok(time::OffsetDateTime::now_utc()) - } - } -} - -// parse_git_blame parses the output of `git blame --incremental`, which returns -// all the blame-entries for a given path incrementally, as it finds them. -// -// Each entry *always* starts with: -// -// <40-byte-hex-sha1> -// -// Each entry *always* ends with: -// -// filename -// -// Line numbers are 1-indexed. -// -// A `git blame --incremental` entry looks like this: -// -// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1 -// author Joe Schmoe -// author-mail -// author-time 1709741400 -// author-tz +0100 -// committer Joe Schmoe -// committer-mail -// committer-time 1709741400 -// committer-tz +0100 -// summary Joe's cool commit -// previous 486c2409237a2c627230589e567024a96751d475 index.js -// filename index.js -// -// If the entry has the same SHA as an entry that was already printed then no -// signature information is printed: -// -// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1 -// previous 486c2409237a2c627230589e567024a96751d475 index.js -// filename index.js -// -// More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html -fn parse_git_blame(output: &str) -> Result> { - let mut entries: Vec = Vec::new(); - let mut index: HashMap = HashMap::default(); - - let mut current_entry: Option = None; - - for line in output.lines() { - let mut done = false; - - match &mut current_entry { - None => { - let mut new_entry = BlameEntry::new_from_blame_line(line)?; - - if let Some(existing_entry) = index - .get(&new_entry.sha) - .and_then(|slot| entries.get(*slot)) - { - new_entry.author.clone_from(&existing_entry.author); - new_entry - .author_mail - .clone_from(&existing_entry.author_mail); - new_entry.author_time = existing_entry.author_time; - new_entry.author_tz.clone_from(&existing_entry.author_tz); - new_entry - .committer_name - .clone_from(&existing_entry.committer_name); - new_entry - .committer_email - .clone_from(&existing_entry.committer_email); - new_entry.committer_time = existing_entry.committer_time; - new_entry - .committer_tz - .clone_from(&existing_entry.committer_tz); - new_entry.summary.clone_from(&existing_entry.summary); - } - - current_entry.replace(new_entry); - } - Some(entry) => { - let Some((key, value)) = line.split_once(' ') else { - continue; - }; - let is_committed = !entry.sha.is_zero(); - match key { - "filename" => { - entry.filename = value.into(); - done = true; - } - "previous" => entry.previous = Some(value.into()), - - "summary" if is_committed => entry.summary = Some(value.into()), - "author" if is_committed => entry.author = Some(value.into()), - "author-mail" if is_committed => entry.author_mail = Some(value.into()), - "author-time" if is_committed => { - entry.author_time = Some(value.parse::()?) - } - "author-tz" if is_committed => entry.author_tz = Some(value.into()), - - "committer" if is_committed => entry.committer_name = Some(value.into()), - "committer-mail" if is_committed => entry.committer_email = Some(value.into()), - "committer-time" if is_committed => { - entry.committer_time = Some(value.parse::()?) - } - "committer-tz" if is_committed => entry.committer_tz = Some(value.into()), - _ => {} - } - } - }; - - if done { - if let Some(entry) = current_entry.take() { - index.insert(entry.sha, entries.len()); - - // We only want annotations that have a commit. - if !entry.sha.is_zero() { - entries.push(entry); - } - } - } - } - - Ok(entries) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::BlameEntry; - use super::parse_git_blame; - - fn read_test_data(filename: &str) -> String { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("test_data"); - path.push(filename); - - std::fs::read_to_string(&path) - .unwrap_or_else(|_| panic!("Could not read test data at {:?}. Is it generated?", path)) - } - - fn assert_eq_golden(entries: &Vec, golden_filename: &str) { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("test_data"); - path.push("golden"); - path.push(format!("{}.json", golden_filename)); - - let mut have_json = - serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON"); - // We always want to save with a trailing newline. - have_json.push('\n'); - - let update = std::env::var("UPDATE_GOLDEN") - .map(|val| val.eq_ignore_ascii_case("true")) - .unwrap_or(false); - - if update { - std::fs::create_dir_all(path.parent().unwrap()) - .expect("could not create golden test data directory"); - std::fs::write(&path, have_json).expect("could not write out golden data"); - } else { - let want_json = - std::fs::read_to_string(&path).unwrap_or_else(|_| { - panic!("could not read golden test data file at {:?}. Did you run the test with UPDATE_GOLDEN=true before?", path); - }).replace("\r\n", "\n"); - - pretty_assertions::assert_eq!(have_json, want_json, "wrong blame entries"); - } - } - - #[test] - fn test_parse_git_blame_not_committed() { - let output = read_test_data("blame_incremental_not_committed"); - let entries = parse_git_blame(&output).unwrap(); - assert_eq_golden(&entries, "blame_incremental_not_committed"); - } - - #[test] - fn test_parse_git_blame_simple() { - let output = read_test_data("blame_incremental_simple"); - let entries = parse_git_blame(&output).unwrap(); - assert_eq_golden(&entries, "blame_incremental_simple"); - } - - #[test] - fn test_parse_git_blame_complex() { - let output = read_test_data("blame_incremental_complex"); - let entries = parse_git_blame(&output).unwrap(); - assert_eq_golden(&entries, "blame_incremental_complex"); - } -} diff --git a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff deleted file mode 100644 index c13a223c63f..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff +++ /dev/null @@ -1,11 +0,0 @@ -@@ -94,6 +94,10 @@ - - let output = child.output().await.context("reading git blame output")?; - -+ handle_command_output(output) -+} -+ -+fn handle_command_output(output: std::process::Output) -> Result { - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let trimmed = stderr.trim(); diff --git a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff deleted file mode 100644 index aa36a9241e9..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff +++ /dev/null @@ -1,26 +0,0 @@ -@@ -95,15 +95,19 @@ - let output = child.output().await.context("reading git blame output")?; - - if !output.status.success() { -- let stderr = String::from_utf8_lossy(&output.stderr); -- let trimmed = stderr.trim(); -- if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { -- return Ok(String::new()); -- } -- anyhow::bail!("git blame process failed: {stderr}"); -+ return handle_command_output(output); - } - - Ok(String::from_utf8(output.stdout)?) -+} -+ -+fn handle_command_output(output: std::process::Output) -> Result { -+ let stderr = String::from_utf8_lossy(&output.stderr); -+ let trimmed = stderr.trim(); -+ if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { -+ return Ok(String::new()); -+ } -+ anyhow::bail!("git blame process failed: {stderr}"); - } - - #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff deleted file mode 100644 index d3c19b43803..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff +++ /dev/null @@ -1,11 +0,0 @@ -@@ -93,7 +93,10 @@ - stdin.flush().await?; - - let output = child.output().await.context("reading git blame output")?; -+ handle_command_output(output) -+} - -+fn handle_command_output(output: std::process::Output) -> Result { - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let trimmed = stderr.trim(); diff --git a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff deleted file mode 100644 index 1f87e4352c6..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff +++ /dev/null @@ -1,24 +0,0 @@ -@@ -93,17 +93,20 @@ - stdin.flush().await?; - - let output = child.output().await.context("reading git blame output")?; -+ handle_command_output(&output)?; -+ Ok(String::from_utf8(output.stdout)?) -+} - -+fn handle_command_output(output: &std::process::Output) -> Result<()> { - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let trimmed = stderr.trim(); - if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { -- return Ok(String::new()); -+ return Ok(()); - } - anyhow::bail!("git blame process failed: {stderr}"); - } -- -- Ok(String::from_utf8(output.stdout)?) -+ Ok(()) - } - - #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff deleted file mode 100644 index 8f4b745b9a1..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff +++ /dev/null @@ -1,26 +0,0 @@ -@@ -95,15 +95,19 @@ - let output = child.output().await.context("reading git blame output")?; - - if !output.status.success() { -- let stderr = String::from_utf8_lossy(&output.stderr); -- let trimmed = stderr.trim(); -- if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { -- return Ok(String::new()); -- } -- anyhow::bail!("git blame process failed: {stderr}"); -+ return handle_command_output(&output); - } - - Ok(String::from_utf8(output.stdout)?) -+} -+ -+fn handle_command_output(output: &std::process::Output) -> Result { -+ let stderr = String::from_utf8_lossy(&output.stderr); -+ let trimmed = stderr.trim(); -+ if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { -+ return Ok(String::new()); -+ } -+ anyhow::bail!("git blame process failed: {stderr}"); - } - - #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff deleted file mode 100644 index 3514d9c8e29..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff +++ /dev/null @@ -1,23 +0,0 @@ -@@ -93,7 +93,12 @@ - stdin.flush().await?; - - let output = child.output().await.context("reading git blame output")?; -+ handle_command_output(&output)?; - -+ Ok(String::from_utf8(output.stdout)?) -+} -+ -+fn handle_command_output(output: &std::process::Output) -> Result { - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let trimmed = stderr.trim(); -@@ -102,8 +107,7 @@ - } - anyhow::bail!("git blame process failed: {stderr}"); - } -- -- Ok(String::from_utf8(output.stdout)?) -+ Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } - - #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff deleted file mode 100644 index 9691479e299..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff +++ /dev/null @@ -1,26 +0,0 @@ -@@ -95,15 +95,19 @@ - let output = child.output().await.context("reading git blame output")?; - - if !output.status.success() { -- let stderr = String::from_utf8_lossy(&output.stderr); -- let trimmed = stderr.trim(); -- if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { -- return Ok(String::new()); -- } -- anyhow::bail!("git blame process failed: {stderr}"); -+ return handle_command_output(output); - } - - Ok(String::from_utf8(output.stdout)?) -+} -+ -+fn handle_command_output(output: std::process::Output) -> Result { -+ let stderr = String::from_utf8_lossy(&output.stderr); -+ let trimmed = stderr.trim(); -+ if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { -+ return Ok(String::new()); -+ } -+ anyhow::bail!("git blame process failed: {stderr}"); - } - - #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff deleted file mode 100644 index f5da859005a..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff +++ /dev/null @@ -1,26 +0,0 @@ -@@ -95,15 +95,19 @@ - let output = child.output().await.context("reading git blame output")?; - - if !output.status.success() { -- let stderr = String::from_utf8_lossy(&output.stderr); -- let trimmed = stderr.trim(); -- if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { -- return Ok(String::new()); -- } -- anyhow::bail!("git blame process failed: {stderr}"); -+ return handle_command_output(output); - } - - Ok(String::from_utf8(output.stdout)?) -+} -+ -+fn handle_command_output(output: std::process::Output) -> Result { -+ let stderr = String::from_utf8_lossy(&output.stderr); -+ let trimmed = stderr.trim(); -+ if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { -+ return Ok(String::new()); -+ } -+ anyhow::bail!("git blame process failed: {stderr}") - } - - #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs b/crates/agent/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs deleted file mode 100644 index 12590fe6e93..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs +++ /dev/null @@ -1,339 +0,0 @@ -// font-kit/src/canvas.rs -// -// Copyright © 2018 The Pathfinder Project Developers. -// -// Licensed under the Apache License, Version 2.0 or the MIT license -// , at your -// option. This file may not be copied, modified, or distributed -// except according to those terms. - -//! An in-memory bitmap surface for glyph rasterization. - -use lazy_static::lazy_static; -use pathfinder_geometry::rect::RectI; -use pathfinder_geometry::vector::Vector2I; -use std::cmp; -use std::fmt; - -use crate::utils; - -lazy_static! { - static ref BITMAP_1BPP_TO_8BPP_LUT: [[u8; 8]; 256] = { - let mut lut = [[0; 8]; 256]; - for byte in 0..0x100 { - let mut value = [0; 8]; - for bit in 0..8 { - if (byte & (0x80 >> bit)) != 0 { - value[bit] = 0xff; - } - } - lut[byte] = value - } - lut - }; -} - -/// An in-memory bitmap surface for glyph rasterization. -pub struct Canvas { - /// The raw pixel data. - pub pixels: Vec, - /// The size of the buffer, in pixels. - pub size: Vector2I, - /// The number of *bytes* between successive rows. - pub stride: usize, - /// The image format of the canvas. - pub format: Format, -} - -impl Canvas { - /// Creates a new blank canvas with the given pixel size and format. - /// - /// Stride is automatically calculated from width. - /// - /// The canvas is initialized with transparent black (all values 0). - #[inline] - pub fn new(size: Vector2I, format: Format) -> Canvas { - Canvas::with_stride( - size, - size.x() as usize * format.bytes_per_pixel() as usize, - format, - ) - } - - /// Creates a new blank canvas with the given pixel size, stride (number of bytes between - /// successive rows), and format. - /// - /// The canvas is initialized with transparent black (all values 0). - pub fn with_stride(size: Vector2I, stride: usize, format: Format) -> Canvas { - Canvas { - pixels: vec![0; stride * size.y() as usize], - size, - stride, - format, - } - } - - #[allow(dead_code)] - pub(crate) fn blit_from_canvas(&mut self, src: &Canvas) { - self.blit_from( - Vector2I::default(), - &src.pixels, - src.size, - src.stride, - src.format, - ) - } - - /// Blits to a rectangle with origin at `dst_point` and size according to `src_size`. - /// If the target area overlaps the boundaries of the canvas, only the drawable region is blitted. - /// `dst_point` and `src_size` are specified in pixels. `src_stride` is specified in bytes. - /// `src_stride` must be equal or larger than the actual data length. - #[allow(dead_code)] - pub(crate) fn blit_from( - &mut self, - dst_point: Vector2I, - src_bytes: &[u8], - src_size: Vector2I, - src_stride: usize, - src_format: Format, - ) { - assert_eq!( - src_stride * src_size.y() as usize, - src_bytes.len(), - "Number of pixels in src_bytes does not match stride and size." - ); - assert!( - src_stride >= src_size.x() as usize * src_format.bytes_per_pixel() as usize, - "src_stride must be >= than src_size.x()" - ); - - let dst_rect = RectI::new(dst_point, src_size); - let dst_rect = dst_rect.intersection(RectI::new(Vector2I::default(), self.size)); - let dst_rect = match dst_rect { - Some(dst_rect) => dst_rect, - None => return, - }; - - match (self.format, src_format) { - (Format::A8, Format::A8) - | (Format::Rgb24, Format::Rgb24) - | (Format::Rgba32, Format::Rgba32) => { - self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) - } - (Format::A8, Format::Rgb24) => { - self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) - } - (Format::Rgb24, Format::A8) => { - self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) - } - (Format::Rgb24, Format::Rgba32) => self - .blit_from_with::(dst_rect, src_bytes, src_stride, src_format), - (Format::Rgba32, Format::Rgb24) => self - .blit_from_with::(dst_rect, src_bytes, src_stride, src_format), - (Format::Rgba32, Format::A8) | (Format::A8, Format::Rgba32) => unimplemented!(), - } - } - - #[allow(dead_code)] - pub(crate) fn blit_from_bitmap_1bpp( - &mut self, - dst_point: Vector2I, - src_bytes: &[u8], - src_size: Vector2I, - src_stride: usize, - ) { - if self.format != Format::A8 { - unimplemented!() - } - - let dst_rect = RectI::new(dst_point, src_size); - let dst_rect = dst_rect.intersection(RectI::new(Vector2I::default(), self.size)); - let dst_rect = match dst_rect { - Some(dst_rect) => dst_rect, - None => return, - }; - - let size = dst_rect.size(); - - let dest_bytes_per_pixel = self.format.bytes_per_pixel() as usize; - let dest_row_stride = size.x() as usize * dest_bytes_per_pixel; - let src_row_stride = utils::div_round_up(size.x() as usize, 8); - - for y in 0..size.y() { - let (dest_row_start, src_row_start) = ( - (y + dst_rect.origin_y()) as usize * self.stride - + dst_rect.origin_x() as usize * dest_bytes_per_pixel, - y as usize * src_stride, - ); - let dest_row_end = dest_row_start + dest_row_stride; - let src_row_end = src_row_start + src_row_stride; - let dest_row_pixels = &mut self.pixels[dest_row_start..dest_row_end]; - let src_row_pixels = &src_bytes[src_row_start..src_row_end]; - for x in 0..src_row_stride { - let pattern = &BITMAP_1BPP_TO_8BPP_LUT[src_row_pixels[x] as usize]; - let dest_start = x * 8; - let dest_end = cmp::min(dest_start + 8, dest_row_stride); - let src = &pattern[0..(dest_end - dest_start)]; - dest_row_pixels[dest_start..dest_end].clone_from_slice(src); - } - } - } - - /// Blits to area `rect` using the data given in the buffer `src_bytes`. - /// `src_stride` must be specified in bytes. - /// The dimensions of `rect` must be in pixels. - fn blit_from_with( - &mut self, - rect: RectI, - src_bytes: &[u8], - src_stride: usize, - src_format: Format, - ) { - let src_bytes_per_pixel = src_format.bytes_per_pixel() as usize; - let dest_bytes_per_pixel = self.format.bytes_per_pixel() as usize; - - for y in 0..rect.height() { - let (dest_row_start, src_row_start) = ( - (y + rect.origin_y()) as usize * self.stride - + rect.origin_x() as usize * dest_bytes_per_pixel, - y as usize * src_stride, - ); - let dest_row_end = dest_row_start + rect.width() as usize * dest_bytes_per_pixel; - let src_row_end = src_row_start + rect.width() as usize * src_bytes_per_pixel; - let dest_row_pixels = &mut self.pixels[dest_row_start..dest_row_end]; - let src_row_pixels = &src_bytes[src_row_start..src_row_end]; - B::blit(dest_row_pixels, src_row_pixels) - } - } -} - -impl fmt::Debug for Canvas { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.debug_struct("Canvas") - .field("pixels", &self.pixels.len()) // Do not dump a vector content. - .field("size", &self.size) - .field("stride", &self.stride) - .field("format", &self.format) - .finish() - } -} - -/// The image format for the canvas. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Format { - /// Premultiplied R8G8B8A8, little-endian. - Rgba32, - /// R8G8B8, little-endian. - Rgb24, - /// A8. - A8, -} - -impl Format { - /// Returns the number of bits per pixel that this image format corresponds to. - #[inline] - pub fn bits_per_pixel(self) -> u8 { - match self { - Format::Rgba32 => 32, - Format::Rgb24 => 24, - Format::A8 => 8, - } - } - - /// Returns the number of color channels per pixel that this image format corresponds to. - #[inline] - pub fn components_per_pixel(self) -> u8 { - match self { - Format::Rgba32 => 4, - Format::Rgb24 => 3, - Format::A8 => 1, - } - } - - /// Returns the number of bits per color channel that this image format contains. - #[inline] - pub fn bits_per_component(self) -> u8 { - self.bits_per_pixel() / self.components_per_pixel() - } - - /// Returns the number of bytes per pixel that this image format corresponds to. - #[inline] - pub fn bytes_per_pixel(self) -> u8 { - self.bits_per_pixel() / 8 - } -} - -/// The antialiasing strategy that should be used when rasterizing glyphs. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum RasterizationOptions { - /// "Black-and-white" rendering. Each pixel is either entirely on or off. - Bilevel, - /// Grayscale antialiasing. Only one channel is used. - GrayscaleAa, - /// Subpixel RGB antialiasing, for LCD screens. - SubpixelAa, -} - -trait Blit { - fn blit(dest: &mut [u8], src: &[u8]); -} - -struct BlitMemcpy; - -impl Blit for BlitMemcpy { - #[inline] - fn blit(dest: &mut [u8], src: &[u8]) { - dest.clone_from_slice(src) - } -} - -struct BlitRgb24ToA8; - -impl Blit for BlitRgb24ToA8 { - #[inline] - fn blit(dest: &mut [u8], src: &[u8]) { - // TODO(pcwalton): SIMD. - for (dest, src) in dest.iter_mut().zip(src.chunks(3)) { - *dest = src[1] - } - } -} - -struct BlitA8ToRgb24; - -impl Blit for BlitA8ToRgb24 { - #[inline] - fn blit(dest: &mut [u8], src: &[u8]) { - for (dest, src) in dest.chunks_mut(3).zip(src.iter()) { - dest[0] = *src; - dest[1] = *src; - dest[2] = *src; - } - } -} - -struct BlitRgba32ToRgb24; - -impl Blit for BlitRgba32ToRgb24 { - #[inline] - fn blit(dest: &mut [u8], src: &[u8]) { - // TODO(pcwalton): SIMD. - for (dest, src) in dest.chunks_mut(3).zip(src.chunks(4)) { - dest.copy_from_slice(&src[0..3]) - } - } -} - -struct BlitRgb24ToRgba32; - -impl Blit for BlitRgb24ToRgba32 { - fn blit(dest: &mut [u8], src: &[u8]) { - for (dest, src) in dest.chunks_mut(4).zip(src.chunks(3)) { - dest[0] = src[0]; - dest[1] = src[1]; - dest[2] = src[2]; - dest[3] = 255; - } - } -} diff --git a/crates/agent/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs b/crates/agent/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs deleted file mode 100644 index 12590fe6e93..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs +++ /dev/null @@ -1,339 +0,0 @@ -// font-kit/src/canvas.rs -// -// Copyright © 2018 The Pathfinder Project Developers. -// -// Licensed under the Apache License, Version 2.0 or the MIT license -// , at your -// option. This file may not be copied, modified, or distributed -// except according to those terms. - -//! An in-memory bitmap surface for glyph rasterization. - -use lazy_static::lazy_static; -use pathfinder_geometry::rect::RectI; -use pathfinder_geometry::vector::Vector2I; -use std::cmp; -use std::fmt; - -use crate::utils; - -lazy_static! { - static ref BITMAP_1BPP_TO_8BPP_LUT: [[u8; 8]; 256] = { - let mut lut = [[0; 8]; 256]; - for byte in 0..0x100 { - let mut value = [0; 8]; - for bit in 0..8 { - if (byte & (0x80 >> bit)) != 0 { - value[bit] = 0xff; - } - } - lut[byte] = value - } - lut - }; -} - -/// An in-memory bitmap surface for glyph rasterization. -pub struct Canvas { - /// The raw pixel data. - pub pixels: Vec, - /// The size of the buffer, in pixels. - pub size: Vector2I, - /// The number of *bytes* between successive rows. - pub stride: usize, - /// The image format of the canvas. - pub format: Format, -} - -impl Canvas { - /// Creates a new blank canvas with the given pixel size and format. - /// - /// Stride is automatically calculated from width. - /// - /// The canvas is initialized with transparent black (all values 0). - #[inline] - pub fn new(size: Vector2I, format: Format) -> Canvas { - Canvas::with_stride( - size, - size.x() as usize * format.bytes_per_pixel() as usize, - format, - ) - } - - /// Creates a new blank canvas with the given pixel size, stride (number of bytes between - /// successive rows), and format. - /// - /// The canvas is initialized with transparent black (all values 0). - pub fn with_stride(size: Vector2I, stride: usize, format: Format) -> Canvas { - Canvas { - pixels: vec![0; stride * size.y() as usize], - size, - stride, - format, - } - } - - #[allow(dead_code)] - pub(crate) fn blit_from_canvas(&mut self, src: &Canvas) { - self.blit_from( - Vector2I::default(), - &src.pixels, - src.size, - src.stride, - src.format, - ) - } - - /// Blits to a rectangle with origin at `dst_point` and size according to `src_size`. - /// If the target area overlaps the boundaries of the canvas, only the drawable region is blitted. - /// `dst_point` and `src_size` are specified in pixels. `src_stride` is specified in bytes. - /// `src_stride` must be equal or larger than the actual data length. - #[allow(dead_code)] - pub(crate) fn blit_from( - &mut self, - dst_point: Vector2I, - src_bytes: &[u8], - src_size: Vector2I, - src_stride: usize, - src_format: Format, - ) { - assert_eq!( - src_stride * src_size.y() as usize, - src_bytes.len(), - "Number of pixels in src_bytes does not match stride and size." - ); - assert!( - src_stride >= src_size.x() as usize * src_format.bytes_per_pixel() as usize, - "src_stride must be >= than src_size.x()" - ); - - let dst_rect = RectI::new(dst_point, src_size); - let dst_rect = dst_rect.intersection(RectI::new(Vector2I::default(), self.size)); - let dst_rect = match dst_rect { - Some(dst_rect) => dst_rect, - None => return, - }; - - match (self.format, src_format) { - (Format::A8, Format::A8) - | (Format::Rgb24, Format::Rgb24) - | (Format::Rgba32, Format::Rgba32) => { - self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) - } - (Format::A8, Format::Rgb24) => { - self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) - } - (Format::Rgb24, Format::A8) => { - self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) - } - (Format::Rgb24, Format::Rgba32) => self - .blit_from_with::(dst_rect, src_bytes, src_stride, src_format), - (Format::Rgba32, Format::Rgb24) => self - .blit_from_with::(dst_rect, src_bytes, src_stride, src_format), - (Format::Rgba32, Format::A8) | (Format::A8, Format::Rgba32) => unimplemented!(), - } - } - - #[allow(dead_code)] - pub(crate) fn blit_from_bitmap_1bpp( - &mut self, - dst_point: Vector2I, - src_bytes: &[u8], - src_size: Vector2I, - src_stride: usize, - ) { - if self.format != Format::A8 { - unimplemented!() - } - - let dst_rect = RectI::new(dst_point, src_size); - let dst_rect = dst_rect.intersection(RectI::new(Vector2I::default(), self.size)); - let dst_rect = match dst_rect { - Some(dst_rect) => dst_rect, - None => return, - }; - - let size = dst_rect.size(); - - let dest_bytes_per_pixel = self.format.bytes_per_pixel() as usize; - let dest_row_stride = size.x() as usize * dest_bytes_per_pixel; - let src_row_stride = utils::div_round_up(size.x() as usize, 8); - - for y in 0..size.y() { - let (dest_row_start, src_row_start) = ( - (y + dst_rect.origin_y()) as usize * self.stride - + dst_rect.origin_x() as usize * dest_bytes_per_pixel, - y as usize * src_stride, - ); - let dest_row_end = dest_row_start + dest_row_stride; - let src_row_end = src_row_start + src_row_stride; - let dest_row_pixels = &mut self.pixels[dest_row_start..dest_row_end]; - let src_row_pixels = &src_bytes[src_row_start..src_row_end]; - for x in 0..src_row_stride { - let pattern = &BITMAP_1BPP_TO_8BPP_LUT[src_row_pixels[x] as usize]; - let dest_start = x * 8; - let dest_end = cmp::min(dest_start + 8, dest_row_stride); - let src = &pattern[0..(dest_end - dest_start)]; - dest_row_pixels[dest_start..dest_end].clone_from_slice(src); - } - } - } - - /// Blits to area `rect` using the data given in the buffer `src_bytes`. - /// `src_stride` must be specified in bytes. - /// The dimensions of `rect` must be in pixels. - fn blit_from_with( - &mut self, - rect: RectI, - src_bytes: &[u8], - src_stride: usize, - src_format: Format, - ) { - let src_bytes_per_pixel = src_format.bytes_per_pixel() as usize; - let dest_bytes_per_pixel = self.format.bytes_per_pixel() as usize; - - for y in 0..rect.height() { - let (dest_row_start, src_row_start) = ( - (y + rect.origin_y()) as usize * self.stride - + rect.origin_x() as usize * dest_bytes_per_pixel, - y as usize * src_stride, - ); - let dest_row_end = dest_row_start + rect.width() as usize * dest_bytes_per_pixel; - let src_row_end = src_row_start + rect.width() as usize * src_bytes_per_pixel; - let dest_row_pixels = &mut self.pixels[dest_row_start..dest_row_end]; - let src_row_pixels = &src_bytes[src_row_start..src_row_end]; - B::blit(dest_row_pixels, src_row_pixels) - } - } -} - -impl fmt::Debug for Canvas { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.debug_struct("Canvas") - .field("pixels", &self.pixels.len()) // Do not dump a vector content. - .field("size", &self.size) - .field("stride", &self.stride) - .field("format", &self.format) - .finish() - } -} - -/// The image format for the canvas. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Format { - /// Premultiplied R8G8B8A8, little-endian. - Rgba32, - /// R8G8B8, little-endian. - Rgb24, - /// A8. - A8, -} - -impl Format { - /// Returns the number of bits per pixel that this image format corresponds to. - #[inline] - pub fn bits_per_pixel(self) -> u8 { - match self { - Format::Rgba32 => 32, - Format::Rgb24 => 24, - Format::A8 => 8, - } - } - - /// Returns the number of color channels per pixel that this image format corresponds to. - #[inline] - pub fn components_per_pixel(self) -> u8 { - match self { - Format::Rgba32 => 4, - Format::Rgb24 => 3, - Format::A8 => 1, - } - } - - /// Returns the number of bits per color channel that this image format contains. - #[inline] - pub fn bits_per_component(self) -> u8 { - self.bits_per_pixel() / self.components_per_pixel() - } - - /// Returns the number of bytes per pixel that this image format corresponds to. - #[inline] - pub fn bytes_per_pixel(self) -> u8 { - self.bits_per_pixel() / 8 - } -} - -/// The antialiasing strategy that should be used when rasterizing glyphs. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum RasterizationOptions { - /// "Black-and-white" rendering. Each pixel is either entirely on or off. - Bilevel, - /// Grayscale antialiasing. Only one channel is used. - GrayscaleAa, - /// Subpixel RGB antialiasing, for LCD screens. - SubpixelAa, -} - -trait Blit { - fn blit(dest: &mut [u8], src: &[u8]); -} - -struct BlitMemcpy; - -impl Blit for BlitMemcpy { - #[inline] - fn blit(dest: &mut [u8], src: &[u8]) { - dest.clone_from_slice(src) - } -} - -struct BlitRgb24ToA8; - -impl Blit for BlitRgb24ToA8 { - #[inline] - fn blit(dest: &mut [u8], src: &[u8]) { - // TODO(pcwalton): SIMD. - for (dest, src) in dest.iter_mut().zip(src.chunks(3)) { - *dest = src[1] - } - } -} - -struct BlitA8ToRgb24; - -impl Blit for BlitA8ToRgb24 { - #[inline] - fn blit(dest: &mut [u8], src: &[u8]) { - for (dest, src) in dest.chunks_mut(3).zip(src.iter()) { - dest[0] = *src; - dest[1] = *src; - dest[2] = *src; - } - } -} - -struct BlitRgba32ToRgb24; - -impl Blit for BlitRgba32ToRgb24 { - #[inline] - fn blit(dest: &mut [u8], src: &[u8]) { - // TODO(pcwalton): SIMD. - for (dest, src) in dest.chunks_mut(3).zip(src.chunks(4)) { - dest.copy_from_slice(&src[0..3]) - } - } -} - -struct BlitRgb24ToRgba32; - -impl Blit for BlitRgb24ToRgba32 { - fn blit(dest: &mut [u8], src: &[u8]) { - for (dest, src) in dest.chunks_mut(4).zip(src.chunks(3)) { - dest[0] = src[0]; - dest[1] = src[1]; - dest[2] = src[2]; - dest[3] = 255; - } - } -} diff --git a/crates/agent/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs b/crates/agent/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs deleted file mode 100644 index cfa28fe1ad6..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs +++ /dev/null @@ -1,1629 +0,0 @@ -#![doc = include_str!("../README.md")] -#![cfg_attr(docsrs, feature(doc_cfg))] - -#[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))] -use std::ops::Range; -#[cfg(feature = "tree-sitter-highlight")] -use std::sync::Mutex; -use std::{ - collections::HashMap, - env, - ffi::{OsStr, OsString}, - fs, - io::{BufRead, BufReader}, - mem, - path::{Path, PathBuf}, - process::Command, - sync::LazyLock, - time::SystemTime, -}; - -#[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))] -use anyhow::Error; -use anyhow::{Context as _, Result, anyhow}; -use etcetera::BaseStrategy as _; -use fs4::fs_std::FileExt; -use indoc::indoc; -use libloading::{Library, Symbol}; -use once_cell::unsync::OnceCell; -use path_slash::PathBufExt as _; -use regex::{Regex, RegexBuilder}; -use semver::Version; -use serde::{Deserialize, Deserializer, Serialize}; -use tree_sitter::Language; -#[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))] -use tree_sitter::QueryError; -#[cfg(feature = "tree-sitter-highlight")] -use tree_sitter::QueryErrorKind; -#[cfg(feature = "tree-sitter-highlight")] -use tree_sitter_highlight::HighlightConfiguration; -#[cfg(feature = "tree-sitter-tags")] -use tree_sitter_tags::{Error as TagsError, TagsConfiguration}; -use url::Url; - -static GRAMMAR_NAME_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r#""name":\s*"(.*?)""#).unwrap()); - -pub const EMSCRIPTEN_TAG: &str = concat!("docker.io/emscripten/emsdk:", env!("EMSCRIPTEN_VERSION")); - -#[derive(Default, Deserialize, Serialize)] -pub struct Config { - #[serde(default)] - #[serde( - rename = "parser-directories", - deserialize_with = "deserialize_parser_directories" - )] - pub parser_directories: Vec, -} - -#[derive(Serialize, Deserialize, Clone, Default)] -#[serde(untagged)] -pub enum PathsJSON { - #[default] - Empty, - Single(PathBuf), - Multiple(Vec), -} - -impl PathsJSON { - fn into_vec(self) -> Option> { - match self { - Self::Empty => None, - Self::Single(s) => Some(vec![s]), - Self::Multiple(s) => Some(s), - } - } - - const fn is_empty(&self) -> bool { - matches!(self, Self::Empty) - } -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(untagged)] -pub enum PackageJSONAuthor { - String(String), - Object { - name: String, - email: Option, - url: Option, - }, -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(untagged)] -pub enum PackageJSONRepository { - String(String), - Object { url: String }, -} - -#[derive(Serialize, Deserialize)] -pub struct PackageJSON { - pub name: String, - pub version: Version, - pub description: Option, - pub author: Option, - pub maintainers: Option>, - pub license: Option, - pub repository: Option, - #[serde(default)] - #[serde(rename = "tree-sitter", skip_serializing_if = "Option::is_none")] - pub tree_sitter: Option>, -} - -fn default_path() -> PathBuf { - PathBuf::from(".") -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(rename_all = "kebab-case")] -pub struct LanguageConfigurationJSON { - #[serde(default = "default_path")] - pub path: PathBuf, - pub scope: Option, - pub file_types: Option>, - pub content_regex: Option, - pub first_line_regex: Option, - pub injection_regex: Option, - #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] - pub highlights: PathsJSON, - #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] - pub injections: PathsJSON, - #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] - pub locals: PathsJSON, - #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] - pub tags: PathsJSON, - #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] - pub external_files: PathsJSON, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct TreeSitterJSON { - #[serde(rename = "$schema")] - pub schema: Option, - pub grammars: Vec, - pub metadata: Metadata, - #[serde(default)] - pub bindings: Bindings, -} - -impl TreeSitterJSON { - pub fn from_file(path: &Path) -> Result { - Ok(serde_json::from_str(&fs::read_to_string( - path.join("tree-sitter.json"), - )?)?) - } - - #[must_use] - pub fn has_multiple_language_configs(&self) -> bool { - self.grammars.len() > 1 - } -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct Grammar { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub camelcase: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - pub scope: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, - #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] - pub external_files: PathsJSON, - pub file_types: Option>, - #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] - pub highlights: PathsJSON, - #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] - pub injections: PathsJSON, - #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] - pub locals: PathsJSON, - #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] - pub tags: PathsJSON, - #[serde(skip_serializing_if = "Option::is_none")] - pub injection_regex: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub first_line_regex: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub content_regex: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub class_name: Option, -} - -#[derive(Serialize, Deserialize)] -pub struct Metadata { - pub version: Version, - #[serde(skip_serializing_if = "Option::is_none")] - pub license: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub authors: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub links: Option, - #[serde(skip)] - pub namespace: Option, -} - -#[derive(Serialize, Deserialize)] -pub struct Author { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option, -} - -#[derive(Serialize, Deserialize)] -pub struct Links { - pub repository: Url, - #[serde(skip_serializing_if = "Option::is_none")] - pub funding: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub homepage: Option, -} - -#[derive(Serialize, Deserialize)] -#[serde(default)] -pub struct Bindings { - pub c: bool, - pub go: bool, - #[serde(skip)] - pub java: bool, - #[serde(skip)] - pub kotlin: bool, - pub node: bool, - pub python: bool, - pub rust: bool, - pub swift: bool, - pub zig: bool, -} - -impl Default for Bindings { - fn default() -> Self { - Self { - c: true, - go: true, - java: false, - kotlin: false, - node: true, - python: true, - rust: true, - swift: true, - zig: false, - } - } -} - -// Replace `~` or `$HOME` with home path string. -// (While paths like "~/.tree-sitter/config.json" can be deserialized, -// they're not valid path for I/O modules.) -fn deserialize_parser_directories<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let paths = Vec::::deserialize(deserializer)?; - let Ok(home) = etcetera::home_dir() else { - return Ok(paths); - }; - let standardized = paths - .into_iter() - .map(|path| standardize_path(path, &home)) - .collect(); - Ok(standardized) -} - -fn standardize_path(path: PathBuf, home: &Path) -> PathBuf { - if let Ok(p) = path.strip_prefix("~") { - return home.join(p); - } - if let Ok(p) = path.strip_prefix("$HOME") { - return home.join(p); - } - path -} - -impl Config { - #[must_use] - pub fn initial() -> Self { - let home_dir = etcetera::home_dir().expect("Cannot determine home directory"); - Self { - parser_directories: vec![ - home_dir.join("github"), - home_dir.join("src"), - home_dir.join("source"), - home_dir.join("projects"), - home_dir.join("dev"), - home_dir.join("git"), - ], - } - } -} - -const BUILD_TARGET: &str = env!("BUILD_TARGET"); -const BUILD_HOST: &str = env!("BUILD_HOST"); - -pub struct LanguageConfiguration<'a> { - pub scope: Option, - pub content_regex: Option, - pub first_line_regex: Option, - pub injection_regex: Option, - pub file_types: Vec, - pub root_path: PathBuf, - pub highlights_filenames: Option>, - pub injections_filenames: Option>, - pub locals_filenames: Option>, - pub tags_filenames: Option>, - pub language_name: String, - language_id: usize, - #[cfg(feature = "tree-sitter-highlight")] - highlight_config: OnceCell>, - #[cfg(feature = "tree-sitter-tags")] - tags_config: OnceCell>, - #[cfg(feature = "tree-sitter-highlight")] - highlight_names: &'a Mutex>, - #[cfg(feature = "tree-sitter-highlight")] - use_all_highlight_names: bool, -} - -pub struct Loader { - pub parser_lib_path: PathBuf, - languages_by_id: Vec<(PathBuf, OnceCell, Option>)>, - language_configurations: Vec>, - language_configuration_ids_by_file_type: HashMap>, - language_configuration_in_current_path: Option, - language_configuration_ids_by_first_line_regex: HashMap>, - #[cfg(feature = "tree-sitter-highlight")] - highlight_names: Box>>, - #[cfg(feature = "tree-sitter-highlight")] - use_all_highlight_names: bool, - debug_build: bool, - sanitize_build: bool, - force_rebuild: bool, - - #[cfg(feature = "wasm")] - wasm_store: Mutex>, -} - -pub struct CompileConfig<'a> { - pub src_path: &'a Path, - pub header_paths: Vec<&'a Path>, - pub parser_path: PathBuf, - pub scanner_path: Option, - pub external_files: Option<&'a [PathBuf]>, - pub output_path: Option, - pub flags: &'a [&'a str], - pub sanitize: bool, - pub name: String, -} - -impl<'a> CompileConfig<'a> { - #[must_use] - pub fn new( - src_path: &'a Path, - externals: Option<&'a [PathBuf]>, - output_path: Option, - ) -> Self { - Self { - src_path, - header_paths: vec![src_path], - parser_path: src_path.join("parser.c"), - scanner_path: None, - external_files: externals, - output_path, - flags: &[], - sanitize: false, - name: String::new(), - } - } -} - -unsafe impl Sync for Loader {} - -impl Loader { - pub fn new() -> Result { - let parser_lib_path = if let Ok(path) = env::var("TREE_SITTER_LIBDIR") { - PathBuf::from(path) - } else { - if cfg!(target_os = "macos") { - let legacy_apple_path = etcetera::base_strategy::Apple::new()? - .cache_dir() // `$HOME/Library/Caches/` - .join("tree-sitter"); - if legacy_apple_path.exists() && legacy_apple_path.is_dir() { - std::fs::remove_dir_all(legacy_apple_path)?; - } - } - - etcetera::choose_base_strategy()? - .cache_dir() - .join("tree-sitter") - .join("lib") - }; - Ok(Self::with_parser_lib_path(parser_lib_path)) - } - - #[must_use] - pub fn with_parser_lib_path(parser_lib_path: PathBuf) -> Self { - Self { - parser_lib_path, - languages_by_id: Vec::new(), - language_configurations: Vec::new(), - language_configuration_ids_by_file_type: HashMap::new(), - language_configuration_in_current_path: None, - language_configuration_ids_by_first_line_regex: HashMap::new(), - #[cfg(feature = "tree-sitter-highlight")] - highlight_names: Box::new(Mutex::new(Vec::new())), - #[cfg(feature = "tree-sitter-highlight")] - use_all_highlight_names: true, - debug_build: false, - sanitize_build: false, - force_rebuild: false, - - #[cfg(feature = "wasm")] - wasm_store: Mutex::default(), - } - } - - #[cfg(feature = "tree-sitter-highlight")] - #[cfg_attr(docsrs, doc(cfg(feature = "tree-sitter-highlight")))] - pub fn configure_highlights(&mut self, names: &[String]) { - self.use_all_highlight_names = false; - let mut highlights = self.highlight_names.lock().unwrap(); - highlights.clear(); - highlights.extend(names.iter().cloned()); - } - - #[must_use] - #[cfg(feature = "tree-sitter-highlight")] - #[cfg_attr(docsrs, doc(cfg(feature = "tree-sitter-highlight")))] - pub fn highlight_names(&self) -> Vec { - self.highlight_names.lock().unwrap().clone() - } - - pub fn find_all_languages(&mut self, config: &Config) -> Result<()> { - if config.parser_directories.is_empty() { - eprintln!("Warning: You have not configured any parser directories!"); - eprintln!("Please run `tree-sitter init-config` and edit the resulting"); - eprintln!("configuration file to indicate where we should look for"); - eprintln!("language grammars.\n"); - } - for parser_container_dir in &config.parser_directories { - if let Ok(entries) = fs::read_dir(parser_container_dir) { - for entry in entries { - let entry = entry?; - if let Some(parser_dir_name) = entry.file_name().to_str() { - if parser_dir_name.starts_with("tree-sitter-") { - self.find_language_configurations_at_path( - &parser_container_dir.join(parser_dir_name), - false, - ) - .ok(); - } - } - } - } - } - Ok(()) - } - - pub fn languages_at_path(&mut self, path: &Path) -> Result> { - if let Ok(configurations) = self.find_language_configurations_at_path(path, true) { - let mut language_ids = configurations - .iter() - .map(|c| (c.language_id, c.language_name.clone())) - .collect::>(); - language_ids.sort_unstable(); - language_ids.dedup(); - language_ids - .into_iter() - .map(|(id, name)| Ok((self.language_for_id(id)?, name))) - .collect::>>() - } else { - Ok(Vec::new()) - } - } - - #[must_use] - pub fn get_all_language_configurations(&self) -> Vec<(&LanguageConfiguration, &Path)> { - self.language_configurations - .iter() - .map(|c| (c, self.languages_by_id[c.language_id].0.as_ref())) - .collect() - } - - pub fn language_configuration_for_scope( - &self, - scope: &str, - ) -> Result> { - for configuration in &self.language_configurations { - if configuration.scope.as_ref().is_some_and(|s| s == scope) { - let language = self.language_for_id(configuration.language_id)?; - return Ok(Some((language, configuration))); - } - } - Ok(None) - } - - pub fn language_configuration_for_first_line_regex( - &self, - path: &Path, - ) -> Result> { - self.language_configuration_ids_by_first_line_regex - .iter() - .try_fold(None, |_, (regex, ids)| { - if let Some(regex) = Self::regex(Some(regex)) { - let file = fs::File::open(path)?; - let reader = BufReader::new(file); - let first_line = reader.lines().next().transpose()?; - if let Some(first_line) = first_line { - if regex.is_match(&first_line) && !ids.is_empty() { - let configuration = &self.language_configurations[ids[0]]; - let language = self.language_for_id(configuration.language_id)?; - return Ok(Some((language, configuration))); - } - } - } - - Ok(None) - }) - } - - pub fn language_configuration_for_file_name( - &self, - path: &Path, - ) -> Result> { - // Find all the language configurations that match this file name - // or a suffix of the file name. - let configuration_ids = path - .file_name() - .and_then(|n| n.to_str()) - .and_then(|file_name| self.language_configuration_ids_by_file_type.get(file_name)) - .or_else(|| { - let mut path = path.to_owned(); - let mut extensions = Vec::with_capacity(2); - while let Some(extension) = path.extension() { - extensions.push(extension.to_str()?.to_string()); - path = PathBuf::from(path.file_stem()?.to_os_string()); - } - extensions.reverse(); - self.language_configuration_ids_by_file_type - .get(&extensions.join(".")) - }); - - if let Some(configuration_ids) = configuration_ids { - if !configuration_ids.is_empty() { - let configuration = if configuration_ids.len() == 1 { - &self.language_configurations[configuration_ids[0]] - } - // If multiple language configurations match, then determine which - // one to use by applying the configurations' content regexes. - else { - let file_contents = fs::read(path) - .with_context(|| format!("Failed to read path {}", path.display()))?; - let file_contents = String::from_utf8_lossy(&file_contents); - let mut best_score = -2isize; - let mut best_configuration_id = None; - for configuration_id in configuration_ids { - let config = &self.language_configurations[*configuration_id]; - - // If the language configuration has a content regex, assign - // a score based on the length of the first match. - let score; - if let Some(content_regex) = &config.content_regex { - if let Some(mat) = content_regex.find(&file_contents) { - score = (mat.end() - mat.start()) as isize; - } - // If the content regex does not match, then *penalize* this - // language configuration, so that language configurations - // without content regexes are preferred over those with - // non-matching content regexes. - else { - score = -1; - } - } else { - score = 0; - } - if score > best_score { - best_configuration_id = Some(*configuration_id); - best_score = score; - } - } - - &self.language_configurations[best_configuration_id.unwrap()] - }; - - let language = self.language_for_id(configuration.language_id)?; - return Ok(Some((language, configuration))); - } - } - - Ok(None) - } - - pub fn language_configuration_for_injection_string( - &self, - string: &str, - ) -> Result> { - let mut best_match_length = 0; - let mut best_match_position = None; - for (i, configuration) in self.language_configurations.iter().enumerate() { - if let Some(injection_regex) = &configuration.injection_regex { - if let Some(mat) = injection_regex.find(string) { - let length = mat.end() - mat.start(); - if length > best_match_length { - best_match_position = Some(i); - best_match_length = length; - } - } - } - } - - if let Some(i) = best_match_position { - let configuration = &self.language_configurations[i]; - let language = self.language_for_id(configuration.language_id)?; - Ok(Some((language, configuration))) - } else { - Ok(None) - } - } - - pub fn language_for_configuration( - &self, - configuration: &LanguageConfiguration, - ) -> Result { - self.language_for_id(configuration.language_id) - } - - fn language_for_id(&self, id: usize) -> Result { - let (path, language, externals) = &self.languages_by_id[id]; - language - .get_or_try_init(|| { - let src_path = path.join("src"); - self.load_language_at_path(CompileConfig::new( - &src_path, - externals.as_deref(), - None, - )) - }) - .cloned() - } - - pub fn compile_parser_at_path( - &self, - grammar_path: &Path, - output_path: PathBuf, - flags: &[&str], - ) -> Result<()> { - let src_path = grammar_path.join("src"); - let mut config = CompileConfig::new(&src_path, None, Some(output_path)); - config.flags = flags; - self.load_language_at_path(config).map(|_| ()) - } - - pub fn load_language_at_path(&self, mut config: CompileConfig) -> Result { - let grammar_path = config.src_path.join("grammar.json"); - config.name = Self::grammar_json_name(&grammar_path)?; - self.load_language_at_path_with_name(config) - } - - pub fn load_language_at_path_with_name(&self, mut config: CompileConfig) -> Result { - let mut lib_name = config.name.to_string(); - let language_fn_name = format!( - "tree_sitter_{}", - replace_dashes_with_underscores(&config.name) - ); - if self.debug_build { - lib_name.push_str(".debug._"); - } - - if self.sanitize_build { - lib_name.push_str(".sanitize._"); - config.sanitize = true; - } - - if config.output_path.is_none() { - fs::create_dir_all(&self.parser_lib_path)?; - } - - let mut recompile = self.force_rebuild || config.output_path.is_some(); // if specified, always recompile - - let output_path = config.output_path.unwrap_or_else(|| { - let mut path = self.parser_lib_path.join(lib_name); - path.set_extension(env::consts::DLL_EXTENSION); - #[cfg(feature = "wasm")] - if self.wasm_store.lock().unwrap().is_some() { - path.set_extension("wasm"); - } - path - }); - config.output_path = Some(output_path.clone()); - - let parser_path = config.src_path.join("parser.c"); - config.scanner_path = self.get_scanner_path(config.src_path); - - let mut paths_to_check = vec![parser_path]; - - if let Some(scanner_path) = config.scanner_path.as_ref() { - paths_to_check.push(scanner_path.clone()); - } - - paths_to_check.extend( - config - .external_files - .unwrap_or_default() - .iter() - .map(|p| config.src_path.join(p)), - ); - - if !recompile { - recompile = needs_recompile(&output_path, &paths_to_check) - .with_context(|| "Failed to compare source and binary timestamps")?; - } - - #[cfg(feature = "wasm")] - if let Some(wasm_store) = self.wasm_store.lock().unwrap().as_mut() { - if recompile { - self.compile_parser_to_wasm( - &config.name, - None, - config.src_path, - config - .scanner_path - .as_ref() - .and_then(|p| p.strip_prefix(config.src_path).ok()), - &output_path, - false, - )?; - } - - let wasm_bytes = fs::read(&output_path)?; - return Ok(wasm_store.load_language(&config.name, &wasm_bytes)?); - } - - let lock_path = if env::var("CROSS_RUNNER").is_ok() { - tempfile::tempdir() - .unwrap() - .path() - .join("tree-sitter") - .join("lock") - .join(format!("{}.lock", config.name)) - } else { - etcetera::choose_base_strategy()? - .cache_dir() - .join("tree-sitter") - .join("lock") - .join(format!("{}.lock", config.name)) - }; - - if let Ok(lock_file) = fs::OpenOptions::new().write(true).open(&lock_path) { - recompile = false; - if lock_file.try_lock_exclusive().is_err() { - // if we can't acquire the lock, another process is compiling the parser, wait for - // it and don't recompile - lock_file.lock_exclusive()?; - recompile = false; - } else { - // if we can acquire the lock, check if the lock file is older than 30 seconds, a - // run that was interrupted and left the lock file behind should not block - // subsequent runs - let time = lock_file.metadata()?.modified()?.elapsed()?.as_secs(); - if time > 30 { - fs::remove_file(&lock_path)?; - recompile = true; - } - } - } - - if recompile { - fs::create_dir_all(lock_path.parent().unwrap()).with_context(|| { - format!( - "Failed to create directory {}", - lock_path.parent().unwrap().display() - ) - })?; - let lock_file = fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&lock_path)?; - lock_file.lock_exclusive()?; - - self.compile_parser_to_dylib(&config, &lock_file, &lock_path)?; - - if config.scanner_path.is_some() { - self.check_external_scanner(&config.name, &output_path)?; - } - } - - let library = unsafe { Library::new(&output_path) } - .with_context(|| format!("Error opening dynamic library {}", output_path.display()))?; - let language = unsafe { - let language_fn = library - .get:: Language>>(language_fn_name.as_bytes()) - .with_context(|| format!("Failed to load symbol {language_fn_name}"))?; - language_fn() - }; - mem::forget(library); - Ok(language) - } - - fn compile_parser_to_dylib( - &self, - config: &CompileConfig, - lock_file: &fs::File, - lock_path: &Path, - ) -> Result<(), Error> { - let mut cc_config = cc::Build::new(); - cc_config - .cargo_metadata(false) - .cargo_warnings(false) - .target(BUILD_TARGET) - .host(BUILD_HOST) - .debug(self.debug_build) - .file(&config.parser_path) - .includes(&config.header_paths) - .std("c11"); - - if let Some(scanner_path) = config.scanner_path.as_ref() { - cc_config.file(scanner_path); - } - - if self.debug_build { - cc_config.opt_level(0).extra_warnings(true); - } else { - cc_config.opt_level(2).extra_warnings(false); - } - - for flag in config.flags { - cc_config.define(flag, None); - } - - let compiler = cc_config.get_compiler(); - let mut command = Command::new(compiler.path()); - command.args(compiler.args()); - for (key, value) in compiler.env() { - command.env(key, value); - } - - let output_path = config.output_path.as_ref().unwrap(); - - if compiler.is_like_msvc() { - let out = format!("-out:{}", output_path.to_str().unwrap()); - command.arg(if self.debug_build { "-LDd" } else { "-LD" }); - command.arg("-utf-8"); - command.args(cc_config.get_files()); - command.arg("-link").arg(out); - } else { - command.arg("-Werror=implicit-function-declaration"); - if cfg!(any(target_os = "macos", target_os = "ios")) { - command.arg("-dynamiclib"); - // TODO: remove when supported - command.arg("-UTREE_SITTER_REUSE_ALLOCATOR"); - } else { - command.arg("-shared"); - } - command.args(cc_config.get_files()); - command.arg("-o").arg(output_path); - } - - let output = command.output().with_context(|| { - format!("Failed to execute the C compiler with the following command:\n{command:?}") - })?; - - FileExt::unlock(lock_file)?; - fs::remove_file(lock_path)?; - anyhow::ensure!( - output.status.success(), - "Parser compilation failed.\nStdout: {}\nStderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - Ok(()) - } - - #[cfg(unix)] - fn check_external_scanner(&self, name: &str, library_path: &Path) -> Result<()> { - let prefix = if cfg!(any(target_os = "macos", target_os = "ios")) { - "_" - } else { - "" - }; - let mut must_have = vec![ - format!("{prefix}tree_sitter_{name}_external_scanner_create"), - format!("{prefix}tree_sitter_{name}_external_scanner_destroy"), - format!("{prefix}tree_sitter_{name}_external_scanner_serialize"), - format!("{prefix}tree_sitter_{name}_external_scanner_deserialize"), - format!("{prefix}tree_sitter_{name}_external_scanner_scan"), - ]; - - let command = Command::new("nm") - .arg("-W") - .arg("-U") - .arg(library_path) - .output(); - if let Ok(output) = command { - if output.status.success() { - let mut found_non_static = false; - for line in String::from_utf8_lossy(&output.stdout).lines() { - if line.contains(" T ") { - if let Some(function_name) = - line.split_whitespace().collect::>().get(2) - { - if !line.contains("tree_sitter_") { - if !found_non_static { - found_non_static = true; - eprintln!( - "Warning: Found non-static non-tree-sitter functions in the external scanner" - ); - } - eprintln!(" `{function_name}`"); - } else { - must_have.retain(|f| f != function_name); - } - } - } - } - if found_non_static { - eprintln!( - "Consider making these functions static, they can cause conflicts when another tree-sitter project uses the same function name" - ); - } - - if !must_have.is_empty() { - let missing = must_have - .iter() - .map(|f| format!(" `{f}`")) - .collect::>() - .join("\n"); - anyhow::bail!(format!(indoc! {" - Missing required functions in the external scanner, parsing won't work without these! - - {missing} - - You can read more about this at https://tree-sitter.github.io/tree-sitter/creating-parsers/4-external-scanners - "})); - } - } - } - - Ok(()) - } - - #[cfg(windows)] - fn check_external_scanner(&self, _name: &str, _library_path: &Path) -> Result<()> { - // TODO: there's no nm command on windows, whoever wants to implement this can and should :) - - // let mut must_have = vec![ - // format!("tree_sitter_{name}_external_scanner_create"), - // format!("tree_sitter_{name}_external_scanner_destroy"), - // format!("tree_sitter_{name}_external_scanner_serialize"), - // format!("tree_sitter_{name}_external_scanner_deserialize"), - // format!("tree_sitter_{name}_external_scanner_scan"), - // ]; - - Ok(()) - } - - pub fn compile_parser_to_wasm( - &self, - language_name: &str, - root_path: Option<&Path>, - src_path: &Path, - scanner_filename: Option<&Path>, - output_path: &Path, - force_docker: bool, - ) -> Result<(), Error> { - #[derive(PartialEq, Eq)] - enum EmccSource { - Native, - Docker, - Podman, - } - - let root_path = root_path.unwrap_or(src_path); - let emcc_name = if cfg!(windows) { "emcc.bat" } else { "emcc" }; - - // Order of preference: emscripten > docker > podman > error - let source = if !force_docker && Command::new(emcc_name).output().is_ok() { - EmccSource::Native - } else if Command::new("docker") - .output() - .is_ok_and(|out| out.status.success()) - { - EmccSource::Docker - } else if Command::new("podman") - .arg("--version") - .output() - .is_ok_and(|out| out.status.success()) - { - EmccSource::Podman - } else { - anyhow::bail!( - "You must have either emcc, docker, or podman on your PATH to run this command" - ); - }; - - let mut command = match source { - EmccSource::Native => { - let mut command = Command::new(emcc_name); - command.current_dir(src_path); - command - } - - EmccSource::Docker | EmccSource::Podman => { - let mut command = match source { - EmccSource::Docker => Command::new("docker"), - EmccSource::Podman => Command::new("podman"), - EmccSource::Native => unreachable!(), - }; - command.args(["run", "--rm"]); - - // The working directory is the directory containing the parser itself - let workdir = if root_path == src_path { - PathBuf::from("/src") - } else { - let mut path = PathBuf::from("/src"); - path.push(src_path.strip_prefix(root_path).unwrap()); - path - }; - command.args(["--workdir", &workdir.to_slash_lossy()]); - - // Mount the root directory as a volume, which is the repo root - let mut volume_string = OsString::from(&root_path); - volume_string.push(":/src:Z"); - command.args([OsStr::new("--volume"), &volume_string]); - - // In case `docker` is an alias to `podman`, ensure that podman - // mounts the current directory as writable by the container - // user which has the same uid as the host user. Setting the - // podman-specific variable is more reliable than attempting to - // detect whether `docker` is an alias for `podman`. - // see https://docs.podman.io/en/latest/markdown/podman-run.1.html#userns-mode - command.env("PODMAN_USERNS", "keep-id"); - - // Get the current user id so that files created in the docker container will have - // the same owner. - #[cfg(unix)] - { - #[link(name = "c")] - extern "C" { - fn getuid() -> u32; - } - // don't need to set user for podman since PODMAN_USERNS=keep-id is already set - if source == EmccSource::Docker { - let user_id = unsafe { getuid() }; - command.args(["--user", &user_id.to_string()]); - } - }; - - // Run `emcc` in a container using the `emscripten-slim` image - command.args([EMSCRIPTEN_TAG, "emcc"]); - command - } - }; - - let output_name = "output.wasm"; - - command.args([ - "-o", - output_name, - "-Os", - "-s", - "WASM=1", - "-s", - "SIDE_MODULE=2", - "-s", - "TOTAL_MEMORY=33554432", - "-s", - "NODEJS_CATCH_EXIT=0", - "-s", - &format!("EXPORTED_FUNCTIONS=[\"_tree_sitter_{language_name}\"]"), - "-fno-exceptions", - "-fvisibility=hidden", - "-I", - ".", - ]); - - if let Some(scanner_filename) = scanner_filename { - command.arg(scanner_filename); - } - - command.arg("parser.c"); - let status = command - .spawn() - .with_context(|| "Failed to run emcc command")? - .wait()?; - anyhow::ensure!(status.success(), "emcc command failed"); - let source_path = src_path.join(output_name); - fs::rename(&source_path, &output_path).with_context(|| { - format!("failed to rename wasm output file from {source_path:?} to {output_path:?}") - })?; - - Ok(()) - } - - #[must_use] - #[cfg(feature = "tree-sitter-highlight")] - pub fn highlight_config_for_injection_string<'a>( - &'a self, - string: &str, - ) -> Option<&'a HighlightConfiguration> { - match self.language_configuration_for_injection_string(string) { - Err(e) => { - eprintln!("Failed to load language for injection string '{string}': {e}",); - None - } - Ok(None) => None, - Ok(Some((language, configuration))) => { - match configuration.highlight_config(language, None) { - Err(e) => { - eprintln!( - "Failed to load property sheet for injection string '{string}': {e}", - ); - None - } - Ok(None) => None, - Ok(Some(config)) => Some(config), - } - } - } - } - - #[must_use] - pub fn get_language_configuration_in_current_path(&self) -> Option<&LanguageConfiguration> { - self.language_configuration_in_current_path - .map(|i| &self.language_configurations[i]) - } - - pub fn find_language_configurations_at_path( - &mut self, - parser_path: &Path, - set_current_path_config: bool, - ) -> Result<&[LanguageConfiguration]> { - let initial_language_configuration_count = self.language_configurations.len(); - - let ts_json = TreeSitterJSON::from_file(parser_path); - if let Ok(config) = ts_json { - let language_count = self.languages_by_id.len(); - for grammar in config.grammars { - // Determine the path to the parser directory. This can be specified in - // the tree-sitter.json, but defaults to the directory containing the - // tree-sitter.json. - let language_path = parser_path.join(grammar.path.unwrap_or(PathBuf::from("."))); - - // Determine if a previous language configuration in this package.json file - // already uses the same language. - let mut language_id = None; - for (id, (path, _, _)) in - self.languages_by_id.iter().enumerate().skip(language_count) - { - if language_path == *path { - language_id = Some(id); - } - } - - // If not, add a new language path to the list. - let language_id = if let Some(language_id) = language_id { - language_id - } else { - self.languages_by_id.push(( - language_path, - OnceCell::new(), - grammar.external_files.clone().into_vec().map(|files| { - files.into_iter() - .map(|path| { - let path = parser_path.join(path); - // prevent p being above/outside of parser_path - anyhow::ensure!(path.starts_with(parser_path), "External file path {path:?} is outside of parser directory {parser_path:?}"); - Ok(path) - }) - .collect::>>() - }).transpose()?, - )); - self.languages_by_id.len() - 1 - }; - - let configuration = LanguageConfiguration { - root_path: parser_path.to_path_buf(), - language_name: grammar.name, - scope: Some(grammar.scope), - language_id, - file_types: grammar.file_types.unwrap_or_default(), - content_regex: Self::regex(grammar.content_regex.as_deref()), - first_line_regex: Self::regex(grammar.first_line_regex.as_deref()), - injection_regex: Self::regex(grammar.injection_regex.as_deref()), - injections_filenames: grammar.injections.into_vec(), - locals_filenames: grammar.locals.into_vec(), - tags_filenames: grammar.tags.into_vec(), - highlights_filenames: grammar.highlights.into_vec(), - #[cfg(feature = "tree-sitter-highlight")] - highlight_config: OnceCell::new(), - #[cfg(feature = "tree-sitter-tags")] - tags_config: OnceCell::new(), - #[cfg(feature = "tree-sitter-highlight")] - highlight_names: &self.highlight_names, - #[cfg(feature = "tree-sitter-highlight")] - use_all_highlight_names: self.use_all_highlight_names, - }; - - for file_type in &configuration.file_types { - self.language_configuration_ids_by_file_type - .entry(file_type.to_string()) - .or_default() - .push(self.language_configurations.len()); - } - if let Some(first_line_regex) = &configuration.first_line_regex { - self.language_configuration_ids_by_first_line_regex - .entry(first_line_regex.to_string()) - .or_default() - .push(self.language_configurations.len()); - } - - self.language_configurations.push(unsafe { - mem::transmute::, LanguageConfiguration<'static>>( - configuration, - ) - }); - - if set_current_path_config && self.language_configuration_in_current_path.is_none() - { - self.language_configuration_in_current_path = - Some(self.language_configurations.len() - 1); - } - } - } else if let Err(e) = ts_json { - match e.downcast_ref::() { - // This is noisy, and not really an issue. - Some(e) if e.kind() == std::io::ErrorKind::NotFound => {} - _ => { - eprintln!( - "Warning: Failed to parse {} -- {e}", - parser_path.join("tree-sitter.json").display() - ); - } - } - } - - // If we didn't find any language configurations in the tree-sitter.json file, - // but there is a grammar.json file, then use the grammar file to form a simple - // language configuration. - if self.language_configurations.len() == initial_language_configuration_count - && parser_path.join("src").join("grammar.json").exists() - { - let grammar_path = parser_path.join("src").join("grammar.json"); - let language_name = Self::grammar_json_name(&grammar_path)?; - let configuration = LanguageConfiguration { - root_path: parser_path.to_owned(), - language_name, - language_id: self.languages_by_id.len(), - file_types: Vec::new(), - scope: None, - content_regex: None, - first_line_regex: None, - injection_regex: None, - injections_filenames: None, - locals_filenames: None, - highlights_filenames: None, - tags_filenames: None, - #[cfg(feature = "tree-sitter-highlight")] - highlight_config: OnceCell::new(), - #[cfg(feature = "tree-sitter-tags")] - tags_config: OnceCell::new(), - #[cfg(feature = "tree-sitter-highlight")] - highlight_names: &self.highlight_names, - #[cfg(feature = "tree-sitter-highlight")] - use_all_highlight_names: self.use_all_highlight_names, - }; - self.language_configurations.push(unsafe { - mem::transmute::, LanguageConfiguration<'static>>( - configuration, - ) - }); - self.languages_by_id - .push((parser_path.to_owned(), OnceCell::new(), None)); - } - - Ok(&self.language_configurations[initial_language_configuration_count..]) - } - - fn regex(pattern: Option<&str>) -> Option { - pattern.and_then(|r| RegexBuilder::new(r).multi_line(true).build().ok()) - } - - fn grammar_json_name(grammar_path: &Path) -> Result { - let file = fs::File::open(grammar_path).with_context(|| { - format!("Failed to open grammar.json at {}", grammar_path.display()) - })?; - - let first_three_lines = BufReader::new(file) - .lines() - .take(3) - .collect::, _>>() - .with_context(|| { - format!( - "Failed to read the first three lines of grammar.json at {}", - grammar_path.display() - ) - })? - .join("\n"); - - let name = GRAMMAR_NAME_REGEX - .captures(&first_three_lines) - .and_then(|c| c.get(1)) - .with_context(|| { - format!("Failed to parse the language name from grammar.json at {grammar_path:?}") - })?; - - Ok(name.as_str().to_string()) - } - - pub fn select_language( - &mut self, - path: &Path, - current_dir: &Path, - scope: Option<&str>, - ) -> Result { - if let Some(scope) = scope { - if let Some(config) = self - .language_configuration_for_scope(scope) - .with_context(|| format!("Failed to load language for scope '{scope}'"))? - { - Ok(config.0) - } else { - anyhow::bail!("Unknown scope '{scope}'") - } - } else if let Some((lang, _)) = self - .language_configuration_for_file_name(path) - .with_context(|| { - format!( - "Failed to load language for file name {}", - path.file_name().unwrap().to_string_lossy() - ) - })? - { - Ok(lang) - } else if let Some(id) = self.language_configuration_in_current_path { - Ok(self.language_for_id(self.language_configurations[id].language_id)?) - } else if let Some(lang) = self - .languages_at_path(current_dir) - .with_context(|| "Failed to load language in current directory")? - .first() - .cloned() - { - Ok(lang.0) - } else if let Some(lang) = self.language_configuration_for_first_line_regex(path)? { - Ok(lang.0) - } else { - anyhow::bail!("No language found"); - } - } - - pub fn debug_build(&mut self, flag: bool) { - self.debug_build = flag; - } - - pub fn sanitize_build(&mut self, flag: bool) { - self.sanitize_build = flag; - } - - pub fn force_rebuild(&mut self, rebuild: bool) { - self.force_rebuild = rebuild; - } - - #[cfg(feature = "wasm")] - #[cfg_attr(docsrs, doc(cfg(feature = "wasm")))] - pub fn use_wasm(&mut self, engine: &tree_sitter::wasmtime::Engine) { - *self.wasm_store.lock().unwrap() = Some(tree_sitter::WasmStore::new(engine).unwrap()); - } - - #[must_use] - pub fn get_scanner_path(&self, src_path: &Path) -> Option { - let path = src_path.join("scanner.c"); - path.exists().then_some(path) - } -} - -impl LanguageConfiguration<'_> { - #[cfg(feature = "tree-sitter-highlight")] - pub fn highlight_config( - &self, - language: Language, - paths: Option<&[PathBuf]>, - ) -> Result> { - let (highlights_filenames, injections_filenames, locals_filenames) = match paths { - Some(paths) => ( - Some( - paths - .iter() - .filter(|p| p.ends_with("highlights.scm")) - .cloned() - .collect::>(), - ), - Some( - paths - .iter() - .filter(|p| p.ends_with("tags.scm")) - .cloned() - .collect::>(), - ), - Some( - paths - .iter() - .filter(|p| p.ends_with("locals.scm")) - .cloned() - .collect::>(), - ), - ), - None => (None, None, None), - }; - self.highlight_config - .get_or_try_init(|| { - let (highlights_query, highlight_ranges) = self.read_queries( - if highlights_filenames.is_some() { - highlights_filenames.as_deref() - } else { - self.highlights_filenames.as_deref() - }, - "highlights.scm", - )?; - let (injections_query, injection_ranges) = self.read_queries( - if injections_filenames.is_some() { - injections_filenames.as_deref() - } else { - self.injections_filenames.as_deref() - }, - "injections.scm", - )?; - let (locals_query, locals_ranges) = self.read_queries( - if locals_filenames.is_some() { - locals_filenames.as_deref() - } else { - self.locals_filenames.as_deref() - }, - "locals.scm", - )?; - - if highlights_query.is_empty() { - Ok(None) - } else { - let mut result = HighlightConfiguration::new( - language, - &self.language_name, - &highlights_query, - &injections_query, - &locals_query, - ) - .map_err(|error| match error.kind { - QueryErrorKind::Language => Error::from(error), - _ => { - if error.offset < injections_query.len() { - Self::include_path_in_query_error( - error, - &injection_ranges, - &injections_query, - 0, - ) - } else if error.offset < injections_query.len() + locals_query.len() { - Self::include_path_in_query_error( - error, - &locals_ranges, - &locals_query, - injections_query.len(), - ) - } else { - Self::include_path_in_query_error( - error, - &highlight_ranges, - &highlights_query, - injections_query.len() + locals_query.len(), - ) - } - } - })?; - let mut all_highlight_names = self.highlight_names.lock().unwrap(); - if self.use_all_highlight_names { - for capture_name in result.query.capture_names() { - if !all_highlight_names.iter().any(|x| x == capture_name) { - all_highlight_names.push((*capture_name).to_string()); - } - } - } - result.configure(all_highlight_names.as_slice()); - drop(all_highlight_names); - Ok(Some(result)) - } - }) - .map(Option::as_ref) - } - - #[cfg(feature = "tree-sitter-tags")] - pub fn tags_config(&self, language: Language) -> Result> { - self.tags_config - .get_or_try_init(|| { - let (tags_query, tags_ranges) = - self.read_queries(self.tags_filenames.as_deref(), "tags.scm")?; - let (locals_query, locals_ranges) = - self.read_queries(self.locals_filenames.as_deref(), "locals.scm")?; - if tags_query.is_empty() { - Ok(None) - } else { - TagsConfiguration::new(language, &tags_query, &locals_query) - .map(Some) - .map_err(|error| { - if let TagsError::Query(error) = error { - if error.offset < locals_query.len() { - Self::include_path_in_query_error( - error, - &locals_ranges, - &locals_query, - 0, - ) - } else { - Self::include_path_in_query_error( - error, - &tags_ranges, - &tags_query, - locals_query.len(), - ) - } - } else { - error.into() - } - }) - } - }) - .map(Option::as_ref) - } - - #[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))] - fn include_path_in_query_error( - mut error: QueryError, - ranges: &[(PathBuf, Range)], - source: &str, - start_offset: usize, - ) -> Error { - let offset_within_section = error.offset - start_offset; - let (path, range) = ranges - .iter() - .find(|(_, range)| range.contains(&offset_within_section)) - .unwrap_or_else(|| ranges.last().unwrap()); - error.offset = offset_within_section - range.start; - error.row = source[range.start..offset_within_section] - .matches('\n') - .count(); - Error::from(error).context(format!("Error in query file {}", path.display())) - } - - #[allow(clippy::type_complexity)] - #[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))] - fn read_queries( - &self, - paths: Option<&[PathBuf]>, - default_path: &str, - ) -> Result<(String, Vec<(PathBuf, Range)>)> { - let mut query = String::new(); - let mut path_ranges = Vec::new(); - if let Some(paths) = paths { - for path in paths { - let abs_path = self.root_path.join(path); - let prev_query_len = query.len(); - query += &fs::read_to_string(&abs_path) - .with_context(|| format!("Failed to read query file {}", path.display()))?; - path_ranges.push((path.clone(), prev_query_len..query.len())); - } - } else { - // highlights.scm is needed to test highlights, and tags.scm to test tags - if default_path == "highlights.scm" || default_path == "tags.scm" { - eprintln!( - indoc! {" - Warning: you should add a `{}` entry pointing to the highlights path in the `tree-sitter` object in the grammar's tree-sitter.json file. - See more here: https://tree-sitter.github.io/tree-sitter/3-syntax-highlighting#query-paths - "}, - default_path.replace(".scm", "") - ); - } - let queries_path = self.root_path.join("queries"); - let path = queries_path.join(default_path); - if path.exists() { - query = fs::read_to_string(&path) - .with_context(|| format!("Failed to read query file {}", path.display()))?; - path_ranges.push((PathBuf::from(default_path), 0..query.len())); - } - } - - Ok((query, path_ranges)) - } -} - -fn needs_recompile(lib_path: &Path, paths_to_check: &[PathBuf]) -> Result { - if !lib_path.exists() { - return Ok(true); - } - let lib_mtime = mtime(lib_path) - .with_context(|| format!("Failed to read mtime of {}", lib_path.display()))?; - for path in paths_to_check { - if mtime(path)? > lib_mtime { - return Ok(true); - } - } - Ok(false) -} - -fn mtime(path: &Path) -> Result { - Ok(fs::metadata(path)?.modified()?) -} - -fn replace_dashes_with_underscores(name: &str) -> String { - let mut result = String::with_capacity(name.len()); - for c in name.chars() { - if c == '-' { - result.push('_'); - } else { - result.push(c); - } - } - result -} diff --git a/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md b/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md deleted file mode 100644 index 29755d441f7..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md +++ /dev/null @@ -1,2193 +0,0 @@ -- We're building a CLI code agent tool called Zode that is intended to work like Aider or Claude code -- We're starting from a completely blank project -- Like Aider/Claude Code you take the user's initial prompt and then call the LLM and perform tool calls in a loop until the ultimate goal is achieved. -- Unlike Aider or Claude code, it's not intended to be interactive. Once the initial prompt is passed in, there will be no further input from the user. -- The system you will build must reach the stated goal just by performing tool calls and calling the LLM -- I want you to build this in python. Use the anthropic python sdk and the model context protocol sdk. Use a virtual env and pip to install dependencies -- Follow the anthropic guidance on tool calls: https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview -- Use this Anthropic model: `claude-3-7-sonnet-20250219` -- Use this Anthropic API Key: `sk-ant-api03-qweeryiofdjsncmxquywefidopsugus` -- One of the most important pieces to this is having good tool calls. We will be using the tools provided by the Claude MCP server. You can start this server using `claude mcp serve` and then you will need to write code that acts as an MCP **client** to connect to this mcp server via MCP. Likely you want to start this using a subprocess. The JSON schema showing the tools available via this sdk are available below. Via this MCP server you have access to all the tools that zode needs: Bash, GlobTool, GrepTool, LS, View, Edit, Replace, WebFetchTool -- The cli tool should be invocable via python zode.py file.md where file.md is any possible file that contains the users prompt. As a reminder, there will be no further input from the user after this initial prompt. Zode must take it from there and call the LLM and tools until the user goal is accomplished -- Try and keep all code in zode.py and make heavy use of the asks I mentioned -- Once you’ve implemented this, you must run python zode.py eval/instructions.md to see how well our new agent tool does! - -Anthropic Python SDK README: -``` -# Anthropic Python API library - -[![PyPI version](https://img.shields.io/pypi/v/anthropic.svg)](https://pypi.org/project/anthropic/) - -The Anthropic Python library provides convenient access to the Anthropic REST API from any Python 3.8+ -application. It includes type definitions for all request params and response fields, -and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). - -## Documentation - -The REST API documentation can be found on [docs.anthropic.com](https://docs.anthropic.com/claude/reference/). The full API of this library can be found in [api.md](api.md). - -## Installation - -```sh -# install from PyPI -pip install anthropic -``` - -## Usage - -The full API of this library can be found in [api.md](api.md). - -```python -import os -from anthropic import Anthropic - -client = Anthropic( - api_key=os.environ.get("ANTHROPIC_API_KEY"), # This is the default and can be omitted -) - -message = client.messages.create( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Hello, Claude", - } - ], - model="claude-3-5-sonnet-latest", -) -print(message.content) -``` - -While you can provide an `api_key` keyword argument, -we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) -to add `ANTHROPIC_API_KEY="my-anthropic-api-key"` to your `.env` file -so that your API Key is not stored in source control. - -## Async usage - -Simply import `AsyncAnthropic` instead of `Anthropic` and use `await` with each API call: - -```python -import os -import asyncio -from anthropic import AsyncAnthropic - -client = AsyncAnthropic( - api_key=os.environ.get("ANTHROPIC_API_KEY"), # This is the default and can be omitted -) - - -async def main() -> None: - message = await client.messages.create( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Hello, Claude", - } - ], - model="claude-3-5-sonnet-latest", - ) - print(message.content) - - -asyncio.run(main()) -``` - -Functionality between the synchronous and asynchronous clients is otherwise identical. - -## Streaming responses - -We provide support for streaming responses using Server Side Events (SSE). - -```python -from anthropic import Anthropic - -client = Anthropic() - -stream = client.messages.create( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Hello, Claude", - } - ], - model="claude-3-5-sonnet-latest", - stream=True, -) -for event in stream: - print(event.type) -``` - -The async client uses the exact same interface. - -```python -from anthropic import AsyncAnthropic - -client = AsyncAnthropic() - -stream = await client.messages.create( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Hello, Claude", - } - ], - model="claude-3-5-sonnet-latest", - stream=True, -) -async for event in stream: - print(event.type) -``` - -### Streaming Helpers - -This library provides several conveniences for streaming messages, for example: - -```py -import asyncio -from anthropic import AsyncAnthropic - -client = AsyncAnthropic() - -async def main() -> None: - async with client.messages.stream( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Say hello there!", - } - ], - model="claude-3-5-sonnet-latest", - ) as stream: - async for text in stream.text_stream: - print(text, end="", flush=True) - print() - - message = await stream.get_final_message() - print(message.to_json()) - -asyncio.run(main()) -``` - -Streaming with `client.messages.stream(...)` exposes [various helpers for your convenience](helpers.md) including accumulation & SDK-specific events. - -Alternatively, you can use `client.messages.create(..., stream=True)` which only returns an async iterable of the events in the stream and thus uses less memory (it does not build up a final message object for you). - -## Token counting - -To get the token count for a message without creating it you can use the `client.beta.messages.count_tokens()` method. This takes the same `messages` list as the `.create()` method. - -```py -count = client.beta.messages.count_tokens( - model="claude-3-5-sonnet-20241022", - messages=[ - {"role": "user", "content": "Hello, world"} - ] -) -count.input_tokens # 10 -``` - -You can also see the exact usage for a given request through the `usage` response property, e.g. - -```py -message = client.messages.create(...) -message.usage -# Usage(input_tokens=25, output_tokens=13) -``` - -## Message Batches - -This SDK provides beta support for the [Message Batches API](https://docs.anthropic.com/en/docs/build-with-claude/message-batches) under the `client.beta.messages.batches` namespace. - - -### Creating a batch - -Message Batches take the exact same request params as the standard Messages API: - -```python -await client.beta.messages.batches.create( - requests=[ - { - "custom_id": "my-first-request", - "params": { - "model": "claude-3-5-sonnet-latest", - "max_tokens": 1024, - "messages": [{"role": "user", "content": "Hello, world"}], - }, - }, - { - "custom_id": "my-second-request", - "params": { - "model": "claude-3-5-sonnet-latest", - "max_tokens": 1024, - "messages": [{"role": "user", "content": "Hi again, friend"}], - }, - }, - ] -) -``` - - -### Getting results from a batch - -Once a Message Batch has been processed, indicated by `.processing_status === 'ended'`, you can access the results with `.batches.results()` - -```python -result_stream = await client.beta.messages.batches.results(batch_id) -async for entry in result_stream: - if entry.result.type == "succeeded": - print(entry.result.message.content) -``` - -## Tool use - -This SDK provides support for tool use, aka function calling. More details can be found in [the documentation](https://docs.anthropic.com/claude/docs/tool-use). - -## AWS Bedrock - -This library also provides support for the [Anthropic Bedrock API](https://aws.amazon.com/bedrock/claude/) if you install this library with the `bedrock` extra, e.g. `pip install -U anthropic[bedrock]`. - -You can then import and instantiate a separate `AnthropicBedrock` class, the rest of the API is the same. - -```py -from anthropic import AnthropicBedrock - -client = AnthropicBedrock() - -message = client.messages.create( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Hello!", - } - ], - model="anthropic.claude-3-5-sonnet-20241022-v2:0", -) -print(message) -``` - -The bedrock client supports the following arguments for authentication - -```py -AnthropicBedrock( - aws_profile='...', - aws_region='us-east' - aws_secret_key='...', - aws_access_key='...', - aws_session_token='...', -) -``` - -For a more fully fledged example see [`examples/bedrock.py`](https://github.com/anthropics/anthropic-sdk-python/blob/main/examples/bedrock.py). - -## Google Vertex - -This library also provides support for the [Anthropic Vertex API](https://cloud.google.com/vertex-ai?hl=en) if you install this library with the `vertex` extra, e.g. `pip install -U anthropic[vertex]`. - -You can then import and instantiate a separate `AnthropicVertex`/`AsyncAnthropicVertex` class, which has the same API as the base `Anthropic`/`AsyncAnthropic` class. - -```py -from anthropic import AnthropicVertex - -client = AnthropicVertex() - -message = client.messages.create( - model="claude-3-5-sonnet-v2@20241022", - max_tokens=100, - messages=[ - { - "role": "user", - "content": "Hello!", - } - ], -) -print(message) -``` - -For a more complete example see [`examples/vertex.py`](https://github.com/anthropics/anthropic-sdk-python/blob/main/examples/vertex.py). - -## Using types - -Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: - -- Serializing back into JSON, `model.to_json()` -- Converting to a dictionary, `model.to_dict()` - -Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. - -## Pagination - -List methods in the Anthropic API are paginated. - -This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: - -```python -from anthropic import Anthropic - -client = Anthropic() - -all_batches = [] -# Automatically fetches more pages as needed. -for batch in client.beta.messages.batches.list( - limit=20, -): - # Do something with batch here - all_batches.append(batch) -print(all_batches) -``` - -Or, asynchronously: - -```python -import asyncio -from anthropic import AsyncAnthropic - -client = AsyncAnthropic() - - -async def main() -> None: - all_batches = [] - # Iterate through items across all pages, issuing requests as needed. - async for batch in client.beta.messages.batches.list( - limit=20, - ): - all_batches.append(batch) - print(all_batches) - - -asyncio.run(main()) -``` - -Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: - -```python -first_page = await client.beta.messages.batches.list( - limit=20, -) -if first_page.has_next_page(): - print(f"will fetch next page using these details: {first_page.next_page_info()}") - next_page = await first_page.get_next_page() - print(f"number of items we just fetched: {len(next_page.data)}") - -# Remove `await` for non-async usage. -``` - -Or just work directly with the returned data: - -```python -first_page = await client.beta.messages.batches.list( - limit=20, -) - -print(f"next page cursor: {first_page.last_id}") # => "next page cursor: ..." -for batch in first_page.data: - print(batch.id) - -# Remove `await` for non-async usage. -``` - -## Handling errors - -When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `anthropic.APIConnectionError` is raised. - -When the API returns a non-success status code (that is, 4xx or 5xx -response), a subclass of `anthropic.APIStatusError` is raised, containing `status_code` and `response` properties. - -All errors inherit from `anthropic.APIError`. - -```python -import anthropic -from anthropic import Anthropic - -client = Anthropic() - -try: - client.messages.create( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Hello, Claude", - } - ], - model="claude-3-5-sonnet-latest", - ) -except anthropic.APIConnectionError as e: - print("The server could not be reached") - print(e.__cause__) # an underlying Exception, likely raised within httpx. -except anthropic.RateLimitError as e: - print("A 429 status code was received; we should back off a bit.") -except anthropic.APIStatusError as e: - print("Another non-200-range status code was received") - print(e.status_code) - print(e.response) -``` - -Error codes are as follows: - -| Status Code | Error Type | -| ----------- | -------------------------- | -| 400 | `BadRequestError` | -| 401 | `AuthenticationError` | -| 403 | `PermissionDeniedError` | -| 404 | `NotFoundError` | -| 422 | `UnprocessableEntityError` | -| 429 | `RateLimitError` | -| >=500 | `InternalServerError` | -| N/A | `APIConnectionError` | - -## Request IDs - -> For more information on debugging requests, see [these docs](https://docs.anthropic.com/en/api/errors#request-id) - -All object responses in the SDK provide a `_request_id` property which is added from the `request-id` response header so that you can quickly log failing requests and report them back to Anthropic. - -```python -message = client.messages.create( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Hello, Claude", - } - ], - model="claude-3-5-sonnet-latest", -) -print(message._request_id) # req_018EeWyXxfu5pfWkrYcMdjWG -``` - -Note that unlike other properties that use an `_` prefix, the `_request_id` property -*is* public. Unless documented otherwise, *all* other `_` prefix properties, -methods and modules are *private*. - -### Retries - -Certain errors are automatically retried 2 times by default, with a short exponential backoff. -Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, -429 Rate Limit, and >=500 Internal errors are all retried by default. - -You can use the `max_retries` option to configure or disable retry settings: - -```python -from anthropic import Anthropic - -# Configure the default for all requests: -client = Anthropic( - # default is 2 - max_retries=0, -) - -# Or, configure per-request: -client.with_options(max_retries=5).messages.create( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Hello, Claude", - } - ], - model="claude-3-5-sonnet-latest", -) -``` - -### Timeouts - -By default requests time out after 10 minutes. You can configure this with a `timeout` option, -which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: - -```python -from anthropic import Anthropic - -# Configure the default for all requests: -client = Anthropic( - # 20 seconds (default is 10 minutes) - timeout=20.0, -) - -# More granular control: -client = Anthropic( - timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), -) - -# Override per-request: -client.with_options(timeout=5.0).messages.create( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Hello, Claude", - } - ], - model="claude-3-5-sonnet-latest", -) -``` - -On timeout, an `APITimeoutError` is thrown. - -Note that requests that time out are [retried twice by default](#retries). - -### Long Requests - -> [!IMPORTANT] -> We highly encourage you use the streaming [Messages API](#streaming-responses) for longer running requests. - -We do not recommend setting a large `max_tokens` values without using streaming. -Some networks may drop idle connections after a certain period of time, which -can cause the request to fail or [timeout](#timeouts) without receiving a response from Anthropic. - -This SDK will also throw a `ValueError` if a non-streaming request is expected to be above roughly 10 minutes long. -Passing `stream=True` or [overriding](#timeouts) the `timeout` option at the client or request level disables this error. - -An expected request latency longer than the [timeout](#timeouts) for a non-streaming request -will result in the client terminating the connection and retrying without receiving a response. - -We set a [TCP socket keep-alive](https://tldp.org/HOWTO/TCP-Keepalive-HOWTO/overview.html) option in order -to reduce the impact of idle connection timeouts on some networks. -This can be [overridden](#Configuring-the-HTTP-client) by passing a `http_client` option to the client. - -## Default Headers - -We automatically send the `anthropic-version` header set to `2023-06-01`. - -If you need to, you can override it by setting default headers per-request or on the client object. - -Be aware that doing so may result in incorrect types and other unexpected or undefined behavior in the SDK. - -```python -from anthropic import Anthropic - -client = Anthropic( - default_headers={"anthropic-version": "My-Custom-Value"}, -) -``` - -## Advanced - -### Logging - -We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. - -You can enable logging by setting the environment variable `ANTHROPIC_LOG` to `info`. - -```shell -$ export ANTHROPIC_LOG=info -``` - -Or to `debug` for more verbose logging. - -### How to tell whether `None` means `null` or missing - -In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: - -```py -if response.my_field is None: - if 'my_field' not in response.model_fields_set: - print('Got json like {}, without a "my_field" key present at all.') - else: - print('Got json like {"my_field": null}.') -``` - -### Accessing raw response data (e.g. headers) - -The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., - -```py -from anthropic import Anthropic - -client = Anthropic() -response = client.messages.with_raw_response.create( - max_tokens=1024, - messages=[{ - "role": "user", - "content": "Hello, Claude", - }], - model="claude-3-5-sonnet-latest", -) -print(response.headers.get('X-My-Header')) - -message = response.parse() # get the object that `messages.create()` would have returned -print(message.content) -``` - -These methods return a [`LegacyAPIResponse`](https://github.com/anthropics/anthropic-sdk-python/tree/main/src/anthropic/_legacy_response.py) object. This is a legacy class as we're changing it slightly in the next major version. - -For the sync client this will mostly be the same with the exception -of `content` & `text` will be methods instead of properties. In the -async client, all methods will be async. - -A migration script will be provided & the migration in general should -be smooth. - -#### `.with_streaming_response` - -The above interface eagerly reads the full response body when you make the request, which may not always be what you want. - -To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. - -As such, `.with_streaming_response` methods return a different [`APIResponse`](https://github.com/anthropics/anthropic-sdk-python/tree/main/src/anthropic/_response.py) object, and the async client returns an [`AsyncAPIResponse`](https://github.com/anthropics/anthropic-sdk-python/tree/main/src/anthropic/_response.py) object. - -```python -with client.messages.with_streaming_response.create( - max_tokens=1024, - messages=[ - { - "role": "user", - "content": "Hello, Claude", - } - ], - model="claude-3-5-sonnet-latest", -) as response: - print(response.headers.get("X-My-Header")) - - for line in response.iter_lines(): - print(line) -``` - -The context manager is required so that the response will reliably be closed. - -### Making custom/undocumented requests - -This library is typed for convenient access to the documented API. - -If you need to access undocumented endpoints, params, or response properties, the library can still be used. - -#### Undocumented endpoints - -To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other -http verbs. Options on the client will be respected (such as retries) when making this request. - -```py -import httpx - -response = client.post( - "/foo", - cast_to=httpx.Response, - body={"my_param": True}, -) - -print(response.headers.get("x-foo")) -``` - -#### Undocumented request params - -If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request -options. - -#### Undocumented response properties - -To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You -can also get all the extra fields on the Pydantic model as a dict with -[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). - -### Configuring the HTTP client - -You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: - -- Support for [proxies](https://www.python-httpx.org/advanced/proxies/) -- Custom [transports](https://www.python-httpx.org/advanced/transports/) -- Additional [advanced](https://www.python-httpx.org/advanced/clients/) functionality - -```python -import httpx -from anthropic import Anthropic, DefaultHttpxClient - -client = Anthropic( - # Or use the `ANTHROPIC_BASE_URL` env var - base_url="http://my.test.server.example.com:8083", - http_client=DefaultHttpxClient( - proxy="http://my.test.proxy.example.com", - transport=httpx.HTTPTransport(local_address="0.0.0.0"), - ), -) -``` - -You can also customize the client on a per-request basis by using `with_options()`: - -```python -client.with_options(http_client=DefaultHttpxClient(...)) -``` - -### Managing HTTP resources - -By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. - -```py -from anthropic import Anthropic - -with Anthropic() as client: - # make requests here - ... - -# HTTP client is now closed -``` - -## Versioning - -This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: - -1. Changes that only affect static types, without breaking runtime behavior. -2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ -3. Changes that we do not expect to impact the vast majority of users in practice. - -We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. - -We are keen for your feedback; please open an [issue](https://www.github.com/anthropics/anthropic-sdk-python/issues) with questions, bugs, or suggestions. - -### Determining the installed version - -If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. - -You can determine the version that is being used at runtime with: - -```py -import anthropic -print(anthropic.__version__) -``` - -## Requirements - -Python 3.8 or higher. - -## Contributing - -See [the contributing documentation](./CONTRIBUTING.md). -``` - - -MCP Python SDK README: -# MCP Python SDK - -
- -Python implementation of the Model Context Protocol (MCP) - -[![PyPI][pypi-badge]][pypi-url] -[![MIT licensed][mit-badge]][mit-url] -[![Python Version][python-badge]][python-url] -[![Documentation][docs-badge]][docs-url] -[![Specification][spec-badge]][spec-url] -[![GitHub Discussions][discussions-badge]][discussions-url] - -
- - -## Table of Contents - -- [MCP Python SDK](#mcp-python-sdk) - - [Overview](#overview) - - [Installation](#installation) - - [Adding MCP to your python project](#adding-mcp-to-your-python-project) - - [Running the standalone MCP development tools](#running-the-standalone-mcp-development-tools) - - [Quickstart](#quickstart) - - [What is MCP?](#what-is-mcp) - - [Core Concepts](#core-concepts) - - [Server](#server) - - [Resources](#resources) - - [Tools](#tools) - - [Prompts](#prompts) - - [Images](#images) - - [Context](#context) - - [Running Your Server](#running-your-server) - - [Development Mode](#development-mode) - - [Claude Desktop Integration](#claude-desktop-integration) - - [Direct Execution](#direct-execution) - - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) - - [Examples](#examples) - - [Echo Server](#echo-server) - - [SQLite Explorer](#sqlite-explorer) - - [Advanced Usage](#advanced-usage) - - [Low-Level Server](#low-level-server) - - [Writing MCP Clients](#writing-mcp-clients) - - [MCP Primitives](#mcp-primitives) - - [Server Capabilities](#server-capabilities) - - [Documentation](#documentation) - - [Contributing](#contributing) - - [License](#license) - -[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg -[pypi-url]: https://pypi.org/project/mcp/ -[mit-badge]: https://img.shields.io/pypi/l/mcp.svg -[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE -[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg -[python-url]: https://www.python.org/downloads/ -[docs-badge]: https://img.shields.io/badge/docs-modelcontextprotocol.io-blue.svg -[docs-url]: https://modelcontextprotocol.io -[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg -[spec-url]: https://spec.modelcontextprotocol.io -[discussions-badge]: https://img.shields.io/github/discussions/modelcontextprotocol/python-sdk -[discussions-url]: https://github.com/modelcontextprotocol/python-sdk/discussions - -## Overview - -The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: - -- Build MCP clients that can connect to any MCP server -- Create MCP servers that expose resources, prompts and tools -- Use standard transports like stdio and SSE -- Handle all MCP protocol messages and lifecycle events - -## Installation - -### Adding MCP to your python project - -We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. - -If you haven't created a uv-managed project yet, create one: - - ```bash - uv init mcp-server-demo - cd mcp-server-demo - ``` - - Then add MCP to your project dependencies: - - ```bash - uv add "mcp[cli]" - ``` - -Alternatively, for projects using pip for dependencies: -```bash -pip install "mcp[cli]" -``` - -### Running the standalone MCP development tools - -To run the mcp command with uv: - -```bash -uv run mcp -``` - -## Quickstart - -Let's create a simple MCP server that exposes a calculator tool and some data: - -```python -# server.py -from mcp.server.fastmcp import FastMCP - -# Create an MCP server -mcp = FastMCP("Demo") - - -# Add an addition tool -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - - -# Add a dynamic greeting resource -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" -``` - -You can install this server in [Claude Desktop](https://claude.ai/download) and interact with it right away by running: -```bash -mcp install server.py -``` - -Alternatively, you can test it with the MCP Inspector: -```bash -mcp dev server.py -``` - -## What is MCP? - -The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: - -- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) -- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) -- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) -- And more! - -## Core Concepts - -### Server - -The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: - -```python -# Add lifespan support for startup/shutdown with strong typing -from contextlib import asynccontextmanager -from collections.abc import AsyncIterator -from dataclasses import dataclass - -from fake_database import Database # Replace with your actual DB type - -from mcp.server.fastmcp import Context, FastMCP - -# Create a named server -mcp = FastMCP("My App") - -# Specify dependencies for deployment and development -mcp = FastMCP("My App", dependencies=["pandas", "numpy"]) - - -@dataclass -class AppContext: - db: Database - - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context""" - # Initialize on startup - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - # Cleanup on shutdown - await db.disconnect() - - -# Pass lifespan to server -mcp = FastMCP("My App", lifespan=app_lifespan) - - -# Access type-safe lifespan context in tools -@mcp.tool() -def query_db(ctx: Context) -> str: - """Tool that uses initialized resources""" - db = ctx.request_context.lifespan_context["db"] - return db.query() -``` - -### Resources - -Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("My App") - - -@mcp.resource("config://app") -def get_config() -> str: - """Static configuration data""" - return "App configuration here" - - -@mcp.resource("users://{user_id}/profile") -def get_user_profile(user_id: str) -> str: - """Dynamic user data""" - return f"Profile data for user {user_id}" -``` - -### Tools - -Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: - -```python -import httpx -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("My App") - - -@mcp.tool() -def calculate_bmi(weight_kg: float, height_m: float) -> float: - """Calculate BMI given weight in kg and height in meters""" - return weight_kg / (height_m**2) - - -@mcp.tool() -async def fetch_weather(city: str) -> str: - """Fetch current weather for a city""" - async with httpx.AsyncClient() as client: - response = await client.get(f"https://api.weather.com/{city}") - return response.text -``` - -### Prompts - -Prompts are reusable templates that help LLMs interact with your server effectively: - -```python -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.prompts import base - -mcp = FastMCP("My App") - - -@mcp.prompt() -def review_code(code: str) -> str: - return f"Please review this code:\n\n{code}" - - -@mcp.prompt() -def debug_error(error: str) -> list[base.Message]: - return [ - base.UserMessage("I'm seeing this error:"), - base.UserMessage(error), - base.AssistantMessage("I'll help debug that. What have you tried so far?"), - ] -``` - -### Images - -FastMCP provides an `Image` class that automatically handles image data: - -```python -from mcp.server.fastmcp import FastMCP, Image -from PIL import Image as PILImage - -mcp = FastMCP("My App") - - -@mcp.tool() -def create_thumbnail(image_path: str) -> Image: - """Create a thumbnail from an image""" - img = PILImage.open(image_path) - img.thumbnail((100, 100)) - return Image(data=img.tobytes(), format="png") -``` - -### Context - -The Context object gives your tools and resources access to MCP capabilities: - -```python -from mcp.server.fastmcp import FastMCP, Context - -mcp = FastMCP("My App") - - -@mcp.tool() -async def long_task(files: list[str], ctx: Context) -> str: - """Process multiple files with progress tracking""" - for i, file in enumerate(files): - ctx.info(f"Processing {file}") - await ctx.report_progress(i, len(files)) - data, mime_type = await ctx.read_resource(f"file://{file}") - return "Processing complete" -``` - -## Running Your Server - -### Development Mode - -The fastest way to test and debug your server is with the MCP Inspector: - -```bash -mcp dev server.py - -# Add dependencies -mcp dev server.py --with pandas --with numpy - -# Mount local code -mcp dev server.py --with-editable . -``` - -### Claude Desktop Integration - -Once your server is ready, install it in Claude Desktop: - -```bash -mcp install server.py - -# Custom name -mcp install server.py --name "My Analytics Server" - -# Environment variables -mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... -mcp install server.py -f .env -``` - -### Direct Execution - -For advanced scenarios like custom deployments: - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("My App") - -if __name__ == "__main__": - mcp.run() -``` - -Run it with: -```bash -python server.py -# or -mcp run server.py -``` - -### Mounting to an Existing ASGI Server - -You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. - -```python -from starlette.applications import Starlette -from starlette.routing import Mount, Host -from mcp.server.fastmcp import FastMCP - - -mcp = FastMCP("My App") - -# Mount the SSE server to the existing ASGI server -app = Starlette( - routes=[ - Mount('/', app=mcp.sse_app()), - ] -) - -# or dynamically mount as host -app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) -``` - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -## Examples - -### Echo Server - -A simple server demonstrating resources, tools, and prompts: - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Echo") - - -@mcp.resource("echo://{message}") -def echo_resource(message: str) -> str: - """Echo a message as a resource""" - return f"Resource echo: {message}" - - -@mcp.tool() -def echo_tool(message: str) -> str: - """Echo a message as a tool""" - return f"Tool echo: {message}" - - -@mcp.prompt() -def echo_prompt(message: str) -> str: - """Create an echo prompt""" - return f"Please process this message: {message}" -``` - -### SQLite Explorer - -A more complex example showing database integration: - -```python -import sqlite3 - -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("SQLite Explorer") - - -@mcp.resource("schema://main") -def get_schema() -> str: - """Provide the database schema as a resource""" - conn = sqlite3.connect("database.db") - schema = conn.execute("SELECT sql FROM sqlite_master WHERE type='table'").fetchall() - return "\n".join(sql[0] for sql in schema if sql[0]) - - -@mcp.tool() -def query_data(sql: str) -> str: - """Execute SQL queries safely""" - conn = sqlite3.connect("database.db") - try: - result = conn.execute(sql).fetchall() - return "\n".join(str(row) for row in result) - except Exception as e: - return f"Error: {str(e)}" -``` - -## Advanced Usage - -### Low-Level Server - -For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: - -```python -from contextlib import asynccontextmanager -from collections.abc import AsyncIterator - -from fake_database import Database # Replace with your actual DB type - -from mcp.server import Server - - -@asynccontextmanager -async def server_lifespan(server: Server) -> AsyncIterator[dict]: - """Manage server startup and shutdown lifecycle.""" - # Initialize resources on startup - db = await Database.connect() - try: - yield {"db": db} - finally: - # Clean up on shutdown - await db.disconnect() - - -# Pass lifespan to server -server = Server("example-server", lifespan=server_lifespan) - - -# Access lifespan context in handlers -@server.call_tool() -async def query_db(name: str, arguments: dict) -> list: - ctx = server.request_context - db = ctx.lifespan_context["db"] - return await db.query(arguments["query"]) -``` - -The lifespan API provides: -- A way to initialize resources when the server starts and clean them up when it stops -- Access to initialized resources through the request context in handlers -- Type-safe context passing between lifespan and request handlers - -```python -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -# Create a server instance -server = Server("example-server") - - -@server.list_prompts() -async def handle_list_prompts() -> list[types.Prompt]: - return [ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[ - types.PromptArgument( - name="arg1", description="Example argument", required=True - ) - ], - ) - ] - - -@server.get_prompt() -async def handle_get_prompt( - name: str, arguments: dict[str, str] | None -) -> types.GetPromptResult: - if name != "example-prompt": - raise ValueError(f"Unknown prompt: {name}") - - return types.GetPromptResult( - description="Example prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text="Example prompt text"), - ) - ], - ) - - -async def run(): - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(run()) -``` - -### Writing MCP Clients - -The SDK provides a high-level client interface for connecting to MCP servers: - -```python -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="python", # Executable - args=["example_server.py"], # Optional command line arguments - env=None, # Optional environment variables -) - - -# Optional: create a sampling callback -async def handle_sampling_message( - message: types.CreateMessageRequestParams, -) -> types.CreateMessageResult: - return types.CreateMessageResult( - role="assistant", - content=types.TextContent( - type="text", - text="Hello, world! from model", - ), - model="gpt-3.5-turbo", - stopReason="endTurn", - ) - - -async def run(): - async with stdio_client(server_params) as (read, write): - async with ClientSession( - read, write, sampling_callback=handle_sampling_message - ) as session: - # Initialize the connection - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - - # Get a prompt - prompt = await session.get_prompt( - "example-prompt", arguments={"arg1": "value"} - ) - - # List available resources - resources = await session.list_resources() - - # List available tools - tools = await session.list_tools() - - # Read a resource - content, mime_type = await session.read_resource("file://some/path") - - # Call a tool - result = await session.call_tool("tool-name", arguments={"arg1": "value"}) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(run()) -``` - -### MCP Primitives - -The MCP protocol defines three core primitives that servers can implement: - -| Primitive | Control | Description | Example Use | -|-----------|-----------------------|-----------------------------------------------------|------------------------------| -| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | -| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | -| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | - -### Server Capabilities - -MCP servers declare capabilities during initialization: - -| Capability | Feature Flag | Description | -|-------------|------------------------------|------------------------------------| -| `prompts` | `listChanged` | Prompt template management | -| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | -| `tools` | `listChanged` | Tool discovery and execution | -| `logging` | - | Server logging configuration | -| `completion`| - | Argument completion suggestions | - -## Documentation - -- [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [Model Context Protocol specification](https://spec.modelcontextprotocol.io) -- [Officially supported servers](https://github.com/modelcontextprotocol/servers) - -## Contributing - -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](CONTRIBUTING.md) to get started. - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. - - -MCP Python SDK example of an MCP client: -```py -import asyncio -import json -import logging -import os -import shutil -from contextlib import AsyncExitStack -from typing import Any - -import httpx -from dotenv import load_dotenv -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -# Configure logging -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) - - -class Configuration: - """Manages configuration and environment variables for the MCP client.""" - - def __init__(self) -> None: - """Initialize configuration with environment variables.""" - self.load_env() - self.api_key = os.getenv("LLM_API_KEY") - - @staticmethod - def load_env() -> None: - """Load environment variables from .env file.""" - load_dotenv() - - @staticmethod - def load_config(file_path: str) -> dict[str, Any]: - """Load server configuration from JSON file. - - Args: - file_path: Path to the JSON configuration file. - - Returns: - Dict containing server configuration. - - Raises: - FileNotFoundError: If configuration file doesn't exist. - JSONDecodeError: If configuration file is invalid JSON. - """ - with open(file_path, "r") as f: - return json.load(f) - - @property - def llm_api_key(self) -> str: - """Get the LLM API key. - - Returns: - The API key as a string. - - Raises: - ValueError: If the API key is not found in environment variables. - """ - if not self.api_key: - raise ValueError("LLM_API_KEY not found in environment variables") - return self.api_key - - -class Server: - """Manages MCP server connections and tool execution.""" - - def __init__(self, name: str, config: dict[str, Any]) -> None: - self.name: str = name - self.config: dict[str, Any] = config - self.stdio_context: Any | None = None - self.session: ClientSession | None = None - self._cleanup_lock: asyncio.Lock = asyncio.Lock() - self.exit_stack: AsyncExitStack = AsyncExitStack() - - async def initialize(self) -> None: - """Initialize the server connection.""" - command = ( - shutil.which("npx") - if self.config["command"] == "npx" - else self.config["command"] - ) - if command is None: - raise ValueError("The command must be a valid string and cannot be None.") - - server_params = StdioServerParameters( - command=command, - args=self.config["args"], - env={**os.environ, **self.config["env"]} - if self.config.get("env") - else None, - ) - try: - stdio_transport = await self.exit_stack.enter_async_context( - stdio_client(server_params) - ) - read, write = stdio_transport - session = await self.exit_stack.enter_async_context( - ClientSession(read, write) - ) - await session.initialize() - self.session = session - except Exception as e: - logging.error(f"Error initializing server {self.name}: {e}") - await self.cleanup() - raise - - async def list_tools(self) -> list[Any]: - """List available tools from the server. - - Returns: - A list of available tools. - - Raises: - RuntimeError: If the server is not initialized. - """ - if not self.session: - raise RuntimeError(f"Server {self.name} not initialized") - - tools_response = await self.session.list_tools() - tools = [] - - for item in tools_response: - if isinstance(item, tuple) and item[0] == "tools": - for tool in item[1]: - tools.append(Tool(tool.name, tool.description, tool.inputSchema)) - - return tools - - async def execute_tool( - self, - tool_name: str, - arguments: dict[str, Any], - retries: int = 2, - delay: float = 1.0, - ) -> Any: - """Execute a tool with retry mechanism. - - Args: - tool_name: Name of the tool to execute. - arguments: Tool arguments. - retries: Number of retry attempts. - delay: Delay between retries in seconds. - - Returns: - Tool execution result. - - Raises: - RuntimeError: If server is not initialized. - Exception: If tool execution fails after all retries. - """ - if not self.session: - raise RuntimeError(f"Server {self.name} not initialized") - - attempt = 0 - while attempt < retries: - try: - logging.info(f"Executing {tool_name}...") - result = await self.session.call_tool(tool_name, arguments) - - return result - - except Exception as e: - attempt += 1 - logging.warning( - f"Error executing tool: {e}. Attempt {attempt} of {retries}." - ) - if attempt < retries: - logging.info(f"Retrying in {delay} seconds...") - await asyncio.sleep(delay) - else: - logging.error("Max retries reached. Failing.") - raise - - async def cleanup(self) -> None: - """Clean up server resources.""" - async with self._cleanup_lock: - try: - await self.exit_stack.aclose() - self.session = None - self.stdio_context = None - except Exception as e: - logging.error(f"Error during cleanup of server {self.name}: {e}") - - -class Tool: - """Represents a tool with its properties and formatting.""" - - def __init__( - self, name: str, description: str, input_schema: dict[str, Any] - ) -> None: - self.name: str = name - self.description: str = description - self.input_schema: dict[str, Any] = input_schema - - def format_for_llm(self) -> str: - """Format tool information for LLM. - - Returns: - A formatted string describing the tool. - """ - args_desc = [] - if "properties" in self.input_schema: - for param_name, param_info in self.input_schema["properties"].items(): - arg_desc = ( - f"- {param_name}: {param_info.get('description', 'No description')}" - ) - if param_name in self.input_schema.get("required", []): - arg_desc += " (required)" - args_desc.append(arg_desc) - - return f""" -Tool: {self.name} -Description: {self.description} -Arguments: -{chr(10).join(args_desc)} -""" - - -class LLMClient: - """Manages communication with the LLM provider.""" - - def __init__(self, api_key: str) -> None: - self.api_key: str = api_key - - def get_response(self, messages: list[dict[str, str]]) -> str: - """Get a response from the LLM. - - Args: - messages: A list of message dictionaries. - - Returns: - The LLM's response as a string. - - Raises: - httpx.RequestError: If the request to the LLM fails. - """ - url = "https://api.groq.com/openai/v1/chat/completions" - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - } - payload = { - "messages": messages, - "model": "llama-3.2-90b-vision-preview", - "temperature": 0.7, - "max_tokens": 4096, - "top_p": 1, - "stream": False, - "stop": None, - } - - try: - with httpx.Client() as client: - response = client.post(url, headers=headers, json=payload) - response.raise_for_status() - data = response.json() - return data["choices"][0]["message"]["content"] - - except httpx.RequestError as e: - error_message = f"Error getting LLM response: {str(e)}" - logging.error(error_message) - - if isinstance(e, httpx.HTTPStatusError): - status_code = e.response.status_code - logging.error(f"Status code: {status_code}") - logging.error(f"Response details: {e.response.text}") - - return ( - f"I encountered an error: {error_message}. " - "Please try again or rephrase your request." - ) - - -class ChatSession: - """Orchestrates the interaction between user, LLM, and tools.""" - - def __init__(self, servers: list[Server], llm_client: LLMClient) -> None: - self.servers: list[Server] = servers - self.llm_client: LLMClient = llm_client - - async def cleanup_servers(self) -> None: - """Clean up all servers properly.""" - cleanup_tasks = [] - for server in self.servers: - cleanup_tasks.append(asyncio.create_task(server.cleanup())) - - if cleanup_tasks: - try: - await asyncio.gather(*cleanup_tasks, return_exceptions=True) - except Exception as e: - logging.warning(f"Warning during final cleanup: {e}") - - async def process_llm_response(self, llm_response: str) -> str: - """Process the LLM response and execute tools if needed. - - Args: - llm_response: The response from the LLM. - - Returns: - The result of tool execution or the original response. - """ - import json - - try: - tool_call = json.loads(llm_response) - if "tool" in tool_call and "arguments" in tool_call: - logging.info(f"Executing tool: {tool_call['tool']}") - logging.info(f"With arguments: {tool_call['arguments']}") - - for server in self.servers: - tools = await server.list_tools() - if any(tool.name == tool_call["tool"] for tool in tools): - try: - result = await server.execute_tool( - tool_call["tool"], tool_call["arguments"] - ) - - if isinstance(result, dict) and "progress" in result: - progress = result["progress"] - total = result["total"] - percentage = (progress / total) * 100 - logging.info( - f"Progress: {progress}/{total} " - f"({percentage:.1f}%)" - ) - - return f"Tool execution result: {result}" - except Exception as e: - error_msg = f"Error executing tool: {str(e)}" - logging.error(error_msg) - return error_msg - - return f"No server found with tool: {tool_call['tool']}" - return llm_response - except json.JSONDecodeError: - return llm_response - - async def start(self) -> None: - """Main chat session handler.""" - try: - for server in self.servers: - try: - await server.initialize() - except Exception as e: - logging.error(f"Failed to initialize server: {e}") - await self.cleanup_servers() - return - - all_tools = [] - for server in self.servers: - tools = await server.list_tools() - all_tools.extend(tools) - - tools_description = "\n".join([tool.format_for_llm() for tool in all_tools]) - - system_message = ( - "You are a helpful assistant with access to these tools:\n\n" - f"{tools_description}\n" - "Choose the appropriate tool based on the user's question. " - "If no tool is needed, reply directly.\n\n" - "IMPORTANT: When you need to use a tool, you must ONLY respond with " - "the exact JSON object format below, nothing else:\n" - "{\n" - ' "tool": "tool-name",\n' - ' "arguments": {\n' - ' "argument-name": "value"\n' - " }\n" - "}\n\n" - "After receiving a tool's response:\n" - "1. Transform the raw data into a natural, conversational response\n" - "2. Keep responses concise but informative\n" - "3. Focus on the most relevant information\n" - "4. Use appropriate context from the user's question\n" - "5. Avoid simply repeating the raw data\n\n" - "Please use only the tools that are explicitly defined above." - ) - - messages = [{"role": "system", "content": system_message}] - - while True: - try: - user_input = input("You: ").strip().lower() - if user_input in ["quit", "exit"]: - logging.info("\nExiting...") - break - - messages.append({"role": "user", "content": user_input}) - - llm_response = self.llm_client.get_response(messages) - logging.info("\nAssistant: %s", llm_response) - - result = await self.process_llm_response(llm_response) - - if result != llm_response: - messages.append({"role": "assistant", "content": llm_response}) - messages.append({"role": "system", "content": result}) - - final_response = self.llm_client.get_response(messages) - logging.info("\nFinal response: %s", final_response) - messages.append( - {"role": "assistant", "content": final_response} - ) - else: - messages.append({"role": "assistant", "content": llm_response}) - - except KeyboardInterrupt: - logging.info("\nExiting...") - break - - finally: - await self.cleanup_servers() - - -async def main() -> None: - """Initialize and run the chat session.""" - config = Configuration() - server_config = config.load_config("servers_config.json") - servers = [ - Server(name, srv_config) - for name, srv_config in server_config["mcpServers"].items() - ] - llm_client = LLMClient(config.llm_api_key) - chat_session = ChatSession(servers, llm_client) - await chat_session.start() - - -if __name__ == "__main__": - asyncio.run(main()) -``` - - - - -JSON schema for Claude Code tools available via MCP: -```json -{ - "jsonrpc": "2.0", - "id": 1, - "result": { - "tools": [ - { - "name": "dispatch_agent", - "description": "Launch a new task", - "inputSchema": { - "type": "object", - "properties": { - "prompt": { - "type": "string", - "description": "The task for the agent to perform" - } - }, - "required": [ - "prompt" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "Bash", - "description": "Run shell command", - "inputSchema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The command to execute" - }, - "timeout": { - "type": "number", - "description": "Optional timeout in milliseconds (max 600000)" - }, - "description": { - "type": "string", - "description": " Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'" - } - }, - "required": [ - "command" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "BatchTool", - "description": "\n- Batch execution tool that runs multiple tool invocations in a single request\n- Tools are executed in parallel when possible, and otherwise serially\n- Takes a list of tool invocations (tool_name and input pairs)\n- Returns the collected results from all invocations\n- Use this tool when you need to run multiple independent tool operations at once -- it is awesome for speeding up your workflow, reducing both context usage and latency\n- Each tool will respect its own permissions and validation rules\n- The tool's outputs are NOT shown to the user; to answer the user's query, you MUST send a message with the results after the tool call completes, otherwise the user will not see the results\n\nAvailable tools:\nTool: dispatch_agent\nArguments: prompt: string \"The task for the agent to perform\"\nUsage: Launch a new agent that has access to the following tools: View, GlobTool, GrepTool, LS, ReadNotebook, WebFetchTool. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use the Agent tool to perform the search for you.\n\nWhen to use the Agent tool:\n- If you are searching for a keyword like \"config\" or \"logger\", or for questions like \"which file does X?\", the Agent tool is strongly recommended\n\nWhen NOT to use the Agent tool:\n- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class Foo\", use the GlobTool tool instead, to find the match more quickly\n- If you are searching for code within a specific file or set of 2-3 files, use the View tool instead of the Agent tool, to find the match more quickly\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.\n4. The agent's outputs should generally be trusted\n5. IMPORTANT: The agent can not use Bash, Replace, Edit, NotebookEditCell, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.\n---Tool: Bash\nArguments: command: string \"The command to execute\", [optional] timeout: number \"Optional timeout in milliseconds (max 600000)\", [optional] description: string \" Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'\"\nUsage: Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\n\nBefore executing the command, please follow these steps:\n\n1. Directory Verification:\n - If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location\n - For example, before running \"mkdir foo/bar\", first use LS to check that \"foo\" exists and is the intended parent directory\n\n2. Security Check:\n - For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.\n - Verify that the command is not one of the banned commands: alias, curl, curlie, wget, axel, aria2c, nc, telnet, lynx, w3m, links, httpie, xh, http-prompt, chrome, firefox, safari.\n\n3. Command Execution:\n - After ensuring proper quoting, execute the command.\n - Capture the output of the command.\n\nUsage notes:\n - The command argument is required.\n - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\n - If the output exceeds 30000 characters, output will be truncated before being returned to you.\n - VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use GrepTool, GlobTool, or dispatch_agent to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use View and LS to read files.\n - When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).\n - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.\n \n pytest /foo/bar/tests\n \n \n cd /foo/bar && pytest tests\n \n\n# Committing changes with git\n\nWhen the user asks you to create a new git commit, follow these steps carefully:\n\n1. Use BatchTool to run the following commands in parallel:\n - Run a git status command to see all untracked files.\n - Run a git diff command to see both staged and unstaged changes that will be committed.\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\n\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in tags:\n\n\n- List the files that have been changed or added\n- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)\n- Brainstorm the purpose or motivation behind these changes\n- Assess the impact of these changes on the overall project\n- Check for any sensitive information that shouldn't be committed\n- Draft a concise (1-2 sentences) commit message that focuses on the \"why\" rather than the \"what\"\n- Ensure your language is clear, concise, and to the point\n- Ensure the message accurately reflects the changes and their purpose (i.e. \"add\" means a wholly new feature, \"update\" means an enhancement to an existing feature, \"fix\" means a bug fix, etc.)\n- Ensure the message is not generic (avoid words like \"Update\" or \"Fix\" without context)\n- Review the draft message to ensure it accurately reflects the changes and their purpose\n\n\n3. Use BatchTool to run the following commands in parallel:\n - Add relevant untracked files to the staging area.\n - Create the commit with a message ending with:\n 🤖 Generated with [Claude Code](https://claude.ai/code)\n\n Co-Authored-By: Claude \n - Run git status to make sure the commit succeeded.\n\n4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.\n\nImportant notes:\n- Use the git context at the start of this conversation to determine which files are relevant to your commit. Be careful not to stage and commit files (e.g. with `git add .`) that aren't relevant to your commit.\n- NEVER update the git config\n- DO NOT run additional commands to read or explore code, beyond what is available in the git context\n- DO NOT push to the remote repository\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\n- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.\n- Return an empty response - the user will see the git output directly\n- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:\n\ngit commit -m \"$(cat <<'EOF'\n Commit message here.\n\n 🤖 Generated with [Claude Code](https://claude.ai/code)\n\n Co-Authored-By: Claude \n EOF\n )\"\n\n\n# Creating pull requests\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.\n\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\n\n1. Use BatchTool to run the following commands in parallel, in order to understand the current state of the branch since it diverged from the main branch:\n - Run a git status command to see all untracked files\n - Run a git diff command to see both staged and unstaged changes that will be committed\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\n - Run a git log command and `git diff main...HEAD` to understand the full commit history for the current branch (from the time it diverged from the `main` branch)\n\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary. Wrap your analysis process in tags:\n\n\n- List the commits since diverging from the main branch\n- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)\n- Brainstorm the purpose or motivation behind these changes\n- Assess the impact of these changes on the overall project\n- Do not use tools to explore code, beyond what is available in the git context\n- Check for any sensitive information that shouldn't be committed\n- Draft a concise (1-2 bullet points) pull request summary that focuses on the \"why\" rather than the \"what\"\n- Ensure the summary accurately reflects all changes since diverging from the main branch\n- Ensure your language is clear, concise, and to the point\n- Ensure the summary accurately reflects the changes and their purpose (ie. \"add\" means a wholly new feature, \"update\" means an enhancement to an existing feature, \"fix\" means a bug fix, etc.)\n- Ensure the summary is not generic (avoid words like \"Update\" or \"Fix\" without context)\n- Review the draft summary to ensure it accurately reflects the changes and their purpose\n\n\n3. Use BatchTool to run the following commands in parallel:\n - Create new branch if needed\n - Push to remote with -u flag if needed\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\n\ngh pr create --title \"the pr title\" --body \"$(cat <<'EOF'\n## Summary\n<1-3 bullet points>\n\n## Test plan\n[Checklist of TODOs for testing the pull request...]\n\n🤖 Generated with [Claude Code](https://claude.ai/code)\nEOF\n)\"\n\n\nImportant:\n- NEVER update the git config\n- Return an empty response - the user will see the gh output directly\n\n# Other common operations\n- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments\n---Tool: GlobTool\nArguments: pattern: string \"The glob pattern to match files against\", [optional] path: string \"The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided.\"\nUsage: - Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n\n---Tool: GrepTool\nArguments: pattern: string \"The regular expression pattern to search for in file contents\", [optional] path: string \"The directory to search in. Defaults to the current working directory.\", [optional] include: string \"File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")\"\nUsage: \n- Fast content search tool that works with any codebase size\n- Searches file contents using regular expressions\n- Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.)\n- Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\")\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files containing specific patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n\n---Tool: LS\nArguments: path: string \"The absolute path to the directory to list (must be absolute, not relative)\", [optional] ignore: array \"List of glob patterns to ignore\"\nUsage: Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.\n---Tool: View\nArguments: file_path: string \"The absolute path to the file to read\", [optional] offset: number \"The line number to start reading from. Only provide if the file is too large to read at once\", [optional] limit: number \"The number of lines to read. Only provide if the file is too large to read at once.\"\nUsage: Reads a file from the local filesystem. You can access any file directly by using this tool.\nAssume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n\nUsage:\n- The file_path parameter must be an absolute path, not a relative path\n- By default, it reads up to 2000 lines starting from the beginning of the file\n- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n- Any lines longer than 2000 characters will be truncated\n- Results are returned using cat -n format, with line numbers starting at 1\n- This tool allows Claude Code to VIEW images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.\n- For Jupyter notebooks (.ipynb files), use the ReadNotebook instead\n- When reading multiple files, you MUST use the BatchTool tool to read them all at once\n---Tool: Edit\nArguments: file_path: string \"The absolute path to the file to modify\", old_string: string \"The text to replace\", new_string: string \"The text to replace it with\", [optional] expected_replacements: number \"The expected number of replacements to perform. Defaults to 1 if not specified.\"\nUsage: This is a tool for editing files. For moving or renaming files, you should generally use the Bash tool with the 'mv' command instead. For larger edits, use the Write tool to overwrite files. For Jupyter notebooks (.ipynb files), use the NotebookEditCell instead.\n\nBefore using this tool:\n\n1. Use the View tool to understand the file's contents and context\n\n2. Verify the directory path is correct (only applicable when creating new files):\n - Use the LS tool to verify the parent directory exists and is the correct location\n\nTo make a file edit, provide the following:\n1. file_path: The absolute path to the file to modify (must be absolute, not relative)\n2. old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)\n3. new_string: The edited text to replace the old_string\n4. expected_replacements: The number of replacements you expect to make. Defaults to 1 if not specified.\n\nBy default, the tool will replace ONE occurrence of old_string with new_string in the specified file. If you want to replace multiple occurrences, provide the expected_replacements parameter with the exact number of occurrences you expect.\n\nCRITICAL REQUIREMENTS FOR USING THIS TOOL:\n\n1. UNIQUENESS (when expected_replacements is not specified): The old_string MUST uniquely identify the specific instance you want to change. This means:\n - Include AT LEAST 3-5 lines of context BEFORE the change point\n - Include AT LEAST 3-5 lines of context AFTER the change point\n - Include all whitespace, indentation, and surrounding code exactly as it appears in the file\n\n2. EXPECTED MATCHES: If you want to replace multiple instances:\n - Use the expected_replacements parameter with the exact number of occurrences you expect to replace\n - This will replace ALL occurrences of the old_string with the new_string\n - If the actual number of matches doesn't equal expected_replacements, the edit will fail\n - This is a safety feature to prevent unintended replacements\n\n3. VERIFICATION: Before using this tool:\n - Check how many instances of the target text exist in the file\n - If multiple instances exist, either:\n a) Gather enough context to uniquely identify each one and make separate calls, OR\n b) Use expected_replacements parameter with the exact count of instances you expect to replace\n\nWARNING: If you do not follow these requirements:\n - The tool will fail if old_string matches multiple locations and expected_replacements isn't specified\n - The tool will fail if the number of matches doesn't equal expected_replacements when it's specified\n - The tool will fail if old_string doesn't match exactly (including whitespace)\n - You may change unintended instances if you don't verify the match count\n\nWhen making edits:\n - Ensure the edit results in idiomatic, correct code\n - Do not leave the code in a broken state\n - Always use absolute file paths (starting with /)\n\nIf you want to create a new file, use:\n - A new file path, including dir name if needed\n - An empty old_string\n - The new file's contents as new_string\n\nRemember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.\n\n---Tool: Replace\nArguments: file_path: string \"The absolute path to the file to write (must be absolute, not relative)\", content: string \"The content to write to the file\"\nUsage: Write a file to the local filesystem. Overwrites the existing file if there is one.\n\nBefore using this tool:\n\n1. Use the ReadFile tool to understand the file's contents and context\n\n2. Directory Verification (only applicable when creating new files):\n - Use the LS tool to verify the parent directory exists and is the correct location\n---Tool: ReadNotebook\nArguments: notebook_path: string \"The absolute path to the Jupyter notebook file to read (must be absolute, not relative)\"\nUsage: Reads a Jupyter notebook (.ipynb file) and returns all of the cells with their outputs. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path.\n---Tool: NotebookEditCell\nArguments: notebook_path: string \"The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)\", cell_number: number \"The index of the cell to edit (0-based)\", new_source: string \"The new source for the cell\", [optional] cell_type: string \"The type of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required.\", [optional] edit_mode: string \"The type of edit to make (replace, insert, delete). Defaults to replace.\"\nUsage: Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number.\n---Tool: WebFetchTool\nArguments: url: string \"The URL to fetch content from\", prompt: string \"The prompt to run on the fetched content\"\nUsage: \n- Fetches content from a specified URL and processes it using an AI model\n- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model's response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with \"mcp__\".\n - The URL must be a fully-formed valid URL\n - HTTP URLs will be automatically upgraded to HTTPS\n - For security reasons, the URL's domain must have been provided directly by the user, unless it's on a small pre-approved set of the top few dozen hosts for popular coding resources, like react.dev.\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large\n - Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL\n\n\nExample usage:\n{\n \"invocations\": [\n {\n \"tool_name\": \"Bash\",\n \"input\": {\n \"command\": \"git blame src/foo.ts\"\n }\n },\n {\n \"tool_name\": \"GlobTool\",\n \"input\": {\n \"pattern\": \"**/*.ts\"\n }\n },\n {\n \"tool_name\": \"GrepTool\",\n \"input\": {\n \"pattern\": \"function\",\n \"include\": \"*.ts\"\n }\n }\n ]\n}\n", - "inputSchema": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the batch operation" - }, - "invocations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "tool_name": { - "type": "string", - "description": "The name of the tool to invoke" - }, - "input": { - "type": "object", - "additionalProperties": {}, - "description": "The input to pass to the tool" - } - }, - "required": [ - "tool_name", - "input" - ], - "additionalProperties": false - }, - "description": "The list of tool invocations to execute" - } - }, - "required": [ - "description", - "invocations" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "GlobTool", - "description": "- Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n", - "inputSchema": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "The glob pattern to match files against" - }, - "path": { - "type": "string", - "description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided." - } - }, - "required": [ - "pattern" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "GrepTool", - "description": "\n- Fast content search tool that works with any codebase size\n- Searches file contents using regular expressions\n- Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.)\n- Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\")\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files containing specific patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n", - "inputSchema": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "The regular expression pattern to search for in file contents" - }, - "path": { - "type": "string", - "description": "The directory to search in. Defaults to the current working directory." - }, - "include": { - "type": "string", - "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")" - } - }, - "required": [ - "pattern" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "LS", - "description": "Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.", - "inputSchema": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The absolute path to the directory to list (must be absolute, not relative)" - }, - "ignore": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of glob patterns to ignore" - } - }, - "required": [ - "path" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "View", - "description": "Read a file from the local filesystem.", - "inputSchema": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "The absolute path to the file to read" - }, - "offset": { - "type": "number", - "description": "The line number to start reading from. Only provide if the file is too large to read at once" - }, - "limit": { - "type": "number", - "description": "The number of lines to read. Only provide if the file is too large to read at once." - } - }, - "required": [ - "file_path" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "Edit", - "description": "A tool for editing files", - "inputSchema": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "The absolute path to the file to modify" - }, - "old_string": { - "type": "string", - "description": "The text to replace" - }, - "new_string": { - "type": "string", - "description": "The text to replace it with" - }, - "expected_replacements": { - "type": "number", - "default": 1, - "description": "The expected number of replacements to perform. Defaults to 1 if not specified." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "Replace", - "description": "Write a file to the local filesystem.", - "inputSchema": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "The absolute path to the file to write (must be absolute, not relative)" - }, - "content": { - "type": "string", - "description": "The content to write to the file" - } - }, - "required": [ - "file_path", - "content" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "ReadNotebook", - "description": "Extract and read source code from all code cells in a Jupyter notebook.", - "inputSchema": { - "type": "object", - "properties": { - "notebook_path": { - "type": "string", - "description": "The absolute path to the Jupyter notebook file to read (must be absolute, not relative)" - } - }, - "required": [ - "notebook_path" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "NotebookEditCell", - "description": "Replace the contents of a specific cell in a Jupyter notebook.", - "inputSchema": { - "type": "object", - "properties": { - "notebook_path": { - "type": "string", - "description": "The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)" - }, - "cell_number": { - "type": "number", - "description": "The index of the cell to edit (0-based)" - }, - "new_source": { - "type": "string", - "description": "The new source for the cell" - }, - "cell_type": { - "type": "string", - "enum": [ - "code", - "markdown" - ], - "description": "The type of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required." - }, - "edit_mode": { - "type": "string", - "description": "The type of edit to make (replace, insert, delete). Defaults to replace." - } - }, - "required": [ - "notebook_path", - "cell_number", - "new_source" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "WebFetchTool", - "description": "Claude wants to fetch content from this URL", - "inputSchema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "The URL to fetch content from" - }, - "prompt": { - "type": "string", - "description": "The prompt to run on the fetched content" - } - }, - "required": [ - "url", - "prompt" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - } - ] - } -} -``` diff --git a/crates/agent/src/edit_agent/evals/fixtures/zode/react.py b/crates/agent/src/edit_agent/evals/fixtures/zode/react.py deleted file mode 100644 index 03ff02e7891..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/zode/react.py +++ /dev/null @@ -1,14 +0,0 @@ -class InputCell: - def __init__(self, initial_value): - self.value = None - - -class ComputeCell: - def __init__(self, inputs, compute_function): - self.value = None - - def add_callback(self, callback): - pass - - def remove_callback(self, callback): - pass diff --git a/crates/agent/src/edit_agent/evals/fixtures/zode/react_test.py b/crates/agent/src/edit_agent/evals/fixtures/zode/react_test.py deleted file mode 100644 index 1f917e40b41..00000000000 --- a/crates/agent/src/edit_agent/evals/fixtures/zode/react_test.py +++ /dev/null @@ -1,271 +0,0 @@ -# These tests are auto-generated with test data from: -# https://github.com/exercism/problem-specifications/tree/main/exercises/react/canonical-data.json -# File last updated on 2023-07-19 - -from functools import partial -import unittest - -from react import ( - InputCell, - ComputeCell, -) - - -class ReactTest(unittest.TestCase): - def test_input_cells_have_a_value(self): - input = InputCell(10) - self.assertEqual(input.value, 10) - - def test_an_input_cell_s_value_can_be_set(self): - input = InputCell(4) - input.value = 20 - self.assertEqual(input.value, 20) - - def test_compute_cells_calculate_initial_value(self): - input = InputCell(1) - output = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] + 1, - ) - self.assertEqual(output.value, 2) - - def test_compute_cells_take_inputs_in_the_right_order(self): - one = InputCell(1) - two = InputCell(2) - output = ComputeCell( - [ - one, - two, - ], - lambda inputs: inputs[0] + inputs[1] * 10, - ) - self.assertEqual(output.value, 21) - - def test_compute_cells_update_value_when_dependencies_are_changed(self): - input = InputCell(1) - output = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] + 1, - ) - input.value = 3 - self.assertEqual(output.value, 4) - - def test_compute_cells_can_depend_on_other_compute_cells(self): - input = InputCell(1) - times_two = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] * 2, - ) - times_thirty = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] * 30, - ) - output = ComputeCell( - [ - times_two, - times_thirty, - ], - lambda inputs: inputs[0] + inputs[1], - ) - self.assertEqual(output.value, 32) - input.value = 3 - self.assertEqual(output.value, 96) - - def test_compute_cells_fire_callbacks(self): - input = InputCell(1) - output = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] + 1, - ) - cb1_observer = [] - callback1 = self.callback_factory(cb1_observer) - output.add_callback(callback1) - input.value = 3 - self.assertEqual(cb1_observer[-1], 4) - - def test_callback_cells_only_fire_on_change(self): - input = InputCell(1) - output = ComputeCell([input], lambda inputs: 111 if inputs[0] < 3 else 222) - cb1_observer = [] - callback1 = self.callback_factory(cb1_observer) - output.add_callback(callback1) - input.value = 2 - self.assertEqual(cb1_observer, []) - input.value = 4 - self.assertEqual(cb1_observer[-1], 222) - - def test_callbacks_do_not_report_already_reported_values(self): - input = InputCell(1) - output = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] + 1, - ) - cb1_observer = [] - callback1 = self.callback_factory(cb1_observer) - output.add_callback(callback1) - input.value = 2 - self.assertEqual(cb1_observer[-1], 3) - input.value = 3 - self.assertEqual(cb1_observer[-1], 4) - - def test_callbacks_can_fire_from_multiple_cells(self): - input = InputCell(1) - plus_one = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] + 1, - ) - minus_one = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] - 1, - ) - cb1_observer = [] - cb2_observer = [] - callback1 = self.callback_factory(cb1_observer) - callback2 = self.callback_factory(cb2_observer) - plus_one.add_callback(callback1) - minus_one.add_callback(callback2) - input.value = 10 - self.assertEqual(cb1_observer[-1], 11) - self.assertEqual(cb2_observer[-1], 9) - - def test_callbacks_can_be_added_and_removed(self): - input = InputCell(11) - output = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] + 1, - ) - cb1_observer = [] - cb2_observer = [] - cb3_observer = [] - callback1 = self.callback_factory(cb1_observer) - callback2 = self.callback_factory(cb2_observer) - callback3 = self.callback_factory(cb3_observer) - output.add_callback(callback1) - output.add_callback(callback2) - input.value = 31 - self.assertEqual(cb1_observer[-1], 32) - self.assertEqual(cb2_observer[-1], 32) - output.remove_callback(callback1) - output.add_callback(callback3) - input.value = 41 - self.assertEqual(len(cb1_observer), 1) - self.assertEqual(cb2_observer[-1], 42) - self.assertEqual(cb3_observer[-1], 42) - - def test_removing_a_callback_multiple_times_doesn_t_interfere_with_other_callbacks( - self, - ): - input = InputCell(1) - output = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] + 1, - ) - cb1_observer = [] - cb2_observer = [] - callback1 = self.callback_factory(cb1_observer) - callback2 = self.callback_factory(cb2_observer) - output.add_callback(callback1) - output.add_callback(callback2) - output.remove_callback(callback1) - output.remove_callback(callback1) - output.remove_callback(callback1) - input.value = 2 - self.assertEqual(cb1_observer, []) - self.assertEqual(cb2_observer[-1], 3) - - def test_callbacks_should_only_be_called_once_even_if_multiple_dependencies_change( - self, - ): - input = InputCell(1) - plus_one = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] + 1, - ) - minus_one1 = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] - 1, - ) - minus_one2 = ComputeCell( - [ - minus_one1, - ], - lambda inputs: inputs[0] - 1, - ) - output = ComputeCell( - [ - plus_one, - minus_one2, - ], - lambda inputs: inputs[0] * inputs[1], - ) - cb1_observer = [] - callback1 = self.callback_factory(cb1_observer) - output.add_callback(callback1) - input.value = 4 - self.assertEqual(cb1_observer[-1], 10) - - def test_callbacks_should_not_be_called_if_dependencies_change_but_output_value_doesn_t_change( - self, - ): - input = InputCell(1) - plus_one = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] + 1, - ) - minus_one = ComputeCell( - [ - input, - ], - lambda inputs: inputs[0] - 1, - ) - always_two = ComputeCell( - [ - plus_one, - minus_one, - ], - lambda inputs: inputs[0] - inputs[1], - ) - cb1_observer = [] - callback1 = self.callback_factory(cb1_observer) - always_two.add_callback(callback1) - input.value = 2 - self.assertEqual(cb1_observer, []) - input.value = 3 - self.assertEqual(cb1_observer, []) - input.value = 4 - self.assertEqual(cb1_observer, []) - input.value = 5 - self.assertEqual(cb1_observer, []) - - # Utility functions. - def callback_factory(self, observer): - def callback(observer, value): - observer.append(value) - - return partial(callback, observer) diff --git a/crates/agent/src/tests/edit_file_thread_test.rs b/crates/agent/src/tests/edit_file_thread_test.rs deleted file mode 100644 index 7e6d131c98f..00000000000 --- a/crates/agent/src/tests/edit_file_thread_test.rs +++ /dev/null @@ -1,407 +0,0 @@ -use super::*; -use crate::{AgentTool, EditFileTool, ReadFileTool}; -use acp_thread::UserMessageId; -use fs::FakeFs; -use language_model::{ - LanguageModelCompletionEvent, LanguageModelToolUse, StopReason, - fake_provider::FakeLanguageModel, -}; -use prompt_store::ProjectContext; -use serde_json::json; -use std::{sync::Arc, time::Duration}; -use util::path; - -#[gpui::test] -async fn test_edit_file_tool_in_thread_context(cx: &mut TestAppContext) { - // This test verifies that the edit_file tool works correctly when invoked - // through the full thread flow (model sends ToolUse event -> tool runs -> result sent back). - // This is different from tests that call tool.run() directly. - super::init_test(cx); - super::always_allow_tools(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "src": { - "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}\n" - } - }), - ) - .await; - - let project = project::Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let project_context = cx.new(|_cx| ProjectContext::default()); - let context_server_store = project.read_with(cx, |project, _| project.context_server_store()); - let context_server_registry = - cx.new(|cx| crate::ContextServerRegistry::new(context_server_store.clone(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let fake_model = model.as_fake(); - - let thread = cx.new(|cx| { - let mut thread = crate::Thread::new( - project.clone(), - project_context, - context_server_registry, - crate::Templates::new(), - Some(model.clone()), - cx, - ); - // Add just the tools we need for this test - let language_registry = project.read(cx).languages().clone(); - thread.add_tool(crate::ReadFileTool::new( - project.clone(), - thread.action_log().clone(), - true, - )); - thread.add_tool(crate::EditFileTool::new( - project.clone(), - cx.weak_entity(), - language_registry, - crate::Templates::new(), - )); - thread - }); - - // First, read the file so the thread knows about its contents - let _events = thread - .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Read the file src/main.rs"], cx) - }) - .unwrap(); - cx.run_until_parked(); - - // Model calls read_file tool - let read_tool_use = LanguageModelToolUse { - id: "read_tool_1".into(), - name: ReadFileTool::NAME.into(), - raw_input: json!({"path": "project/src/main.rs"}).to_string(), - input: json!({"path": "project/src/main.rs"}), - is_input_complete: true, - thought_signature: None, - }; - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(read_tool_use)); - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // Wait for the read tool to complete and model to be called again - while fake_model.pending_completions().is_empty() { - cx.run_until_parked(); - } - - // Model responds after seeing the file content, then calls edit_file - fake_model.send_last_completion_stream_text_chunk("I'll edit the file now."); - let edit_tool_use = LanguageModelToolUse { - id: "edit_tool_1".into(), - name: EditFileTool::NAME.into(), - raw_input: json!({ - "display_description": "Change greeting message", - "path": "project/src/main.rs", - "mode": "edit" - }) - .to_string(), - input: json!({ - "display_description": "Change greeting message", - "path": "project/src/main.rs", - "mode": "edit" - }), - is_input_complete: true, - thought_signature: None, - }; - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(edit_tool_use)); - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // The edit_file tool creates an EditAgent which makes its own model request. - // We need to respond to that request with the edit instructions. - // Wait for the edit agent's completion request - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while fake_model.pending_completions().is_empty() { - if std::time::Instant::now() >= deadline { - panic!( - "Timed out waiting for edit agent completion request. Pending: {}", - fake_model.pending_completions().len() - ); - } - cx.run_until_parked(); - cx.background_executor - .timer(Duration::from_millis(10)) - .await; - } - - // Send the edit agent's response with the XML format it expects - let edit_response = "println!(\"Hello, world!\");\nprintln!(\"Hello, Zed!\");"; - fake_model.send_last_completion_stream_text_chunk(edit_response); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // Wait for the edit to complete and the thread to call the model again with tool results - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while fake_model.pending_completions().is_empty() { - if std::time::Instant::now() >= deadline { - panic!("Timed out waiting for model to be called after edit completion"); - } - cx.run_until_parked(); - cx.background_executor - .timer(Duration::from_millis(10)) - .await; - } - - // Verify the file was edited - let file_content = fs - .load(path!("/project/src/main.rs").as_ref()) - .await - .expect("file should exist"); - assert!( - file_content.contains("Hello, Zed!"), - "File should have been edited. Content: {}", - file_content - ); - assert!( - !file_content.contains("Hello, world!"), - "Old content should be replaced. Content: {}", - file_content - ); - - // Verify the tool result was sent back to the model - let pending = fake_model.pending_completions(); - assert!( - !pending.is_empty(), - "Model should have been called with tool result" - ); - - let last_request = pending.last().unwrap(); - let has_tool_result = last_request.messages.iter().any(|m| { - m.content - .iter() - .any(|c| matches!(c, language_model::MessageContent::ToolResult(_))) - }); - assert!( - has_tool_result, - "Tool result should be in the messages sent back to the model" - ); - - // Complete the turn - fake_model.send_last_completion_stream_text_chunk("I've updated the greeting message."); - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // Verify the thread completed successfully - thread.update(cx, |thread, _cx| { - assert!( - thread.is_turn_complete(), - "Thread should be complete after the turn ends" - ); - }); -} - -#[gpui::test] -async fn test_streaming_edit_json_parse_error_does_not_cause_unsaved_changes( - cx: &mut TestAppContext, -) { - super::init_test(cx); - super::always_allow_tools(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "src": { - "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}\n" - } - }), - ) - .await; - - let project = project::Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let project_context = cx.new(|_cx| ProjectContext::default()); - let context_server_store = project.read_with(cx, |project, _| project.context_server_store()); - let context_server_registry = - cx.new(|cx| crate::ContextServerRegistry::new(context_server_store.clone(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - model.as_fake().set_supports_streaming_tools(true); - let fake_model = model.as_fake(); - - let thread = cx.new(|cx| { - let mut thread = crate::Thread::new( - project.clone(), - project_context, - context_server_registry, - crate::Templates::new(), - Some(model.clone()), - cx, - ); - let language_registry = project.read(cx).languages().clone(); - thread.add_tool(crate::StreamingEditFileTool::new( - project.clone(), - cx.weak_entity(), - thread.action_log().clone(), - language_registry, - )); - thread - }); - - let _events = thread - .update(cx, |thread, cx| { - thread.send( - UserMessageId::new(), - ["Write new content to src/main.rs"], - cx, - ) - }) - .unwrap(); - cx.run_until_parked(); - - let tool_use_id = "edit_1"; - let partial_1 = LanguageModelToolUse { - id: tool_use_id.into(), - name: EditFileTool::NAME.into(), - raw_input: json!({ - "display_description": "Rewrite main.rs", - "path": "project/src/main.rs", - "mode": "write" - }) - .to_string(), - input: json!({ - "display_description": "Rewrite main.rs", - "path": "project/src/main.rs", - "mode": "write" - }), - is_input_complete: false, - thought_signature: None, - }; - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(partial_1)); - cx.run_until_parked(); - - let partial_2 = LanguageModelToolUse { - id: tool_use_id.into(), - name: EditFileTool::NAME.into(), - raw_input: json!({ - "display_description": "Rewrite main.rs", - "path": "project/src/main.rs", - "mode": "write", - "content": "fn main() { /* rewritten */ }" - }) - .to_string(), - input: json!({ - "display_description": "Rewrite main.rs", - "path": "project/src/main.rs", - "mode": "write", - "content": "fn main() { /* rewritten */ }" - }), - is_input_complete: false, - thought_signature: None, - }; - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(partial_2)); - cx.run_until_parked(); - - // Now send a json parse error. At this point we have started writing content to the buffer. - fake_model.send_last_completion_stream_event( - LanguageModelCompletionEvent::ToolUseJsonParseError { - id: tool_use_id.into(), - tool_name: EditFileTool::NAME.into(), - raw_input: r#"{"display_description":"Rewrite main.rs","path":"project/src/main.rs","mode":"write","content":"fn main() { /* rewritten "#.into(), - json_parse_error: "EOF while parsing a string at line 1 column 95".into(), - }, - ); - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // cx.executor().advance_clock(Duration::from_secs(5)); - // cx.run_until_parked(); - - assert!( - !fake_model.pending_completions().is_empty(), - "Thread should have retried after the error" - ); - - // Respond with a new, well-formed, complete edit_file tool use. - let tool_use = LanguageModelToolUse { - id: "edit_2".into(), - name: EditFileTool::NAME.into(), - raw_input: json!({ - "display_description": "Rewrite main.rs", - "path": "project/src/main.rs", - "mode": "write", - "content": "fn main() {\n println!(\"Hello, rewritten!\");\n}\n" - }) - .to_string(), - input: json!({ - "display_description": "Rewrite main.rs", - "path": "project/src/main.rs", - "mode": "write", - "content": "fn main() {\n println!(\"Hello, rewritten!\");\n}\n" - }), - is_input_complete: true, - thought_signature: None, - }; - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use)); - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - let pending_completions = fake_model.pending_completions(); - assert!( - pending_completions.len() == 1, - "Expected only the follow-up completion containing the successful tool result" - ); - - let completion = pending_completions - .into_iter() - .last() - .expect("Expected a completion containing the tool result for edit_2"); - - let tool_result = completion - .messages - .iter() - .flat_map(|msg| &msg.content) - .find_map(|content| match content { - language_model::MessageContent::ToolResult(result) - if result.tool_use_id == language_model::LanguageModelToolUseId::from("edit_2") => - { - Some(result) - } - _ => None, - }) - .expect("Should have a tool result for edit_2"); - - // Ensure that the second tool call completed successfully and edits were applied. - assert!( - !tool_result.is_error, - "Tool result should succeed, got: {:?}", - tool_result - ); - let content_text = tool_result.text_contents(); - assert!( - !content_text.contains("file has been modified since you last read it"), - "Did not expect a stale last-read error, got: {content_text}" - ); - assert!( - !content_text.contains("This file has unsaved changes"), - "Did not expect an unsaved-changes error, got: {content_text}" - ); - - let file_content = fs - .load(path!("/project/src/main.rs").as_ref()) - .await - .expect("file should exist"); - super::assert_eq!( - file_content, - "fn main() {\n println!(\"Hello, rewritten!\");\n}\n", - "The second edit should be applied and saved gracefully" - ); - - fake_model.end_last_completion_stream(); - cx.run_until_parked(); -} diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index d9f451f135d..513ffce0fa9 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -53,7 +53,6 @@ use std::{ }; use util::path; -mod edit_file_thread_test; mod test_tools; use test_tools::*; @@ -6054,13 +6053,14 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) { cx, ) }); + let action_log = cx.update(|cx| thread.read(cx).action_log.clone()); #[allow(clippy::arc_with_non_send_sync)] let tool = Arc::new(crate::EditFileTool::new( project.clone(), thread.downgrade(), + action_log, language_registry, - templates, )); let (event_stream, _rx) = crate::ToolCallEventStream::test(); @@ -6070,6 +6070,8 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) { display_description: "Edit sensitive file".to_string(), path: "root/sensitive_config.txt".into(), mode: crate::EditFileMode::Edit, + content: None, + edits: Some(vec![]), }), event_stream, cx, @@ -6486,13 +6488,14 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte cx, ) }); + let action_log = thread.read_with(cx, |thread, _cx| thread.action_log().clone()); #[allow(clippy::arc_with_non_send_sync)] let tool = Arc::new(crate::EditFileTool::new( project, thread.downgrade(), + action_log, language_registry, - templates, )); let (event_stream, mut rx) = crate::ToolCallEventStream::test(); @@ -6502,6 +6505,8 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte display_description: "Edit README".to_string(), path: "root/README.md".into(), mode: crate::EditFileMode::Edit, + content: None, + edits: Some(vec![]), }), event_stream, cx, @@ -6554,13 +6559,14 @@ async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut Tes cx, ) }); + let action_log = thread.read_with(cx, |thread, _cx| thread.action_log().clone()); #[allow(clippy::arc_with_non_send_sync)] let tool = Arc::new(crate::EditFileTool::new( project, thread.downgrade(), + action_log, language_registry, - templates, )); // Editing a file inside .zed/ should still prompt even with global default: allow, @@ -6572,6 +6578,8 @@ async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut Tes display_description: "Edit local settings".to_string(), path: "root/.zed/settings.json".into(), mode: crate::EditFileMode::Edit, + content: None, + edits: Some(vec![]), }), event_stream, cx, diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 7b3eab5d03f..308bc843b1a 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2,9 +2,9 @@ use crate::{ ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, - RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, StreamingEditFileTool, - SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, - UpdatePlanTool, WebSearchTool, decide_permission_from_settings, + RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template, + Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool, + decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; @@ -1544,12 +1544,6 @@ impl Thread { )); self.add_tool(DiagnosticsTool::new(self.project.clone())); self.add_tool(EditFileTool::new( - self.project.clone(), - cx.weak_entity(), - language_registry.clone(), - Templates::new(), - )); - self.add_tool(StreamingEditFileTool::new( self.project.clone(), cx.weak_entity(), self.action_log.clone(), @@ -2865,30 +2859,14 @@ impl Thread { } } - let use_streaming_edit_tool = model.supports_streaming_tools(); - let mut tools = self .tools .iter() .filter_map(|(tool_name, tool)| { - // For streaming_edit_file, check profile against "edit_file" since that's what users configure - let profile_tool_name = if tool_name == StreamingEditFileTool::NAME { - EditFileTool::NAME - } else { - tool_name.as_ref() - }; - if tool.supports_provider(&model.provider_id()) - && profile.is_tool_enabled(profile_tool_name) + && profile.is_tool_enabled(tool_name) { - match (tool_name.as_ref(), use_streaming_edit_tool) { - (StreamingEditFileTool::NAME, false) | (EditFileTool::NAME, true) => None, - (StreamingEditFileTool::NAME, true) => { - // Expose streaming tool as "edit_file" - Some((SharedString::from(EditFileTool::NAME), tool.clone())) - } - _ => Some((truncate(tool_name), tool.clone())), - } + Some((truncate(tool_name), tool.clone())) } else { None } diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index 2d3638265f7..48ea6f9e6a6 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -558,9 +558,9 @@ pub fn most_restrictive( #[cfg(test)] mod tests { use super::*; - use crate::AgentTool; use crate::pattern_extraction::extract_terminal_pattern; - use crate::tools::{DeletePathTool, EditFileTool, FetchTool, TerminalTool}; + use crate::tools::{DeletePathTool, FetchTool, TerminalTool}; + use crate::{AgentTool, EditFileTool}; use agent_settings::{AgentProfileId, CompiledRegex, InvalidRegexPattern, ToolRules}; use gpui::px; use settings::{DockPosition, NotifyWhenAgentWaiting, PlaySoundWhenAgentDone}; diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index f3a6ac7ec6d..e9596d038fa 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -17,7 +17,6 @@ mod read_file_tool; mod restore_file_from_disk_tool; mod save_file_tool; mod spawn_agent_tool; -mod streaming_edit_file_tool; mod terminal_tool; mod tool_edit_parser; mod tool_permissions; @@ -44,7 +43,6 @@ pub use read_file_tool::*; pub use restore_file_from_disk_tool::*; pub use save_file_tool::*; pub use spawn_agent_tool::*; -pub use streaming_edit_file_tool::*; pub use terminal_tool::*; pub use tool_permissions::*; pub use update_plan_tool::*; diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 85c17c58e8f..9d5f7953fff 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -1,29 +1,40 @@ +mod reindent; +mod streaming_fuzzy_matcher; + use super::restore_file_from_disk_tool::RestoreFileFromDiskTool; use super::save_file_tool::SaveFileTool; -use super::tool_permissions::authorize_file_edit; -use crate::{ - AgentTool, Templates, Thread, ToolCallEventStream, ToolInput, - edit_agent::{EditAgent, EditAgentOutputEvent, EditFormat}, +use super::tool_edit_parser::{ToolEditEvent, ToolEditParser}; +use crate::ToolInputPayload; +use crate::tools::edit_file_tool::{ + reindent::{Reindenter, compute_indent_delta}, + streaming_fuzzy_matcher::StreamingFuzzyMatcher, }; +use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput}; use acp_thread::Diff; -use agent_client_protocol::schema as acp; -use anyhow::{Context as _, Result}; +use action_log::ActionLog; +use agent_client_protocol::schema::{self as acp, ToolCallLocation, ToolCallUpdateFields}; +use anyhow::Result; use collections::HashSet; -use futures::{FutureExt as _, StreamExt as _}; +use futures::FutureExt as _; use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; -use indoc::formatdoc; use language::language_settings::{self, FormatOnSave}; -use language::{LanguageRegistry, ToPoint}; -use language_model::{CompletionIntent, LanguageModelToolResultContent}; +use language::{Buffer, LanguageRegistry}; +use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; -use project::{Project, ProjectPath}; +use project::{AgentLocation, Project, ProjectPath}; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{ + Deserialize, Deserializer, Serialize, + de::{DeserializeOwned, Error as _}, +}; +use std::ops::Range; use std::path::PathBuf; use std::sync::Arc; +use streaming_diff::{CharOperation, StreamingDiff}; +use text::ToOffset; use ui::SharedString; -use util::ResultExt; use util::rel_path::RelPath; +use util::{Deferred, ResultExt}; const DEFAULT_UI_TEXT: &str = "Editing file"; @@ -37,7 +48,7 @@ const DEFAULT_UI_TEXT: &str = "Editing file"; /// - Use the `list_directory` tool to verify the parent directory exists and is the correct location #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct EditFileToolInput { - /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI and also passed to another model to perform the edit. + /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI. /// /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions. /// @@ -67,30 +78,96 @@ pub struct EditFileToolInput { /// `frontend/db.js` /// pub path: PathBuf, + /// The mode of operation on the file. Possible values: - /// - 'edit': Make granular edits to an existing file. - /// - 'create': Create a new file if it doesn't exist. - /// - 'overwrite': Replace the entire contents of an existing file. + /// - 'write': Replace the entire contents of the file. If the file doesn't exist, it will be created. Requires 'content' field. + /// - 'edit': Make granular edits to an existing file. Requires 'edits' field. /// /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch. + #[serde(deserialize_with = "deserialize_maybe_stringified")] pub mode: EditFileMode, + + /// The complete content for the new file (required for 'write' mode). + /// This field should contain the entire file content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + + /// List of edit operations to apply sequentially (required for 'edit' mode). + /// Each edit finds `old_text` in the file and replaces it with `new_text`. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_maybe_stringified" + )] + pub edits: Option>, } +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum EditFileMode { + /// Overwrite the file with new content (replacing any existing content). + /// If the file does not exist, it will be created. + Write, + /// Make granular edits to an existing file + Edit, +} + +/// A single edit operation that replaces old text with new text +/// Properly escape all text fields as valid JSON strings. +/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct Edit { + /// The exact text to find in the file. This will be matched using fuzzy matching + /// to handle minor differences in whitespace or formatting. + /// + /// Be minimal with replacements: + /// - For unique lines, include only those lines + /// - For non-unique lines, include enough context to identify them + pub old_text: String, + /// The text to replace it with + pub new_text: String, +} + +#[derive(Clone, Default, Debug, Deserialize)] struct EditFileToolPartialInput { #[serde(default)] - path: String, + display_description: Option, #[serde(default)] - display_description: String, + path: Option, + #[serde(default, deserialize_with = "deserialize_maybe_stringified")] + mode: Option, + #[serde(default)] + content: Option, + #[serde(default, deserialize_with = "deserialize_maybe_stringified")] + edits: Option>, } -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -#[schemars(inline)] -pub enum EditFileMode { - Edit, - Create, - Overwrite, +#[derive(Clone, Default, Debug, Deserialize)] +pub struct PartialEdit { + #[serde(default)] + pub old_text: Option, + #[serde(default)] + pub new_text: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum ValueOrJsonString { + Value(T), + String(String), +} + +fn deserialize_maybe_stringified<'de, T, D>(deserializer: D) -> Result +where + T: DeserializeOwned, + D: Deserializer<'de>, +{ + match ValueOrJsonString::::deserialize(deserializer)? { + ValueOrJsonString::Value(value) => Ok(value), + ValueOrJsonString::String(string) => serde_json::from_str::(&string).map_err(|error| { + D::Error::custom(format!("failed to parse stringified value: {error}")) + }), + } } #[derive(Debug, Serialize, Deserialize)] @@ -106,9 +183,23 @@ pub enum EditFileToolOutput { }, Error { error: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + input_path: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + diff: String, }, } +impl EditFileToolOutput { + pub fn error(error: impl Into) -> Self { + Self::Error { + error: error.into(), + input_path: None, + diff: String::new(), + } + } +} + impl std::fmt::Display for EditFileToolOutput { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -125,7 +216,24 @@ impl std::fmt::Display for EditFileToolOutput { ) } } - EditFileToolOutput::Error { error } => write!(f, "{error}"), + EditFileToolOutput::Error { + error, + diff, + input_path, + } => { + write!(f, "{error}\n")?; + if let Some(input_path) = input_path + && !diff.is_empty() + { + write!( + f, + "Edited {}:\n\n```diff\n{diff}\n```", + input_path.display() + ) + } else { + write!(f, "No edits were made.") + } + } } } } @@ -137,42 +245,215 @@ impl From for LanguageModelToolResultContent { } pub struct EditFileTool { - thread: WeakEntity, - language_registry: Arc, project: Entity, - templates: Arc, + thread: WeakEntity, + action_log: Entity, + language_registry: Arc, +} + +enum EditSessionResult { + Completed(EditSession), + Failed { + error: String, + session: Option, + }, } impl EditFileTool { pub fn new( project: Entity, thread: WeakEntity, + action_log: Entity, language_registry: Arc, - templates: Arc, ) -> Self { Self { project, thread, + action_log, language_registry, - templates, } } fn authorize( &self, - input: &EditFileToolInput, + path: &PathBuf, + description: &str, event_stream: &ToolCallEventStream, cx: &mut App, ) -> Task> { - authorize_file_edit( - Self::NAME, - &input.path, - &input.display_description, + super::tool_permissions::authorize_file_edit( + EditFileTool::NAME, + path, + description, &self.thread, event_stream, cx, ) } + + fn set_agent_location(&self, buffer: WeakEntity, position: text::Anchor, cx: &mut App) { + let should_update_agent_location = self + .thread + .read_with(cx, |thread, _cx| !thread.is_subagent()) + .unwrap_or_default(); + if should_update_agent_location { + self.project.update(cx, |project, cx| { + project.set_agent_location(Some(AgentLocation { buffer, position }), cx); + }); + } + } + + async fn ensure_buffer_saved(&self, buffer: &Entity, cx: &mut AsyncApp) { + let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { + let settings = language_settings::LanguageSettings::for_buffer(buffer, cx); + settings.format_on_save != FormatOnSave::Off + }); + + if format_on_save_enabled { + self.project + .update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + LspFormatTarget::Buffers, + false, + FormatTrigger::Save, + cx, + ) + }) + .await + .log_err(); + } + + self.project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .log_err(); + + self.action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + }); + } + + async fn process_streaming_edits( + &self, + input: &mut ToolInput, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> EditSessionResult { + let mut session: Option = None; + let mut last_partial: Option = None; + + loop { + futures::select! { + payload = input.next().fuse() => { + match payload { + Ok(payload) => match payload { + ToolInputPayload::Partial(partial) => { + if let Ok(parsed) = serde_json::from_value::(partial) { + let path_complete = parsed.path.is_some() + && parsed.path.as_ref() == last_partial.as_ref().and_then(|partial| partial.path.as_ref()); + + last_partial = Some(parsed.clone()); + + if session.is_none() + && path_complete + && let EditFileToolPartialInput { + path: Some(path), + display_description: Some(display_description), + mode: Some(mode), + .. + } = &parsed + { + match EditSession::new( + PathBuf::from(path), + display_description, + *mode, + self, + event_stream, + cx, + ) + .await + { + Ok(created_session) => session = Some(created_session), + Err(error) => { + log::error!("Failed to create edit session: {}", error); + return EditSessionResult::Failed { + error, + session: None, + }; + } + } + } + + if let Some(current_session) = &mut session + && let Err(error) = current_session.process(parsed, self, event_stream, cx) + { + log::error!("Failed to process edit: {}", error); + return EditSessionResult::Failed { error, session }; + } + } + } + ToolInputPayload::Full(full_input) => { + let mut session = if let Some(session) = session { + session + } else { + match EditSession::new( + full_input.path.clone(), + &full_input.display_description, + full_input.mode, + self, + event_stream, + cx, + ) + .await + { + Ok(created_session) => created_session, + Err(error) => { + log::error!("Failed to create edit session: {}", error); + return EditSessionResult::Failed { + error, + session: None, + }; + } + } + }; + + return match session.finalize(full_input, self, event_stream, cx).await { + Ok(()) => EditSessionResult::Completed(session), + Err(error) => { + log::error!("Failed to finalize edit: {}", error); + EditSessionResult::Failed { + error, + session: Some(session), + } + } + }; + } + ToolInputPayload::InvalidJson { error_message } => { + log::error!("Received invalid JSON: {error_message}"); + return EditSessionResult::Failed { + error: error_message, + session, + }; + } + }, + Err(error) => { + return EditSessionResult::Failed { + error: format!("Failed to receive tool input: {error}"), + session, + }; + } + } + } + _ = event_stream.cancelled_by_user().fuse() => { + return EditSessionResult::Failed { + error: "Edit cancelled by user".to_string(), + session, + }; + } + } + } + } } impl AgentTool for EditFileTool { @@ -181,6 +462,10 @@ impl AgentTool for EditFileTool { const NAME: &'static str = "edit_file"; + fn supports_input_streaming() -> bool { + true + } + fn kind() -> acp::ToolKind { acp::ToolKind::Edit } @@ -203,25 +488,25 @@ impl AgentTool for EditFileTool { .unwrap_or(input.path.to_string_lossy().into_owned()) .into(), Err(raw_input) => { - if let Some(input) = - serde_json::from_value::(raw_input).ok() - { - let path = input.path.trim(); + if let Ok(input) = serde_json::from_value::(raw_input) { + let path = input.path.unwrap_or_default(); + let path = path.trim(); if !path.is_empty() { return self .project .read(cx) - .find_project_path(&input.path, cx) + .find_project_path(&path, cx) .and_then(|project_path| { self.project .read(cx) .short_full_path_for_project_path(&project_path, cx) }) - .unwrap_or(input.path) + .unwrap_or_else(|| path.to_string()) .into(); } - let description = input.display_description.trim(); + let description = input.display_description.unwrap_or_default(); + let description = description.trim(); if !description.is_empty() { return description.to_string().into(); } @@ -234,275 +519,46 @@ impl AgentTool for EditFileTool { fn run( self: Arc, - input: ToolInput, + mut input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { cx.spawn(async move |cx: &mut AsyncApp| { - let input = input.recv().await.map_err(|e| EditFileToolOutput::Error { - error: format!("Failed to receive tool input: {e}"), - })?; - - let project = self - .thread - .read_with(cx, |thread, _cx| thread.project().clone()) - .map_err(|_| EditFileToolOutput::Error { - error: "thread was dropped".to_string(), - })?; - - let (project_path, abs_path, allow_thinking, update_agent_location, authorize) = - cx.update(|cx| { - let project_path = resolve_path(&input, project.clone(), cx).map_err(|err| { - EditFileToolOutput::Error { - error: err.to_string(), - } - })?; - let abs_path = project.read(cx).absolute_path(&project_path, cx); - if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields( - acp::ToolCallUpdateFields::new() - .locations(vec![acp::ToolCallLocation::new(abs_path)]), - ); - } - let allow_thinking = self - .thread - .read_with(cx, |thread, _cx| thread.thinking_enabled()) - .unwrap_or(true); - - let update_agent_location = self.thread.read_with(cx, |thread, _cx| !thread.is_subagent()).unwrap_or_default(); - - let authorize = self.authorize(&input, &event_stream, cx); - Ok::<_, EditFileToolOutput>((project_path, abs_path, allow_thinking, update_agent_location, authorize)) - })?; - - let result: anyhow::Result = async { - authorize.await?; - - let (request, model, action_log) = self.thread.update(cx, |thread, cx| { - let request = thread.build_completion_request(CompletionIntent::ToolResults, cx); - (request, thread.model().cloned(), thread.action_log().clone()) - })?; - let request = request?; - let model = model.context("No language model configured")?; - - let edit_format = EditFormat::from_model(model.clone())?; - let edit_agent = EditAgent::new( - model, - project.clone(), - action_log.clone(), - self.templates.clone(), - edit_format, - allow_thinking, - update_agent_location, - ); - - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(project_path.clone(), cx) + match self + .process_streaming_edits(&mut input, &event_stream, cx) + .await + { + EditSessionResult::Completed(session) => { + self.ensure_buffer_saved(&session.buffer, cx).await; + let (new_text, diff) = session.compute_new_text_and_diff(cx).await; + Ok(EditFileToolOutput::Success { + old_text: session.old_text.clone(), + new_text, + input_path: session.input_path, + diff, }) - .await?; - - // Check if the file has been modified since the agent last read it - if let Some(abs_path) = abs_path.as_ref() { - let last_read_mtime = action_log.read_with(cx, |log, _| log.file_read_time(abs_path)); - let (current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.read_with(cx, |thread, cx| { - let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime()); - let dirty = buffer.read(cx).is_dirty(); - let has_save = thread.has_tool(SaveFileTool::NAME); - let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME); - (current, dirty, has_save, has_restore) - })?; - - // Check for unsaved changes first - these indicate modifications we don't know about - if is_dirty { - let message = match (has_save_tool, has_restore_tool) { - (true, true) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ - If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." - } - (true, false) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ - If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed." - } - (false, true) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \ - If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." - } - (false, false) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ - then ask them to save or revert the file manually and inform you when it's ok to proceed." - } - }; - anyhow::bail!("{}", message); - } - - // Check if the file was modified on disk since we last read it - if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) { - // MTime can be unreliable for comparisons, so our newtype intentionally - // doesn't support comparing them. If the mtime at all different - // (which could be because of a modification or because e.g. system clock changed), - // we pessimistically assume it was modified. - if current != last_read { - anyhow::bail!( - "The file {} has been modified since you last read it. \ - Please read the file again to get the current state before editing it.", - input.path.display() - ); - } - } } - - let diff = cx.new(|cx| Diff::new(buffer.clone(), cx)); - event_stream.update_diff(diff.clone()); - let _finalize_diff = util::defer({ - let diff = diff.downgrade(); - let mut cx = cx.clone(); - move || { - diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); - } - }); - - let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let old_text = cx - .background_spawn({ - let old_snapshot = old_snapshot.clone(); - async move { Arc::new(old_snapshot.text()) } + EditSessionResult::Failed { + error, + session: Some(session), + } => { + self.ensure_buffer_saved(&session.buffer, cx).await; + let (_new_text, diff) = session.compute_new_text_and_diff(cx).await; + Err(EditFileToolOutput::Error { + error, + input_path: Some(session.input_path), + diff, }) - .await; - - let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) { - edit_agent.edit( - buffer.clone(), - input.display_description.clone(), - &request, - cx, - ) - } else { - edit_agent.overwrite( - buffer.clone(), - input.display_description.clone(), - &request, - cx, - ) - }; - - let mut hallucinated_old_text = false; - let mut ambiguous_ranges = Vec::new(); - let mut emitted_location = false; - loop { - let event = futures::select! { - event = events.next().fuse() => match event { - Some(event) => event, - None => break, - }, - _ = event_stream.cancelled_by_user().fuse() => { - anyhow::bail!("Edit cancelled by user"); - } - }; - match event { - EditAgentOutputEvent::Edited(range) => { - if !emitted_location { - let line = Some(buffer.update(cx, |buffer, _cx| { - range.start.to_point(&buffer.snapshot()).row - })); - if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields(acp::ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path).line(line)])); - } - emitted_location = true; - } - }, - EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true, - EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges, - EditAgentOutputEvent::ResolvingEditRange(range) => { - diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx)); - } - } } - - output.await?; - - let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { - let settings = language_settings::LanguageSettings::for_buffer(buffer, cx); - settings.format_on_save != FormatOnSave::Off - }); - - if format_on_save_enabled { - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - }); - - let format_task = project.update(cx, |project, cx| { - project.format( - HashSet::from_iter([buffer.clone()]), - LspFormatTarget::Buffers, - false, // Don't push to history since the tool did it. - FormatTrigger::Save, - cx, - ) - }); - format_task.await.log_err(); - } - - project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .await?; - - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - }); - - let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let (new_text, unified_diff) = cx - .background_spawn({ - let new_snapshot = new_snapshot.clone(); - let old_text = old_text.clone(); - async move { - let new_text = new_snapshot.text(); - let diff = language::unified_diff(&old_text, &new_text); - (new_text, diff) - } - }) - .await; - - let input_path = input.path.display(); - if unified_diff.is_empty() { - anyhow::ensure!( - !hallucinated_old_text, - formatdoc! {" - Some edits were produced but none of them could be applied. - Read the relevant sections of {input_path} again so that - I can perform the requested edits. - "} - ); - anyhow::ensure!( - ambiguous_ranges.is_empty(), - { - let line_numbers = ambiguous_ranges - .iter() - .map(|range| range.start.to_string()) - .collect::>() - .join(", "); - formatdoc! {" - matches more than one position in the file (lines: {line_numbers}). Read the - relevant sections of {input_path} again and extend so - that I can perform the requested edits. - "} - } - ); - } - - anyhow::Ok(EditFileToolOutput::Success { - input_path: input.path, - new_text, - old_text, - diff: unified_diff, - }) - }.await; - result - .map_err(|e| EditFileToolOutput::Error { error: e.to_string() }) + EditSessionResult::Failed { + error, + session: None, + } => Err(EditFileToolOutput::Error { + error, + input_path: None, + diff: String::new(), + }), + } }) } @@ -536,69 +592,641 @@ impl AgentTool for EditFileTool { } } -/// Validate that the file path is valid, meaning: -/// -/// - For `edit` and `overwrite`, the path must point to an existing file. -/// - For `create`, the file must not already exist, but it's parent dir must exist. +pub struct EditSession { + abs_path: PathBuf, + input_path: PathBuf, + buffer: Entity, + old_text: Arc, + diff: Entity, + mode: EditFileMode, + parser: ToolEditParser, + pipeline: EditPipeline, + file_changed_since_last_read: bool, + _finalize_diff_guard: Deferred>, +} + +struct EditPipeline { + current_edit: Option, + content_written: bool, +} + +enum EditPipelineEntry { + ResolvingOldText { + matcher: StreamingFuzzyMatcher, + }, + StreamingNewText { + streaming_diff: StreamingDiff, + edit_cursor: usize, + reindenter: Reindenter, + original_snapshot: text::BufferSnapshot, + }, +} + +impl EditPipeline { + fn new() -> Self { + Self { + current_edit: None, + content_written: false, + } + } + + fn ensure_resolving_old_text(&mut self, buffer: &Entity, cx: &mut AsyncApp) { + if self.current_edit.is_none() { + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); + self.current_edit = Some(EditPipelineEntry::ResolvingOldText { + matcher: StreamingFuzzyMatcher::new(snapshot), + }); + } + } +} + +impl EditSession { + async fn new( + path: PathBuf, + display_description: &str, + mode: EditFileMode, + tool: &EditFileTool, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result { + let project_path = cx.update(|cx| resolve_path(mode, &path, &tool.project, cx))?; + + let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx)) + else { + return Err(format!( + "Worktree at '{}' does not exist", + path.to_string_lossy() + )); + }; + + event_stream.update_fields( + ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]), + ); + + cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx)) + .await + .map_err(|e| e.to_string())?; + + let buffer = tool + .project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .map_err(|e| e.to_string())?; + + let file_changed_since_last_read = ensure_buffer_saved(&buffer, &abs_path, tool, cx)?; + + let diff = cx.new(|cx| Diff::new(buffer.clone(), cx)); + event_stream.update_diff(diff.clone()); + let finalize_diff_guard = util::defer(Box::new({ + let diff = diff.downgrade(); + let mut cx = cx.clone(); + move || { + diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); + } + }) as Box); + + tool.action_log.update(cx, |log, cx| match mode { + EditFileMode::Write => log.buffer_created(buffer.clone(), cx), + EditFileMode::Edit => log.buffer_read(buffer.clone(), cx), + }); + + let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let old_text = cx + .background_spawn({ + let old_snapshot = old_snapshot.clone(); + async move { Arc::new(old_snapshot.text()) } + }) + .await; + + Ok(Self { + abs_path, + input_path: path, + buffer, + old_text, + diff, + mode, + parser: ToolEditParser::default(), + pipeline: EditPipeline::new(), + file_changed_since_last_read, + _finalize_diff_guard: finalize_diff_guard, + }) + } + + async fn finalize( + &mut self, + input: EditFileToolInput, + tool: &EditFileTool, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result<(), String> { + match input.mode { + EditFileMode::Write => { + let content = input + .content + .ok_or_else(|| "'content' field is required for write mode".to_string())?; + + let events = self.parser.finalize_content(&content); + self.process_events(&events, tool, event_stream, cx)?; + } + EditFileMode::Edit => { + let edits = input + .edits + .ok_or_else(|| "'edits' field is required for edit mode".to_string())?; + let events = self.parser.finalize_edits(&edits); + self.process_events(&events, tool, event_stream, cx)?; + + if log::log_enabled!(log::Level::Debug) { + log::debug!("Got edits:"); + for edit in &edits { + log::debug!( + " old_text: '{}', new_text: '{}'", + edit.old_text.replace('\n', "\\n"), + edit.new_text.replace('\n', "\\n") + ); + } + } + } + } + Ok(()) + } + + async fn compute_new_text_and_diff(&self, cx: &mut AsyncApp) -> (String, String) { + let new_snapshot = self.buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let (new_text, unified_diff) = cx + .background_spawn({ + let new_snapshot = new_snapshot.clone(); + let old_text = self.old_text.clone(); + async move { + let new_text = new_snapshot.text(); + let diff = language::unified_diff(&old_text, &new_text); + (new_text, diff) + } + }) + .await; + (new_text, unified_diff) + } + + fn process( + &mut self, + partial: EditFileToolPartialInput, + tool: &EditFileTool, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result<(), String> { + match &self.mode { + EditFileMode::Write => { + if let Some(content) = &partial.content { + let events = self.parser.push_content(content); + self.process_events(&events, tool, event_stream, cx)?; + } + } + EditFileMode::Edit => { + if let Some(edits) = partial.edits { + let events = self.parser.push_edits(&edits); + self.process_events(&events, tool, event_stream, cx)?; + } + } + } + Ok(()) + } + + fn process_events( + &mut self, + events: &[ToolEditEvent], + tool: &EditFileTool, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result<(), String> { + for event in events { + match event { + ToolEditEvent::ContentChunk { chunk } => { + let (buffer_id, buffer_len) = self + .buffer + .read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len())); + let edit_range = if self.pipeline.content_written { + buffer_len..buffer_len + } else { + 0..buffer_len + }; + + agent_edit_buffer( + &self.buffer, + [(edit_range, chunk.as_str())], + &tool.action_log, + cx, + ); + cx.update(|cx| { + tool.set_agent_location( + self.buffer.downgrade(), + text::Anchor::max_for_buffer(buffer_id), + cx, + ); + }); + self.pipeline.content_written = true; + } + + ToolEditEvent::OldTextChunk { + chunk, done: false, .. + } => { + log::debug!("old_text_chunk: done=false, chunk='{}'", chunk); + self.pipeline.ensure_resolving_old_text(&self.buffer, cx); + + if let Some(EditPipelineEntry::ResolvingOldText { matcher }) = + &mut self.pipeline.current_edit + && !chunk.is_empty() + { + if let Some(match_range) = matcher.push(chunk, None) { + let anchor_range = self.buffer.read_with(cx, |buffer, _cx| { + buffer.anchor_range_outside(match_range.clone()) + }); + self.diff + .update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); + + cx.update(|cx| { + let position = self.buffer.read(cx).anchor_before(match_range.end); + tool.set_agent_location(self.buffer.downgrade(), position, cx); + }); + } + } + } + + ToolEditEvent::OldTextChunk { + edit_index, + chunk, + done: true, + } => { + log::debug!("old_text_chunk: done=true, chunk='{}'", chunk); + + self.pipeline.ensure_resolving_old_text(&self.buffer, cx); + + let Some(EditPipelineEntry::ResolvingOldText { matcher }) = + &mut self.pipeline.current_edit + else { + continue; + }; + + if !chunk.is_empty() { + matcher.push(chunk, None); + } + let range = extract_match( + matcher.finish(), + &self.buffer, + edit_index, + self.file_changed_since_last_read, + cx, + )?; + + let anchor_range = self + .buffer + .read_with(cx, |buffer, _cx| buffer.anchor_range_outside(range.clone())); + self.diff + .update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); + + let snapshot = self.buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + + let line = snapshot.offset_to_point(range.start).row; + event_stream.update_fields( + ToolCallUpdateFields::new().locations(vec![ + ToolCallLocation::new(&self.abs_path).line(Some(line)), + ]), + ); + + let buffer_indent = snapshot.line_indent_for_row(line); + let query_indent = text::LineIndent::from_iter( + matcher + .query_lines() + .first() + .map(|s| s.as_str()) + .unwrap_or("") + .chars(), + ); + let indent_delta = compute_indent_delta(buffer_indent, query_indent); + + let old_text_in_buffer = + snapshot.text_for_range(range.clone()).collect::(); + + log::debug!( + "edit[{}] old_text matched at {}..{}: {:?}", + edit_index, + range.start, + range.end, + old_text_in_buffer, + ); + + let text_snapshot = self + .buffer + .read_with(cx, |buffer, _cx| buffer.text_snapshot()); + self.pipeline.current_edit = Some(EditPipelineEntry::StreamingNewText { + streaming_diff: StreamingDiff::new(old_text_in_buffer), + edit_cursor: range.start, + reindenter: Reindenter::new(indent_delta), + original_snapshot: text_snapshot, + }); + + cx.update(|cx| { + let position = self.buffer.read(cx).anchor_before(range.end); + tool.set_agent_location(self.buffer.downgrade(), position, cx); + }); + } + + ToolEditEvent::NewTextChunk { + chunk, done: false, .. + } => { + log::debug!("new_text_chunk: done=false, chunk='{}'", chunk); + + let Some(EditPipelineEntry::StreamingNewText { + streaming_diff, + edit_cursor, + reindenter, + original_snapshot, + .. + }) = &mut self.pipeline.current_edit + else { + continue; + }; + + let reindented = reindenter.push(chunk); + if reindented.is_empty() { + continue; + } + + let char_ops = streaming_diff.push_new(&reindented); + apply_char_operations( + &char_ops, + &self.buffer, + original_snapshot, + edit_cursor, + &tool.action_log, + cx, + ); + + let position = original_snapshot.anchor_before(*edit_cursor); + cx.update(|cx| { + tool.set_agent_location(self.buffer.downgrade(), position, cx); + }); + } + + ToolEditEvent::NewTextChunk { + chunk, done: true, .. + } => { + log::debug!("new_text_chunk: done=true, chunk='{}'", chunk); + + let Some(EditPipelineEntry::StreamingNewText { + mut streaming_diff, + mut edit_cursor, + mut reindenter, + original_snapshot, + }) = self.pipeline.current_edit.take() + else { + continue; + }; + + // Flush any remaining reindent buffer + final chunk. + let mut final_text = reindenter.push(chunk); + final_text.push_str(&reindenter.finish()); + + log::debug!("new_text_chunk: done=true, final_text='{}'", final_text); + + if !final_text.is_empty() { + let char_ops = streaming_diff.push_new(&final_text); + apply_char_operations( + &char_ops, + &self.buffer, + &original_snapshot, + &mut edit_cursor, + &tool.action_log, + cx, + ); + } + + let remaining_ops = streaming_diff.finish(); + apply_char_operations( + &remaining_ops, + &self.buffer, + &original_snapshot, + &mut edit_cursor, + &tool.action_log, + cx, + ); + + let position = original_snapshot.anchor_before(edit_cursor); + cx.update(|cx| { + tool.set_agent_location(self.buffer.downgrade(), position, cx); + }); + } + } + } + Ok(()) + } +} + +fn apply_char_operations( + ops: &[CharOperation], + buffer: &Entity, + snapshot: &text::BufferSnapshot, + edit_cursor: &mut usize, + action_log: &Entity, + cx: &mut AsyncApp, +) { + for op in ops { + match op { + CharOperation::Insert { text } => { + let anchor = snapshot.anchor_after(*edit_cursor); + agent_edit_buffer(&buffer, [(anchor..anchor, text.as_str())], action_log, cx); + } + CharOperation::Delete { bytes } => { + let delete_end = *edit_cursor + bytes; + let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end); + agent_edit_buffer(&buffer, [(anchor_range, "")], action_log, cx); + *edit_cursor = delete_end; + } + CharOperation::Keep { bytes } => { + *edit_cursor += bytes; + } + } + } +} + +fn extract_match( + matches: Vec>, + buffer: &Entity, + edit_index: &usize, + file_changed_since_last_read: bool, + cx: &mut AsyncApp, +) -> Result, String> { + let file_changed_since_last_read_message = if file_changed_since_last_read { + " The file has changed on disk since you last read it." + } else { + "" + }; + + match matches.len() { + 0 => Err(format!( + "Could not find matching text for edit at index {}. \ + The old_text did not match any content in the file.{} \ + Please read the file again to get the current content.", + edit_index, file_changed_since_last_read_message, + )), + 1 => Ok(matches.into_iter().next().unwrap()), + _ => { + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let lines = matches + .iter() + .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string()) + .collect::>() + .join(", "); + Err(format!( + "Edit {} matched multiple locations in the file at lines: {}. \ + Please provide more context in old_text to uniquely \ + identify the location.", + edit_index, lines + )) + } + } +} + +/// Edits a buffer and reports the edit to the action log in the same effect +/// cycle. This ensures the action log's subscription handler sees the version +/// already updated by `buffer_edited`, so it does not misattribute the agent's +/// edit as a user edit. +fn agent_edit_buffer( + buffer: &Entity, + edits: I, + action_log: &Entity, + cx: &mut AsyncApp, +) where + I: IntoIterator, T)>, + S: ToOffset, + T: Into>, +{ + cx.update(|cx| { + buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); +} + +fn ensure_buffer_saved( + buffer: &Entity, + abs_path: &PathBuf, + tool: &EditFileTool, + cx: &mut AsyncApp, +) -> Result { + let last_read_mtime = tool + .action_log + .read_with(cx, |log, _| log.file_read_time(abs_path)); + let check_result = tool.thread.read_with(cx, |thread, cx| { + let current = buffer + .read(cx) + .file() + .and_then(|file| file.disk_state().mtime()); + let dirty = buffer.read(cx).is_dirty(); + let has_save = thread.has_tool(SaveFileTool::NAME); + let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME); + (current, dirty, has_save, has_restore) + }); + + let Ok((current_mtime, is_dirty, has_save_tool, has_restore_tool)) = check_result else { + return Ok(false); + }; + + if is_dirty { + let message = match (has_save_tool, has_restore_tool) { + (true, true) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ + If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." + } + (true, false) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ + If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed." + } + (false, true) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \ + If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." + } + (false, false) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ + then ask them to save or revert the file manually and inform you when it's ok to proceed." + } + }; + return Err(message.to_string()); + } + + if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) + && current != last_read + { + return Ok(true); + } + + Ok(false) +} + fn resolve_path( - input: &EditFileToolInput, - project: Entity, + mode: EditFileMode, + path: &PathBuf, + project: &Entity, cx: &mut App, -) -> Result { +) -> Result { let project = project.read(cx); - match input.mode { - EditFileMode::Edit | EditFileMode::Overwrite => { + match mode { + EditFileMode::Edit => { let path = project - .find_project_path(&input.path, cx) - .context("Can't edit file: path not found")?; + .find_project_path(&path, cx) + .ok_or_else(|| "Can't edit file: path not found".to_string())?; let entry = project .entry_for_path(&path, cx) - .context("Can't edit file: path not found")?; + .ok_or_else(|| "Can't edit file: path not found".to_string())?; - anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory"); - Ok(path) + if entry.is_file() { + Ok(path) + } else { + Err("Can't edit file: path is a directory".to_string()) + } } - - EditFileMode::Create => { - if let Some(path) = project.find_project_path(&input.path, cx) { - anyhow::ensure!( - project.entry_for_path(&path, cx).is_none(), - "Can't create file: file already exists" - ); + EditFileMode::Write => { + if let Some(path) = project.find_project_path(&path, cx) + && let Some(entry) = project.entry_for_path(&path, cx) + { + if entry.is_file() { + return Ok(path); + } else { + return Err("Can't write to file: path is a directory".to_string()); + } } - let parent_path = input - .path + let parent_path = path .parent() - .context("Can't create file: incorrect path")?; + .ok_or_else(|| "Can't create file: incorrect path".to_string())?; let parent_project_path = project.find_project_path(&parent_path, cx); let parent_entry = parent_project_path .as_ref() .and_then(|path| project.entry_for_path(path, cx)) - .context("Can't create file: parent directory doesn't exist")?; + .ok_or_else(|| "Can't create file: parent directory doesn't exist")?; - anyhow::ensure!( - parent_entry.is_dir(), - "Can't create file: parent is not a directory" - ); + if !parent_entry.is_dir() { + return Err("Can't create file: parent is not a directory".to_string()); + } - let file_name = input - .path + let file_name = path .file_name() .and_then(|file_name| file_name.to_str()) .and_then(|file_name| RelPath::unix(file_name).ok()) - .context("Can't create file: invalid filename")?; + .ok_or_else(|| "Can't create file: invalid filename".to_string())?; let new_file_path = parent_project_path.map(|parent| ProjectPath { path: parent.path.join(file_name), ..parent }); - new_file_path.context("Can't create file") + new_file_path.ok_or_else(|| "Can't create file".to_string()) } } } @@ -606,111 +1234,1024 @@ fn resolve_path( #[cfg(test)] mod tests { use super::*; - use crate::tools::tool_permissions::{SensitiveSettingsKind, sensitive_settings_kind}; - use crate::{ContextServerRegistry, Templates}; + use crate::{ContextServerRegistry, Templates, ToolInputSender}; use fs::Fs as _; + use futures::StreamExt as _; use gpui::{TestAppContext, UpdateGlobal}; use language_model::fake_provider::FakeLanguageModel; use prompt_store::ProjectContext; use serde_json::json; use settings::Settings; use settings::SettingsStore; - use util::{path, rel_path::rel_path}; + use util::path; + use util::rel_path::rel_path; #[gpui::test] - async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({})).await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); + async fn test_streaming_edit_create_file(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; let result = cx .update(|cx| { - let input = EditFileToolInput { - display_description: "Some edit".into(), - path: "root/nonexistent_file.txt".into(), - mode: EditFileMode::Edit, - }; - Arc::new(EditFileTool::new( - project, - thread.downgrade(), - language_registry, - Templates::new(), - )) - .run( - ToolInput::resolved(input), + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Create new file".into(), + path: "root/dir/new_file.txt".into(), + mode: EditFileMode::Write, + content: Some("Hello, World!".into()), + edits: None, + }), ToolCallEventStream::test().0, cx, ) }) .await; + + let EditFileToolOutput::Success { new_text, diff, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "Hello, World!"); + assert!(!diff.is_empty()); + } + + #[gpui::test] + async fn test_streaming_edit_overwrite_file(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old content"})).await; + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Overwrite file".into(), + path: "root/file.txt".into(), + mode: EditFileMode::Write, + content: Some("new content".into()), + edits: None, + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Success { + new_text, old_text, .. + } = result.unwrap() + else { + panic!("expected success"); + }; + assert_eq!(new_text, "new content"); + assert_eq!(*old_text, "old content"); + } + + #[gpui::test] + async fn test_streaming_edit_granular_edits(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Edit lines".into(), + path: "root/file.txt".into(), + mode: EditFileMode::Edit, + content: None, + edits: Some(vec![Edit { + old_text: "line 2".into(), + new_text: "modified line 2".into(), + }]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); + } + + #[gpui::test] + async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = setup_test( + cx, + json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), + ) + .await; + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Edit multiple lines".into(), + path: "root/file.txt".into(), + mode: EditFileMode::Edit, + content: None, + edits: Some(vec![ + Edit { + old_text: "line 5".into(), + new_text: "modified line 5".into(), + }, + Edit { + old_text: "line 1".into(), + new_text: "modified line 1".into(), + }, + ]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; assert_eq!( - result.unwrap_err().to_string(), - "Can't edit file: path not found" + new_text, + "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n" ); } #[gpui::test] - async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) { - let mode = &EditFileMode::Create; + async fn test_streaming_edit_adjacent_edits(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = setup_test( + cx, + json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), + ) + .await; + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Edit adjacent lines".into(), + path: "root/file.txt".into(), + mode: EditFileMode::Edit, + content: None, + edits: Some(vec![ + Edit { + old_text: "line 2".into(), + new_text: "modified line 2".into(), + }, + Edit { + old_text: "line 3".into(), + new_text: "modified line 3".into(), + }, + ]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; - let result = test_resolve_path(mode, "root/new.txt", cx); - assert_resolved_path_eq(result.await, rel_path("new.txt")); - - let result = test_resolve_path(mode, "new.txt", cx); - assert_resolved_path_eq(result.await, rel_path("new.txt")); - - let result = test_resolve_path(mode, "dir/new.txt", cx); - assert_resolved_path_eq(result.await, rel_path("dir/new.txt")); - - let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; assert_eq!( - result.await.unwrap_err().to_string(), - "Can't create file: file already exists" + new_text, + "line 1\nmodified line 2\nmodified line 3\nline 4\nline 5\n" + ); + } + + #[gpui::test] + async fn test_streaming_edit_ascending_order_edits(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = setup_test( + cx, + json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), + ) + .await; + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Edit multiple lines in ascending order".into(), + path: "root/file.txt".into(), + mode: EditFileMode::Edit, + content: None, + edits: Some(vec![ + Edit { + old_text: "line 1".into(), + new_text: "modified line 1".into(), + }, + Edit { + old_text: "line 5".into(), + new_text: "modified line 5".into(), + }, + ]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!( + new_text, + "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n" + ); + } + + #[gpui::test] + async fn test_streaming_edit_nonexistent_file(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await; + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Some edit".into(), + path: "root/nonexistent_file.txt".into(), + mode: EditFileMode::Edit, + content: None, + edits: Some(vec![Edit { + old_text: "foo".into(), + new_text: "bar".into(), + }]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Error { + error, + diff, + input_path, + } = result.unwrap_err() + else { + panic!("expected error"); + }; + assert_eq!(error, "Can't edit file: path not found"); + assert!(diff.is_empty()); + assert_eq!(input_path, None); + } + + #[gpui::test] + async fn test_streaming_edit_failed_match(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello world"})).await; + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Edit file".into(), + path: "root/file.txt".into(), + mode: EditFileMode::Edit, + content: None, + edits: Some(vec![Edit { + old_text: "nonexistent text that is not in the file".into(), + new_text: "replacement".into(), + }]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Error { error, .. } = result.unwrap_err() else { + panic!("expected error"); + }; + assert!( + error.contains("Could not find matching text"), + "Expected error containing 'Could not find matching text' but got: {error}" + ); + } + + #[gpui::test] + async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Send partials simulating LLM streaming: description first, then path, then mode + sender.send_partial(json!({"display_description": "Edit lines"})); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt" + })); + cx.run_until_parked(); + + // Path is NOT yet complete because mode hasn't appeared — no buffer open yet + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // Now send the final complete input + sender.send_full(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); + } + + #[gpui::test] + async fn test_streaming_path_completeness_heuristic(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello world"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Send partial with path but NO mode — path should NOT be treated as complete + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file" + })); + cx.run_until_parked(); + + // Now the path grows and mode appears + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "write" + })); + cx.run_until_parked(); + + // Send final + sender.send_full(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "write", + "content": "new content" + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new content"); + } + + #[gpui::test] + async fn test_streaming_cancellation_during_partials(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello world"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver, mut cancellation_tx) = + ToolCallEventStream::test_with_cancellation(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Send a partial + sender.send_partial(json!({"display_description": "Edit"})); + cx.run_until_parked(); + + // Cancel during streaming + ToolCallEventStream::signal_cancellation_with_sender(&mut cancellation_tx); + cx.run_until_parked(); + + // The sender is still alive so the partial loop should detect cancellation + // We need to drop the sender to also unblock recv() if the loop didn't catch it + drop(sender); + + let result = task.await; + let EditFileToolOutput::Error { error, .. } = result.unwrap_err() else { + panic!("expected error"); + }; + assert!( + error.contains("cancelled"), + "Expected cancellation error but got: {error}" + ); + } + + #[gpui::test] + async fn test_streaming_edit_with_multiple_partials(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = setup_test( + cx, + json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), + ) + .await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Simulate fine-grained streaming of the JSON + sender.send_partial(json!({"display_description": "Edit multiple"})); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 1"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "modified line 1"}, + {"old_text": "line 5"} + ] + })); + cx.run_until_parked(); + + // Send final complete input + sender.send_full(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "modified line 1"}, + {"old_text": "line 5", "new_text": "modified line 5"} + ] + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!( + new_text, + "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n" + ); + } + + #[gpui::test] + async fn test_streaming_create_file_with_partials(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Stream partials for create mode + sender.send_partial(json!({"display_description": "Create new file"})); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "write" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "write", + "content": "Hello, " + })); + cx.run_until_parked(); + + // Final with full content + sender.send_full(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "write", + "content": "Hello, World!" + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "Hello, World!"); + } + + #[gpui::test] + async fn test_streaming_no_partials_direct_final(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Send final immediately with no partials (simulates non-streaming path) + sender.send_full(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); + } + + #[gpui::test] + async fn test_streaming_incremental_edit_application(cx: &mut TestAppContext) { + let (tool, project, _action_log, _fs, _thread) = setup_test( + cx, + json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), + ) + .await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Stream description, path, mode + sender.send_partial(json!({"display_description": "Edit multiple lines"})); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // First edit starts streaming (old_text only, still in progress) + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 1"}] + })); + cx.run_until_parked(); + + // Buffer should not have changed yet — the first edit is still in progress + // (no second edit has appeared to prove the first is complete) + let buffer_text = project.update(cx, |project, cx| { + let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx); + project_path.and_then(|pp| { + project + .get_open_buffer(&pp, cx) + .map(|buffer| buffer.read(cx).text()) + }) + }); + // Buffer is open (from streaming) but edit 1 is still in-progress + assert_eq!( + buffer_text.as_deref(), + Some("line 1\nline 2\nline 3\nline 4\nline 5\n"), + "Buffer should not be modified while first edit is still in progress" ); - let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx); + // Second edit appears — this proves the first edit is complete, so it + // should be applied immediately during streaming + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "MODIFIED 1"}, + {"old_text": "line 5"} + ] + })); + cx.run_until_parked(); + + // First edit should now be applied to the buffer + let buffer_text = project.update(cx, |project, cx| { + let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx); + project_path.and_then(|pp| { + project + .get_open_buffer(&pp, cx) + .map(|buffer| buffer.read(cx).text()) + }) + }); assert_eq!( - result.await.unwrap_err().to_string(), + buffer_text.as_deref(), + Some("MODIFIED 1\nline 2\nline 3\nline 4\nline 5\n"), + "First edit should be applied during streaming when second edit appears" + ); + + // Send final complete input + sender.send_full(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "MODIFIED 1"}, + {"old_text": "line 5", "new_text": "MODIFIED 5"} + ] + })); + + let result = task.await; + let EditFileToolOutput::Success { + new_text, old_text, .. + } = result.unwrap() + else { + panic!("expected success"); + }; + assert_eq!(new_text, "MODIFIED 1\nline 2\nline 3\nline 4\nMODIFIED 5\n"); + assert_eq!( + *old_text, "line 1\nline 2\nline 3\nline 4\nline 5\n", + "old_text should reflect the original file content before any edits" + ); + } + + #[gpui::test] + async fn test_streaming_incremental_three_edits(cx: &mut TestAppContext) { + let (tool, project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Setup: description + path + mode + sender.send_partial(json!({ + "display_description": "Edit three lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // Edit 1 in progress + sender.send_partial(json!({ + "display_description": "Edit three lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "aaa", "new_text": "AAA"}] + })); + cx.run_until_parked(); + + // Edit 2 appears — edit 1 is now complete and should be applied + sender.send_partial(json!({ + "display_description": "Edit three lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc", "new_text": "CCC"} + ] + })); + cx.run_until_parked(); + + // Verify edit 1 fully applied. Edit 2's new_text is being + // streamed: "CCC" is inserted but the old "ccc" isn't deleted + // yet (StreamingDiff::finish runs when edit 3 marks edit 2 done). + let buffer_text = project.update(cx, |project, cx| { + let pp = project + .find_project_path(&PathBuf::from("root/file.txt"), cx) + .unwrap(); + project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) + }); + assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCCccc\nddd\neee\n")); + + // Edit 3 appears — edit 2 is now complete and should be applied + sender.send_partial(json!({ + "display_description": "Edit three lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc", "new_text": "CCC"}, + {"old_text": "eee", "new_text": "EEE"} + ] + })); + cx.run_until_parked(); + + // Verify edits 1 and 2 fully applied. Edit 3's new_text is being + // streamed: "EEE" is inserted but old "eee" isn't deleted yet. + let buffer_text = project.update(cx, |project, cx| { + let pp = project + .find_project_path(&PathBuf::from("root/file.txt"), cx) + .unwrap(); + project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) + }); + assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCC\nddd\nEEEeee\n")); + + // Send final + sender.send_full(json!({ + "display_description": "Edit three lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc", "new_text": "CCC"}, + {"old_text": "eee", "new_text": "EEE"} + ] + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "AAA\nbbb\nCCC\nddd\nEEE\n"); + } + + #[gpui::test] + async fn test_streaming_edit_failure_mid_stream(cx: &mut TestAppContext) { + let (tool, project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Setup + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // Edit 1 (valid) in progress — not yet complete (no second edit) + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "MODIFIED"} + ] + })); + cx.run_until_parked(); + + // Edit 2 appears (will fail to match) — this makes edit 1 complete. + // Edit 1 should be applied. Edit 2 is still in-progress (last edit). + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "MODIFIED"}, + {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"} + ] + })); + cx.run_until_parked(); + + let buffer = project.update(cx, |project, cx| { + let pp = project + .find_project_path(&PathBuf::from("root/file.txt"), cx) + .unwrap(); + project.get_open_buffer(&pp, cx).unwrap() + }); + + // Verify edit 1 was applied + let buffer_text = buffer.read_with(cx, |buffer, _cx| buffer.text()); + assert_eq!( + buffer_text, "MODIFIED\nline 2\nline 3\n", + "First edit should be applied even though second edit will fail" + ); + + // Edit 3 appears — this makes edit 2 "complete", triggering its + // resolution which should fail (old_text doesn't exist in the file). + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "MODIFIED"}, + {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"}, + {"old_text": "line 3", "new_text": "MODIFIED 3"} + ] + })); + cx.run_until_parked(); + + // The error from edit 2 should have propagated out of the partial loop. + // Drop sender to unblock recv() if the loop didn't catch it. + drop(sender); + + let result = task.await; + let EditFileToolOutput::Error { + error, + diff, + input_path, + } = result.unwrap_err() + else { + panic!("expected error"); + }; + + assert!( + error.contains("Could not find matching text for edit at index 1"), + "Expected error about edit 1 failing, got: {error}" + ); + // Ensure that first edit was applied successfully and that we saved the buffer + assert_eq!(input_path, Some(PathBuf::from("root/file.txt"))); + assert_eq!( + diff, + "@@ -1,3 +1,3 @@\n-line 1\n+MODIFIED\n line 2\n line 3\n" + ); + } + + #[gpui::test] + async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) { + let (tool, project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello world\n"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Setup + single edit that stays in-progress (no second edit to prove completion) + sender.send_partial(json!({ + "display_description": "Single edit", + "path": "root/file.txt", + "mode": "edit", + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Single edit", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "hello world", "new_text": "goodbye world"}] + })); + cx.run_until_parked(); + + // The edit's old_text and new_text both arrived in one partial, so + // the old_text is resolved and new_text is being streamed via + // StreamingDiff. The buffer reflects the in-progress diff (new text + // inserted, old text not yet fully removed until finalization). + let buffer_text = project.update(cx, |project, cx| { + let pp = project + .find_project_path(&PathBuf::from("root/file.txt"), cx) + .unwrap(); + project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) + }); + assert_eq!( + buffer_text.as_deref(), + Some("goodbye worldhello world\n"), + "In-progress streaming diff: new text inserted, old text not yet removed" + ); + + // Send final — the edit is applied during finalization + sender.send_full(json!({ + "display_description": "Single edit", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "hello world", "new_text": "goodbye world"}] + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "goodbye world\n"); + } + + #[gpui::test] + async fn test_streaming_input_partials_then_final(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; + let (mut sender, input): (ToolInputSender, ToolInput) = + ToolInput::test(); + let (event_stream, _event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Send progressively more complete partial snapshots, as the LLM would + sender.send_partial(json!({ + "display_description": "Edit lines" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] + })); + cx.run_until_parked(); + + // Send the final complete input + sender.send_full(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); + } + + #[gpui::test] + async fn test_streaming_input_sender_dropped_before_final(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello world\n"})).await; + let (mut sender, input): (ToolInputSender, ToolInput) = + ToolInput::test(); + let (event_stream, _event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Send a partial then drop the sender without sending final + sender.send_partial(json!({ + "display_description": "Edit file" + })); + cx.run_until_parked(); + + drop(sender); + + let result = task.await; + assert!( + result.is_err(), + "Tool should error when sender is dropped without sending final input" + ); + } + + #[gpui::test] + async fn test_streaming_input_recv_drains_partials(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; + // Create a channel and send multiple partials before a final, then use + // ToolInput::resolved-style immediate delivery to confirm recv() works + // when partials are already buffered. + let (mut sender, input): (ToolInputSender, ToolInput) = + ToolInput::test(); + let (event_stream, _event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Buffer several partials before sending the final + sender.send_partial(json!({"display_description": "Create"})); + sender.send_partial(json!({"display_description": "Create", "path": "root/dir/new.txt"})); + sender.send_partial(json!({ + "display_description": "Create", + "path": "root/dir/new.txt", + "mode": "write" + })); + sender.send_full(json!({ + "display_description": "Create", + "path": "root/dir/new.txt", + "mode": "write", + "content": "streamed content" + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "streamed content"); + } + + #[gpui::test] + async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) { + let mode = EditFileMode::Write; + + let result = test_resolve_path(&mode, "root/new.txt", cx); + assert_resolved_path_eq(result.await, rel_path("new.txt")); + + let result = test_resolve_path(&mode, "new.txt", cx); + assert_resolved_path_eq(result.await, rel_path("new.txt")); + + let result = test_resolve_path(&mode, "dir/new.txt", cx); + assert_resolved_path_eq(result.await, rel_path("dir/new.txt")); + + let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx); + assert_resolved_path_eq(result.await, rel_path("dir/subdir/existing.txt")); + + let result = test_resolve_path(&mode, "root/dir/subdir", cx); + assert_eq!( + result.await.unwrap_err(), + "Can't write to file: path is a directory" + ); + + let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx); + assert_eq!( + result.await.unwrap_err(), "Can't create file: parent directory doesn't exist" ); } #[gpui::test] - async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) { - let mode = &EditFileMode::Edit; + async fn test_streaming_resolve_path_for_editing_file(cx: &mut TestAppContext) { + let mode = EditFileMode::Edit; let path_with_root = "root/dir/subdir/existing.txt"; let path_without_root = "dir/subdir/existing.txt"; - let result = test_resolve_path(mode, path_with_root, cx); + let result = test_resolve_path(&mode, path_with_root, cx); assert_resolved_path_eq(result.await, rel_path(path_without_root)); - let result = test_resolve_path(mode, path_without_root, cx); + let result = test_resolve_path(&mode, path_without_root, cx); assert_resolved_path_eq(result.await, rel_path(path_without_root)); - let result = test_resolve_path(mode, "root/nonexistent.txt", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't edit file: path not found" - ); + let result = test_resolve_path(&mode, "root/nonexistent.txt", cx); + assert_eq!(result.await.unwrap_err(), "Can't edit file: path not found"); - let result = test_resolve_path(mode, "root/dir", cx); + let result = test_resolve_path(&mode, "root/dir", cx); assert_eq!( - result.await.unwrap_err().to_string(), + result.await.unwrap_err(), "Can't edit file: path is a directory" ); } @@ -719,7 +2260,7 @@ mod tests { mode: &EditFileMode, path: &str, cx: &mut TestAppContext, - ) -> anyhow::Result { + ) -> Result { init_test(cx); let fs = project::FakeFs::new(cx.executor()); @@ -736,31 +2277,24 @@ mod tests { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let input = EditFileToolInput { - display_description: "Some edit".into(), - path: path.into(), - mode: mode.clone(), - }; - - cx.update(|cx| resolve_path(&input, project, cx)) + cx.update(|cx| resolve_path(*mode, &PathBuf::from(path), &project, cx)) } #[track_caller] - fn assert_resolved_path_eq(path: anyhow::Result, expected: &RelPath) { + fn assert_resolved_path_eq(path: Result, expected: &RelPath) { let actual = path.expect("Should return valid path").path; assert_eq!(actual.as_ref(), expected); } #[gpui::test] - async fn test_format_on_save(cx: &mut TestAppContext) { + async fn test_streaming_format_on_save(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/root", json!({"src": {}})).await; + let (tool, project, action_log, fs, thread) = + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - // Set up a Rust language with LSP formatting support let rust_language = Arc::new(language::Language::new( language::LanguageConfig { name: "Rust".into(), @@ -773,7 +2307,6 @@ mod tests { None, )); - // Register the language and fake LSP let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_language); @@ -788,7 +2321,6 @@ mod tests { }, ); - // Create the file fs.save( path!("/root/src/main.rs").as_ref(), &"initial content".into(), @@ -810,9 +2342,10 @@ mod tests { project.register_buffer_with_language_servers(&buffer, cx) }); - const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; - const FORMATTED_CONTENT: &str = - "This file was formatted by the fake formatter in the test.\n"; + const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\ +"; + const FORMATTED_CONTENT: &str = "This file was formatted by the fake formatter in the test.\ +"; // Get the fake language server and set up formatting handler let fake_language_server = fake_language_servers.next().await.unwrap(); @@ -825,21 +2358,7 @@ mod tests { } }); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model.clone()), - cx, - ) - }); - - // First, test with format_on_save enabled + // Test with format_on_save enabled cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { @@ -850,43 +2369,33 @@ mod tests { }); }); - // Have the model stream unformatted content - let edit_result = { - let edit_task = cx.update(|cx| { - let input = EditFileToolInput { - display_description: "Create main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }; - Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry.clone(), - Templates::new(), - )) - .run( - ToolInput::resolved(input), - ToolCallEventStream::test().0, - cx, - ) - }); + // Use streaming pattern so executor can pump the LSP request/response + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); - // Stream the unformatted content - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); - model.end_last_completion_stream(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - edit_task.await - }; - assert!(edit_result.is_ok()); + sender.send_partial(json!({ + "display_description": "Create main function", + "path": "root/src/main.rs", + "mode": "write" + })); + cx.run_until_parked(); + + sender.send_full(json!({ + "display_description": "Create main function", + "path": "root/src/main.rs", + "mode": "write", + "content": UNFORMATTED_CONTENT + })); + + let result = task.await; + assert!(result.is_ok()); - // Wait for any async operations (e.g. formatting) to complete cx.executor().run_until_parked(); - // Read the file to verify it was formatted automatically let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); assert_eq!( - // Ignore carriage returns on Windows new_content.replace("\r\n", "\n"), FORMATTED_CONTENT, "Code should be formatted when format_on_save is enabled" @@ -898,12 +2407,11 @@ mod tests { assert_eq!( stale_buffer_count, 0, - "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \ - This causes the agent to think the file was modified externally when it was just formatted.", + "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers.", stale_buffer_count ); - // Next, test with format_on_save disabled + // Test with format_on_save disabled cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { @@ -913,43 +2421,39 @@ mod tests { }); }); - // Stream unformatted edits again - let edit_result = { - let edit_task = cx.update(|cx| { - let input = EditFileToolInput { - display_description: "Update main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }; - Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry, - Templates::new(), - )) - .run( - ToolInput::resolved(input), - ToolCallEventStream::test().0, - cx, - ) - }); + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); - // Stream the unformatted content - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); - model.end_last_completion_stream(); + let tool2 = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + action_log.clone(), + language_registry, + )); - edit_task.await - }; - assert!(edit_result.is_ok()); + let task = cx.update(|cx| tool2.run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Update main function", + "path": "root/src/main.rs", + "mode": "write" + })); + cx.run_until_parked(); + + sender.send_full(json!({ + "display_description": "Update main function", + "path": "root/src/main.rs", + "mode": "write", + "content": UNFORMATTED_CONTENT + })); + + let result = task.await; + assert!(result.is_ok()); - // Wait for any async operations (e.g. formatting) to complete cx.executor().run_until_parked(); - // Verify the file was not formatted let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); assert_eq!( - // Ignore carriage returns on Windows new_content.replace("\r\n", "\n"), UNFORMATTED_CONTENT, "Code should not be formatted when format_on_save is disabled" @@ -957,13 +2461,11 @@ mod tests { } #[gpui::test] - async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { + async fn test_streaming_remove_trailing_whitespace(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/root", json!({"src": {}})).await; - - // Create a simple file with trailing whitespace fs.save( path!("/root/src/main.rs").as_ref(), &"initial content".into(), @@ -971,24 +2473,11 @@ mod tests { ) .await .unwrap(); + let (tool, project, action_log, fs, thread) = + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; + let language_registry = project.read_with(cx, |p, _cx| p.languages().clone()); - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model.clone()), - cx, - ) - }); - - // First, test with remove_trailing_whitespace_on_save enabled + // Test with remove_trailing_whitespace_on_save enabled cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { @@ -1004,44 +2493,26 @@ mod tests { const CONTENT_WITH_TRAILING_WHITESPACE: &str = "fn main() { \n println!(\"Hello!\"); \n}\n"; - // Have the model stream content that contains trailing whitespace - let edit_result = { - let edit_task = cx.update(|cx| { - let input = EditFileToolInput { - display_description: "Create main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }; - Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry.clone(), - Templates::new(), - )) - .run( - ToolInput::resolved(input), + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Create main function".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Write, + content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), + edits: None, + }), ToolCallEventStream::test().0, cx, ) - }); + }) + .await; + assert!(result.is_ok()); - // Stream the content with trailing whitespace - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk( - CONTENT_WITH_TRAILING_WHITESPACE.to_string(), - ); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete cx.executor().run_until_parked(); - // Read the file to verify trailing whitespace was removed automatically assert_eq!( - // Ignore carriage returns on Windows fs.load(path!("/root/src/main.rs").as_ref()) .await .unwrap() @@ -1050,7 +2521,7 @@ mod tests { "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" ); - // Next, test with remove_trailing_whitespace_on_save disabled + // Test with remove_trailing_whitespace_on_save disabled cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { @@ -1063,46 +2534,34 @@ mod tests { }); }); - // Stream edits again with trailing whitespace - let edit_result = { - let edit_task = cx.update(|cx| { - let input = EditFileToolInput { - display_description: "Update main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }; - Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry, - Templates::new(), - )) - .run( - ToolInput::resolved(input), + let tool2 = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + action_log.clone(), + language_registry, + )); + + let result = cx + .update(|cx| { + tool2.run( + ToolInput::resolved(EditFileToolInput { + display_description: "Update main function".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Write, + content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), + edits: None, + }), ToolCallEventStream::test().0, cx, ) - }); + }) + .await; + assert!(result.is_ok()); - // Stream the content with trailing whitespace - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk( - CONTENT_WITH_TRAILING_WHITESPACE.to_string(), - ); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete cx.executor().run_until_parked(); - // Verify the file still has trailing whitespace - // Read the file again - it should still have trailing whitespace let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); assert_eq!( - // Ignore carriage returns on Windows final_content.replace("\r\n", "\n"), CONTENT_WITH_TRAILING_WHITESPACE, "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" @@ -1110,41 +2569,15 @@ mod tests { } #[gpui::test] - async fn test_authorize(cx: &mut TestAppContext) { - init_test(cx); - let fs = project::FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model.clone()), - cx, - ) - }); - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry, - Templates::new(), - )); - fs.insert_tree("/root", json!({})).await; + async fn test_streaming_authorize(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await; // Test 1: Path with .zed component should require confirmation let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let _auth = cx.update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "test 1".into(), - path: ".zed/settings.json".into(), - mode: EditFileMode::Edit, - }, + &PathBuf::from(".zed/settings.json"), + "test 1", &stream_tx, cx, ) @@ -1158,17 +2591,8 @@ mod tests { // Test 2: Path outside project should require confirmation let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx.update(|cx| { - tool.authorize( - &EditFileToolInput { - display_description: "test 2".into(), - path: "/etc/hosts".into(), - mode: EditFileMode::Edit, - }, - &stream_tx, - cx, - ) - }); + let _auth = + cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 2", &stream_tx, cx)); let event = stream_rx.expect_authorization().await; assert_eq!(event.tool_call.fields.title, Some("test 2".into())); @@ -1176,15 +2600,7 @@ mod tests { // Test 3: Relative path without .zed should not require confirmation let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); cx.update(|cx| { - tool.authorize( - &EditFileToolInput { - display_description: "test 3".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Edit, - }, - &stream_tx, - cx, - ) + tool.authorize(&PathBuf::from("root/src/main.rs"), "test 3", &stream_tx, cx) }) .await .unwrap(); @@ -1194,11 +2610,8 @@ mod tests { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let _auth = cx.update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "test 4".into(), - path: "root/.zed/tasks.json".into(), - mode: EditFileMode::Edit, - }, + &PathBuf::from("root/.zed/tasks.json"), + "test 4", &stream_tx, cx, ) @@ -1221,11 +2634,8 @@ mod tests { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let _auth = cx.update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "test 5.1".into(), - path: ".zed/settings.json".into(), - mode: EditFileMode::Edit, - }, + &PathBuf::from(".zed/settings.json"), + "test 5.1", &stream_tx, cx, ) @@ -1238,30 +2648,17 @@ mod tests { // 5.2: /etc/hosts is outside the project, but Allow auto-approves let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - cx.update(|cx| { - tool.authorize( - &EditFileToolInput { - display_description: "test 5.2".into(), - path: "/etc/hosts".into(), - mode: EditFileMode::Edit, - }, - &stream_tx, - cx, - ) - }) - .await - .unwrap(); + cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.2", &stream_tx, cx)) + .await + .unwrap(); assert!(stream_rx.try_recv().is_err()); // 5.3: Normal in-project path with allow — no confirmation needed let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); cx.update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "test 5.3".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Edit, - }, + &PathBuf::from("root/src/main.rs"), + "test 5.3", &stream_tx, cx, ) @@ -1278,24 +2675,15 @@ mod tests { }); let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx.update(|cx| { - tool.authorize( - &EditFileToolInput { - display_description: "test 5.4".into(), - path: "/etc/hosts".into(), - mode: EditFileMode::Edit, - }, - &stream_tx, - cx, - ) - }); + let _auth = cx + .update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.4", &stream_tx, cx)); let event = stream_rx.expect_authorization().await; assert_eq!(event.tool_call.fields.title, Some("test 5.4".into())); } #[gpui::test] - async fn test_authorize_create_under_symlink_with_allow(cx: &mut TestAppContext) { + async fn test_streaming_authorize_create_under_symlink_with_allow(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); @@ -1303,28 +2691,8 @@ mod tests { fs.insert_tree("/outside", json!({})).await; fs.insert_symlink("/root/link", PathBuf::from("/outside")) .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(EditFileTool::new( - project, - thread.downgrade(), - language_registry, - Templates::new(), - )); + let (tool, _project, _action_log, _fs, _thread) = + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; cx.update(|cx| { let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); @@ -1335,11 +2703,8 @@ mod tests { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let authorize_task = cx.update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "create through symlink".into(), - path: "link/new.txt".into(), - mode: EditFileMode::Create, - }, + &PathBuf::from("link/new.txt"), + "create through symlink", &stream_tx, cx, ) @@ -1367,7 +2732,9 @@ mod tests { } #[gpui::test] - async fn test_edit_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) { + async fn test_streaming_edit_file_symlink_escape_requests_authorization( + cx: &mut TestAppContext, + ) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); @@ -1391,39 +2758,14 @@ mod tests { ) .await .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - cx.executor().run_until_parked(); - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry, - Templates::new(), - )); + let (tool, _project, _action_log, _fs, _thread) = + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let _authorize_task = cx.update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "edit through symlink".into(), - path: PathBuf::from("link_to_external/config.txt"), - mode: EditFileMode::Edit, - }, + &PathBuf::from("link_to_external/config.txt"), + "edit through symlink", &stream_tx, cx, ) @@ -1438,7 +2780,7 @@ mod tests { } #[gpui::test] - async fn test_edit_file_symlink_escape_denied(cx: &mut TestAppContext) { + async fn test_streaming_edit_file_symlink_escape_denied(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); @@ -1462,39 +2804,14 @@ mod tests { ) .await .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - cx.executor().run_until_parked(); - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry, - Templates::new(), - )); + let (tool, _project, _action_log, _fs, _thread) = + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let authorize_task = cx.update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "edit through symlink".into(), - path: PathBuf::from("link_to_external/config.txt"), - mode: EditFileMode::Edit, - }, + &PathBuf::from("link_to_external/config.txt"), + "edit through symlink", &stream_tx, cx, ) @@ -1508,7 +2825,7 @@ mod tests { } #[gpui::test] - async fn test_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) { + async fn test_streaming_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); @@ -1543,40 +2860,15 @@ mod tests { ) .await .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - cx.executor().run_until_parked(); - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry, - Templates::new(), - )); + let (tool, _project, _action_log, _fs, _thread) = + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let result = cx .update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "edit through symlink".into(), - path: PathBuf::from("link_to_external/config.txt"), - mode: EditFileMode::Edit, - }, + &PathBuf::from("link_to_external/config.txt"), + "edit through symlink", &stream_tx, cx, ) @@ -1594,33 +2886,13 @@ mod tests { } #[gpui::test] - async fn test_authorize_global_config(cx: &mut TestAppContext) { + async fn test_streaming_authorize_global_config(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/project", json!({})).await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model.clone()), - cx, - ) - }); - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry, - Templates::new(), - )); + let (tool, _project, _action_log, _fs, _thread) = + setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; - // Test global config paths - these should require confirmation if they exist and are outside the project let test_cases = vec![ ( "/etc/hosts", @@ -1641,17 +2913,8 @@ mod tests { for (path, should_confirm, description) in test_cases { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let auth = cx.update(|cx| { - tool.authorize( - &EditFileToolInput { - display_description: "Edit file".into(), - path: path.into(), - mode: EditFileMode::Edit, - }, - &stream_tx, - cx, - ) - }); + let auth = + cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx)); if should_confirm { stream_rx.expect_authorization().await; @@ -1668,11 +2931,9 @@ mod tests { } #[gpui::test] - async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { + async fn test_streaming_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); - - // Create multiple worktree directories fs.insert_tree( "/workspace/frontend", json!({ @@ -1700,40 +2961,17 @@ mod tests { }), ) .await; - - // Create project with multiple worktrees - let project = Project::test( - fs.clone(), - [ + let (tool, _project, _action_log, _fs, _thread) = setup_test_with_fs( + cx, + fs, + &[ path!("/workspace/frontend").as_ref(), path!("/workspace/backend").as_ref(), path!("/workspace/shared").as_ref(), ], - cx, ) .await; - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry.clone(), - Templates::new(), - Some(model.clone()), - cx, - ) - }); - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry, - Templates::new(), - )); - // Test files in different worktrees let test_cases = vec![ ("frontend/src/main.js", false, "File in first worktree"), ("backend/src/main.rs", false, "File in second worktree"), @@ -1752,17 +2990,8 @@ mod tests { for (path, should_confirm, description) in test_cases { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let auth = cx.update(|cx| { - tool.authorize( - &EditFileToolInput { - display_description: "Edit file".into(), - path: path.into(), - mode: EditFileMode::Edit, - }, - &stream_tx, - cx, - ) - }); + let auth = + cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx)); if should_confirm { stream_rx.expect_authorization().await; @@ -1779,7 +3008,7 @@ mod tests { } #[gpui::test] - async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { + async fn test_streaming_needs_confirmation_edge_cases(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); fs.insert_tree( @@ -1796,35 +3025,12 @@ mod tests { }), ) .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry.clone(), - Templates::new(), - Some(model.clone()), - cx, - ) - }); - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry, - Templates::new(), - )); + let (tool, _project, _action_log, _fs, _thread) = + setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; - // Test edge cases let test_cases = vec![ - // Empty path - find_project_path returns Some for empty paths ("", false, "Empty path is treated as project root"), - // Root directory ("/", true, "Root directory should be outside project"), - // Parent directory references - find_project_path resolves these ( "project/../other", true, @@ -1835,7 +3041,6 @@ mod tests { false, "Path with . should work normally", ), - // Windows-style paths (if on Windows) #[cfg(target_os = "windows")] ("C:\\Windows\\System32\\hosts", true, "Windows system path"), #[cfg(target_os = "windows")] @@ -1844,17 +3049,8 @@ mod tests { for (path, should_confirm, description) in test_cases { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let auth = cx.update(|cx| { - tool.authorize( - &EditFileToolInput { - display_description: "Edit file".into(), - path: path.into(), - mode: EditFileMode::Edit, - }, - &stream_tx, - cx, - ) - }); + let auth = + cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx)); cx.run_until_parked(); @@ -1873,7 +3069,7 @@ mod tests { } #[gpui::test] - async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { + async fn test_streaming_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); fs.insert_tree( @@ -1886,45 +3082,18 @@ mod tests { }), ) .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry.clone(), - Templates::new(), - Some(model.clone()), - cx, - ) - }); - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - language_registry, - Templates::new(), - )); + let (tool, _project, _action_log, _fs, _thread) = + setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; - // Test different EditFileMode values - let modes = vec![ - EditFileMode::Edit, - EditFileMode::Create, - EditFileMode::Overwrite, - ]; + let modes = vec![EditFileMode::Edit, EditFileMode::Write]; - for mode in modes { + for _mode in modes { // Test .zed path with different modes let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let _auth = cx.update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "Edit settings".into(), - path: "project/.zed/settings.json".into(), - mode: mode.clone(), - }, + &PathBuf::from("project/.zed/settings.json"), + "Edit settings", &stream_tx, cx, ) @@ -1936,11 +3105,8 @@ mod tests { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let _auth = cx.update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "Edit file".into(), - path: "/outside/file.txt".into(), - mode: mode.clone(), - }, + &PathBuf::from("/outside/file.txt"), + "Edit file", &stream_tx, cx, ) @@ -1952,11 +3118,8 @@ mod tests { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); cx.update(|cx| { tool.authorize( - &EditFileToolInput { - display_description: "Edit file".into(), - path: "project/normal.txt".into(), - mode: mode.clone(), - }, + &PathBuf::from("project/normal.txt"), + "Edit file", &stream_tx, cx, ) @@ -1968,40 +3131,19 @@ mod tests { } #[gpui::test] - async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) { + async fn test_streaming_initial_title_with_partial_input(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model.clone()), - cx, - ) - }); - let tool = Arc::new(EditFileTool::new( - project, - thread.downgrade(), - language_registry, - Templates::new(), - )); + fs.insert_tree("/project", json!({})).await; + let (tool, _project, _action_log, _fs, _thread) = + setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; cx.update(|cx| { - // ... assert_eq!( tool.initial_title( Err(json!({ "path": "src/main.rs", "display_description": "", - "old_string": "old code", - "new_string": "new code" })), cx ), @@ -2012,8 +3154,6 @@ mod tests { Err(json!({ "path": "", "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" })), cx ), @@ -2024,8 +3164,6 @@ mod tests { Err(json!({ "path": "src/main.rs", "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" })), cx ), @@ -2036,8 +3174,6 @@ mod tests { Err(json!({ "path": "", "display_description": "", - "old_string": "old code", - "new_string": "new code" })), cx ), @@ -2051,42 +3187,25 @@ mod tests { } #[gpui::test] - async fn test_diff_finalization(cx: &mut TestAppContext) { + async fn test_streaming_diff_finalization(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/", json!({"main.rs": ""})).await; - - let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; - let languages = project.read_with(cx, |project, _cx| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry.clone(), - Templates::new(), - Some(model.clone()), - cx, - ) - }); + let (tool, project, action_log, _fs, thread) = + setup_test_with_fs(cx, fs, &[path!("/").as_ref()]).await; + let language_registry = project.read_with(cx, |p, _cx| p.languages().clone()); // Ensure the diff is finalized after the edit completes. { - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - languages.clone(), - Templates::new(), - )); let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let edit = cx.update(|cx| { - tool.run( + tool.clone().run( ToolInput::resolved(EditFileToolInput { display_description: "Edit file".into(), path: path!("/main.rs").into(), - mode: EditFileMode::Edit, + mode: EditFileMode::Write, + content: Some("new content".into()), + edits: None, }), stream_tx, cx, @@ -2096,47 +3215,17 @@ mod tests { let diff = stream_rx.expect_diff().await; diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); cx.run_until_parked(); - model.end_last_completion_stream(); edit.await.unwrap(); diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); } - // Ensure the diff is finalized if an error occurs while editing. - { - model.forbid_requests(); - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - languages.clone(), - Templates::new(), - )); - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let edit = cx.update(|cx| { - tool.run( - ToolInput::resolved(EditFileToolInput { - display_description: "Edit file".into(), - path: path!("/main.rs").into(), - mode: EditFileMode::Edit, - }), - stream_tx, - cx, - ) - }); - stream_rx.expect_update_fields().await; - let diff = stream_rx.expect_diff().await; - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); - edit.await.unwrap_err(); - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); - model.allow_requests(); - } - // Ensure the diff is finalized if the tool call gets dropped. { let tool = Arc::new(EditFileTool::new( project.clone(), thread.downgrade(), - languages.clone(), - Templates::new(), + action_log, + language_registry, )); let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let edit = cx.update(|cx| { @@ -2144,7 +3233,9 @@ mod tests { ToolInput::resolved(EditFileToolInput { display_description: "Edit file".into(), path: path!("/main.rs").into(), - mode: EditFileMode::Edit, + mode: EditFileMode::Write, + content: Some("dropped content".into()), + edits: None, }), stream_tx, cx, @@ -2160,143 +3251,15 @@ mod tests { } #[gpui::test] - async fn test_file_read_times_tracking(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "test.txt": "original content" - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model.clone()), - cx, - ) - }); - let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - - // Initially, file_read_times should be empty - let is_empty = action_log.read_with(cx, |action_log, _| { - action_log - .file_read_time(path!("/root/test.txt").as_ref()) - .is_none() - }); - assert!(is_empty, "file_read_times should start empty"); - - // Create read tool + async fn test_streaming_consecutive_edits_work(cx: &mut TestAppContext) { + let (tool, project, action_log, _fs, _thread) = + setup_test(cx, json!({"test.txt": "original content"})).await; let read_tool = Arc::new(crate::ReadFileTool::new( project.clone(), action_log.clone(), true, )); - // Read the file to record the read time - cx.update(|cx| { - read_tool.clone().run( - ToolInput::resolved(crate::ReadFileToolInput { - path: "root/test.txt".to_string(), - start_line: None, - end_line: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - - // Verify that file_read_times now contains an entry for the file - let has_entry = action_log.read_with(cx, |log, _| { - log.file_read_time(path!("/root/test.txt").as_ref()) - .is_some() - }); - assert!( - has_entry, - "file_read_times should contain an entry after reading the file" - ); - - // Read the file again - should update the entry - cx.update(|cx| { - read_tool.clone().run( - ToolInput::resolved(crate::ReadFileToolInput { - path: "root/test.txt".to_string(), - start_line: None, - end_line: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - - // Should still have an entry after re-reading - let has_entry = action_log.read_with(cx, |log, _| { - log.file_read_time(path!("/root/test.txt").as_ref()) - .is_some() - }); - assert!( - has_entry, - "file_read_times should still have an entry after re-reading" - ); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); - } - - #[gpui::test] - async fn test_consecutive_edits_work(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "test.txt": "original content" - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model.clone()), - cx, - ) - }); - let languages = project.read_with(cx, |project, _| project.languages().clone()); - let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - - let read_tool = Arc::new(crate::ReadFileTool::new(project.clone(), action_log, true)); - let edit_tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - languages, - Templates::new(), - )); - // Read the file first cx.update(|cx| { read_tool.clone().run( @@ -2313,28 +3276,24 @@ mod tests { .unwrap(); // First edit should work - let edit_result = { - let edit_task = cx.update(|cx| { - edit_tool.clone().run( + let edit_result = cx + .update(|cx| { + tool.clone().run( ToolInput::resolved(EditFileToolInput { display_description: "First edit".into(), path: "root/test.txt".into(), mode: EditFileMode::Edit, + content: None, + edits: Some(vec![Edit { + old_text: "original content".into(), + new_text: "modified content".into(), + }]), }), ToolCallEventStream::test().0, cx, ) - }); - - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk( - "original contentmodified content" - .to_string(), - ); - model.end_last_completion_stream(); - - edit_task.await - }; + }) + .await; assert!( edit_result.is_ok(), "First edit should succeed, got error: {:?}", @@ -2342,27 +3301,24 @@ mod tests { ); // Second edit should also work because the edit updated the recorded read time - let edit_result = { - let edit_task = cx.update(|cx| { - edit_tool.clone().run( + let edit_result = cx + .update(|cx| { + tool.clone().run( ToolInput::resolved(EditFileToolInput { display_description: "Second edit".into(), path: "root/test.txt".into(), mode: EditFileMode::Edit, + content: None, + edits: Some(vec![Edit { + old_text: "modified content".into(), + new_text: "further modified content".into(), + }]), }), ToolCallEventStream::test().0, cx, ) - }); - - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk( - "modified contentfurther modified content".to_string(), - ); - model.end_last_completion_stream(); - - edit_task.await - }; + }) + .await; assert!( edit_result.is_ok(), "Second consecutive edit should succeed, got error: {:?}", @@ -2371,40 +3327,13 @@ mod tests { } #[gpui::test] - async fn test_external_modification_detected(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "test.txt": "original content" - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model.clone()), - cx, - ) - }); - let languages = project.read_with(cx, |project, _| project.languages().clone()); - let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - - let read_tool = Arc::new(crate::ReadFileTool::new(project.clone(), action_log, true)); - let edit_tool = Arc::new(EditFileTool::new( + async fn test_streaming_external_modification_matching_edit_succeeds(cx: &mut TestAppContext) { + let (tool, project, action_log, fs, _thread) = + setup_test(cx, json!({"test.txt": "original content"})).await; + let read_tool = Arc::new(crate::ReadFileTool::new( project.clone(), - thread.downgrade(), - languages, - Templates::new(), + action_log.clone(), + true, )); // Read the file first @@ -2422,7 +3351,7 @@ mod tests { .await .unwrap(); - // Simulate external modification - advance time and save file + // Simulate external modification cx.background_executor .advance_clock(std::time::Duration::from_secs(2)); fs.save( @@ -2450,14 +3379,103 @@ mod tests { cx.executor().run_until_parked(); - // Try to edit - should fail because file was modified externally let result = cx .update(|cx| { - edit_tool.clone().run( + tool.clone().run( ToolInput::resolved(EditFileToolInput { display_description: "Edit after external change".into(), path: "root/test.txt".into(), mode: EditFileMode::Edit, + content: None, + edits: Some(vec![Edit { + old_text: "externally modified content".into(), + new_text: "new content".into(), + }]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + let EditFileToolOutput::Success { + new_text, + input_path, + .. + } = result + else { + panic!("expected success"); + }; + + assert_eq!(new_text, "new content"); + assert_eq!(input_path, PathBuf::from("root/test.txt")); + } + + #[gpui::test] + async fn test_streaming_external_modification_mentioned_when_match_fails( + cx: &mut TestAppContext, + ) { + let (tool, project, action_log, fs, _thread) = + setup_test(cx, json!({"test.txt": "original content"})).await; + let read_tool = Arc::new(crate::ReadFileTool::new( + project.clone(), + action_log.clone(), + true, + )); + + cx.update(|cx| { + read_tool.clone().run( + ToolInput::resolved(crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + cx.background_executor + .advance_clock(std::time::Duration::from_secs(2)); + fs.save( + path!("/root/test.txt").as_ref(), + &"externally modified content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/test.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + buffer + .update(cx, |buffer, cx| buffer.reload(cx)) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Edit after external change".into(), + path: "root/test.txt".into(), + mode: EditFileMode::Edit, + content: None, + edits: Some(vec![Edit { + old_text: "original content".into(), + new_text: "new content".into(), + }]), }), ToolCallEventStream::test().0, cx, @@ -2465,53 +3483,35 @@ mod tests { }) .await; + let EditFileToolOutput::Error { + error, + diff, + input_path, + } = result.unwrap_err() + else { + panic!("expected error"); + }; + assert!( - result.is_err(), - "Edit should fail after external modification" + error.contains("Could not find matching text for edit at index 0"), + "Error should mention failed match, got: {error}" ); - let error_msg = result.unwrap_err().to_string(); assert!( - error_msg.contains("has been modified since you last read it"), - "Error should mention file modification, got: {}", - error_msg + error.contains("has changed on disk since you last read it"), + "Error should mention possible disk change, got: {error}" ); + assert!(diff.is_empty()); + assert_eq!(input_path, Some(PathBuf::from("root/test.txt"))); } #[gpui::test] - async fn test_dirty_buffer_detected(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "test.txt": "original content" - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model.clone()), - cx, - ) - }); - let languages = project.read_with(cx, |project, _| project.languages().clone()); - let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - - let read_tool = Arc::new(crate::ReadFileTool::new(project.clone(), action_log, true)); - let edit_tool = Arc::new(EditFileTool::new( + async fn test_streaming_dirty_buffer_detected(cx: &mut TestAppContext) { + let (tool, project, action_log, _fs, _thread) = + setup_test(cx, json!({"test.txt": "original content"})).await; + let read_tool = Arc::new(crate::ReadFileTool::new( project.clone(), - thread.downgrade(), - languages, - Templates::new(), + action_log.clone(), + true, )); // Read the file first @@ -2529,7 +3529,7 @@ mod tests { .await .unwrap(); - // Open the buffer and make it dirty by editing without saving + // Open the buffer and make it dirty let project_path = project .read_with(cx, |project, cx| { project.find_project_path("root/test.txt", cx) @@ -2540,24 +3540,27 @@ mod tests { .await .unwrap(); - // Make an in-memory edit to the buffer (making it dirty) buffer.update(cx, |buffer, cx| { let end_point = buffer.max_point(); buffer.edit([(end_point..end_point, " added text")], None, cx); }); - // Verify buffer is dirty let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty()); assert!(is_dirty, "Buffer should be dirty after in-memory edit"); // Try to edit - should fail because buffer has unsaved changes let result = cx .update(|cx| { - edit_tool.clone().run( + tool.clone().run( ToolInput::resolved(EditFileToolInput { display_description: "Edit with dirty buffer".into(), path: "root/test.txt".into(), mode: EditFileMode::Edit, + content: None, + edits: Some(vec![Edit { + old_text: "original content".into(), + new_text: "new content".into(), + }]), }), ToolCallEventStream::test().0, cx, @@ -2565,75 +3568,841 @@ mod tests { }) .await; - assert!(result.is_err(), "Edit should fail when buffer is dirty"); - let error_msg = result.unwrap_err().to_string(); + let EditFileToolOutput::Error { + error, + diff, + input_path, + } = result.unwrap_err() + else { + panic!("expected error"); + }; assert!( - error_msg.contains("This file has unsaved changes."), + error.contains("This file has unsaved changes."), "Error should mention unsaved changes, got: {}", - error_msg + error ); assert!( - error_msg.contains("keep or discard"), + error.contains("keep or discard"), "Error should ask whether to keep or discard changes, got: {}", - error_msg + error ); - // Since save_file and restore_file_from_disk tools aren't added to the thread, - // the error message should ask the user to manually save or revert assert!( - error_msg.contains("save or revert the file manually"), + error.contains("save or revert the file manually"), "Error should ask user to manually save or revert when tools aren't available, got: {}", - error_msg + error + ); + assert!(diff.is_empty()); + assert!(input_path.is_none()); + } + + #[gpui::test] + async fn test_streaming_overlapping_edits_resolved_sequentially(cx: &mut TestAppContext) { + // Edit 1's replacement introduces text that contains edit 2's + // old_text as a substring. Because edits resolve sequentially + // against the current buffer, edit 2 finds a unique match in + // the modified buffer and succeeds. + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Setup: resolve the buffer + sender.send_partial(json!({ + "display_description": "Overlapping edits", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // Edit 1 replaces "bbb\nccc" with "XXX\nccc\nddd", so the + // buffer becomes "aaa\nXXX\nccc\nddd\nddd\neee\n". + // Edit 2's old_text "ccc\nddd" matches the first occurrence + // in the modified buffer and replaces it with "ZZZ". + // Edit 3 exists only to mark edit 2 as "complete" during streaming. + sender.send_partial(json!({ + "display_description": "Overlapping edits", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"}, + {"old_text": "ccc\nddd", "new_text": "ZZZ"}, + {"old_text": "eee", "new_text": "DUMMY"} + ] + })); + cx.run_until_parked(); + + // Send the final input with all three edits. + sender.send_full(json!({ + "display_description": "Overlapping edits", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"}, + {"old_text": "ccc\nddd", "new_text": "ZZZ"}, + {"old_text": "eee", "new_text": "DUMMY"} + ] + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n"); + } + + #[gpui::test] + async fn test_streaming_create_content_streamed(cx: &mut TestAppContext) { + let (tool, project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Transition to BufferResolved + sender.send_partial(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "write" + })); + cx.run_until_parked(); + + // Stream content incrementally + sender.send_partial(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "write", + "content": "line 1\n" + })); + cx.run_until_parked(); + + // Verify buffer has partial content + let buffer = project.update(cx, |project, cx| { + let path = project + .find_project_path("root/dir/new_file.txt", cx) + .unwrap(); + project.get_open_buffer(&path, cx).unwrap() + }); + assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\n"); + + // Stream more content + sender.send_partial(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "write", + "content": "line 1\nline 2\n" + })); + cx.run_until_parked(); + assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\nline 2\n"); + + // Stream final chunk + sender.send_partial(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "write", + "content": "line 1\nline 2\nline 3\n" + })); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |b, _| b.text()), + "line 1\nline 2\nline 3\n" + ); + + // Send final input + sender.send_full(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "write", + "content": "line 1\nline 2\nline 3\n" + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "line 1\nline 2\nline 3\n"); + } + + #[gpui::test] + async fn test_streaming_overwrite_diff_revealed_during_streaming(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = setup_test( + cx, + json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}), + ) + .await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, mut receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Transition to BufferResolved + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "write" + })); + cx.run_until_parked(); + + // Get the diff entity from the event stream + receiver.expect_update_fields().await; + let diff = receiver.expect_diff().await; + + // Diff starts pending with no revealed ranges + diff.read_with(cx, |diff, cx| { + assert!(matches!(diff, Diff::Pending(_))); + assert!(!diff.has_revealed_range(cx)); + }); + + // Stream first content chunk + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "write", + "content": "new line 1\n" + })); + cx.run_until_parked(); + + // Diff should now have revealed ranges showing the new content + diff.read_with(cx, |diff, cx| { + assert!(diff.has_revealed_range(cx)); + }); + + // Send final input + sender.send_full(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "write", + "content": "new line 1\nnew line 2\n" + })); + + let result = task.await; + let EditFileToolOutput::Success { + new_text, old_text, .. + } = result.unwrap() + else { + panic!("expected success"); + }; + assert_eq!(new_text, "new line 1\nnew line 2\n"); + assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n"); + + // Diff is finalized after completion + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + + #[gpui::test] + async fn test_streaming_overwrite_content_streamed(cx: &mut TestAppContext) { + let (tool, project, _action_log, _fs, _thread) = setup_test( + cx, + json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}), + ) + .await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + // Transition to BufferResolved + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "write" + })); + cx.run_until_parked(); + + // Verify buffer still has old content (no content partial yet) + let buffer = project.update(cx, |project, cx| { + let path = project.find_project_path("root/file.txt", cx).unwrap(); + project.open_buffer(path, cx) + }); + let buffer = buffer.await.unwrap(); + assert_eq!( + buffer.read_with(cx, |b, _| b.text()), + "old line 1\nold line 2\nold line 3\n" + ); + + // First content partial replaces old content + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "write", + "content": "new line 1\n" + })); + cx.run_until_parked(); + assert_eq!(buffer.read_with(cx, |b, _| b.text()), "new line 1\n"); + + // Subsequent content partials append + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "write", + "content": "new line 1\nnew line 2\n" + })); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |b, _| b.text()), + "new line 1\nnew line 2\n" + ); + + // Send final input with complete content + sender.send_full(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "write", + "content": "new line 1\nnew line 2\nnew line 3\n" + })); + + let result = task.await; + let EditFileToolOutput::Success { + new_text, old_text, .. + } = result.unwrap() + else { + panic!("expected success"); + }; + assert_eq!(new_text, "new line 1\nnew line 2\nnew line 3\n"); + assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n"); + } + + #[gpui::test] + async fn test_streaming_edit_json_fixer_escape_corruption(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello\nworld\nfoo\n"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // Simulate JSON fixer producing a literal backslash when the LLM + // stream cuts in the middle of a \n escape sequence. + // The old_text "hello\nworld" would be streamed as: + // partial 1: old_text = "hello\\" (fixer closes incomplete \n as \\) + // partial 2: old_text = "hello\nworld" (fixer corrected the escape) + sender.send_partial(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "hello\\"}] + })); + cx.run_until_parked(); + + // Now the fixer corrects it to the real newline. + sender.send_partial(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "hello\nworld"}] + })); + cx.run_until_parked(); + + // Send final. + sender.send_full(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "hello\nworld", "new_text": "HELLO\nWORLD"}] + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "HELLO\nWORLD\nfoo\n"); + } + + #[gpui::test] + async fn test_streaming_final_input_stringified_edits_succeeds(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello\nworld\n"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_full(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "edit", + "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "HELLO\nWORLD\n"); + } + + // Verifies that after streaming_edit_file_tool edits a file, the action log + // reports changed buffers so that the Accept All / Reject All review UI appears. + #[gpui::test] + async fn test_streaming_edit_file_tool_registers_changed_buffers(cx: &mut TestAppContext) { + let (tool, _project, action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let (event_stream, _rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Edit lines".to_string(), + path: "root/file.txt".into(), + mode: EditFileMode::Edit, + content: None, + edits: Some(vec![Edit { + old_text: "line 2".into(), + new_text: "modified line 2".into(), + }]), + }), + event_stream, + cx, + ) + }); + + let result = task.await; + assert!(result.is_ok(), "edit should succeed: {:?}", result.err()); + + cx.run_until_parked(); + + let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); + assert!( + !changed.is_empty(), + "action_log.changed_buffers() should be non-empty after streaming edit, + but no changed buffers were found - Accept All / Reject All will not appear" + ); + } + + // Same test but for Write mode (overwrite entire file). + #[gpui::test] + async fn test_streaming_edit_file_tool_write_mode_registers_changed_buffers( + cx: &mut TestAppContext, + ) { + let (tool, _project, action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "original content"})).await; + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let (event_stream, _rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Overwrite file".to_string(), + path: "root/file.txt".into(), + mode: EditFileMode::Write, + content: Some("completely new content".into()), + edits: None, + }), + event_stream, + cx, + ) + }); + + let result = task.await; + assert!(result.is_ok(), "write should succeed: {:?}", result.err()); + + cx.run_until_parked(); + + let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); + assert!( + !changed.is_empty(), + "action_log.changed_buffers() should be non-empty after streaming write, \ + but no changed buffers were found \u{2014} Accept All / Reject All will not appear" ); } #[gpui::test] - async fn test_sensitive_settings_kind_detects_nonexistent_subdirectory( + async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode( cx: &mut TestAppContext, ) { - let fs = project::FakeFs::new(cx.executor()); - let config_dir = paths::config_dir(); - fs.insert_tree(&*config_dir.to_string_lossy(), json!({})) + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content", + "path": "root" + })); + cx.run_until_parked(); + + // Send final. + sender.send_full(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content", + "path": "root/file.txt" + })); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + + #[gpui::test] + async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode( + cx: &mut TestAppContext, + ) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}], + "path": "root" + })); + cx.run_until_parked(); + + // Send final. + sender.send_full(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}], + "path": "root/file.txt" + })); + cx.run_until_parked(); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + + #[gpui::test] + async fn test_streaming_edit_partial_last_line(cx: &mut TestAppContext) { + let file_content = indoc::indoc! {r#" + fn on_query_change(&mut self, cx: &mut Context) { + self.filter(cx); + } + + + + fn render_search(&self, cx: &mut Context) -> Div { + div() + } + "#} + .to_string(); + + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.rs": file_content})).await; + + // The model sends old_text with a PARTIAL last line. + let old_text = "}\n\n\n\nfn render_search"; + let new_text = "}\n\nfn render_search"; + + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_full(json!({ + "display_description": "Remove extra blank lines", + "path": "root/file.rs", + "mode": "edit", + "edits": [{"old_text": old_text, "new_text": new_text}] + })); + + let result = task.await; + let EditFileToolOutput::Success { + new_text: final_text, + .. + } = result.unwrap() + else { + panic!("expected success"); + }; + + // The edit should reduce 3 blank lines to 1 blank line before + // fn render_search, without duplicating the function signature. + let expected = file_content.replace("}\n\n\n\nfn render_search", "}\n\nfn render_search"); + pretty_assertions::assert_eq!( + final_text, + expected, + "Edit should only remove blank lines before render_search" + ); + } + + #[gpui::test] + async fn test_streaming_edit_preserves_blank_line_after_trailing_newline_replacement( + cx: &mut TestAppContext, + ) { + let file_content = "before\ntarget\n\nafter\n"; + let old_text = "target\n"; + let new_text = "one\ntwo\ntarget\n"; + let expected = "before\none\ntwo\ntarget\n\nafter\n"; + + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.rs": file_content})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_full(json!({ + "display_description": "description", + "path": "root/file.rs", + "mode": "edit", + "edits": [{"old_text": old_text, "new_text": new_text}] + })); + + let result = task.await; + + let EditFileToolOutput::Success { + new_text: final_text, + .. + } = result.unwrap() + else { + panic!("expected success"); + }; + + pretty_assertions::assert_eq!( + final_text, + expected, + "Edit should preserve a single blank line before test_after" + ); + } + + #[gpui::test] + async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) { + let (tool, _project, action_log, fs, _thread) = setup_test(cx, json!({"dir": {}})).await; + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + // Create a new file via the streaming edit file tool + let (event_stream, _rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.clone().run( + ToolInput::resolved(EditFileToolInput { + display_description: "Create new file".into(), + path: "root/dir/new_file.txt".into(), + mode: EditFileMode::Write, + content: Some("Hello, World!".into()), + edits: None, + }), + event_stream, + cx, + ) + }); + let result = task.await; + assert!(result.is_ok(), "create should succeed: {:?}", result.err()); + cx.run_until_parked(); + + assert!( + fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await, + "file should exist after creation" + ); + + // Reject all edits — this should delete the newly created file + let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); + assert!( + !changed.is_empty(), + "action_log should track the created file as changed" + ); + + action_log + .update(cx, |log, cx| log.reject_all_edits(None, cx)) .await; - let path = config_dir.join("nonexistent_subdir_xyz").join("evil.json"); + cx.run_until_parked(); + assert!( - matches!( - sensitive_settings_kind(&path, fs.as_ref()).await, - Some(SensitiveSettingsKind::Global) - ), - "Path in non-existent subdirectory of config dir should be detected as sensitive: {:?}", - path + !fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await, + "file should be deleted after rejecting creation, but an empty file was left behind" ); } - #[gpui::test] - async fn test_sensitive_settings_kind_detects_deeply_nested_nonexistent_subdirectory( - cx: &mut TestAppContext, - ) { - let fs = project::FakeFs::new(cx.executor()); - let config_dir = paths::config_dir(); - fs.insert_tree(&*config_dir.to_string_lossy(), json!({})) - .await; - let path = config_dir.join("a").join("b").join("c").join("evil.json"); - assert!( - matches!( - sensitive_settings_kind(&path, fs.as_ref()).await, - Some(SensitiveSettingsKind::Global) - ), - "Path in deeply nested non-existent subdirectory of config dir should be detected as sensitive: {:?}", - path - ); + #[test] + fn test_input_deserializes_double_encoded_fields() { + let input = serde_json::from_value::(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "\"edit\"", + "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" + })) + .expect("input should deserialize"); + + assert!(matches!(input.mode, EditFileMode::Edit)); + let edits = input.edits.expect("edits should deserialize"); + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].old_text, "hello\nworld"); + assert_eq!(edits[0].new_text, "HELLO\nWORLD"); + + let input = serde_json::from_value::(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "\"edit\"" + })) + .expect("input should deserialize"); + assert!(input.edits.is_none()); + + let input = serde_json::from_value::(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "\"edit\"", + "edits": null + })) + .expect("input should deserialize"); + assert!(input.edits.is_none()); + + let input = serde_json::from_value::(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "\"edit\"", + "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" + })) + .expect("input should deserialize"); + + assert!(matches!(input.mode, Some(EditFileMode::Edit))); + let edits = input.edits.expect("edits should deserialize"); + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].old_text.as_deref(), Some("hello\nworld")); + assert_eq!(edits[0].new_text.as_deref(), Some("HELLO\nWORLD")); + + let input = serde_json::from_value::(json!({ + "display_description": "Edit", + "path": "root/file.txt" + })) + .expect("input should deserialize"); + assert!(input.mode.is_none()); + assert!(input.edits.is_none()); + + let input = serde_json::from_value::(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": null, + "edits": null + })) + .expect("input should deserialize"); + assert!(input.mode.is_none()); + assert!(input.edits.is_none()); } - #[gpui::test] - async fn test_sensitive_settings_kind_returns_none_for_non_config_path( + async fn setup_test_with_fs( cx: &mut TestAppContext, + fs: Arc, + worktree_paths: &[&std::path::Path], + ) -> ( + Arc, + Entity, + Entity, + Arc, + Entity, ) { + let project = Project::test(fs.clone(), worktree_paths.iter().copied(), cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + let tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + action_log.clone(), + language_registry, + )); + (tool, project, action_log, fs, thread) + } + + async fn setup_test( + cx: &mut TestAppContext, + initial_tree: serde_json::Value, + ) -> ( + Arc, + Entity, + Entity, + Arc, + Entity, + ) { + init_test(cx); let fs = project::FakeFs::new(cx.executor()); - let path = PathBuf::from("/tmp/not_a_config_dir/some_file.json"); - assert!( - sensitive_settings_kind(&path, fs.as_ref()).await.is_none(), - "Path outside config dir should not be detected as sensitive: {:?}", - path - ); + fs.insert_tree("/root", initial_tree).await; + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .ensure_final_newline_on_save = Some(false); + }); + }); + }); } } diff --git a/crates/agent/src/edit_agent/reindent.rs b/crates/agent/src/tools/edit_file_tool/reindent.rs similarity index 100% rename from crates/agent/src/edit_agent/reindent.rs rename to crates/agent/src/tools/edit_file_tool/reindent.rs diff --git a/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs b/crates/agent/src/tools/edit_file_tool/streaming_fuzzy_matcher.rs similarity index 100% rename from crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs rename to crates/agent/src/tools/edit_file_tool/streaming_fuzzy_matcher.rs diff --git a/crates/agent/src/tools/evals/streaming_edit_file.rs b/crates/agent/src/tools/evals/streaming_edit_file.rs index c82f652daca..770e1f0effc 100644 --- a/crates/agent/src/tools/evals/streaming_edit_file.rs +++ b/crates/agent/src/tools/evals/streaming_edit_file.rs @@ -1,8 +1,8 @@ -use crate::tools::streaming_edit_file_tool::*; +use crate::tools::edit_file_tool::*; use crate::{ AgentTool, ContextServerRegistry, EditFileTool, GrepTool, GrepToolInput, ListDirectoryTool, - ListDirectoryToolInput, ReadFileTool, ReadFileToolInput, StreamingEditFileTool, Template, - Templates, Thread, ToolCallEventStream, ToolInput, + ListDirectoryToolInput, ReadFileTool, ReadFileToolInput, Template, Templates, Thread, + ToolCallEventStream, ToolInput, }; use Role::*; use anyhow::{Context as _, Result}; @@ -73,7 +73,7 @@ impl EvalInput { struct EvalSample { text_before: String, text_after: String, - tool_input: StreamingEditFileToolInput, + tool_input: EditFileToolInput, diff: String, } @@ -359,12 +359,10 @@ impl StreamingEditToolTest { .collect(); tools.push(LanguageModelRequestTool { name: EditFileTool::NAME.to_string(), - description: StreamingEditFileTool::description().to_string(), - input_schema: StreamingEditFileTool::input_schema( - LanguageModelToolSchemaFormat::JsonSchema, - ) - .to_value(), - use_input_streaming: StreamingEditFileTool::supports_input_streaming(), + description: EditFileTool::description().to_string(), + input_schema: EditFileTool::input_schema(LanguageModelToolSchemaFormat::JsonSchema) + .to_value(), + use_input_streaming: EditFileTool::supports_input_streaming(), }); tools } @@ -464,7 +462,7 @@ impl StreamingEditToolTest { }); let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - let tool = Arc::new(StreamingEditFileTool::new( + let tool = Arc::new(EditFileTool::new( self.project.clone(), thread.downgrade(), action_log, @@ -488,7 +486,7 @@ impl StreamingEditToolTest { } }; - let StreamingEditFileToolOutput::Success { new_text, .. } = &output else { + let EditFileToolOutput::Success { new_text, .. } = &output else { anyhow::bail!("Tool returned error output: {}", output); }; @@ -517,7 +515,7 @@ impl StreamingEditToolTest { &self, request: LanguageModelRequest, cx: &mut TestAppContext, - ) -> Result { + ) -> Result { let model = self.model.clone(); let events = cx .update(|cx| { @@ -539,7 +537,7 @@ impl StreamingEditToolTest { if tool_use.is_input_complete && tool_use.name.as_ref() == EditFileTool::NAME => { - let input: StreamingEditFileToolInput = serde_json::from_value(tool_use.input) + let input: EditFileToolInput = serde_json::from_value(tool_use.input) .context("Failed to parse tool input as StreamingEditFileToolInput")?; return Ok(input); } diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs deleted file mode 100644 index 7d229e1f53f..00000000000 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ /dev/null @@ -1,4410 +0,0 @@ -use super::edit_file_tool::EditFileTool; -use super::restore_file_from_disk_tool::RestoreFileFromDiskTool; -use super::save_file_tool::SaveFileTool; -use super::tool_edit_parser::{ToolEditEvent, ToolEditParser}; -use crate::ToolInputPayload; -use crate::{ - AgentTool, Thread, ToolCallEventStream, ToolInput, - edit_agent::{ - reindent::{Reindenter, compute_indent_delta}, - streaming_fuzzy_matcher::StreamingFuzzyMatcher, - }, -}; -use acp_thread::Diff; -use action_log::ActionLog; -use agent_client_protocol::schema::{self as acp, ToolCallLocation, ToolCallUpdateFields}; -use anyhow::Result; -use collections::HashSet; -use futures::FutureExt as _; -use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; -use language::language_settings::{self, FormatOnSave}; -use language::{Buffer, LanguageRegistry}; -use language_model::LanguageModelToolResultContent; -use project::lsp_store::{FormatTrigger, LspFormatTarget}; -use project::{AgentLocation, Project, ProjectPath}; -use schemars::JsonSchema; -use serde::{ - Deserialize, Deserializer, Serialize, - de::{DeserializeOwned, Error as _}, -}; -use std::ops::Range; -use std::path::PathBuf; -use std::sync::Arc; -use streaming_diff::{CharOperation, StreamingDiff}; -use text::ToOffset; -use ui::SharedString; -use util::rel_path::RelPath; -use util::{Deferred, ResultExt}; - -const DEFAULT_UI_TEXT: &str = "Editing file"; - -/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `move_path` tool instead. -/// -/// Before using this tool: -/// -/// 1. Use the `read_file` tool to understand the file's contents and context -/// -/// 2. Verify the directory path is correct (only applicable when creating new files): -/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct StreamingEditFileToolInput { - /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI. - /// - /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions. - /// - /// NEVER mention the file path in this description. - /// - /// Fix API endpoint URLs - /// Update copyright year in `page_footer` - /// - /// Make sure to include this field before all the others in the input object so that we can display it immediately. - pub display_description: String, - - /// The full path of the file to create or modify in the project. - /// - /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories. - /// - /// The following examples assume we have two root directories in the project: - /// - /a/b/backend - /// - /c/d/frontend - /// - /// - /// `backend/src/main.rs` - /// - /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail! - /// - /// - /// - /// `frontend/db.js` - /// - pub path: PathBuf, - - /// The mode of operation on the file. Possible values: - /// - 'write': Replace the entire contents of the file. If the file doesn't exist, it will be created. Requires 'content' field. - /// - 'edit': Make granular edits to an existing file. Requires 'edits' field. - /// - /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch. - #[serde(deserialize_with = "deserialize_maybe_stringified")] - pub mode: StreamingEditFileMode, - - /// The complete content for the new file (required for 'write' mode). - /// This field should contain the entire file content. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub content: Option, - - /// List of edit operations to apply sequentially (required for 'edit' mode). - /// Each edit finds `old_text` in the file and replaces it with `new_text`. - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_maybe_stringified" - )] - pub edits: Option>, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum StreamingEditFileMode { - /// Overwrite the file with new content (replacing any existing content). - /// If the file does not exist, it will be created. - Write, - /// Make granular edits to an existing file - Edit, -} - -/// A single edit operation that replaces old text with new text -/// Properly escape all text fields as valid JSON strings. -/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings. -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct Edit { - /// The exact text to find in the file. This will be matched using fuzzy matching - /// to handle minor differences in whitespace or formatting. - /// - /// Be minimal with replacements: - /// - For unique lines, include only those lines - /// - For non-unique lines, include enough context to identify them - pub old_text: String, - /// The text to replace it with - pub new_text: String, -} - -#[derive(Clone, Default, Debug, Deserialize)] -struct StreamingEditFileToolPartialInput { - #[serde(default)] - display_description: Option, - #[serde(default)] - path: Option, - #[serde(default, deserialize_with = "deserialize_maybe_stringified")] - mode: Option, - #[serde(default)] - content: Option, - #[serde(default, deserialize_with = "deserialize_maybe_stringified")] - edits: Option>, -} - -#[derive(Clone, Default, Debug, Deserialize)] -pub struct PartialEdit { - #[serde(default)] - pub old_text: Option, - #[serde(default)] - pub new_text: Option, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum ValueOrJsonString { - Value(T), - String(String), -} - -fn deserialize_maybe_stringified<'de, T, D>(deserializer: D) -> Result -where - T: DeserializeOwned, - D: Deserializer<'de>, -{ - match ValueOrJsonString::::deserialize(deserializer)? { - ValueOrJsonString::Value(value) => Ok(value), - ValueOrJsonString::String(string) => serde_json::from_str::(&string).map_err(|error| { - D::Error::custom(format!("failed to parse stringified value: {error}")) - }), - } -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum StreamingEditFileToolOutput { - Success { - #[serde(alias = "original_path")] - input_path: PathBuf, - new_text: String, - old_text: Arc, - #[serde(default)] - diff: String, - }, - Error { - error: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - input_path: Option, - #[serde(default, skip_serializing_if = "String::is_empty")] - diff: String, - }, -} - -impl StreamingEditFileToolOutput { - pub fn error(error: impl Into) -> Self { - Self::Error { - error: error.into(), - input_path: None, - diff: String::new(), - } - } -} - -impl std::fmt::Display for StreamingEditFileToolOutput { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - StreamingEditFileToolOutput::Success { - diff, input_path, .. - } => { - if diff.is_empty() { - write!(f, "No edits were made.") - } else { - write!( - f, - "Edited {}:\n\n```diff\n{diff}\n```", - input_path.display() - ) - } - } - StreamingEditFileToolOutput::Error { - error, - diff, - input_path, - } => { - write!(f, "{error}\n")?; - if let Some(input_path) = input_path - && !diff.is_empty() - { - write!( - f, - "Edited {}:\n\n```diff\n{diff}\n```", - input_path.display() - ) - } else { - write!(f, "No edits were made.") - } - } - } - } -} - -impl From for LanguageModelToolResultContent { - fn from(output: StreamingEditFileToolOutput) -> Self { - output.to_string().into() - } -} - -pub struct StreamingEditFileTool { - project: Entity, - thread: WeakEntity, - action_log: Entity, - language_registry: Arc, -} - -enum EditSessionResult { - Completed(EditSession), - Failed { - error: String, - session: Option, - }, -} - -impl StreamingEditFileTool { - pub fn new( - project: Entity, - thread: WeakEntity, - action_log: Entity, - language_registry: Arc, - ) -> Self { - Self { - project, - thread, - action_log, - language_registry, - } - } - - fn authorize( - &self, - path: &PathBuf, - description: &str, - event_stream: &ToolCallEventStream, - cx: &mut App, - ) -> Task> { - super::tool_permissions::authorize_file_edit( - EditFileTool::NAME, - path, - description, - &self.thread, - event_stream, - cx, - ) - } - - fn set_agent_location(&self, buffer: WeakEntity, position: text::Anchor, cx: &mut App) { - let should_update_agent_location = self - .thread - .read_with(cx, |thread, _cx| !thread.is_subagent()) - .unwrap_or_default(); - if should_update_agent_location { - self.project.update(cx, |project, cx| { - project.set_agent_location(Some(AgentLocation { buffer, position }), cx); - }); - } - } - - async fn ensure_buffer_saved(&self, buffer: &Entity, cx: &mut AsyncApp) { - let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { - let settings = language_settings::LanguageSettings::for_buffer(buffer, cx); - settings.format_on_save != FormatOnSave::Off - }); - - if format_on_save_enabled { - self.project - .update(cx, |project, cx| { - project.format( - HashSet::from_iter([buffer.clone()]), - LspFormatTarget::Buffers, - false, - FormatTrigger::Save, - cx, - ) - }) - .await - .log_err(); - } - - self.project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .await - .log_err(); - - self.action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - }); - } - - async fn process_streaming_edits( - &self, - input: &mut ToolInput, - event_stream: &ToolCallEventStream, - cx: &mut AsyncApp, - ) -> EditSessionResult { - let mut session: Option = None; - let mut last_partial: Option = None; - - loop { - futures::select! { - payload = input.next().fuse() => { - match payload { - Ok(payload) => match payload { - ToolInputPayload::Partial(partial) => { - if let Ok(parsed) = serde_json::from_value::(partial) { - let path_complete = parsed.path.is_some() - && parsed.path.as_ref() == last_partial.as_ref().and_then(|partial| partial.path.as_ref()); - - last_partial = Some(parsed.clone()); - - if session.is_none() - && path_complete - && let StreamingEditFileToolPartialInput { - path: Some(path), - display_description: Some(display_description), - mode: Some(mode), - .. - } = &parsed - { - match EditSession::new( - PathBuf::from(path), - display_description, - *mode, - self, - event_stream, - cx, - ) - .await - { - Ok(created_session) => session = Some(created_session), - Err(error) => { - log::error!("Failed to create edit session: {}", error); - return EditSessionResult::Failed { - error, - session: None, - }; - } - } - } - - if let Some(current_session) = &mut session - && let Err(error) = current_session.process(parsed, self, event_stream, cx) - { - log::error!("Failed to process edit: {}", error); - return EditSessionResult::Failed { error, session }; - } - } - } - ToolInputPayload::Full(full_input) => { - let mut session = if let Some(session) = session { - session - } else { - match EditSession::new( - full_input.path.clone(), - &full_input.display_description, - full_input.mode, - self, - event_stream, - cx, - ) - .await - { - Ok(created_session) => created_session, - Err(error) => { - log::error!("Failed to create edit session: {}", error); - return EditSessionResult::Failed { - error, - session: None, - }; - } - } - }; - - return match session.finalize(full_input, self, event_stream, cx).await { - Ok(()) => EditSessionResult::Completed(session), - Err(error) => { - log::error!("Failed to finalize edit: {}", error); - EditSessionResult::Failed { - error, - session: Some(session), - } - } - }; - } - ToolInputPayload::InvalidJson { error_message } => { - log::error!("Received invalid JSON: {error_message}"); - return EditSessionResult::Failed { - error: error_message, - session, - }; - } - }, - Err(error) => { - return EditSessionResult::Failed { - error: format!("Failed to receive tool input: {error}"), - session, - }; - } - } - } - _ = event_stream.cancelled_by_user().fuse() => { - return EditSessionResult::Failed { - error: "Edit cancelled by user".to_string(), - session, - }; - } - } - } - } -} - -impl AgentTool for StreamingEditFileTool { - type Input = StreamingEditFileToolInput; - type Output = StreamingEditFileToolOutput; - - const NAME: &'static str = "streaming_edit_file"; - - fn supports_input_streaming() -> bool { - true - } - - fn kind() -> acp::ToolKind { - acp::ToolKind::Edit - } - - fn initial_title( - &self, - input: Result, - cx: &mut App, - ) -> SharedString { - match input { - Ok(input) => self - .project - .read(cx) - .find_project_path(&input.path, cx) - .and_then(|project_path| { - self.project - .read(cx) - .short_full_path_for_project_path(&project_path, cx) - }) - .unwrap_or(input.path.to_string_lossy().into_owned()) - .into(), - Err(raw_input) => { - if let Ok(input) = - serde_json::from_value::(raw_input) - { - let path = input.path.unwrap_or_default(); - let path = path.trim(); - if !path.is_empty() { - return self - .project - .read(cx) - .find_project_path(&path, cx) - .and_then(|project_path| { - self.project - .read(cx) - .short_full_path_for_project_path(&project_path, cx) - }) - .unwrap_or_else(|| path.to_string()) - .into(); - } - - let description = input.display_description.unwrap_or_default(); - let description = description.trim(); - if !description.is_empty() { - return description.to_string().into(); - } - } - - DEFAULT_UI_TEXT.into() - } - } - } - - fn run( - self: Arc, - mut input: ToolInput, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - cx.spawn(async move |cx: &mut AsyncApp| { - match self - .process_streaming_edits(&mut input, &event_stream, cx) - .await - { - EditSessionResult::Completed(session) => { - self.ensure_buffer_saved(&session.buffer, cx).await; - let (new_text, diff) = session.compute_new_text_and_diff(cx).await; - Ok(StreamingEditFileToolOutput::Success { - old_text: session.old_text.clone(), - new_text, - input_path: session.input_path, - diff, - }) - } - EditSessionResult::Failed { - error, - session: Some(session), - } => { - self.ensure_buffer_saved(&session.buffer, cx).await; - let (_new_text, diff) = session.compute_new_text_and_diff(cx).await; - Err(StreamingEditFileToolOutput::Error { - error, - input_path: Some(session.input_path), - diff, - }) - } - EditSessionResult::Failed { - error, - session: None, - } => Err(StreamingEditFileToolOutput::Error { - error, - input_path: None, - diff: String::new(), - }), - } - }) - } - - fn replay( - &self, - _input: Self::Input, - output: Self::Output, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Result<()> { - match output { - StreamingEditFileToolOutput::Success { - input_path, - old_text, - new_text, - .. - } => { - event_stream.update_diff(cx.new(|cx| { - Diff::finalized( - input_path.to_string_lossy().into_owned(), - Some(old_text.to_string()), - new_text, - self.language_registry.clone(), - cx, - ) - })); - Ok(()) - } - StreamingEditFileToolOutput::Error { .. } => Ok(()), - } - } -} - -pub struct EditSession { - abs_path: PathBuf, - input_path: PathBuf, - buffer: Entity, - old_text: Arc, - diff: Entity, - mode: StreamingEditFileMode, - parser: ToolEditParser, - pipeline: EditPipeline, - file_changed_since_last_read: bool, - _finalize_diff_guard: Deferred>, -} - -struct EditPipeline { - current_edit: Option, - content_written: bool, -} - -enum EditPipelineEntry { - ResolvingOldText { - matcher: StreamingFuzzyMatcher, - }, - StreamingNewText { - streaming_diff: StreamingDiff, - edit_cursor: usize, - reindenter: Reindenter, - original_snapshot: text::BufferSnapshot, - }, -} - -impl EditPipeline { - fn new() -> Self { - Self { - current_edit: None, - content_written: false, - } - } - - fn ensure_resolving_old_text(&mut self, buffer: &Entity, cx: &mut AsyncApp) { - if self.current_edit.is_none() { - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); - self.current_edit = Some(EditPipelineEntry::ResolvingOldText { - matcher: StreamingFuzzyMatcher::new(snapshot), - }); - } - } -} - -impl EditSession { - async fn new( - path: PathBuf, - display_description: &str, - mode: StreamingEditFileMode, - tool: &StreamingEditFileTool, - event_stream: &ToolCallEventStream, - cx: &mut AsyncApp, - ) -> Result { - let project_path = cx.update(|cx| resolve_path(mode, &path, &tool.project, cx))?; - - let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx)) - else { - return Err(format!( - "Worktree at '{}' does not exist", - path.to_string_lossy() - )); - }; - - event_stream.update_fields( - ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]), - ); - - cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx)) - .await - .map_err(|e| e.to_string())?; - - let buffer = tool - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)) - .await - .map_err(|e| e.to_string())?; - - let file_changed_since_last_read = ensure_buffer_saved(&buffer, &abs_path, tool, cx)?; - - let diff = cx.new(|cx| Diff::new(buffer.clone(), cx)); - event_stream.update_diff(diff.clone()); - let finalize_diff_guard = util::defer(Box::new({ - let diff = diff.downgrade(); - let mut cx = cx.clone(); - move || { - diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); - } - }) as Box); - - tool.action_log.update(cx, |log, cx| match mode { - StreamingEditFileMode::Write => log.buffer_created(buffer.clone(), cx), - StreamingEditFileMode::Edit => log.buffer_read(buffer.clone(), cx), - }); - - let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let old_text = cx - .background_spawn({ - let old_snapshot = old_snapshot.clone(); - async move { Arc::new(old_snapshot.text()) } - }) - .await; - - Ok(Self { - abs_path, - input_path: path, - buffer, - old_text, - diff, - mode, - parser: ToolEditParser::default(), - pipeline: EditPipeline::new(), - file_changed_since_last_read, - _finalize_diff_guard: finalize_diff_guard, - }) - } - - async fn finalize( - &mut self, - input: StreamingEditFileToolInput, - tool: &StreamingEditFileTool, - event_stream: &ToolCallEventStream, - cx: &mut AsyncApp, - ) -> Result<(), String> { - match input.mode { - StreamingEditFileMode::Write => { - let content = input - .content - .ok_or_else(|| "'content' field is required for write mode".to_string())?; - - let events = self.parser.finalize_content(&content); - self.process_events(&events, tool, event_stream, cx)?; - } - StreamingEditFileMode::Edit => { - let edits = input - .edits - .ok_or_else(|| "'edits' field is required for edit mode".to_string())?; - let events = self.parser.finalize_edits(&edits); - self.process_events(&events, tool, event_stream, cx)?; - - if log::log_enabled!(log::Level::Debug) { - log::debug!("Got edits:"); - for edit in &edits { - log::debug!( - " old_text: '{}', new_text: '{}'", - edit.old_text.replace('\n', "\\n"), - edit.new_text.replace('\n', "\\n") - ); - } - } - } - } - Ok(()) - } - - async fn compute_new_text_and_diff(&self, cx: &mut AsyncApp) -> (String, String) { - let new_snapshot = self.buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let (new_text, unified_diff) = cx - .background_spawn({ - let new_snapshot = new_snapshot.clone(); - let old_text = self.old_text.clone(); - async move { - let new_text = new_snapshot.text(); - let diff = language::unified_diff(&old_text, &new_text); - (new_text, diff) - } - }) - .await; - (new_text, unified_diff) - } - - fn process( - &mut self, - partial: StreamingEditFileToolPartialInput, - tool: &StreamingEditFileTool, - event_stream: &ToolCallEventStream, - cx: &mut AsyncApp, - ) -> Result<(), String> { - match &self.mode { - StreamingEditFileMode::Write => { - if let Some(content) = &partial.content { - let events = self.parser.push_content(content); - self.process_events(&events, tool, event_stream, cx)?; - } - } - StreamingEditFileMode::Edit => { - if let Some(edits) = partial.edits { - let events = self.parser.push_edits(&edits); - self.process_events(&events, tool, event_stream, cx)?; - } - } - } - Ok(()) - } - - fn process_events( - &mut self, - events: &[ToolEditEvent], - tool: &StreamingEditFileTool, - event_stream: &ToolCallEventStream, - cx: &mut AsyncApp, - ) -> Result<(), String> { - for event in events { - match event { - ToolEditEvent::ContentChunk { chunk } => { - let (buffer_id, buffer_len) = self - .buffer - .read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len())); - let edit_range = if self.pipeline.content_written { - buffer_len..buffer_len - } else { - 0..buffer_len - }; - - agent_edit_buffer( - &self.buffer, - [(edit_range, chunk.as_str())], - &tool.action_log, - cx, - ); - cx.update(|cx| { - tool.set_agent_location( - self.buffer.downgrade(), - text::Anchor::max_for_buffer(buffer_id), - cx, - ); - }); - self.pipeline.content_written = true; - } - - ToolEditEvent::OldTextChunk { - chunk, done: false, .. - } => { - log::debug!("old_text_chunk: done=false, chunk='{}'", chunk); - self.pipeline.ensure_resolving_old_text(&self.buffer, cx); - - if let Some(EditPipelineEntry::ResolvingOldText { matcher }) = - &mut self.pipeline.current_edit - && !chunk.is_empty() - { - if let Some(match_range) = matcher.push(chunk, None) { - let anchor_range = self.buffer.read_with(cx, |buffer, _cx| { - buffer.anchor_range_outside(match_range.clone()) - }); - self.diff - .update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); - - cx.update(|cx| { - let position = self.buffer.read(cx).anchor_before(match_range.end); - tool.set_agent_location(self.buffer.downgrade(), position, cx); - }); - } - } - } - - ToolEditEvent::OldTextChunk { - edit_index, - chunk, - done: true, - } => { - log::debug!("old_text_chunk: done=true, chunk='{}'", chunk); - - self.pipeline.ensure_resolving_old_text(&self.buffer, cx); - - let Some(EditPipelineEntry::ResolvingOldText { matcher }) = - &mut self.pipeline.current_edit - else { - continue; - }; - - if !chunk.is_empty() { - matcher.push(chunk, None); - } - let range = extract_match( - matcher.finish(), - &self.buffer, - edit_index, - self.file_changed_since_last_read, - cx, - )?; - - let anchor_range = self - .buffer - .read_with(cx, |buffer, _cx| buffer.anchor_range_outside(range.clone())); - self.diff - .update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); - - let snapshot = self.buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - - let line = snapshot.offset_to_point(range.start).row; - event_stream.update_fields( - ToolCallUpdateFields::new().locations(vec![ - ToolCallLocation::new(&self.abs_path).line(Some(line)), - ]), - ); - - let buffer_indent = snapshot.line_indent_for_row(line); - let query_indent = text::LineIndent::from_iter( - matcher - .query_lines() - .first() - .map(|s| s.as_str()) - .unwrap_or("") - .chars(), - ); - let indent_delta = compute_indent_delta(buffer_indent, query_indent); - - let old_text_in_buffer = - snapshot.text_for_range(range.clone()).collect::(); - - log::debug!( - "edit[{}] old_text matched at {}..{}: {:?}", - edit_index, - range.start, - range.end, - old_text_in_buffer, - ); - - let text_snapshot = self - .buffer - .read_with(cx, |buffer, _cx| buffer.text_snapshot()); - self.pipeline.current_edit = Some(EditPipelineEntry::StreamingNewText { - streaming_diff: StreamingDiff::new(old_text_in_buffer), - edit_cursor: range.start, - reindenter: Reindenter::new(indent_delta), - original_snapshot: text_snapshot, - }); - - cx.update(|cx| { - let position = self.buffer.read(cx).anchor_before(range.end); - tool.set_agent_location(self.buffer.downgrade(), position, cx); - }); - } - - ToolEditEvent::NewTextChunk { - chunk, done: false, .. - } => { - log::debug!("new_text_chunk: done=false, chunk='{}'", chunk); - - let Some(EditPipelineEntry::StreamingNewText { - streaming_diff, - edit_cursor, - reindenter, - original_snapshot, - .. - }) = &mut self.pipeline.current_edit - else { - continue; - }; - - let reindented = reindenter.push(chunk); - if reindented.is_empty() { - continue; - } - - let char_ops = streaming_diff.push_new(&reindented); - apply_char_operations( - &char_ops, - &self.buffer, - original_snapshot, - edit_cursor, - &tool.action_log, - cx, - ); - - let position = original_snapshot.anchor_before(*edit_cursor); - cx.update(|cx| { - tool.set_agent_location(self.buffer.downgrade(), position, cx); - }); - } - - ToolEditEvent::NewTextChunk { - chunk, done: true, .. - } => { - log::debug!("new_text_chunk: done=true, chunk='{}'", chunk); - - let Some(EditPipelineEntry::StreamingNewText { - mut streaming_diff, - mut edit_cursor, - mut reindenter, - original_snapshot, - }) = self.pipeline.current_edit.take() - else { - continue; - }; - - // Flush any remaining reindent buffer + final chunk. - let mut final_text = reindenter.push(chunk); - final_text.push_str(&reindenter.finish()); - - log::debug!("new_text_chunk: done=true, final_text='{}'", final_text); - - if !final_text.is_empty() { - let char_ops = streaming_diff.push_new(&final_text); - apply_char_operations( - &char_ops, - &self.buffer, - &original_snapshot, - &mut edit_cursor, - &tool.action_log, - cx, - ); - } - - let remaining_ops = streaming_diff.finish(); - apply_char_operations( - &remaining_ops, - &self.buffer, - &original_snapshot, - &mut edit_cursor, - &tool.action_log, - cx, - ); - - let position = original_snapshot.anchor_before(edit_cursor); - cx.update(|cx| { - tool.set_agent_location(self.buffer.downgrade(), position, cx); - }); - } - } - } - Ok(()) - } -} - -fn apply_char_operations( - ops: &[CharOperation], - buffer: &Entity, - snapshot: &text::BufferSnapshot, - edit_cursor: &mut usize, - action_log: &Entity, - cx: &mut AsyncApp, -) { - for op in ops { - match op { - CharOperation::Insert { text } => { - let anchor = snapshot.anchor_after(*edit_cursor); - agent_edit_buffer(&buffer, [(anchor..anchor, text.as_str())], action_log, cx); - } - CharOperation::Delete { bytes } => { - let delete_end = *edit_cursor + bytes; - let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end); - agent_edit_buffer(&buffer, [(anchor_range, "")], action_log, cx); - *edit_cursor = delete_end; - } - CharOperation::Keep { bytes } => { - *edit_cursor += bytes; - } - } - } -} - -fn extract_match( - matches: Vec>, - buffer: &Entity, - edit_index: &usize, - file_changed_since_last_read: bool, - cx: &mut AsyncApp, -) -> Result, String> { - let file_changed_since_last_read_message = if file_changed_since_last_read { - " The file has changed on disk since you last read it." - } else { - "" - }; - - match matches.len() { - 0 => Err(format!( - "Could not find matching text for edit at index {}. \ - The old_text did not match any content in the file.{} \ - Please read the file again to get the current content.", - edit_index, file_changed_since_last_read_message, - )), - 1 => Ok(matches.into_iter().next().unwrap()), - _ => { - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let lines = matches - .iter() - .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string()) - .collect::>() - .join(", "); - Err(format!( - "Edit {} matched multiple locations in the file at lines: {}. \ - Please provide more context in old_text to uniquely \ - identify the location.", - edit_index, lines - )) - } - } -} - -/// Edits a buffer and reports the edit to the action log in the same effect -/// cycle. This ensures the action log's subscription handler sees the version -/// already updated by `buffer_edited`, so it does not misattribute the agent's -/// edit as a user edit. -fn agent_edit_buffer( - buffer: &Entity, - edits: I, - action_log: &Entity, - cx: &mut AsyncApp, -) where - I: IntoIterator, T)>, - S: ToOffset, - T: Into>, -{ - cx.update(|cx| { - buffer.update(cx, |buffer, cx| { - buffer.edit(edits, None, cx); - }); - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - }); -} - -fn ensure_buffer_saved( - buffer: &Entity, - abs_path: &PathBuf, - tool: &StreamingEditFileTool, - cx: &mut AsyncApp, -) -> Result { - let last_read_mtime = tool - .action_log - .read_with(cx, |log, _| log.file_read_time(abs_path)); - let check_result = tool.thread.read_with(cx, |thread, cx| { - let current = buffer - .read(cx) - .file() - .and_then(|file| file.disk_state().mtime()); - let dirty = buffer.read(cx).is_dirty(); - let has_save = thread.has_tool(SaveFileTool::NAME); - let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME); - (current, dirty, has_save, has_restore) - }); - - let Ok((current_mtime, is_dirty, has_save_tool, has_restore_tool)) = check_result else { - return Ok(false); - }; - - if is_dirty { - let message = match (has_save_tool, has_restore_tool) { - (true, true) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ - If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." - } - (true, false) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ - If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed." - } - (false, true) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \ - If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." - } - (false, false) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ - then ask them to save or revert the file manually and inform you when it's ok to proceed." - } - }; - return Err(message.to_string()); - } - - if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) - && current != last_read - { - return Ok(true); - } - - Ok(false) -} - -fn resolve_path( - mode: StreamingEditFileMode, - path: &PathBuf, - project: &Entity, - cx: &mut App, -) -> Result { - let project = project.read(cx); - - match mode { - StreamingEditFileMode::Edit => { - let path = project - .find_project_path(&path, cx) - .ok_or_else(|| "Can't edit file: path not found".to_string())?; - - let entry = project - .entry_for_path(&path, cx) - .ok_or_else(|| "Can't edit file: path not found".to_string())?; - - if entry.is_file() { - Ok(path) - } else { - Err("Can't edit file: path is a directory".to_string()) - } - } - StreamingEditFileMode::Write => { - if let Some(path) = project.find_project_path(&path, cx) - && let Some(entry) = project.entry_for_path(&path, cx) - { - if entry.is_file() { - return Ok(path); - } else { - return Err("Can't write to file: path is a directory".to_string()); - } - } - - let parent_path = path - .parent() - .ok_or_else(|| "Can't create file: incorrect path".to_string())?; - - let parent_project_path = project.find_project_path(&parent_path, cx); - - let parent_entry = parent_project_path - .as_ref() - .and_then(|path| project.entry_for_path(path, cx)) - .ok_or_else(|| "Can't create file: parent directory doesn't exist")?; - - if !parent_entry.is_dir() { - return Err("Can't create file: parent is not a directory".to_string()); - } - - let file_name = path - .file_name() - .and_then(|file_name| file_name.to_str()) - .and_then(|file_name| RelPath::unix(file_name).ok()) - .ok_or_else(|| "Can't create file: invalid filename".to_string())?; - - let new_file_path = parent_project_path.map(|parent| ProjectPath { - path: parent.path.join(file_name), - ..parent - }); - - new_file_path.ok_or_else(|| "Can't create file".to_string()) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ContextServerRegistry, Templates, ToolInputSender}; - use fs::Fs as _; - use futures::StreamExt as _; - use gpui::{TestAppContext, UpdateGlobal}; - use language_model::fake_provider::FakeLanguageModel; - use prompt_store::ProjectContext; - use serde_json::json; - use settings::Settings; - use settings::SettingsStore; - use util::path; - use util::rel_path::rel_path; - - #[gpui::test] - async fn test_streaming_edit_create_file(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Create new file".into(), - path: "root/dir/new_file.txt".into(), - mode: StreamingEditFileMode::Write, - content: Some("Hello, World!".into()), - edits: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let StreamingEditFileToolOutput::Success { new_text, diff, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "Hello, World!"); - assert!(!diff.is_empty()); - } - - #[gpui::test] - async fn test_streaming_edit_overwrite_file(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "old content"})).await; - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Overwrite file".into(), - path: "root/file.txt".into(), - mode: StreamingEditFileMode::Write, - content: Some("new content".into()), - edits: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let StreamingEditFileToolOutput::Success { - new_text, old_text, .. - } = result.unwrap() - else { - panic!("expected success"); - }; - assert_eq!(new_text, "new content"); - assert_eq!(*old_text, "old content"); - } - - #[gpui::test] - async fn test_streaming_edit_granular_edits(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit lines".into(), - path: "root/file.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![Edit { - old_text: "line 2".into(), - new_text: "modified line 2".into(), - }]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); - } - - #[gpui::test] - async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test( - cx, - json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), - ) - .await; - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit multiple lines".into(), - path: "root/file.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![ - Edit { - old_text: "line 5".into(), - new_text: "modified line 5".into(), - }, - Edit { - old_text: "line 1".into(), - new_text: "modified line 1".into(), - }, - ]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!( - new_text, - "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n" - ); - } - - #[gpui::test] - async fn test_streaming_edit_adjacent_edits(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test( - cx, - json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), - ) - .await; - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit adjacent lines".into(), - path: "root/file.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![ - Edit { - old_text: "line 2".into(), - new_text: "modified line 2".into(), - }, - Edit { - old_text: "line 3".into(), - new_text: "modified line 3".into(), - }, - ]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!( - new_text, - "line 1\nmodified line 2\nmodified line 3\nline 4\nline 5\n" - ); - } - - #[gpui::test] - async fn test_streaming_edit_ascending_order_edits(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test( - cx, - json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), - ) - .await; - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit multiple lines in ascending order".into(), - path: "root/file.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![ - Edit { - old_text: "line 1".into(), - new_text: "modified line 1".into(), - }, - Edit { - old_text: "line 5".into(), - new_text: "modified line 5".into(), - }, - ]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!( - new_text, - "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n" - ); - } - - #[gpui::test] - async fn test_streaming_edit_nonexistent_file(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await; - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Some edit".into(), - path: "root/nonexistent_file.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![Edit { - old_text: "foo".into(), - new_text: "bar".into(), - }]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let StreamingEditFileToolOutput::Error { - error, - diff, - input_path, - } = result.unwrap_err() - else { - panic!("expected error"); - }; - assert_eq!(error, "Can't edit file: path not found"); - assert!(diff.is_empty()); - assert_eq!(input_path, None); - } - - #[gpui::test] - async fn test_streaming_edit_failed_match(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "hello world"})).await; - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit file".into(), - path: "root/file.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![Edit { - old_text: "nonexistent text that is not in the file".into(), - new_text: "replacement".into(), - }]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let StreamingEditFileToolOutput::Error { error, .. } = result.unwrap_err() else { - panic!("expected error"); - }; - assert!( - error.contains("Could not find matching text"), - "Expected error containing 'Could not find matching text' but got: {error}" - ); - } - - #[gpui::test] - async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Send partials simulating LLM streaming: description first, then path, then mode - sender.send_partial(json!({"display_description": "Edit lines"})); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Edit lines", - "path": "root/file.txt" - })); - cx.run_until_parked(); - - // Path is NOT yet complete because mode hasn't appeared — no buffer open yet - sender.send_partial(json!({ - "display_description": "Edit lines", - "path": "root/file.txt", - "mode": "edit" - })); - cx.run_until_parked(); - - // Now send the final complete input - sender.send_full(json!({ - "display_description": "Edit lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); - } - - #[gpui::test] - async fn test_streaming_path_completeness_heuristic(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "hello world"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Send partial with path but NO mode — path should NOT be treated as complete - sender.send_partial(json!({ - "display_description": "Overwrite file", - "path": "root/file" - })); - cx.run_until_parked(); - - // Now the path grows and mode appears - sender.send_partial(json!({ - "display_description": "Overwrite file", - "path": "root/file.txt", - "mode": "write" - })); - cx.run_until_parked(); - - // Send final - sender.send_full(json!({ - "display_description": "Overwrite file", - "path": "root/file.txt", - "mode": "write", - "content": "new content" - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "new content"); - } - - #[gpui::test] - async fn test_streaming_cancellation_during_partials(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "hello world"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver, mut cancellation_tx) = - ToolCallEventStream::test_with_cancellation(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Send a partial - sender.send_partial(json!({"display_description": "Edit"})); - cx.run_until_parked(); - - // Cancel during streaming - ToolCallEventStream::signal_cancellation_with_sender(&mut cancellation_tx); - cx.run_until_parked(); - - // The sender is still alive so the partial loop should detect cancellation - // We need to drop the sender to also unblock recv() if the loop didn't catch it - drop(sender); - - let result = task.await; - let StreamingEditFileToolOutput::Error { error, .. } = result.unwrap_err() else { - panic!("expected error"); - }; - assert!( - error.contains("cancelled"), - "Expected cancellation error but got: {error}" - ); - } - - #[gpui::test] - async fn test_streaming_edit_with_multiple_partials(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test( - cx, - json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), - ) - .await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Simulate fine-grained streaming of the JSON - sender.send_partial(json!({"display_description": "Edit multiple"})); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Edit multiple lines", - "path": "root/file.txt" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Edit multiple lines", - "path": "root/file.txt", - "mode": "edit" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Edit multiple lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "line 1"}] - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Edit multiple lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "line 1", "new_text": "modified line 1"}, - {"old_text": "line 5"} - ] - })); - cx.run_until_parked(); - - // Send final complete input - sender.send_full(json!({ - "display_description": "Edit multiple lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "line 1", "new_text": "modified line 1"}, - {"old_text": "line 5", "new_text": "modified line 5"} - ] - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!( - new_text, - "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n" - ); - } - - #[gpui::test] - async fn test_streaming_create_file_with_partials(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Stream partials for create mode - sender.send_partial(json!({"display_description": "Create new file"})); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Create new file", - "path": "root/dir/new_file.txt", - "mode": "write" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Create new file", - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "Hello, " - })); - cx.run_until_parked(); - - // Final with full content - sender.send_full(json!({ - "display_description": "Create new file", - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "Hello, World!" - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "Hello, World!"); - } - - #[gpui::test] - async fn test_streaming_no_partials_direct_final(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Send final immediately with no partials (simulates non-streaming path) - sender.send_full(json!({ - "display_description": "Edit lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); - } - - #[gpui::test] - async fn test_streaming_incremental_edit_application(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = setup_test( - cx, - json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), - ) - .await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Stream description, path, mode - sender.send_partial(json!({"display_description": "Edit multiple lines"})); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Edit multiple lines", - "path": "root/file.txt", - "mode": "edit" - })); - cx.run_until_parked(); - - // First edit starts streaming (old_text only, still in progress) - sender.send_partial(json!({ - "display_description": "Edit multiple lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "line 1"}] - })); - cx.run_until_parked(); - - // Buffer should not have changed yet — the first edit is still in progress - // (no second edit has appeared to prove the first is complete) - let buffer_text = project.update(cx, |project, cx| { - let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx); - project_path.and_then(|pp| { - project - .get_open_buffer(&pp, cx) - .map(|buffer| buffer.read(cx).text()) - }) - }); - // Buffer is open (from streaming) but edit 1 is still in-progress - assert_eq!( - buffer_text.as_deref(), - Some("line 1\nline 2\nline 3\nline 4\nline 5\n"), - "Buffer should not be modified while first edit is still in progress" - ); - - // Second edit appears — this proves the first edit is complete, so it - // should be applied immediately during streaming - sender.send_partial(json!({ - "display_description": "Edit multiple lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "line 1", "new_text": "MODIFIED 1"}, - {"old_text": "line 5"} - ] - })); - cx.run_until_parked(); - - // First edit should now be applied to the buffer - let buffer_text = project.update(cx, |project, cx| { - let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx); - project_path.and_then(|pp| { - project - .get_open_buffer(&pp, cx) - .map(|buffer| buffer.read(cx).text()) - }) - }); - assert_eq!( - buffer_text.as_deref(), - Some("MODIFIED 1\nline 2\nline 3\nline 4\nline 5\n"), - "First edit should be applied during streaming when second edit appears" - ); - - // Send final complete input - sender.send_full(json!({ - "display_description": "Edit multiple lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "line 1", "new_text": "MODIFIED 1"}, - {"old_text": "line 5", "new_text": "MODIFIED 5"} - ] - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { - new_text, old_text, .. - } = result.unwrap() - else { - panic!("expected success"); - }; - assert_eq!(new_text, "MODIFIED 1\nline 2\nline 3\nline 4\nMODIFIED 5\n"); - assert_eq!( - *old_text, "line 1\nline 2\nline 3\nline 4\nline 5\n", - "old_text should reflect the original file content before any edits" - ); - } - - #[gpui::test] - async fn test_streaming_incremental_three_edits(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Setup: description + path + mode - sender.send_partial(json!({ - "display_description": "Edit three lines", - "path": "root/file.txt", - "mode": "edit" - })); - cx.run_until_parked(); - - // Edit 1 in progress - sender.send_partial(json!({ - "display_description": "Edit three lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "aaa", "new_text": "AAA"}] - })); - cx.run_until_parked(); - - // Edit 2 appears — edit 1 is now complete and should be applied - sender.send_partial(json!({ - "display_description": "Edit three lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "aaa", "new_text": "AAA"}, - {"old_text": "ccc", "new_text": "CCC"} - ] - })); - cx.run_until_parked(); - - // Verify edit 1 fully applied. Edit 2's new_text is being - // streamed: "CCC" is inserted but the old "ccc" isn't deleted - // yet (StreamingDiff::finish runs when edit 3 marks edit 2 done). - let buffer_text = project.update(cx, |project, cx| { - let pp = project - .find_project_path(&PathBuf::from("root/file.txt"), cx) - .unwrap(); - project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) - }); - assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCCccc\nddd\neee\n")); - - // Edit 3 appears — edit 2 is now complete and should be applied - sender.send_partial(json!({ - "display_description": "Edit three lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "aaa", "new_text": "AAA"}, - {"old_text": "ccc", "new_text": "CCC"}, - {"old_text": "eee", "new_text": "EEE"} - ] - })); - cx.run_until_parked(); - - // Verify edits 1 and 2 fully applied. Edit 3's new_text is being - // streamed: "EEE" is inserted but old "eee" isn't deleted yet. - let buffer_text = project.update(cx, |project, cx| { - let pp = project - .find_project_path(&PathBuf::from("root/file.txt"), cx) - .unwrap(); - project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) - }); - assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCC\nddd\nEEEeee\n")); - - // Send final - sender.send_full(json!({ - "display_description": "Edit three lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "aaa", "new_text": "AAA"}, - {"old_text": "ccc", "new_text": "CCC"}, - {"old_text": "eee", "new_text": "EEE"} - ] - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "AAA\nbbb\nCCC\nddd\nEEE\n"); - } - - #[gpui::test] - async fn test_streaming_edit_failure_mid_stream(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Setup - sender.send_partial(json!({ - "display_description": "Edit lines", - "path": "root/file.txt", - "mode": "edit" - })); - cx.run_until_parked(); - - // Edit 1 (valid) in progress — not yet complete (no second edit) - sender.send_partial(json!({ - "display_description": "Edit lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "line 1", "new_text": "MODIFIED"} - ] - })); - cx.run_until_parked(); - - // Edit 2 appears (will fail to match) — this makes edit 1 complete. - // Edit 1 should be applied. Edit 2 is still in-progress (last edit). - sender.send_partial(json!({ - "display_description": "Edit lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "line 1", "new_text": "MODIFIED"}, - {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"} - ] - })); - cx.run_until_parked(); - - let buffer = project.update(cx, |project, cx| { - let pp = project - .find_project_path(&PathBuf::from("root/file.txt"), cx) - .unwrap(); - project.get_open_buffer(&pp, cx).unwrap() - }); - - // Verify edit 1 was applied - let buffer_text = buffer.read_with(cx, |buffer, _cx| buffer.text()); - assert_eq!( - buffer_text, "MODIFIED\nline 2\nline 3\n", - "First edit should be applied even though second edit will fail" - ); - - // Edit 3 appears — this makes edit 2 "complete", triggering its - // resolution which should fail (old_text doesn't exist in the file). - sender.send_partial(json!({ - "display_description": "Edit lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "line 1", "new_text": "MODIFIED"}, - {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"}, - {"old_text": "line 3", "new_text": "MODIFIED 3"} - ] - })); - cx.run_until_parked(); - - // The error from edit 2 should have propagated out of the partial loop. - // Drop sender to unblock recv() if the loop didn't catch it. - drop(sender); - - let result = task.await; - let StreamingEditFileToolOutput::Error { - error, - diff, - input_path, - } = result.unwrap_err() - else { - panic!("expected error"); - }; - - assert!( - error.contains("Could not find matching text for edit at index 1"), - "Expected error about edit 1 failing, got: {error}" - ); - // Ensure that first edit was applied successfully and that we saved the buffer - assert_eq!(input_path, Some(PathBuf::from("root/file.txt"))); - assert_eq!( - diff, - "@@ -1,3 +1,3 @@\n-line 1\n+MODIFIED\n line 2\n line 3\n" - ); - } - - #[gpui::test] - async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "hello world\n"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Setup + single edit that stays in-progress (no second edit to prove completion) - sender.send_partial(json!({ - "display_description": "Single edit", - "path": "root/file.txt", - "mode": "edit", - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Single edit", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "hello world", "new_text": "goodbye world"}] - })); - cx.run_until_parked(); - - // The edit's old_text and new_text both arrived in one partial, so - // the old_text is resolved and new_text is being streamed via - // StreamingDiff. The buffer reflects the in-progress diff (new text - // inserted, old text not yet fully removed until finalization). - let buffer_text = project.update(cx, |project, cx| { - let pp = project - .find_project_path(&PathBuf::from("root/file.txt"), cx) - .unwrap(); - project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) - }); - assert_eq!( - buffer_text.as_deref(), - Some("goodbye worldhello world\n"), - "In-progress streaming diff: new text inserted, old text not yet removed" - ); - - // Send final — the edit is applied during finalization - sender.send_full(json!({ - "display_description": "Single edit", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "hello world", "new_text": "goodbye world"}] - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "goodbye world\n"); - } - - #[gpui::test] - async fn test_streaming_input_partials_then_final(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; - let (mut sender, input): (ToolInputSender, ToolInput) = - ToolInput::test(); - let (event_stream, _event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Send progressively more complete partial snapshots, as the LLM would - sender.send_partial(json!({ - "display_description": "Edit lines" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Edit lines", - "path": "root/file.txt", - "mode": "edit" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Edit lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] - })); - cx.run_until_parked(); - - // Send the final complete input - sender.send_full(json!({ - "display_description": "Edit lines", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); - } - - #[gpui::test] - async fn test_streaming_input_sender_dropped_before_final(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "hello world\n"})).await; - let (mut sender, input): (ToolInputSender, ToolInput) = - ToolInput::test(); - let (event_stream, _event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Send a partial then drop the sender without sending final - sender.send_partial(json!({ - "display_description": "Edit file" - })); - cx.run_until_parked(); - - drop(sender); - - let result = task.await; - assert!( - result.is_err(), - "Tool should error when sender is dropped without sending final input" - ); - } - - #[gpui::test] - async fn test_streaming_input_recv_drains_partials(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; - // Create a channel and send multiple partials before a final, then use - // ToolInput::resolved-style immediate delivery to confirm recv() works - // when partials are already buffered. - let (mut sender, input): (ToolInputSender, ToolInput) = - ToolInput::test(); - let (event_stream, _event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Buffer several partials before sending the final - sender.send_partial(json!({"display_description": "Create"})); - sender.send_partial(json!({"display_description": "Create", "path": "root/dir/new.txt"})); - sender.send_partial(json!({ - "display_description": "Create", - "path": "root/dir/new.txt", - "mode": "write" - })); - sender.send_full(json!({ - "display_description": "Create", - "path": "root/dir/new.txt", - "mode": "write", - "content": "streamed content" - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "streamed content"); - } - - #[gpui::test] - async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) { - let mode = StreamingEditFileMode::Write; - - let result = test_resolve_path(&mode, "root/new.txt", cx); - assert_resolved_path_eq(result.await, rel_path("new.txt")); - - let result = test_resolve_path(&mode, "new.txt", cx); - assert_resolved_path_eq(result.await, rel_path("new.txt")); - - let result = test_resolve_path(&mode, "dir/new.txt", cx); - assert_resolved_path_eq(result.await, rel_path("dir/new.txt")); - - let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx); - assert_resolved_path_eq(result.await, rel_path("dir/subdir/existing.txt")); - - let result = test_resolve_path(&mode, "root/dir/subdir", cx); - assert_eq!( - result.await.unwrap_err(), - "Can't write to file: path is a directory" - ); - - let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx); - assert_eq!( - result.await.unwrap_err(), - "Can't create file: parent directory doesn't exist" - ); - } - - #[gpui::test] - async fn test_streaming_resolve_path_for_editing_file(cx: &mut TestAppContext) { - let mode = StreamingEditFileMode::Edit; - - let path_with_root = "root/dir/subdir/existing.txt"; - let path_without_root = "dir/subdir/existing.txt"; - let result = test_resolve_path(&mode, path_with_root, cx); - assert_resolved_path_eq(result.await, rel_path(path_without_root)); - - let result = test_resolve_path(&mode, path_without_root, cx); - assert_resolved_path_eq(result.await, rel_path(path_without_root)); - - let result = test_resolve_path(&mode, "root/nonexistent.txt", cx); - assert_eq!(result.await.unwrap_err(), "Can't edit file: path not found"); - - let result = test_resolve_path(&mode, "root/dir", cx); - assert_eq!( - result.await.unwrap_err(), - "Can't edit file: path is a directory" - ); - } - - async fn test_resolve_path( - mode: &StreamingEditFileMode, - path: &str, - cx: &mut TestAppContext, - ) -> Result { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "dir": { - "subdir": { - "existing.txt": "hello" - } - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - cx.update(|cx| resolve_path(*mode, &PathBuf::from(path), &project, cx)) - } - - #[track_caller] - fn assert_resolved_path_eq(path: Result, expected: &RelPath) { - let actual = path.expect("Should return valid path").path; - assert_eq!(actual.as_ref(), expected); - } - - #[gpui::test] - async fn test_streaming_format_on_save(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"src": {}})).await; - let (tool, project, action_log, fs, thread) = - setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; - - let rust_language = Arc::new(language::Language::new( - language::LanguageConfig { - name: "Rust".into(), - matcher: language::LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - None, - )); - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_language); - - let mut fake_language_servers = language_registry.register_fake_lsp( - "Rust", - language::FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - document_formatting_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - }, - ); - - fs.save( - path!("/root/src/main.rs").as_ref(), - &"initial content".into(), - language::LineEnding::Unix, - ) - .await - .unwrap(); - - // Open the buffer to trigger LSP initialization - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/src/main.rs"), cx) - }) - .await - .unwrap(); - - // Register the buffer with language servers - let _handle = project.update(cx, |project, cx| { - project.register_buffer_with_language_servers(&buffer, cx) - }); - - const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\ -"; - const FORMATTED_CONTENT: &str = "This file was formatted by the fake formatter in the test.\ -"; - - // Get the fake language server and set up formatting handler - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.set_request_handler::({ - |_, _| async move { - Ok(Some(vec![lsp::TextEdit { - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), - new_text: FORMATTED_CONTENT.to_string(), - }])) - } - }); - - // Test with format_on_save enabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On); - settings.project.all_languages.defaults.formatter = - Some(language::language_settings::FormatterList::default()); - }); - }); - }); - - // Use streaming pattern so executor can pump the LSP request/response - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - sender.send_partial(json!({ - "display_description": "Create main function", - "path": "root/src/main.rs", - "mode": "write" - })); - cx.run_until_parked(); - - sender.send_full(json!({ - "display_description": "Create main function", - "path": "root/src/main.rs", - "mode": "write", - "content": UNFORMATTED_CONTENT - })); - - let result = task.await; - assert!(result.is_ok()); - - cx.executor().run_until_parked(); - - let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - new_content.replace("\r\n", "\n"), - FORMATTED_CONTENT, - "Code should be formatted when format_on_save is enabled" - ); - - let stale_buffer_count = thread - .read_with(cx, |thread, _cx| thread.action_log.clone()) - .read_with(cx, |log, cx| log.stale_buffers(cx).count()); - - assert_eq!( - stale_buffer_count, 0, - "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers.", - stale_buffer_count - ); - - // Test with format_on_save disabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.all_languages.defaults.format_on_save = - Some(FormatOnSave::Off); - }); - }); - }); - - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - - let tool2 = Arc::new(StreamingEditFileTool::new( - project.clone(), - thread.downgrade(), - action_log.clone(), - language_registry, - )); - - let task = cx.update(|cx| tool2.run(input, event_stream, cx)); - - sender.send_partial(json!({ - "display_description": "Update main function", - "path": "root/src/main.rs", - "mode": "write" - })); - cx.run_until_parked(); - - sender.send_full(json!({ - "display_description": "Update main function", - "path": "root/src/main.rs", - "mode": "write", - "content": UNFORMATTED_CONTENT - })); - - let result = task.await; - assert!(result.is_ok()); - - cx.executor().run_until_parked(); - - let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - new_content.replace("\r\n", "\n"), - UNFORMATTED_CONTENT, - "Code should not be formatted when format_on_save is disabled" - ); - } - - #[gpui::test] - async fn test_streaming_remove_trailing_whitespace(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"src": {}})).await; - fs.save( - path!("/root/src/main.rs").as_ref(), - &"initial content".into(), - language::LineEnding::Unix, - ) - .await - .unwrap(); - let (tool, project, action_log, fs, thread) = - setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; - let language_registry = project.read_with(cx, |p, _cx| p.languages().clone()); - - // Test with remove_trailing_whitespace_on_save enabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings - .project - .all_languages - .defaults - .remove_trailing_whitespace_on_save = Some(true); - }); - }); - }); - - const CONTENT_WITH_TRAILING_WHITESPACE: &str = - "fn main() { \n println!(\"Hello!\"); \n}\n"; - - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Create main function".into(), - path: "root/src/main.rs".into(), - mode: StreamingEditFileMode::Write, - content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), - edits: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - assert!(result.is_ok()); - - cx.executor().run_until_parked(); - - assert_eq!( - fs.load(path!("/root/src/main.rs").as_ref()) - .await - .unwrap() - .replace("\r\n", "\n"), - "fn main() {\n println!(\"Hello!\");\n}\n", - "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" - ); - - // Test with remove_trailing_whitespace_on_save disabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings - .project - .all_languages - .defaults - .remove_trailing_whitespace_on_save = Some(false); - }); - }); - }); - - let tool2 = Arc::new(StreamingEditFileTool::new( - project.clone(), - thread.downgrade(), - action_log.clone(), - language_registry, - )); - - let result = cx - .update(|cx| { - tool2.run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Update main function".into(), - path: "root/src/main.rs".into(), - mode: StreamingEditFileMode::Write, - content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), - edits: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - assert!(result.is_ok()); - - cx.executor().run_until_parked(); - - let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - final_content.replace("\r\n", "\n"), - CONTENT_WITH_TRAILING_WHITESPACE, - "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" - ); - } - - #[gpui::test] - async fn test_streaming_authorize(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await; - - // Test 1: Path with .zed component should require confirmation - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx.update(|cx| { - tool.authorize( - &PathBuf::from(".zed/settings.json"), - "test 1", - &stream_tx, - cx, - ) - }); - - let event = stream_rx.expect_authorization().await; - assert_eq!( - event.tool_call.fields.title, - Some("test 1 (local settings)".into()) - ); - - // Test 2: Path outside project should require confirmation - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = - cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 2", &stream_tx, cx)); - - let event = stream_rx.expect_authorization().await; - assert_eq!(event.tool_call.fields.title, Some("test 2".into())); - - // Test 3: Relative path without .zed should not require confirmation - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - cx.update(|cx| { - tool.authorize(&PathBuf::from("root/src/main.rs"), "test 3", &stream_tx, cx) - }) - .await - .unwrap(); - assert!(stream_rx.try_recv().is_err()); - - // Test 4: Path with .zed in the middle should require confirmation - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx.update(|cx| { - tool.authorize( - &PathBuf::from("root/.zed/tasks.json"), - "test 4", - &stream_tx, - cx, - ) - }); - let event = stream_rx.expect_authorization().await; - assert_eq!( - event.tool_call.fields.title, - Some("test 4 (local settings)".into()) - ); - - // Test 5: When global default is allow, sensitive and outside-project - // paths still require confirmation - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Allow; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - // 5.1: .zed/settings.json is a sensitive path — still prompts - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx.update(|cx| { - tool.authorize( - &PathBuf::from(".zed/settings.json"), - "test 5.1", - &stream_tx, - cx, - ) - }); - let event = stream_rx.expect_authorization().await; - assert_eq!( - event.tool_call.fields.title, - Some("test 5.1 (local settings)".into()) - ); - - // 5.2: /etc/hosts is outside the project, but Allow auto-approves - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.2", &stream_tx, cx)) - .await - .unwrap(); - assert!(stream_rx.try_recv().is_err()); - - // 5.3: Normal in-project path with allow — no confirmation needed - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - cx.update(|cx| { - tool.authorize( - &PathBuf::from("root/src/main.rs"), - "test 5.3", - &stream_tx, - cx, - ) - }) - .await - .unwrap(); - assert!(stream_rx.try_recv().is_err()); - - // 5.4: With Confirm default, non-project paths still prompt - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Confirm; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx - .update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.4", &stream_tx, cx)); - - let event = stream_rx.expect_authorization().await; - assert_eq!(event.tool_call.fields.title, Some("test 5.4".into())); - } - - #[gpui::test] - async fn test_streaming_authorize_create_under_symlink_with_allow(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({})).await; - fs.insert_tree("/outside", json!({})).await; - fs.insert_symlink("/root/link", PathBuf::from("/outside")) - .await; - let (tool, _project, _action_log, _fs, _thread) = - setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; - - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Allow; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let authorize_task = cx.update(|cx| { - tool.authorize( - &PathBuf::from("link/new.txt"), - "create through symlink", - &stream_tx, - cx, - ) - }); - - let event = stream_rx.expect_authorization().await; - assert!( - event - .tool_call - .fields - .title - .as_deref() - .is_some_and(|title| title.contains("points outside the project")), - "Expected symlink escape authorization for create under external symlink" - ); - - event - .response - .send(acp_thread::SelectedPermissionOutcome::new( - acp::PermissionOptionId::new("allow"), - acp::PermissionOptionKind::AllowOnce, - )) - .unwrap(); - authorize_task.await.unwrap(); - } - - #[gpui::test] - async fn test_streaming_edit_file_symlink_escape_requests_authorization( - cx: &mut TestAppContext, - ) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "src": { "main.rs": "fn main() {}" } - }), - ) - .await; - fs.insert_tree( - path!("/outside"), - json!({ - "config.txt": "old content" - }), - ) - .await; - fs.create_symlink( - path!("/root/link_to_external").as_ref(), - PathBuf::from("/outside"), - ) - .await - .unwrap(); - let (tool, _project, _action_log, _fs, _thread) = - setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; - - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _authorize_task = cx.update(|cx| { - tool.authorize( - &PathBuf::from("link_to_external/config.txt"), - "edit through symlink", - &stream_tx, - cx, - ) - }); - - let auth = stream_rx.expect_authorization().await; - let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); - assert!( - title.contains("points outside the project"), - "title should mention symlink escape, got: {title}" - ); - } - - #[gpui::test] - async fn test_streaming_edit_file_symlink_escape_denied(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "src": { "main.rs": "fn main() {}" } - }), - ) - .await; - fs.insert_tree( - path!("/outside"), - json!({ - "config.txt": "old content" - }), - ) - .await; - fs.create_symlink( - path!("/root/link_to_external").as_ref(), - PathBuf::from("/outside"), - ) - .await - .unwrap(); - let (tool, _project, _action_log, _fs, _thread) = - setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; - - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let authorize_task = cx.update(|cx| { - tool.authorize( - &PathBuf::from("link_to_external/config.txt"), - "edit through symlink", - &stream_tx, - cx, - ) - }); - - let auth = stream_rx.expect_authorization().await; - drop(auth); // deny by dropping - - let result = authorize_task.await; - assert!(result.is_err(), "should fail when denied"); - } - - #[gpui::test] - async fn test_streaming_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) { - init_test(cx); - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.tools.insert( - "edit_file".into(), - agent_settings::ToolRules { - default: Some(settings::ToolPermissionMode::Deny), - ..Default::default() - }, - ); - agent_settings::AgentSettings::override_global(settings, cx); - }); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "src": { "main.rs": "fn main() {}" } - }), - ) - .await; - fs.insert_tree( - path!("/outside"), - json!({ - "config.txt": "old content" - }), - ) - .await; - fs.create_symlink( - path!("/root/link_to_external").as_ref(), - PathBuf::from("/outside"), - ) - .await - .unwrap(); - let (tool, _project, _action_log, _fs, _thread) = - setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; - - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let result = cx - .update(|cx| { - tool.authorize( - &PathBuf::from("link_to_external/config.txt"), - "edit through symlink", - &stream_tx, - cx, - ) - }) - .await; - - assert!(result.is_err(), "Tool should fail when policy denies"); - assert!( - !matches!( - stream_rx.try_recv(), - Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) - ), - "Deny policy should not emit symlink authorization prompt", - ); - } - - #[gpui::test] - async fn test_streaming_authorize_global_config(cx: &mut TestAppContext) { - init_test(cx); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({})).await; - let (tool, _project, _action_log, _fs, _thread) = - setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; - - let test_cases = vec![ - ( - "/etc/hosts", - true, - "System file should require confirmation", - ), - ( - "/usr/local/bin/script", - true, - "System bin file should require confirmation", - ), - ( - "project/normal_file.rs", - false, - "Normal project file should not require confirmation", - ), - ]; - - for (path, should_confirm, description) in test_cases { - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let auth = - cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx)); - - if should_confirm { - stream_rx.expect_authorization().await; - } else { - auth.await.unwrap(); - assert!( - stream_rx.try_recv().is_err(), - "Failed for case: {} - path: {} - expected no confirmation but got one", - description, - path - ); - } - } - } - - #[gpui::test] - async fn test_streaming_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { - init_test(cx); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/workspace/frontend", - json!({ - "src": { - "main.js": "console.log('frontend');" - } - }), - ) - .await; - fs.insert_tree( - "/workspace/backend", - json!({ - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - fs.insert_tree( - "/workspace/shared", - json!({ - ".zed": { - "settings.json": "{}" - } - }), - ) - .await; - let (tool, _project, _action_log, _fs, _thread) = setup_test_with_fs( - cx, - fs, - &[ - path!("/workspace/frontend").as_ref(), - path!("/workspace/backend").as_ref(), - path!("/workspace/shared").as_ref(), - ], - ) - .await; - - let test_cases = vec![ - ("frontend/src/main.js", false, "File in first worktree"), - ("backend/src/main.rs", false, "File in second worktree"), - ( - "shared/.zed/settings.json", - true, - ".zed file in third worktree", - ), - ("/etc/hosts", true, "Absolute path outside all worktrees"), - ( - "../outside/file.txt", - true, - "Relative path outside worktrees", - ), - ]; - - for (path, should_confirm, description) in test_cases { - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let auth = - cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx)); - - if should_confirm { - stream_rx.expect_authorization().await; - } else { - auth.await.unwrap(); - assert!( - stream_rx.try_recv().is_err(), - "Failed for case: {} - path: {} - expected no confirmation but got one", - description, - path - ); - } - } - } - - #[gpui::test] - async fn test_streaming_needs_confirmation_edge_cases(cx: &mut TestAppContext) { - init_test(cx); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - ".zed": { - "settings.json": "{}" - }, - "src": { - ".zed": { - "local.json": "{}" - } - } - }), - ) - .await; - let (tool, _project, _action_log, _fs, _thread) = - setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; - - let test_cases = vec![ - ("", false, "Empty path is treated as project root"), - ("/", true, "Root directory should be outside project"), - ( - "project/../other", - true, - "Path with .. that goes outside of root directory", - ), - ( - "project/./src/file.rs", - false, - "Path with . should work normally", - ), - #[cfg(target_os = "windows")] - ("C:\\Windows\\System32\\hosts", true, "Windows system path"), - #[cfg(target_os = "windows")] - ("project\\src\\main.rs", false, "Windows-style project path"), - ]; - - for (path, should_confirm, description) in test_cases { - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let auth = - cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx)); - - cx.run_until_parked(); - - if should_confirm { - stream_rx.expect_authorization().await; - } else { - assert!( - stream_rx.try_recv().is_err(), - "Failed for case: {} - path: {} - expected no confirmation but got one", - description, - path - ); - auth.await.unwrap(); - } - } - } - - #[gpui::test] - async fn test_streaming_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { - init_test(cx); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "existing.txt": "content", - ".zed": { - "settings.json": "{}" - } - }), - ) - .await; - let (tool, _project, _action_log, _fs, _thread) = - setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; - - let modes = vec![StreamingEditFileMode::Edit, StreamingEditFileMode::Write]; - - for _mode in modes { - // Test .zed path with different modes - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx.update(|cx| { - tool.authorize( - &PathBuf::from("project/.zed/settings.json"), - "Edit settings", - &stream_tx, - cx, - ) - }); - - stream_rx.expect_authorization().await; - - // Test outside path with different modes - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx.update(|cx| { - tool.authorize( - &PathBuf::from("/outside/file.txt"), - "Edit file", - &stream_tx, - cx, - ) - }); - - stream_rx.expect_authorization().await; - - // Test normal path with different modes - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - cx.update(|cx| { - tool.authorize( - &PathBuf::from("project/normal.txt"), - "Edit file", - &stream_tx, - cx, - ) - }) - .await - .unwrap(); - assert!(stream_rx.try_recv().is_err()); - } - } - - #[gpui::test] - async fn test_streaming_initial_title_with_partial_input(cx: &mut TestAppContext) { - init_test(cx); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({})).await; - let (tool, _project, _action_log, _fs, _thread) = - setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; - - cx.update(|cx| { - assert_eq!( - tool.initial_title( - Err(json!({ - "path": "src/main.rs", - "display_description": "", - })), - cx - ), - "src/main.rs" - ); - assert_eq!( - tool.initial_title( - Err(json!({ - "path": "", - "display_description": "Fix error handling", - })), - cx - ), - "Fix error handling" - ); - assert_eq!( - tool.initial_title( - Err(json!({ - "path": "src/main.rs", - "display_description": "Fix error handling", - })), - cx - ), - "src/main.rs" - ); - assert_eq!( - tool.initial_title( - Err(json!({ - "path": "", - "display_description": "", - })), - cx - ), - DEFAULT_UI_TEXT - ); - assert_eq!( - tool.initial_title(Err(serde_json::Value::Null), cx), - DEFAULT_UI_TEXT - ); - }); - } - - #[gpui::test] - async fn test_streaming_diff_finalization(cx: &mut TestAppContext) { - init_test(cx); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/", json!({"main.rs": ""})).await; - let (tool, project, action_log, _fs, thread) = - setup_test_with_fs(cx, fs, &[path!("/").as_ref()]).await; - let language_registry = project.read_with(cx, |p, _cx| p.languages().clone()); - - // Ensure the diff is finalized after the edit completes. - { - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let edit = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit file".into(), - path: path!("/main.rs").into(), - mode: StreamingEditFileMode::Write, - content: Some("new content".into()), - edits: None, - }), - stream_tx, - cx, - ) - }); - stream_rx.expect_update_fields().await; - let diff = stream_rx.expect_diff().await; - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); - cx.run_until_parked(); - edit.await.unwrap(); - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); - } - - // Ensure the diff is finalized if the tool call gets dropped. - { - let tool = Arc::new(StreamingEditFileTool::new( - project.clone(), - thread.downgrade(), - action_log, - language_registry, - )); - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let edit = cx.update(|cx| { - tool.run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit file".into(), - path: path!("/main.rs").into(), - mode: StreamingEditFileMode::Write, - content: Some("dropped content".into()), - edits: None, - }), - stream_tx, - cx, - ) - }); - stream_rx.expect_update_fields().await; - let diff = stream_rx.expect_diff().await; - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); - drop(edit); - cx.run_until_parked(); - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); - } - } - - #[gpui::test] - async fn test_streaming_consecutive_edits_work(cx: &mut TestAppContext) { - let (tool, project, action_log, _fs, _thread) = - setup_test(cx, json!({"test.txt": "original content"})).await; - let read_tool = Arc::new(crate::ReadFileTool::new( - project.clone(), - action_log.clone(), - true, - )); - - // Read the file first - cx.update(|cx| { - read_tool.clone().run( - ToolInput::resolved(crate::ReadFileToolInput { - path: "root/test.txt".to_string(), - start_line: None, - end_line: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - - // First edit should work - let edit_result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "First edit".into(), - path: "root/test.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![Edit { - old_text: "original content".into(), - new_text: "modified content".into(), - }]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - assert!( - edit_result.is_ok(), - "First edit should succeed, got error: {:?}", - edit_result.as_ref().err() - ); - - // Second edit should also work because the edit updated the recorded read time - let edit_result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Second edit".into(), - path: "root/test.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![Edit { - old_text: "modified content".into(), - new_text: "further modified content".into(), - }]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - assert!( - edit_result.is_ok(), - "Second consecutive edit should succeed, got error: {:?}", - edit_result.as_ref().err() - ); - } - - #[gpui::test] - async fn test_streaming_external_modification_matching_edit_succeeds(cx: &mut TestAppContext) { - let (tool, project, action_log, fs, _thread) = - setup_test(cx, json!({"test.txt": "original content"})).await; - let read_tool = Arc::new(crate::ReadFileTool::new( - project.clone(), - action_log.clone(), - true, - )); - - // Read the file first - cx.update(|cx| { - read_tool.clone().run( - ToolInput::resolved(crate::ReadFileToolInput { - path: "root/test.txt".to_string(), - start_line: None, - end_line: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - - // Simulate external modification - cx.background_executor - .advance_clock(std::time::Duration::from_secs(2)); - fs.save( - path!("/root/test.txt").as_ref(), - &"externally modified content".into(), - language::LineEnding::Unix, - ) - .await - .unwrap(); - - // Reload the buffer to pick up the new mtime - let project_path = project - .read_with(cx, |project, cx| { - project.find_project_path("root/test.txt", cx) - }) - .expect("Should find project path"); - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx)) - .await - .unwrap(); - buffer - .update(cx, |buffer, cx| buffer.reload(cx)) - .await - .unwrap(); - - cx.executor().run_until_parked(); - - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit after external change".into(), - path: "root/test.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![Edit { - old_text: "externally modified content".into(), - new_text: "new content".into(), - }]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - - let StreamingEditFileToolOutput::Success { - new_text, - input_path, - .. - } = result - else { - panic!("expected success"); - }; - - assert_eq!(new_text, "new content"); - assert_eq!(input_path, PathBuf::from("root/test.txt")); - } - - #[gpui::test] - async fn test_streaming_external_modification_mentioned_when_match_fails( - cx: &mut TestAppContext, - ) { - let (tool, project, action_log, fs, _thread) = - setup_test(cx, json!({"test.txt": "original content"})).await; - let read_tool = Arc::new(crate::ReadFileTool::new( - project.clone(), - action_log.clone(), - true, - )); - - cx.update(|cx| { - read_tool.clone().run( - ToolInput::resolved(crate::ReadFileToolInput { - path: "root/test.txt".to_string(), - start_line: None, - end_line: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - - cx.background_executor - .advance_clock(std::time::Duration::from_secs(2)); - fs.save( - path!("/root/test.txt").as_ref(), - &"externally modified content".into(), - language::LineEnding::Unix, - ) - .await - .unwrap(); - - let project_path = project - .read_with(cx, |project, cx| { - project.find_project_path("root/test.txt", cx) - }) - .expect("Should find project path"); - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx)) - .await - .unwrap(); - buffer - .update(cx, |buffer, cx| buffer.reload(cx)) - .await - .unwrap(); - - cx.executor().run_until_parked(); - - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit after external change".into(), - path: "root/test.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![Edit { - old_text: "original content".into(), - new_text: "new content".into(), - }]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let StreamingEditFileToolOutput::Error { - error, - diff, - input_path, - } = result.unwrap_err() - else { - panic!("expected error"); - }; - - assert!( - error.contains("Could not find matching text for edit at index 0"), - "Error should mention failed match, got: {error}" - ); - assert!( - error.contains("has changed on disk since you last read it"), - "Error should mention possible disk change, got: {error}" - ); - assert!(diff.is_empty()); - assert_eq!(input_path, Some(PathBuf::from("root/test.txt"))); - } - - #[gpui::test] - async fn test_streaming_dirty_buffer_detected(cx: &mut TestAppContext) { - let (tool, project, action_log, _fs, _thread) = - setup_test(cx, json!({"test.txt": "original content"})).await; - let read_tool = Arc::new(crate::ReadFileTool::new( - project.clone(), - action_log.clone(), - true, - )); - - // Read the file first - cx.update(|cx| { - read_tool.clone().run( - ToolInput::resolved(crate::ReadFileToolInput { - path: "root/test.txt".to_string(), - start_line: None, - end_line: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - - // Open the buffer and make it dirty - let project_path = project - .read_with(cx, |project, cx| { - project.find_project_path("root/test.txt", cx) - }) - .expect("Should find project path"); - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx)) - .await - .unwrap(); - - buffer.update(cx, |buffer, cx| { - let end_point = buffer.max_point(); - buffer.edit([(end_point..end_point, " added text")], None, cx); - }); - - let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty()); - assert!(is_dirty, "Buffer should be dirty after in-memory edit"); - - // Try to edit - should fail because buffer has unsaved changes - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit with dirty buffer".into(), - path: "root/test.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![Edit { - old_text: "original content".into(), - new_text: "new content".into(), - }]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let StreamingEditFileToolOutput::Error { - error, - diff, - input_path, - } = result.unwrap_err() - else { - panic!("expected error"); - }; - assert!( - error.contains("This file has unsaved changes."), - "Error should mention unsaved changes, got: {}", - error - ); - assert!( - error.contains("keep or discard"), - "Error should ask whether to keep or discard changes, got: {}", - error - ); - assert!( - error.contains("save or revert the file manually"), - "Error should ask user to manually save or revert when tools aren't available, got: {}", - error - ); - assert!(diff.is_empty()); - assert!(input_path.is_none()); - } - - #[gpui::test] - async fn test_streaming_overlapping_edits_resolved_sequentially(cx: &mut TestAppContext) { - // Edit 1's replacement introduces text that contains edit 2's - // old_text as a substring. Because edits resolve sequentially - // against the current buffer, edit 2 finds a unique match in - // the modified buffer and succeeds. - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Setup: resolve the buffer - sender.send_partial(json!({ - "display_description": "Overlapping edits", - "path": "root/file.txt", - "mode": "edit" - })); - cx.run_until_parked(); - - // Edit 1 replaces "bbb\nccc" with "XXX\nccc\nddd", so the - // buffer becomes "aaa\nXXX\nccc\nddd\nddd\neee\n". - // Edit 2's old_text "ccc\nddd" matches the first occurrence - // in the modified buffer and replaces it with "ZZZ". - // Edit 3 exists only to mark edit 2 as "complete" during streaming. - sender.send_partial(json!({ - "display_description": "Overlapping edits", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"}, - {"old_text": "ccc\nddd", "new_text": "ZZZ"}, - {"old_text": "eee", "new_text": "DUMMY"} - ] - })); - cx.run_until_parked(); - - // Send the final input with all three edits. - sender.send_full(json!({ - "display_description": "Overlapping edits", - "path": "root/file.txt", - "mode": "edit", - "edits": [ - {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"}, - {"old_text": "ccc\nddd", "new_text": "ZZZ"}, - {"old_text": "eee", "new_text": "DUMMY"} - ] - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n"); - } - - #[gpui::test] - async fn test_streaming_create_content_streamed(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Transition to BufferResolved - sender.send_partial(json!({ - "display_description": "Create new file", - "path": "root/dir/new_file.txt", - "mode": "write" - })); - cx.run_until_parked(); - - // Stream content incrementally - sender.send_partial(json!({ - "display_description": "Create new file", - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "line 1\n" - })); - cx.run_until_parked(); - - // Verify buffer has partial content - let buffer = project.update(cx, |project, cx| { - let path = project - .find_project_path("root/dir/new_file.txt", cx) - .unwrap(); - project.get_open_buffer(&path, cx).unwrap() - }); - assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\n"); - - // Stream more content - sender.send_partial(json!({ - "display_description": "Create new file", - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "line 1\nline 2\n" - })); - cx.run_until_parked(); - assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\nline 2\n"); - - // Stream final chunk - sender.send_partial(json!({ - "display_description": "Create new file", - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "line 1\nline 2\nline 3\n" - })); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |b, _| b.text()), - "line 1\nline 2\nline 3\n" - ); - - // Send final input - sender.send_full(json!({ - "display_description": "Create new file", - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "line 1\nline 2\nline 3\n" - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "line 1\nline 2\nline 3\n"); - } - - #[gpui::test] - async fn test_streaming_overwrite_diff_revealed_during_streaming(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test( - cx, - json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}), - ) - .await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, mut receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Transition to BufferResolved - sender.send_partial(json!({ - "display_description": "Overwrite file", - "path": "root/file.txt", - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Overwrite file", - "path": "root/file.txt", - "mode": "write" - })); - cx.run_until_parked(); - - // Get the diff entity from the event stream - receiver.expect_update_fields().await; - let diff = receiver.expect_diff().await; - - // Diff starts pending with no revealed ranges - diff.read_with(cx, |diff, cx| { - assert!(matches!(diff, Diff::Pending(_))); - assert!(!diff.has_revealed_range(cx)); - }); - - // Stream first content chunk - sender.send_partial(json!({ - "display_description": "Overwrite file", - "path": "root/file.txt", - "mode": "write", - "content": "new line 1\n" - })); - cx.run_until_parked(); - - // Diff should now have revealed ranges showing the new content - diff.read_with(cx, |diff, cx| { - assert!(diff.has_revealed_range(cx)); - }); - - // Send final input - sender.send_full(json!({ - "display_description": "Overwrite file", - "path": "root/file.txt", - "mode": "write", - "content": "new line 1\nnew line 2\n" - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { - new_text, old_text, .. - } = result.unwrap() - else { - panic!("expected success"); - }; - assert_eq!(new_text, "new line 1\nnew line 2\n"); - assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n"); - - // Diff is finalized after completion - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); - } - - #[gpui::test] - async fn test_streaming_overwrite_content_streamed(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = setup_test( - cx, - json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}), - ) - .await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Transition to BufferResolved - sender.send_partial(json!({ - "display_description": "Overwrite file", - "path": "root/file.txt", - "mode": "write" - })); - cx.run_until_parked(); - - // Verify buffer still has old content (no content partial yet) - let buffer = project.update(cx, |project, cx| { - let path = project.find_project_path("root/file.txt", cx).unwrap(); - project.open_buffer(path, cx) - }); - let buffer = buffer.await.unwrap(); - assert_eq!( - buffer.read_with(cx, |b, _| b.text()), - "old line 1\nold line 2\nold line 3\n" - ); - - // First content partial replaces old content - sender.send_partial(json!({ - "display_description": "Overwrite file", - "path": "root/file.txt", - "mode": "write", - "content": "new line 1\n" - })); - cx.run_until_parked(); - assert_eq!(buffer.read_with(cx, |b, _| b.text()), "new line 1\n"); - - // Subsequent content partials append - sender.send_partial(json!({ - "display_description": "Overwrite file", - "path": "root/file.txt", - "mode": "write", - "content": "new line 1\nnew line 2\n" - })); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |b, _| b.text()), - "new line 1\nnew line 2\n" - ); - - // Send final input with complete content - sender.send_full(json!({ - "display_description": "Overwrite file", - "path": "root/file.txt", - "mode": "write", - "content": "new line 1\nnew line 2\nnew line 3\n" - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { - new_text, old_text, .. - } = result.unwrap() - else { - panic!("expected success"); - }; - assert_eq!(new_text, "new line 1\nnew line 2\nnew line 3\n"); - assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n"); - } - - #[gpui::test] - async fn test_streaming_edit_json_fixer_escape_corruption(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "hello\nworld\nfoo\n"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - sender.send_partial(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": "edit" - })); - cx.run_until_parked(); - - // Simulate JSON fixer producing a literal backslash when the LLM - // stream cuts in the middle of a \n escape sequence. - // The old_text "hello\nworld" would be streamed as: - // partial 1: old_text = "hello\\" (fixer closes incomplete \n as \\) - // partial 2: old_text = "hello\nworld" (fixer corrected the escape) - sender.send_partial(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "hello\\"}] - })); - cx.run_until_parked(); - - // Now the fixer corrects it to the real newline. - sender.send_partial(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "hello\nworld"}] - })); - cx.run_until_parked(); - - // Send final. - sender.send_full(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": "edit", - "edits": [{"old_text": "hello\nworld", "new_text": "HELLO\nWORLD"}] - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "HELLO\nWORLD\nfoo\n"); - } - - #[gpui::test] - async fn test_streaming_final_input_stringified_edits_succeeds(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "hello\nworld\n"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - sender.send_partial(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": "edit" - })); - cx.run_until_parked(); - - sender.send_full(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": "edit", - "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "HELLO\nWORLD\n"); - } - - // Verifies that after streaming_edit_file_tool edits a file, the action log - // reports changed buffers so that the Accept All / Reject All review UI appears. - #[gpui::test] - async fn test_streaming_edit_file_tool_registers_changed_buffers(cx: &mut TestAppContext) { - let (tool, _project, action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Allow; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - let (event_stream, _rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Edit lines".to_string(), - path: "root/file.txt".into(), - mode: StreamingEditFileMode::Edit, - content: None, - edits: Some(vec![Edit { - old_text: "line 2".into(), - new_text: "modified line 2".into(), - }]), - }), - event_stream, - cx, - ) - }); - - let result = task.await; - assert!(result.is_ok(), "edit should succeed: {:?}", result.err()); - - cx.run_until_parked(); - - let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); - assert!( - !changed.is_empty(), - "action_log.changed_buffers() should be non-empty after streaming edit, - but no changed buffers were found - Accept All / Reject All will not appear" - ); - } - - // Same test but for Write mode (overwrite entire file). - #[gpui::test] - async fn test_streaming_edit_file_tool_write_mode_registers_changed_buffers( - cx: &mut TestAppContext, - ) { - let (tool, _project, action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "original content"})).await; - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Allow; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - let (event_stream, _rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Overwrite file".to_string(), - path: "root/file.txt".into(), - mode: StreamingEditFileMode::Write, - content: Some("completely new content".into()), - edits: None, - }), - event_stream, - cx, - ) - }); - - let result = task.await; - assert!(result.is_ok(), "write should succeed: {:?}", result.err()); - - cx.run_until_parked(); - - let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); - assert!( - !changed.is_empty(), - "action_log.changed_buffers() should be non-empty after streaming write, \ - but no changed buffers were found \u{2014} Accept All / Reject All will not appear" - ); - } - - #[gpui::test] - async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode( - cx: &mut TestAppContext, - ) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "old_content"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - sender.send_partial(json!({ - "display_description": "Overwrite file", - "mode": "write" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Overwrite file", - "mode": "write", - "content": "new_content" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Overwrite file", - "mode": "write", - "content": "new_content", - "path": "root" - })); - cx.run_until_parked(); - - // Send final. - sender.send_full(json!({ - "display_description": "Overwrite file", - "mode": "write", - "content": "new_content", - "path": "root/file.txt" - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "new_content"); - } - - #[gpui::test] - async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode( - cx: &mut TestAppContext, - ) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "old_content"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - sender.send_partial(json!({ - "display_description": "Overwrite file", - "mode": "edit" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Overwrite file", - "mode": "edit", - "edits": [{"old_text": "old_content"}] - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Overwrite file", - "mode": "edit", - "edits": [{"old_text": "old_content", "new_text": "new_content"}] - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "display_description": "Overwrite file", - "mode": "edit", - "edits": [{"old_text": "old_content", "new_text": "new_content"}], - "path": "root" - })); - cx.run_until_parked(); - - // Send final. - sender.send_full(json!({ - "display_description": "Overwrite file", - "mode": "edit", - "edits": [{"old_text": "old_content", "new_text": "new_content"}], - "path": "root/file.txt" - })); - cx.run_until_parked(); - - let result = task.await; - let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "new_content"); - } - - #[gpui::test] - async fn test_streaming_edit_partial_last_line(cx: &mut TestAppContext) { - let file_content = indoc::indoc! {r#" - fn on_query_change(&mut self, cx: &mut Context) { - self.filter(cx); - } - - - - fn render_search(&self, cx: &mut Context) -> Div { - div() - } - "#} - .to_string(); - - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.rs": file_content})).await; - - // The model sends old_text with a PARTIAL last line. - let old_text = "}\n\n\n\nfn render_search"; - let new_text = "}\n\nfn render_search"; - - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - sender.send_full(json!({ - "display_description": "Remove extra blank lines", - "path": "root/file.rs", - "mode": "edit", - "edits": [{"old_text": old_text, "new_text": new_text}] - })); - - let result = task.await; - let StreamingEditFileToolOutput::Success { - new_text: final_text, - .. - } = result.unwrap() - else { - panic!("expected success"); - }; - - // The edit should reduce 3 blank lines to 1 blank line before - // fn render_search, without duplicating the function signature. - let expected = file_content.replace("}\n\n\n\nfn render_search", "}\n\nfn render_search"); - pretty_assertions::assert_eq!( - final_text, - expected, - "Edit should only remove blank lines before render_search" - ); - } - - #[gpui::test] - async fn test_streaming_edit_preserves_blank_line_after_trailing_newline_replacement( - cx: &mut TestAppContext, - ) { - let file_content = "before\ntarget\n\nafter\n"; - let old_text = "target\n"; - let new_text = "one\ntwo\ntarget\n"; - let expected = "before\none\ntwo\ntarget\n\nafter\n"; - - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.rs": file_content})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - sender.send_full(json!({ - "display_description": "description", - "path": "root/file.rs", - "mode": "edit", - "edits": [{"old_text": old_text, "new_text": new_text}] - })); - - let result = task.await; - - let StreamingEditFileToolOutput::Success { - new_text: final_text, - .. - } = result.unwrap() - else { - panic!("expected success"); - }; - - pretty_assertions::assert_eq!( - final_text, - expected, - "Edit should preserve a single blank line before test_after" - ); - } - - #[gpui::test] - async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) { - let (tool, _project, action_log, fs, _thread) = setup_test(cx, json!({"dir": {}})).await; - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Allow; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - // Create a new file via the streaming edit file tool - let (event_stream, _rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(StreamingEditFileToolInput { - display_description: "Create new file".into(), - path: "root/dir/new_file.txt".into(), - mode: StreamingEditFileMode::Write, - content: Some("Hello, World!".into()), - edits: None, - }), - event_stream, - cx, - ) - }); - let result = task.await; - assert!(result.is_ok(), "create should succeed: {:?}", result.err()); - cx.run_until_parked(); - - assert!( - fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await, - "file should exist after creation" - ); - - // Reject all edits — this should delete the newly created file - let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); - assert!( - !changed.is_empty(), - "action_log should track the created file as changed" - ); - - action_log - .update(cx, |log, cx| log.reject_all_edits(None, cx)) - .await; - cx.run_until_parked(); - - assert!( - !fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await, - "file should be deleted after rejecting creation, but an empty file was left behind" - ); - } - - #[test] - fn test_input_deserializes_double_encoded_fields() { - let input = serde_json::from_value::(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": "\"edit\"", - "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" - })) - .expect("input should deserialize"); - - assert!(matches!(input.mode, StreamingEditFileMode::Edit)); - let edits = input.edits.expect("edits should deserialize"); - assert_eq!(edits.len(), 1); - assert_eq!(edits[0].old_text, "hello\nworld"); - assert_eq!(edits[0].new_text, "HELLO\nWORLD"); - - let input = serde_json::from_value::(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": "\"edit\"" - })) - .expect("input should deserialize"); - assert!(input.edits.is_none()); - - let input = serde_json::from_value::(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": "\"edit\"", - "edits": null - })) - .expect("input should deserialize"); - assert!(input.edits.is_none()); - - let input = serde_json::from_value::(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": "\"edit\"", - "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" - })) - .expect("input should deserialize"); - - assert!(matches!(input.mode, Some(StreamingEditFileMode::Edit))); - let edits = input.edits.expect("edits should deserialize"); - assert_eq!(edits.len(), 1); - assert_eq!(edits[0].old_text.as_deref(), Some("hello\nworld")); - assert_eq!(edits[0].new_text.as_deref(), Some("HELLO\nWORLD")); - - let input = serde_json::from_value::(json!({ - "display_description": "Edit", - "path": "root/file.txt" - })) - .expect("input should deserialize"); - assert!(input.mode.is_none()); - assert!(input.edits.is_none()); - - let input = serde_json::from_value::(json!({ - "display_description": "Edit", - "path": "root/file.txt", - "mode": null, - "edits": null - })) - .expect("input should deserialize"); - assert!(input.mode.is_none()); - assert!(input.edits.is_none()); - } - - async fn setup_test_with_fs( - cx: &mut TestAppContext, - fs: Arc, - worktree_paths: &[&std::path::Path], - ) -> ( - Arc, - Entity, - Entity, - Arc, - Entity, - ) { - let project = Project::test(fs.clone(), worktree_paths.iter().copied(), cx).await; - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - crate::Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - let tool = Arc::new(StreamingEditFileTool::new( - project.clone(), - thread.downgrade(), - action_log.clone(), - language_registry, - )); - (tool, project, action_log, fs, thread) - } - - async fn setup_test( - cx: &mut TestAppContext, - initial_tree: serde_json::Value, - ) -> ( - Arc, - Entity, - Entity, - Arc, - Entity, - ) { - init_test(cx); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", initial_tree).await; - setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { - store.update_user_settings(cx, |settings| { - settings - .project - .all_languages - .defaults - .ensure_final_newline_on_save = Some(false); - }); - }); - }); - } -}