diff --git a/Cargo.lock b/Cargo.lock index b58716cd57..c638ebafa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,6 +412,7 @@ dependencies = [ "parking_lot", "paths", "picker", + "pretty_assertions", "project", "proto", "rand 0.8.5", diff --git a/assets/icons/diff.svg b/assets/icons/diff.svg new file mode 100644 index 0000000000..ca43c379da --- /dev/null +++ b/assets/icons/diff.svg @@ -0,0 +1 @@ + diff --git a/assets/prompts/edit_workflow.hbs b/assets/prompts/edit_workflow.hbs index c558bc20d0..99a594cdd8 100644 --- a/assets/prompts/edit_workflow.hbs +++ b/assets/prompts/edit_workflow.hbs @@ -1,85 +1,33 @@ -# Code Change Workflow +The user of a code editor wants to make a change to their codebase. +You must describe the change using the following XML structure: -Your task is to guide the user through code changes using a series of steps. Each step should describe a high-level change, which can consist of multiple edits to distinct locations in the codebase. - -## Output Example - -Provide output as XML, with the following format: - - -Update the Person struct to store an age - -```rust -struct Person { - // existing fields... - age: u8, - height: f32, - // existing fields... -} - -impl Person { - fn age(&self) -> u8 { - self.age - } -} -``` - - -src/person.rs -insert_before -height: f32, -Add the age field - - - -src/person.rs -insert_after -impl Person { -Add the age getter - - - -## Output Format - -First, each `` must contain a written description of the change that should be made. The description should begin with a high-level overview, and can contain markdown code blocks as well. The description should be self-contained and actionable. - -After the description, each `` must contain one or more `` tags, each of which refer to a specific range in a source file. Each `` tag must contain the following child tags: - -### `` (required) - -This tag contains the path to the file that will be changed. It can be an existing path, or a path that should be created. - -### `` (optional) - -This tag contains a search string to locate in the source file, e.g. `pub fn baz() {`. If not provided, the new content will be inserted at the top of the file. Make sure to produce a string that exists in the source file and that isn't ambiguous. When there's ambiguity, add more lines to the search to eliminate it. - -### `` (required) - -This tag contains a single-line description of the edit that should be made at the given location. - -### `` (required) - -This tag indicates what type of change should be made, relative to the given location. It can be one of the following: -- `update`: Rewrites the specified string entirely based on the given description. -- `create`: Creates a new file with the given path based on the provided description. -- `insert_before`: Inserts new text based on the given description before the specified search string. -- `insert_after`: Inserts new text based on the given description after the specified search string. -- `delete`: Deletes the specified string from the containing file. +- - A group of related code changes. + Child tags: + - (required) - A high-level description of the changes. This should be as short + as possible, possibly using common abbreviations. + - <edit> (1 or more) - An edit to make at a particular range within a file. + Includes the following child tags: + - <path> (required) - The path to the file that will be changed. + - <description> (optional) - An arbitrarily-long comment that describes the purpose + of this edit. + - <old_text> (optional) - An excerpt from the file's current contents that uniquely + identifies a range within the file where the edit should occur. If this tag is not + specified, then the entire file will be used as the range. + - <new_text> (required) - The new text to insert into the file. + - <operation> (required) - The type of change that should occur at the given range + of the file. Must be one of the following values: + - `update`: Replaces the entire range with the new text. + - `insert_before`: Inserts the new text before the range. + - `insert_after`: Inserts new text after the range. + - `create`: Creates a new file with the given path and the new text. + - `delete`: Deletes the specified range from the file. <guidelines> -- There's no need to describe *what* to do, just *where* to do it. -- Only reference locations that actually exist (unless you're creating a file). -- If creating a file, assume any subsequent updates are included at the time of creation. -- Don't create and then update a file. Always create new files in one hot. -- Prefer multiple edits to smaller regions, as opposed to one big edit to a larger region. -- Don't produce edits that intersect each other. In that case, merge them into a bigger edit. -- Never nest an edit with another edit. Never include CDATA. All edits are leaf nodes. -- Descriptions are required for all edits except delete. -- When generating multiple edits, ensure the descriptions are specific to each individual operation. -- Avoid referring to the search string in the description. Focus on the change to be made, not the location where it's made. That's implicit with the `search` string you provide. -- Don't generate multiple edits at the same location. Instead, combine them together in a single edit with a succinct combined description. +- Never provide multiple edits whose ranges intersect each other. Instead, merge them into one edit. +- Prefer multiple edits to smaller, disjoint ranges, rather than one edit to a larger range. +- There's no need to escape angle brackets within XML tags. - Always ensure imports are added if you're referencing symbols that are not in scope. </guidelines> @@ -124,189 +72,137 @@ Update all shapes to store their origin as an (x, y) tuple and implement Display <message role="assistant"> We'll need to update both the rectangle and circle modules. -<step> -Add origin fields to both shape types. - -```rust -struct Rectangle { - // existing fields ... - origin: (f64, f64), -} -``` - -```rust -struct Circle { - // existing fields ... - origin: (f64, f64), -} -``` +<patch> +<title>Add origins and display impls to shapes + +src/shapes/rectangle.rs +Add the origin field to Rectangle struct +insert_after + +pub struct Rectangle { + + +origin: (f64, f64), + + src/shapes/rectangle.rs -insert_before - - width: f64, - height: f64, - -Add the origin field to Rectangle +Update the Rectangle's new function to take an origin parameter +update + +fn new(width: f64, height: f64) -> Self { + Rectangle { width, height } +} + + +fn new(origin: (f64, f64), width: f64, height: f64) -> Self { + Rectangle { origin, width, height } +} + src/shapes/circle.rs -insert_before - +Add the origin field to Circle struct +insert_after + +pub struct Circle { radius: f64, - -Add the origin field to Circle - - - -Update both shape's constructors to take an origin. - - -src/shapes/rectangle.rs -update - - fn new(width: f64, height: f64) -> Self { - Rectangle { width, height } - } - -Update the Rectangle new function to take an origin + + + origin: (f64, f64), + src/shapes/circle.rs +Update the Circle's new function to take an origin parameter update - - fn new(radius: f64) -> Self { - Circle { radius } - } - -Update the Circle new function to take an origin + +fn new(radius: f64) -> Self { + Circle { radius } +} + + +fn new(origin: (f64, f64), radius: f64) -> Self { + Circle { origin, radius } +} + - -Implement Display for both shapes - src/shapes/rectangle.rs +Add an import for the std::fmt module insert_before - + struct Rectangle { - -Add an import for the `std::fmt` module + + +use std::fmt; + + src/shapes/rectangle.rs +Add a Display implementation for Rectangle insert_after - + Rectangle { width, height } } } - -Add a Display implementation for Rectangle + + +impl fmt::Display for Rectangle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.format_struct(f, "Rectangle") + .field("origin", &self.origin) + .field("width", &self.width) + .field("height", &self.height) + .finish() + } +} + src/shapes/circle.rs -insert_before - -struct Circle { - Add an import for the `std::fmt` module +insert_before + +struct Circle { + + +use std::fmt; + src/shapes/circle.rs +Add a Display implementation for Circle insert_after - + Circle { radius } } } - -Add a Display implementation for Circle + + +impl fmt::Display for Rectangle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.format_struct(f, "Rectangle") + .field("origin", &self.origin) + .field("width", &self.width) + .field("height", &self.height) + .finish() + } +} + - + + - - - -```rs src/user.rs -struct User { - pub name: String, - age: u32, - email: String, -} - -impl User { - fn new(name: String, age: u32, email: String) -> Self { - User { name, age, email } - } - - pub fn print_info(&self) { - todo!() - } -} -``` - -Let's print all the user information and delete the email field. - - - - -Update the 'print_info' method to print user information - -```rust -impl User { - // ... other methods ... - - pub fn print_info(&self) { - println!("Name: {name}, Age: {age}", name = self.name, age = self.age); - } -} -``` - - -src/user.rs -update - - pub fn print_info(&self) { - todo!() - } - -Print all the user information - - - - -Remove the 'email' field from the User struct - - -src/user.rs -delete - -email: String, - - - - -src/user.rs -update - -fn new(name: String, age: u32, email: String) -> Self { - User { name, age, email } -} - -Remove email parameter from new method - - - - - -You should think step by step. When possible, produce smaller, coherent logical steps as opposed to one big step that combines lots of heterogeneous edits. - diff --git a/assets/prompts/step_resolution.hbs b/assets/prompts/step_resolution.hbs deleted file mode 100644 index 10bbdec81e..0000000000 --- a/assets/prompts/step_resolution.hbs +++ /dev/null @@ -1,496 +0,0 @@ - -Your task is to map a step from a workflow to locations in source code where code needs to be changed to fulfill that step. -Given a workflow containing background context plus a series of tags, you will resolve *one* of these step tags to resolve to one or more locations in the code. -With each location, you will produce a brief, one-line description of the changes to be made. - - -- There's no need to describe *what* to do, just *where* to do it. -- Only reference locations that actually exist (unless you're creating a file). -- If creating a file, assume any subsequent updates are included at the time of creation. -- Don't create and then update a file. Always create new files in shot. -- Prefer updating symbols lower in the syntax tree if possible. -- Never include suggestions on a parent symbol and one of its children in the same suggestions block. -- Never nest an operation with another operation or include CDATA or other content. All suggestions are leaf nodes. -- Descriptions are required for all suggestions except delete. -- When generating multiple suggestions, ensure the descriptions are specific to each individual operation. -- Avoid referring to the location in the description. Focus on the change to be made, not the location where it's made. That's implicit with the symbol you provide. -- Don't generate multiple suggestions at the same location. Instead, combine them together in a single operation with a succinct combined description. -- To add imports respond with a suggestion where the `"symbol"` key is set to `"#imports"` - - - - - - - -```rs src/rectangle.rs -struct Rectangle { - width: f64, - height: f64, -} - -impl Rectangle { - fn new(width: f64, height: f64) -> Self { - Rectangle { width, height } - } -} -``` - -We need to add methods to calculate the area and perimeter of the rectangle. Can you help with that? - - -Sure, I can help with that! - -Add new methods 'calculate_area' and 'calculate_perimeter' to the Rectangle struct -Implement the 'Display' trait for the Rectangle struct - - - - -Add new methods 'calculate_area' and 'calculate_perimeter' to the Rectangle struct - - - -{ - "title": "Add Rectangle methods", - "suggestions": [ - { - "kind": "AppendChild", - "path": "src/shapes.rs", - "symbol": "impl Rectangle", - "description": "Add calculate_area method" - }, - { - "kind": "AppendChild", - "path": "src/shapes.rs", - "symbol": "impl Rectangle", - "description": "Add calculate_perimeter method" - } - ] -} - - - -{ - "title": "Add Rectangle methods", - "suggestions": [ - { - "kind": "AppendChild", - "path": "src/shapes.rs", - "symbol": "impl Rectangle", - "description": "Add calculate area and perimeter methods" - } - ] -} - - - -Implement the 'Display' trait for the Rectangle struct - - - -{ - "title": "Implement Display for Rectangle", - "suggestions": [ - { - "kind": "InsertSiblingAfter", - "path": "src/shapes.rs", - "symbol": "impl Rectangle", - "description": "Implement Display trait for Rectangle" - } - ] -} - - - - - -```rs src/user.rs -struct User { - pub name: String, - age: u32, - email: String, -} - -impl User { - fn new(name: String, age: u32, email: String) -> Self { - User { name, age, email } - } - - pub fn print_info(&self) { - println!("Name: {}, Age: {}, Email: {}", self.name, self.age, self.email); - } -} -``` - - -Certainly! -Update the 'print_info' method to use formatted output -Remove the 'email' field from the User struct - - - - -Update the 'print_info' method to use formatted output - - - -{ - "title": "Use formatted output", - "suggestions": [ - { - "kind": "Update", - "path": "src/user.rs", - "symbol": "impl User pub fn print_info", - "description": "Use formatted output" - } - ] -} - - - -Remove the 'email' field from the User struct - - - -{ - "title": "Remove email field", - "suggestions": [ - { - "kind": "Delete", - "path": "src/user.rs", - "symbol": "struct User email" - } - ] -} - - - - - - -```rs src/vehicle.rs -struct Vehicle { - make: String, - model: String, - year: u32, -} - -impl Vehicle { - fn new(make: String, model: String, year: u32) -> Self { - Vehicle { make, model, year } - } - - fn print_year(&self) { - println!("Year: {}", self.year); - } -} -``` - - -Add a 'use std::fmt;' statement at the beginning of the file -Add a new method 'start_engine' in the Vehicle impl block - - - - -Add a 'use std::fmt;' statement at the beginning of the file - - - -{ - "title": "Add use std::fmt statement", - "suggestions": [ - { - "kind": "PrependChild", - "path": "src/vehicle.rs", - "symbol": "#imports", - "description": "Add 'use std::fmt' statement" - } - ] -} - - - -Add a new method 'start_engine' in the Vehicle impl block - - - -{ - "title": "Add start_engine method", - "suggestions": [ - { - "kind": "InsertSiblingAfter", - "path": "src/vehicle.rs", - "symbol": "impl Vehicle fn new", - "description": "Add start_engine method" - } - ] -} - - - - - - -```rs src/employee.rs -struct Employee { - name: String, - position: String, - salary: u32, - department: String, -} - -impl Employee { - fn new(name: String, position: String, salary: u32, department: String) -> Self { - Employee { name, position, salary, department } - } - - fn print_details(&self) { - println!("Name: {}, Position: {}, Salary: {}, Department: {}", - self.name, self.position, self.salary, self.department); - } - - fn give_raise(&mut self, amount: u32) { - self.salary += amount; - } -} -``` - - -Make salary an f32 -Remove the 'department' field and update the 'print_details' method - - - - -Make salary an f32 - - - -{ - "title": "Change salary to f32", - "suggestions": [ - { - "kind": "Update", - "path": "src/employee.rs", - "symbol": "struct Employee", - "description": "Change the type of salary to an f32" - }, - { - "kind": "Update", - "path": "src/employee.rs", - "symbol": "struct Employee salary", - "description": "Change the type to an f32" - } - ] -} - - - -{ - "title": "Change salary to f32", - "suggestions": [ - { - "kind": "Update", - "path": "src/employee.rs", - "symbol": "struct Employee salary", - "description": "Change the type to an f32" - } - ] -} - - - -Remove the 'department' field and update the 'print_details' method - - - -{ - "title": "Remove department", - "suggestions": [ - { - "kind": "Delete", - "path": "src/employee.rs", - "symbol": "struct Employee department" - }, - { - "kind": "Update", - "path": "src/employee.rs", - "symbol": "impl Employee fn print_details", - "description": "Don't print the 'department' field" - } - ] -} - - - - - - -```rs src/game.rs -struct Player { - name: String, - health: i32, - pub score: u32, -} - -impl Player { - pub fn new(name: String) -> Self { - Player { name, health: 100, score: 0 } - } -} - -struct Game { - players: Vec, -} - -impl Game { - fn new() -> Self { - Game { players: Vec::new() } - } -} -``` - - -Add a 'level' field to Player and update the 'new' method - - - - -Add a 'level' field to Player and update the 'new' method - - - -{ - "title": "Add level field to Player", - "suggestions": [ - { - "kind": "InsertSiblingAfter", - "path": "src/game.rs", - "symbol": "struct Player pub score", - "description": "Add level field to Player" - }, - { - "kind": "Update", - "path": "src/game.rs", - "symbol": "impl Player pub fn new", - "description": "Initialize level in new method" - } - ] -} - - - - - - -```rs src/config.rs -use std::collections::HashMap; - -struct Config { - settings: HashMap, -} - -impl Config { - fn new() -> Self { - Config { settings: HashMap::new() } - } -} -``` - - -Add a 'load_from_file' method to Config and import necessary modules - - - - -Add a 'load_from_file' method to Config and import necessary modules - - - -{ - "title": "Add load_from_file method", - "suggestions": [ - { - "kind": "PrependChild", - "path": "src/config.rs", - "symbol": "#imports", - "description": "Import std::fs and std::io modules" - }, - { - "kind": "AppendChild", - "path": "src/config.rs", - "symbol": "impl Config", - "description": "Add load_from_file method" - } - ] -} - - - - - - -```rs src/database.rs -pub(crate) struct Database { - connection: Connection, -} - -impl Database { - fn new(url: &str) -> Result { - let connection = Connection::connect(url)?; - Ok(Database { connection }) - } - - async fn query(&self, sql: &str) -> Result, Error> { - self.connection.query(sql, &[]) - } -} -``` - - -Add error handling to the 'query' method and create a custom error type - - - - -Add error handling to the 'query' method and create a custom error type - - - -{ - "title": "Add error handling to query", - "suggestions": [ - { - "kind": "PrependChild", - "path": "src/database.rs", - "description": "Import necessary error handling modules" - }, - { - "kind": "InsertSiblingBefore", - "path": "src/database.rs", - "symbol": "pub(crate) struct Database", - "description": "Define custom DatabaseError enum" - }, - { - "kind": "Update", - "path": "src/database.rs", - "symbol": "impl Database async fn query", - "description": "Implement error handling in query method" - } - ] -} - - - - -Now generate the suggestions for the following step: - - -{{{workflow_context}}} - - - -{{{step_to_resolve}}} - diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 9e61eee18a..21153b6fcc 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -97,6 +97,7 @@ language = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } languages = { workspace = true, features = ["test-support"] } log.workspace = true +pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } rand.workspace = true serde_json_lenient.workspace = true diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 9cc63af5a1..e1e574744f 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -6,6 +6,7 @@ mod context; pub mod context_store; mod inline_assistant; mod model_selector; +mod patch; mod prompt_library; mod prompts; mod slash_command; @@ -14,7 +15,6 @@ pub mod slash_command_settings; mod streaming_diff; mod terminal_inline_assistant; mod tools; -mod workflow; pub use assistant_panel::{AssistantPanel, AssistantPanelEvent}; use assistant_settings::AssistantSettings; @@ -35,11 +35,13 @@ use language_model::{ LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, LanguageModelResponseMessage, }; pub(crate) use model_selector::*; +pub use patch::*; pub use prompts::PromptBuilder; use prompts::PromptLoadingParams; use semantic_index::{CloudEmbeddingProvider, SemanticDb}; use serde::{Deserialize, Serialize}; use settings::{update_settings_file, Settings, SettingsStore}; +use slash_command::workflow_command::WorkflowSlashCommand; use slash_command::{ auto_command, cargo_workspace_command, context_server_command, default_command, delta_command, diagnostics_command, docs_command, fetch_command, file_command, now_command, project_command, @@ -50,7 +52,6 @@ use std::path::PathBuf; use std::sync::Arc; pub(crate) use streaming_diff::*; use util::ResultExt; -pub use workflow::*; use crate::slash_command_settings::SlashCommandSettings; @@ -393,12 +394,25 @@ fn register_slash_commands(prompt_builder: Option>, cx: &mut slash_command_registry.register_command(now_command::NowSlashCommand, false); slash_command_registry.register_command(diagnostics_command::DiagnosticsSlashCommand, true); slash_command_registry.register_command(fetch_command::FetchSlashCommand, false); + slash_command_registry.register_command(fetch_command::FetchSlashCommand, false); if let Some(prompt_builder) = prompt_builder { - slash_command_registry.register_command( - workflow_command::WorkflowSlashCommand::new(prompt_builder.clone()), - true, - ); + cx.observe_global::({ + let slash_command_registry = slash_command_registry.clone(); + let prompt_builder = prompt_builder.clone(); + move |cx| { + if AssistantSettings::get_global(cx).are_live_diffs_enabled(cx) { + slash_command_registry.register_command( + workflow_command::WorkflowSlashCommand::new(prompt_builder.clone()), + true, + ); + } else { + slash_command_registry.unregister_command_by_name(WorkflowSlashCommand::NAME); + } + } + }) + .detach(); + cx.observe_flag::({ let slash_command_registry = slash_command_registry.clone(); move |is_enabled, _cx| { diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index a3e189287f..5c20caa863 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -11,12 +11,12 @@ use crate::{ }, slash_command_picker, terminal_inline_assistant::TerminalInlineAssistant, - Assist, CacheStatus, ConfirmCommand, Content, Context, ContextEvent, ContextId, ContextStore, - ContextStoreEvent, CopyCode, CycleMessageRole, DeployHistory, DeployPromptLibrary, - InlineAssistId, InlineAssistant, InsertDraggedFiles, InsertIntoEditor, Message, MessageId, - MessageMetadata, MessageStatus, ModelPickerDelegate, ModelSelector, NewContext, - PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata, - SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, WorkflowStepResolution, + Assist, AssistantPatch, AssistantPatchStatus, CacheStatus, ConfirmCommand, Content, Context, + ContextEvent, ContextId, ContextStore, ContextStoreEvent, CopyCode, CycleMessageRole, + DeployHistory, DeployPromptLibrary, InlineAssistant, InsertDraggedFiles, InsertIntoEditor, + Message, MessageId, MessageMetadata, MessageStatus, ModelPickerDelegate, ModelSelector, + NewContext, PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection, + RemoteContextMetadata, SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, }; use anyhow::Result; use assistant_slash_command::{SlashCommand, SlashCommandOutputSection}; @@ -26,11 +26,12 @@ use collections::{BTreeSet, HashMap, HashSet}; use editor::{ actions::{FoldAt, MoveToEndOfLine, Newline, ShowCompletions, UnfoldAt}, display_map::{ - BlockDisposition, BlockId, BlockProperties, BlockStyle, Crease, CreaseMetadata, - CustomBlockId, FoldId, RenderBlock, ToDisplayPoint, + BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Crease, + CreaseMetadata, CustomBlockId, FoldId, RenderBlock, ToDisplayPoint, }, - scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor}, - Anchor, Editor, EditorEvent, ExcerptRange, MultiBuffer, RowExt, ToOffset as _, ToPoint, + scroll::{Autoscroll, AutoscrollStrategy}, + Anchor, Editor, EditorEvent, ProposedChangeLocation, ProposedChangesEditor, RowExt, + ToOffset as _, ToPoint, }; use editor::{display_map::CreaseId, FoldPlaceholder}; use fs::Fs; @@ -38,15 +39,14 @@ use futures::FutureExt; use gpui::{ canvas, div, img, percentage, point, pulsating_between, size, Action, Animation, AnimationExt, AnyElement, AnyView, AppContext, AsyncWindowContext, ClipboardEntry, ClipboardItem, - Context as _, Empty, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, FocusableView, - FontWeight, InteractiveElement, IntoElement, Model, ParentElement, Pixels, ReadGlobal, Render, - RenderImage, SharedString, Size, StatefulInteractiveElement, Styled, Subscription, Task, - Transformation, UpdateGlobal, View, VisualContext, WeakView, WindowContext, + CursorStyle, Empty, Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView, + FontWeight, InteractiveElement, IntoElement, Model, ParentElement, Pixels, Render, RenderImage, + SharedString, Size, StatefulInteractiveElement, Styled, Subscription, Task, Transformation, + UpdateGlobal, View, VisualContext, WeakView, WindowContext, }; use indexed_docs::IndexedDocsStore; use language::{ - language_settings::SoftWrap, BufferSnapshot, Capability, LanguageRegistry, LspAdapterDelegate, - ToOffset, + language_settings::SoftWrap, BufferSnapshot, LanguageRegistry, LspAdapterDelegate, ToOffset, }; use language_model::{ provider::cloud::PROVIDER_ID, LanguageModelProvider, LanguageModelProviderId, @@ -65,7 +65,6 @@ use smol::stream::StreamExt; use std::{ borrow::Cow, cmp, - collections::hash_map, ops::{ControlFlow, Range}, path::PathBuf, sync::Arc, @@ -1444,61 +1443,16 @@ struct ScrollPosition { cursor: Anchor, } -struct WorkflowStepViewState { - header_block_id: CustomBlockId, - header_crease_id: CreaseId, - footer_block_id: Option, - footer_crease_id: Option, - assist: Option, - resolution: Option>>, +struct PatchViewState { + footer_block_id: CustomBlockId, + crease_id: CreaseId, + editor: Option, + update_task: Option>, } -impl WorkflowStepViewState { - fn status(&self, cx: &AppContext) -> WorkflowStepStatus { - if let Some(assist) = &self.assist { - match assist.status(cx) { - WorkflowAssistStatus::Idle => WorkflowStepStatus::Idle, - WorkflowAssistStatus::Pending => WorkflowStepStatus::Pending, - WorkflowAssistStatus::Done => WorkflowStepStatus::Done, - WorkflowAssistStatus::Confirmed => WorkflowStepStatus::Confirmed, - } - } else if let Some(resolution) = self.resolution.as_deref() { - match resolution { - Err(err) => WorkflowStepStatus::Error(err), - Ok(_) => WorkflowStepStatus::Idle, - } - } else { - WorkflowStepStatus::Resolving - } - } -} - -#[derive(Clone, Copy)] -enum WorkflowStepStatus<'a> { - Resolving, - Error(&'a anyhow::Error), - Idle, - Pending, - Done, - Confirmed, -} - -impl<'a> WorkflowStepStatus<'a> { - pub(crate) fn is_confirmed(&self) -> bool { - matches!(self, Self::Confirmed) - } -} - -#[derive(Debug, Eq, PartialEq)] -struct ActiveWorkflowStep { - range: Range, - resolved: bool, -} - -struct WorkflowAssist { - editor: WeakView, - editor_was_open: bool, - assist_ids: Vec, +struct PatchEditorState { + editor: WeakView, + opened_patch: AssistantPatch, } type MessageHeader = MessageMetadata; @@ -1525,8 +1479,8 @@ pub struct ContextEditor { pending_slash_command_blocks: HashMap, CustomBlockId>, pending_tool_use_creases: HashMap, CreaseId>, _subscriptions: Vec, - workflow_steps: HashMap, WorkflowStepViewState>, - active_workflow_step: Option, + patches: HashMap, PatchViewState>, + active_patch: Option>, assistant_panel: WeakView, last_error: Option, show_accept_terms: bool, @@ -1580,7 +1534,7 @@ impl ContextEditor { ]; let sections = context.read(cx).slash_command_output_sections().to_vec(); - let edit_step_ranges = context.read(cx).workflow_step_ranges().collect::>(); + let patch_ranges = context.read(cx).patch_ranges().collect::>(); let mut this = Self { context, editor, @@ -1596,8 +1550,8 @@ impl ContextEditor { pending_slash_command_blocks: HashMap::default(), pending_tool_use_creases: HashMap::default(), _subscriptions, - workflow_steps: HashMap::default(), - active_workflow_step: None, + patches: HashMap::default(), + active_patch: None, assistant_panel, last_error: None, show_accept_terms: false, @@ -1607,7 +1561,7 @@ impl ContextEditor { this.update_message_headers(cx); this.update_image_blocks(cx); this.insert_slash_command_output_sections(sections, false, cx); - this.workflow_steps_updated(&Vec::new(), &edit_step_ranges, cx); + this.patches_updated(&Vec::new(), &patch_ranges, cx); this } @@ -1642,134 +1596,28 @@ impl ContextEditor { return; } - if !self.apply_active_workflow_step(cx) { - self.last_error = None; - self.send_to_model(cx); - cx.notify(); + if self.focus_active_patch(cx) { + return; } + + self.last_error = None; + self.send_to_model(cx); + cx.notify(); } - fn apply_workflow_step(&mut self, range: Range, cx: &mut ViewContext) { - self.show_workflow_step(range.clone(), cx); - - if let Some(workflow_step) = self.workflow_steps.get(&range) { - if let Some(assist) = workflow_step.assist.as_ref() { - let assist_ids = assist.assist_ids.clone(); - cx.spawn(|this, mut cx| async move { - for assist_id in assist_ids { - let mut receiver = this.update(&mut cx, |_, cx| { - cx.window_context().defer(move |cx| { - InlineAssistant::update_global(cx, |assistant, cx| { - assistant.start_assist(assist_id, cx); - }) - }); - InlineAssistant::update_global(cx, |assistant, _| { - assistant.observe_assist(assist_id) - }) - })?; - while !receiver.borrow().is_done() { - let _ = receiver.changed().await; - } - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - } - } - - fn apply_active_workflow_step(&mut self, cx: &mut ViewContext) -> bool { - let Some((range, step)) = self.active_workflow_step() else { - return false; - }; - - if let Some(assist) = step.assist.as_ref() { - match assist.status(cx) { - WorkflowAssistStatus::Pending => {} - WorkflowAssistStatus::Confirmed => return false, - WorkflowAssistStatus::Done => self.confirm_workflow_step(range, cx), - WorkflowAssistStatus::Idle => self.apply_workflow_step(range, cx), - } - } else { - match step.resolution.as_deref() { - Some(Ok(_)) => self.apply_workflow_step(range, cx), - Some(Err(_)) => self.resolve_workflow_step(range, cx), - None => {} + fn focus_active_patch(&mut self, cx: &mut ViewContext) -> bool { + if let Some((_range, patch)) = self.active_patch() { + if let Some(editor) = patch + .editor + .as_ref() + .and_then(|state| state.editor.upgrade()) + { + cx.focus_view(&editor); + return true; } } - true - } - - fn resolve_workflow_step( - &mut self, - range: Range, - cx: &mut ViewContext, - ) { - self.context - .update(cx, |context, cx| context.resolve_workflow_step(range, cx)); - } - - fn stop_workflow_step(&mut self, range: Range, cx: &mut ViewContext) { - if let Some(workflow_step) = self.workflow_steps.get(&range) { - if let Some(assist) = workflow_step.assist.as_ref() { - let assist_ids = assist.assist_ids.clone(); - cx.window_context().defer(|cx| { - InlineAssistant::update_global(cx, |assistant, cx| { - for assist_id in assist_ids { - assistant.stop_assist(assist_id, cx); - } - }) - }); - } - } - } - - fn undo_workflow_step(&mut self, range: Range, cx: &mut ViewContext) { - if let Some(workflow_step) = self.workflow_steps.get_mut(&range) { - if let Some(assist) = workflow_step.assist.take() { - cx.window_context().defer(|cx| { - InlineAssistant::update_global(cx, |assistant, cx| { - for assist_id in assist.assist_ids { - assistant.undo_assist(assist_id, cx); - } - }) - }); - } - } - } - - fn confirm_workflow_step( - &mut self, - range: Range, - cx: &mut ViewContext, - ) { - if let Some(workflow_step) = self.workflow_steps.get(&range) { - if let Some(assist) = workflow_step.assist.as_ref() { - let assist_ids = assist.assist_ids.clone(); - cx.window_context().defer(move |cx| { - InlineAssistant::update_global(cx, |assistant, cx| { - for assist_id in assist_ids { - assistant.finish_assist(assist_id, false, cx); - } - }) - }); - } - } - } - - fn reject_workflow_step(&mut self, range: Range, cx: &mut ViewContext) { - if let Some(workflow_step) = self.workflow_steps.get_mut(&range) { - if let Some(assist) = workflow_step.assist.take() { - cx.window_context().defer(move |cx| { - InlineAssistant::update_global(cx, |assistant, cx| { - for assist_id in assist.assist_ids { - assistant.finish_assist(assist_id, true, cx); - } - }) - }); - } - } + false } fn send_to_model(&mut self, cx: &mut ViewContext) { @@ -1802,19 +1650,6 @@ impl ContextEditor { return; } - if let Some((range, active_step)) = self.active_workflow_step() { - match active_step.status(cx) { - WorkflowStepStatus::Pending => { - self.stop_workflow_step(range, cx); - return; - } - WorkflowStepStatus::Done => { - self.reject_workflow_step(range, cx); - return; - } - _ => {} - } - } cx.propagate(); } @@ -2068,8 +1903,8 @@ impl ContextEditor { ); }); } - ContextEvent::WorkflowStepsUpdated { removed, updated } => { - self.workflow_steps_updated(removed, updated, cx); + ContextEvent::PatchesUpdated { removed, updated } => { + self.patches_updated(removed, updated, cx); } ContextEvent::PendingSlashCommandsUpdated { removed, updated } => { self.editor.update(cx, |editor, cx| { @@ -2309,7 +2144,7 @@ impl ContextEditor { } } - fn workflow_steps_updated( + fn patches_updated( &mut self, removed: &Vec>, updated: &Vec>, @@ -2320,218 +2155,133 @@ impl ContextEditor { let mut removed_block_ids = HashSet::default(); let mut editors_to_close = Vec::new(); for range in removed { - if let Some(state) = self.workflow_steps.remove(range) { - editors_to_close.extend(self.hide_workflow_step(range.clone(), cx)); - removed_block_ids.insert(state.header_block_id); - removed_crease_ids.push(state.header_crease_id); - removed_block_ids.extend(state.footer_block_id); - removed_crease_ids.extend(state.footer_crease_id); + if let Some(state) = self.patches.remove(range) { + editors_to_close.extend(state.editor.and_then(|state| state.editor.upgrade())); + removed_block_ids.insert(state.footer_block_id); + removed_crease_ids.push(state.crease_id); } } - for range in updated { - editors_to_close.extend(self.hide_workflow_step(range.clone(), cx)); - } - self.editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(cx); let multibuffer = &snapshot.buffer_snapshot; - let (&excerpt_id, _, buffer) = multibuffer.as_singleton().unwrap(); + let (&excerpt_id, _, _) = multibuffer.as_singleton().unwrap(); + let mut replaced_blocks = HashMap::default(); for range in updated { - let Some(step) = self.context.read(cx).workflow_step_for_range(&range, cx) else { + let Some(patch) = self.context.read(cx).patch_for_range(&range, cx).cloned() else { continue; }; - let resolution = step.resolution.clone(); - let header_start = step.range.start; - let header_end = if buffer.contains_str_at(step.leading_tags_end, "\n") { - buffer.anchor_before(step.leading_tags_end.to_offset(&buffer) + 1) - } else { - step.leading_tags_end - }; - let header_range = multibuffer - .anchor_in_excerpt(excerpt_id, header_start) - .unwrap() - ..multibuffer - .anchor_in_excerpt(excerpt_id, header_end) - .unwrap(); - let footer_range = step.trailing_tag_start.map(|start| { - let mut step_range_end = step.range.end.to_offset(&buffer); - if buffer.contains_str_at(step_range_end, "\n") { - // Only include the newline if it belongs to the same message. - let messages = self - .context - .read(cx) - .messages_for_offsets([step_range_end, step_range_end + 1], cx); - if messages.len() == 1 { - step_range_end += 1; - } + let path_count = patch.path_count(); + let patch_start = multibuffer + .anchor_in_excerpt(excerpt_id, patch.range.start) + .unwrap(); + let patch_end = multibuffer + .anchor_in_excerpt(excerpt_id, patch.range.end) + .unwrap(); + let render_block: RenderBlock = Box::new({ + let this = this.clone(); + let patch_range = range.clone(); + move |cx: &mut BlockContext<'_, '_>| { + let max_width = cx.max_width; + let gutter_width = cx.gutter_dimensions.full_width(); + let block_id = cx.block_id; + this.update(&mut **cx, |this, cx| { + this.render_patch_footer( + patch_range.clone(), + max_width, + gutter_width, + block_id, + cx, + ) + }) + .ok() + .flatten() + .unwrap_or_else(|| Empty.into_any()) } - - let end = buffer.anchor_before(step_range_end); - multibuffer.anchor_in_excerpt(excerpt_id, start).unwrap() - ..multibuffer.anchor_in_excerpt(excerpt_id, end).unwrap() }); - let block_ids = editor.insert_blocks( - [BlockProperties { - position: header_range.start, - height: 1, - style: BlockStyle::Flex, - render: Box::new({ - let this = this.clone(); - let range = step.range.clone(); - move |cx| { - let block_id = cx.block_id; - let max_width = cx.max_width; - let gutter_width = cx.gutter_dimensions.full_width(); - this.update(&mut **cx, |this, cx| { - this.render_workflow_step_header( - range.clone(), - max_width, - gutter_width, - block_id, - cx, - ) - }) - .ok() - .flatten() - .unwrap_or_else(|| Empty.into_any()) - } - }), - disposition: BlockDisposition::Above, - priority: 0, - }] - .into_iter() - .chain(footer_range.as_ref().map(|footer_range| { - return BlockProperties { - position: footer_range.end, - height: 1, - style: BlockStyle::Flex, - render: Box::new({ + let header_placeholder = FoldPlaceholder { + render: { + let this = this.clone(); + let patch_range = range.clone(); + Arc::new(move |fold_id, _range, cx| { + this.update(cx, |this, cx| { + this.render_patch_header(patch_range.clone(), fold_id, cx) + }) + .ok() + .flatten() + .unwrap_or_else(|| Empty.into_any()) + }) + }, + constrain_width: false, + merge_adjacent: false, + }; + + if let Some(state) = self.patches.get_mut(&range) { + replaced_blocks.insert(state.footer_block_id, render_block); + if let Some(editor_state) = &state.editor { + if editor_state.opened_patch != patch { + state.update_task = Some({ let this = this.clone(); - let range = step.range.clone(); - move |cx| { - let max_width = cx.max_width; - let gutter_width = cx.gutter_dimensions.full_width(); - this.update(&mut **cx, |this, cx| { - this.render_workflow_step_footer( - range.clone(), - max_width, - gutter_width, - cx, - ) - }) - .ok() - .flatten() - .unwrap_or_else(|| Empty.into_any()) - } - }), + cx.spawn(|_, cx| async move { + Self::update_patch_editor(this.clone(), patch, cx) + .await + .log_err(); + }) + }); + } + } + } else { + let block_ids = editor.insert_blocks( + [BlockProperties { + position: patch_start, + height: path_count as u32 + 1, + style: BlockStyle::Flex, + render: render_block, disposition: BlockDisposition::Below, priority: 0, - }; - })), - None, - cx, - ); + }], + None, + cx, + ); - let header_placeholder = FoldPlaceholder { - render: Arc::new(move |_, _crease_range, _cx| Empty.into_any()), - constrain_width: false, - merge_adjacent: false, - }; - let footer_placeholder = FoldPlaceholder { - render: render_fold_icon_button( - cx.view().downgrade(), - IconName::Code, - "Edits".into(), - ), - constrain_width: false, - merge_adjacent: false, - }; - - let new_crease_ids = editor.insert_creases( - [Crease::new( - header_range.clone(), - header_placeholder.clone(), - fold_toggle("step-header"), - |_, _, _| Empty.into_any_element(), - )] - .into_iter() - .chain(footer_range.clone().map(|footer_range| { - Crease::new( - footer_range, - footer_placeholder.clone(), - |row, is_folded, fold, cx| { - if is_folded { - Empty.into_any_element() - } else { - fold_toggle("step-footer")(row, is_folded, fold, cx) - } - }, + let new_crease_ids = editor.insert_creases( + [Crease::new( + patch_start..patch_end, + header_placeholder.clone(), + fold_toggle("patch-header"), |_, _, _| Empty.into_any_element(), - ) - })), - cx, - ); + )], + cx, + ); - let state = WorkflowStepViewState { - header_block_id: block_ids[0], - header_crease_id: new_crease_ids[0], - footer_block_id: block_ids.get(1).copied(), - footer_crease_id: new_crease_ids.get(1).copied(), - resolution, - assist: None, - }; - - let mut folds_to_insert = [(header_range.clone(), header_placeholder)] - .into_iter() - .chain( - footer_range - .clone() - .map(|range| (range, footer_placeholder)), - ) - .collect::>(); - - match self.workflow_steps.entry(range.clone()) { - hash_map::Entry::Vacant(entry) => { - entry.insert(state); - } - hash_map::Entry::Occupied(mut entry) => { - let entry = entry.get_mut(); - removed_block_ids.insert(entry.header_block_id); - removed_crease_ids.push(entry.header_crease_id); - removed_block_ids.extend(entry.footer_block_id); - removed_crease_ids.extend(entry.footer_crease_id); - folds_to_insert.retain(|(range, _)| snapshot.intersects_fold(range.start)); - *entry = state; - } + self.patches.insert( + range.clone(), + PatchViewState { + footer_block_id: block_ids[0], + crease_id: new_crease_ids[0], + editor: None, + update_task: None, + }, + ); } - editor.unfold_ranges( - [header_range.clone()] - .into_iter() - .chain(footer_range.clone()), - true, - false, - cx, - ); - - if !folds_to_insert.is_empty() { - editor.fold_ranges(folds_to_insert, false, cx); - } + editor.unfold_ranges([patch_start..patch_end], true, false, cx); + editor.fold_ranges([(patch_start..patch_end, header_placeholder)], false, cx); } editor.remove_creases(removed_crease_ids, cx); editor.remove_blocks(removed_block_ids, None, cx); + editor.replace_blocks(replaced_blocks, None, cx); }); - for (editor, editor_was_open) in editors_to_close { - self.close_workflow_editor(cx, editor, editor_was_open); + for editor in editors_to_close { + self.close_patch_editor(editor, cx); } - self.update_active_workflow_step(cx); + self.update_active_patch(cx); } fn insert_slash_command_output_sections( @@ -2604,87 +2354,75 @@ impl ContextEditor { } EditorEvent::SelectionsChanged { .. } => { self.scroll_position = self.cursor_scroll_position(cx); - self.update_active_workflow_step(cx); + self.update_active_patch(cx); } _ => {} } cx.emit(event.clone()); } - fn active_workflow_step(&self) -> Option<(Range, &WorkflowStepViewState)> { - let step = self.active_workflow_step.as_ref()?; - Some((step.range.clone(), self.workflow_steps.get(&step.range)?)) + fn active_patch(&self) -> Option<(Range, &PatchViewState)> { + let patch = self.active_patch.as_ref()?; + Some((patch.clone(), self.patches.get(&patch)?)) } - fn update_active_workflow_step(&mut self, cx: &mut ViewContext) { - let newest_cursor = self.editor.read(cx).selections.newest::(cx).head(); + fn update_active_patch(&mut self, cx: &mut ViewContext) { + let newest_cursor = self.editor.read(cx).selections.newest::(cx).head(); let context = self.context.read(cx); - let new_step = context - .workflow_step_containing(newest_cursor, cx) - .map(|step| ActiveWorkflowStep { - resolved: step.resolution.is_some(), - range: step.range.clone(), - }); + let new_patch = context.patch_containing(newest_cursor, cx).cloned(); - if new_step.as_ref() != self.active_workflow_step.as_ref() { - let mut old_editor = None; - let mut old_editor_was_open = None; - if let Some(old_step) = self.active_workflow_step.take() { - (old_editor, old_editor_was_open) = - self.hide_workflow_step(old_step.range, cx).unzip(); + if new_patch.as_ref().map(|p| &p.range) == self.active_patch.as_ref() { + return; + } + + if let Some(old_patch_range) = self.active_patch.take() { + if let Some(patch_state) = self.patches.get_mut(&old_patch_range) { + if let Some(state) = patch_state.editor.take() { + if let Some(editor) = state.editor.upgrade() { + self.close_patch_editor(editor, cx); + } + } } + } - let mut new_editor = None; - if let Some(new_step) = new_step { - new_editor = self.show_workflow_step(new_step.range.clone(), cx); - self.active_workflow_step = Some(new_step); - } + if let Some(new_patch) = new_patch { + self.active_patch = Some(new_patch.range.clone()); - if new_editor != old_editor { - if let Some((old_editor, old_editor_was_open)) = old_editor.zip(old_editor_was_open) - { - self.close_workflow_editor(cx, old_editor, old_editor_was_open) + if let Some(patch_state) = self.patches.get_mut(&new_patch.range) { + let mut editor = None; + if let Some(state) = &patch_state.editor { + if let Some(opened_editor) = state.editor.upgrade() { + editor = Some(opened_editor); + } + } + + if let Some(editor) = editor { + self.workspace + .update(cx, |workspace, cx| { + workspace.activate_item(&editor, true, false, cx); + }) + .ok(); + } else { + patch_state.update_task = Some(cx.spawn(move |this, cx| async move { + Self::open_patch_editor(this, new_patch, cx).await.log_err(); + })); } } } } - fn hide_workflow_step( - &mut self, - step_range: Range, - cx: &mut ViewContext, - ) -> Option<(View, bool)> { - if let Some(step) = self.workflow_steps.get_mut(&step_range) { - let assist = step.assist.as_ref()?; - let editor = assist.editor.upgrade()?; - - if matches!(step.status(cx), WorkflowStepStatus::Idle) { - let assist = step.assist.take().unwrap(); - InlineAssistant::update_global(cx, |assistant, cx| { - for assist_id in assist.assist_ids { - assistant.finish_assist(assist_id, true, cx) - } - }); - return Some((editor, assist.editor_was_open)); - } - } - - None - } - - fn close_workflow_editor( + fn close_patch_editor( &mut self, + editor: View, cx: &mut ViewContext, - editor: View, - editor_was_open: bool, ) { self.workspace .update(cx, |workspace, cx| { if let Some(pane) = workspace.pane_for(&editor) { pane.update(cx, |pane, cx| { let item_id = editor.entity_id(); - if !editor_was_open && !editor.read(cx).is_focused(cx) { + if !editor.read(cx).focus_handle(cx).is_focused(cx) { pane.close_item_by_id(item_id, SaveIntent::Skip, cx) .detach_and_log_err(cx); } @@ -2694,190 +2432,94 @@ impl ContextEditor { .ok(); } - fn show_workflow_step( - &mut self, - step_range: Range, - cx: &mut ViewContext, - ) -> Option> { - let step = self.workflow_steps.get_mut(&step_range)?; + async fn open_patch_editor( + this: WeakView, + patch: AssistantPatch, + mut cx: AsyncWindowContext, + ) -> Result<()> { + let project = this.update(&mut cx, |this, _| this.project.clone())?; + let resolved_patch = patch.resolve(project.clone(), &mut cx).await; - let mut editor_to_return = None; - let mut scroll_to_assist_id = None; - match step.status(cx) { - WorkflowStepStatus::Idle => { - if let Some(assist) = step.assist.as_ref() { - scroll_to_assist_id = assist.assist_ids.first().copied(); - } else if let Some(Ok(resolved)) = step.resolution.clone().as_deref() { - step.assist = Self::open_assists_for_step( - &resolved, - &self.project, - &self.assistant_panel, - &self.workspace, - cx, - ); - editor_to_return = step - .assist - .as_ref() - .and_then(|assist| assist.editor.upgrade()); - } - } - WorkflowStepStatus::Pending => { - if let Some(assist) = step.assist.as_ref() { - let assistant = InlineAssistant::global(cx); - scroll_to_assist_id = assist - .assist_ids - .iter() - .copied() - .find(|assist_id| assistant.assist_status(*assist_id, cx).is_pending()); - } - } - WorkflowStepStatus::Done => { - if let Some(assist) = step.assist.as_ref() { - scroll_to_assist_id = assist.assist_ids.first().copied(); - } - } - _ => {} - } - - if let Some(assist_id) = scroll_to_assist_id { - if let Some(assist_editor) = step - .assist - .as_ref() - .and_then(|assists| assists.editor.upgrade()) - { - editor_to_return = Some(assist_editor.clone()); - self.workspace - .update(cx, |workspace, cx| { - workspace.activate_item(&assist_editor, false, false, cx); + let editor = cx.new_view(|cx| { + let editor = ProposedChangesEditor::new( + patch.title.clone(), + resolved_patch + .edit_groups + .iter() + .map(|(buffer, groups)| ProposedChangeLocation { + buffer: buffer.clone(), + ranges: groups + .iter() + .map(|group| group.context_range.clone()) + .collect(), }) - .ok(); - InlineAssistant::update_global(cx, |assistant, cx| { - assistant.scroll_to_assist(assist_id, cx) + .collect(), + Some(project.clone()), + cx, + ); + resolved_patch.apply(&editor, cx); + editor + })?; + + this.update(&mut cx, |this, cx| { + if let Some(patch_state) = this.patches.get_mut(&patch.range) { + patch_state.editor = Some(PatchEditorState { + editor: editor.downgrade(), + opened_patch: patch, }); + patch_state.update_task.take(); } - } - editor_to_return - } - - fn open_assists_for_step( - resolved_step: &WorkflowStepResolution, - project: &Model, - assistant_panel: &WeakView, - workspace: &WeakView, - cx: &mut ViewContext, - ) -> Option { - let assistant_panel = assistant_panel.upgrade()?; - if resolved_step.suggestion_groups.is_empty() { - return None; - } - - let editor; - let mut editor_was_open = false; - let mut suggestion_groups = Vec::new(); - if resolved_step.suggestion_groups.len() == 1 - && resolved_step - .suggestion_groups - .values() - .next() - .unwrap() - .len() - == 1 - { - // If there's only one buffer and one suggestion group, open it directly - let (buffer, groups) = resolved_step.suggestion_groups.iter().next().unwrap(); - let group = groups.into_iter().next().unwrap(); - editor = workspace - .update(cx, |workspace, cx| { - let active_pane = workspace.active_pane().clone(); - editor_was_open = - workspace.is_project_item_open::(&active_pane, buffer, cx); - workspace.open_project_item::( - active_pane, - buffer.clone(), - false, - false, - cx, - ) - }) - .log_err()?; - let (&excerpt_id, _, _) = editor - .read(cx) - .buffer() - .read(cx) - .read(cx) - .as_singleton() - .unwrap(); - - // Scroll the editor to the suggested assist - editor.update(cx, |editor, cx| { - let multibuffer = editor.buffer().read(cx).snapshot(cx); - let (&excerpt_id, _, buffer) = multibuffer.as_singleton().unwrap(); - let anchor = if group.context_range.start.to_offset(buffer) == 0 { - Anchor::min() - } else { - multibuffer - .anchor_in_excerpt(excerpt_id, group.context_range.start) - .unwrap() - }; - - editor.set_scroll_anchor( - ScrollAnchor { - offset: gpui::Point::default(), - anchor, - }, - cx, - ); - }); - - suggestion_groups.push((excerpt_id, group)); - } else { - // If there are multiple buffers or suggestion groups, create a multibuffer - let multibuffer = cx.new_model(|cx| { - let mut multibuffer = - MultiBuffer::new(Capability::ReadWrite).with_title(resolved_step.title.clone()); - for (buffer, groups) in &resolved_step.suggestion_groups { - let excerpt_ids = multibuffer.push_excerpts( - buffer.clone(), - groups.iter().map(|suggestion_group| ExcerptRange { - context: suggestion_group.context_range.clone(), - primary: None, - }), - cx, - ); - suggestion_groups.extend(excerpt_ids.into_iter().zip(groups)); - } - multibuffer - }); - - editor = cx.new_view(|cx| { - Editor::for_multibuffer(multibuffer, Some(project.clone()), true, cx) - }); - workspace + this.workspace .update(cx, |workspace, cx| { workspace.add_item_to_active_pane(Box::new(editor.clone()), None, false, cx) }) - .log_err()?; - } + .log_err(); + })?; - let mut assist_ids = Vec::new(); - for (excerpt_id, suggestion_group) in suggestion_groups { - for suggestion in &suggestion_group.suggestions { - assist_ids.extend(suggestion.show( - &editor, - excerpt_id, - workspace, - &assistant_panel, - cx, - )); + Ok(()) + } + + async fn update_patch_editor( + this: WeakView, + patch: AssistantPatch, + mut cx: AsyncWindowContext, + ) -> Result<()> { + let project = this.update(&mut cx, |this, _| this.project.clone())?; + let resolved_patch = patch.resolve(project.clone(), &mut cx).await; + this.update(&mut cx, |this, cx| { + let patch_state = this.patches.get_mut(&patch.range)?; + + let locations = resolved_patch + .edit_groups + .iter() + .map(|(buffer, groups)| ProposedChangeLocation { + buffer: buffer.clone(), + ranges: groups + .iter() + .map(|group| group.context_range.clone()) + .collect(), + }) + .collect(); + + if let Some(state) = &mut patch_state.editor { + if let Some(editor) = state.editor.upgrade() { + editor.update(cx, |editor, cx| { + editor.set_title(patch.title.clone(), cx); + editor.reset_locations(locations, cx); + resolved_patch.apply(editor, cx); + }); + + state.opened_patch = patch; + } else { + patch_state.editor.take(); + } } - } + patch_state.update_task.take(); - Some(WorkflowAssist { - assist_ids, - editor: editor.downgrade(), - editor_was_open, - }) + Some(()) + })?; + Ok(()) } fn handle_editor_search_event( @@ -3782,394 +3424,91 @@ impl ContextEditor { .unwrap_or_else(|| Cow::Borrowed(DEFAULT_TAB_TITLE)) } - fn render_workflow_step_header( + fn render_patch_header( &self, range: Range, + _id: FoldId, + cx: &mut ViewContext, + ) -> Option { + let patch = self.context.read(cx).patch_for_range(&range, cx)?; + let theme = cx.theme().clone(); + Some( + h_flex() + .px_1() + .py_0p5() + .border_b_1() + .border_color(theme.status().info_border) + .gap_1() + .child(Icon::new(IconName::Diff).size(IconSize::Small)) + .child(Label::new(patch.title.clone()).size(LabelSize::Small)) + .into_any(), + ) + } + + fn render_patch_footer( + &mut self, + range: Range, max_width: Pixels, gutter_width: Pixels, id: BlockId, cx: &mut ViewContext, ) -> Option { - let step_state = self.workflow_steps.get(&range)?; - let status = step_state.status(cx); - let this = cx.view().downgrade(); + let snapshot = self.editor.update(cx, |editor, cx| editor.snapshot(cx)); + let (excerpt_id, _buffer_id, _) = snapshot.buffer_snapshot.as_singleton().unwrap(); + let excerpt_id = *excerpt_id; + let anchor = snapshot + .buffer_snapshot + .anchor_in_excerpt(excerpt_id, range.start) + .unwrap(); - let theme = cx.theme().status(); - let is_confirmed = status.is_confirmed(); - let border_color = if is_confirmed { - theme.ignored_border - } else { - theme.info_border - }; - - let editor = self.editor.read(cx); - let focus_handle = editor.focus_handle(cx); - let snapshot = editor - .buffer() - .read(cx) - .as_singleton()? - .read(cx) - .text_snapshot(); - let start_offset = range.start.to_offset(&snapshot); - let parent_message = self - .context - .read(cx) - .messages_for_offsets([start_offset], cx); - debug_assert_eq!(parent_message.len(), 1); - let parent_message = parent_message.first()?; - - let step_index = self - .workflow_steps - .keys() - .filter(|workflow_step_range| { - workflow_step_range - .start - .cmp(&parent_message.anchor_range.start, &snapshot) - .is_ge() - && workflow_step_range.end.cmp(&range.end, &snapshot).is_le() - }) - .count(); - - let step_label = Label::new(format!("Step {step_index}")).size(LabelSize::Small); - - let step_label = if is_confirmed { - h_flex() - .items_center() - .gap_2() - .child(step_label.strikethrough(true).color(Color::Muted)) - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Created), - ) - } else { - div().child(step_label) - }; - - Some( - v_flex() - .w(max_width) - .pl(gutter_width) - .child( - h_flex() - .w_full() - .h_8() - .border_b_1() - .border_color(border_color) - .items_center() - .justify_between() - .gap_2() - .child(h_flex().justify_start().gap_2().child(step_label)) - .child(h_flex().w_full().justify_end().child( - Self::render_workflow_step_status( - status, - range.clone(), - focus_handle.clone(), - this.clone(), - id, - ), - )), - ) - // todo!("do we wanna keep this?") - // .children(edit_paths.iter().map(|path| { - // h_flex() - // .gap_1() - // .child(Icon::new(IconName::File)) - // .child(Label::new(path.clone())) - // })) - .into_any(), - ) - } - - fn render_workflow_step_footer( - &self, - step_range: Range, - max_width: Pixels, - gutter_width: Pixels, - cx: &mut ViewContext, - ) -> Option { - let step = self.workflow_steps.get(&step_range)?; - let current_status = step.status(cx); - let theme = cx.theme().status(); - let border_color = if current_status.is_confirmed() { - theme.ignored_border - } else { - theme.info_border - }; - Some( - v_flex() - .w(max_width) - .pt_1() - .pl(gutter_width) - .child(h_flex().h(px(1.)).bg(border_color)) - .into_any(), - ) - } - - fn render_workflow_step_status( - status: WorkflowStepStatus, - step_range: Range, - focus_handle: FocusHandle, - editor: WeakView, - id: BlockId, - ) -> AnyElement { - let id = EntityId::from(id).as_u64(); - fn display_keybind_in_tooltip( - step_range: &Range, - editor: &WeakView, - cx: &mut WindowContext<'_>, - ) -> bool { - editor - .update(cx, |this, _| { - this.active_workflow_step - .as_ref() - .map(|step| &step.range == step_range) - }) - .ok() - .flatten() - .unwrap_or_default() + if !snapshot.intersects_fold(anchor) { + return None; } - match status { - WorkflowStepStatus::Error(error) => { - let error = error.to_string(); - h_flex() - .gap_2() - .child( - div() - .id("step-resolution-failure") - .child( - Label::new("Step Resolution Failed") - .size(LabelSize::Small) - .color(Color::Error), - ) - .tooltip(move |cx| Tooltip::text(error.clone(), cx)), - ) - .child( - Button::new(("transform", id), "Retry") - .icon(IconName::Update) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .label_size(LabelSize::Small) - .on_click({ - let editor = editor.clone(); - let step_range = step_range.clone(); - move |_, cx| { - editor - .update(cx, |this, cx| { - this.resolve_workflow_step(step_range.clone(), cx) - }) - .ok(); - } - }), - ) - .into_any() - } - WorkflowStepStatus::Idle | WorkflowStepStatus::Resolving { .. } => { - Button::new(("transform", id), "Transform") - .icon(IconName::SparkleAlt) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .label_size(LabelSize::Small) - .style(ButtonStyle::Tinted(TintColor::Accent)) - .tooltip({ - let step_range = step_range.clone(); - let editor = editor.clone(); - move |cx| { - cx.new_view(|cx| { - let tooltip = Tooltip::new("Transform"); - if display_keybind_in_tooltip(&step_range, &editor, cx) { - tooltip.key_binding(KeyBinding::for_action_in( - &Assist, - &focus_handle, - cx, - )) - } else { - tooltip - } - }) - .into() - } - }) - .on_click({ - let editor = editor.clone(); - let step_range = step_range.clone(); - let is_idle = matches!(status, WorkflowStepStatus::Idle); - move |_, cx| { - if is_idle { - editor - .update(cx, |this, cx| { - this.apply_workflow_step(step_range.clone(), cx) - }) - .ok(); - } - } - }) - .map(|this| { - if let WorkflowStepStatus::Resolving = &status { - this.with_animation( - ("resolving-suggestion-animation", id), + let patch = self.context.read(cx).patch_for_range(&range, cx)?; + let paths = patch + .paths() + .map(|p| SharedString::from(p.to_string())) + .collect::>(); + + Some( + v_flex() + .id(id) + .pl(gutter_width) + .w(max_width) + .py_2() + .cursor(CursorStyle::PointingHand) + .on_click(cx.listener(move |this, _, cx| { + this.editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |selections| { + selections.select_ranges(vec![anchor..anchor]); + }); + }); + this.focus_active_patch(cx); + })) + .children(paths.into_iter().map(|path| { + h_flex() + .pl_1() + .gap_1() + .child(Icon::new(IconName::File).size(IconSize::Small)) + .child(Label::new(path).size(LabelSize::Small)) + })) + .when(patch.status == AssistantPatchStatus::Pending, |div| { + div.child( + Label::new("Generating") + .color(Color::Muted) + .size(LabelSize::Small) + .with_animation( + "pulsating-label", Animation::new(Duration::from_secs(2)) .repeat() - .with_easing(pulsating_between(0.4, 0.8)), + .with_easing(pulsating_between(0.4, 1.)), |label, delta| label.alpha(delta), - ) - .into_any_element() - } else { - this.into_any_element() - } - }) - } - WorkflowStepStatus::Pending => h_flex() - .items_center() - .gap_2() - .child( - Label::new("Applying...") - .size(LabelSize::Small) - .with_animation( - ("applying-step-transformation-label", id), - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.alpha(delta), - ), - ) - .child( - IconButton::new(("stop-transformation", id), IconName::Stop) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .style(ButtonStyle::Subtle) - .tooltip({ - let step_range = step_range.clone(); - let editor = editor.clone(); - move |cx| { - cx.new_view(|cx| { - let tooltip = Tooltip::new("Stop Transformation"); - if display_keybind_in_tooltip(&step_range, &editor, cx) { - tooltip.key_binding(KeyBinding::for_action_in( - &editor::actions::Cancel, - &focus_handle, - cx, - )) - } else { - tooltip - } - }) - .into() - } - }) - .on_click({ - let editor = editor.clone(); - let step_range = step_range.clone(); - move |_, cx| { - editor - .update(cx, |this, cx| { - this.stop_workflow_step(step_range.clone(), cx) - }) - .ok(); - } - }), - ) - .into_any_element(), - WorkflowStepStatus::Done => h_flex() - .gap_1() - .child( - IconButton::new(("stop-transformation", id), IconName::Close) - .icon_size(IconSize::Small) - .style(ButtonStyle::Tinted(TintColor::Negative)) - .tooltip({ - let focus_handle = focus_handle.clone(); - let editor = editor.clone(); - let step_range = step_range.clone(); - move |cx| { - cx.new_view(|cx| { - let tooltip = Tooltip::new("Reject Transformation"); - if display_keybind_in_tooltip(&step_range, &editor, cx) { - tooltip.key_binding(KeyBinding::for_action_in( - &editor::actions::Cancel, - &focus_handle, - cx, - )) - } else { - tooltip - } - }) - .into() - } - }) - .on_click({ - let editor = editor.clone(); - let step_range = step_range.clone(); - move |_, cx| { - editor - .update(cx, |this, cx| { - this.reject_workflow_step(step_range.clone(), cx); - }) - .ok(); - } - }), - ) - .child( - Button::new(("confirm-workflow-step", id), "Accept") - .icon(IconName::Check) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .label_size(LabelSize::Small) - .style(ButtonStyle::Tinted(TintColor::Positive)) - .tooltip({ - let editor = editor.clone(); - let step_range = step_range.clone(); - move |cx| { - cx.new_view(|cx| { - let tooltip = Tooltip::new("Accept Transformation"); - if display_keybind_in_tooltip(&step_range, &editor, cx) { - tooltip.key_binding(KeyBinding::for_action_in( - &Assist, - &focus_handle, - cx, - )) - } else { - tooltip - } - }) - .into() - } - }) - .on_click({ - let editor = editor.clone(); - let step_range = step_range.clone(); - move |_, cx| { - editor - .update(cx, |this, cx| { - this.confirm_workflow_step(step_range.clone(), cx); - }) - .ok(); - } - }), - ) - .into_any_element(), - WorkflowStepStatus::Confirmed => h_flex() - .child( - Button::new(("revert-workflow-step", id), "Undo") - .style(ButtonStyle::Filled) - .icon(Some(IconName::Undo)) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .label_size(LabelSize::Small) - .on_click({ - let editor = editor.clone(); - let step_range = step_range.clone(); - move |_, cx| { - editor - .update(cx, |this, cx| { - this.undo_workflow_step(step_range.clone(), cx); - }) - .ok(); - } - }), - ) - .into_any_element(), - } + ), + ) + }) + .into_any(), + ) } fn render_notice(&self, cx: &mut ViewContext) -> Option { @@ -4259,17 +3598,6 @@ impl ContextEditor { fn render_send_button(&self, cx: &mut ViewContext) -> impl IntoElement { let focus_handle = self.focus_handle(cx).clone(); - let button_text = match self.active_workflow_step() { - Some((_, step)) => match step.status(cx) { - WorkflowStepStatus::Error(_) => "Retry Step Resolution", - WorkflowStepStatus::Resolving => "Transform", - WorkflowStepStatus::Idle => "Transform", - WorkflowStepStatus::Pending => "Applying...", - WorkflowStepStatus::Done => "Accept", - WorkflowStepStatus::Confirmed => "Send", - }, - None => "Send", - }; let (style, tooltip) = match token_state(&self.context, cx) { Some(TokenState::NoTokensLeft { .. }) => ( @@ -4309,7 +3637,7 @@ impl ContextEditor { button.tooltip(move |_| tooltip.clone()) }) .layer(ElevationIndex::ModalSurface) - .child(Label::new(button_text)) + .child(Label::new("Send")) .children( KeyBinding::for_action_in(&Assist, &focus_handle, cx) .map(|binding| binding.into_any_element()), @@ -5226,33 +4554,6 @@ pub enum WorkflowAssistStatus { Idle, } -impl WorkflowAssist { - pub fn status(&self, cx: &AppContext) -> WorkflowAssistStatus { - let assistant = InlineAssistant::global(cx); - if self - .assist_ids - .iter() - .any(|assist_id| assistant.assist_status(*assist_id, cx).is_pending()) - { - WorkflowAssistStatus::Pending - } else if self - .assist_ids - .iter() - .all(|assist_id| assistant.assist_status(*assist_id, cx).is_confirmed()) - { - WorkflowAssistStatus::Confirmed - } else if self - .assist_ids - .iter() - .all(|assist_id| assistant.assist_status(*assist_id, cx).is_done()) - { - WorkflowAssistStatus::Done - } else { - WorkflowAssistStatus::Idle - } - } -} - impl Render for ContextHistory { fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { div().size_full().child(self.picker.clone()) diff --git a/crates/assistant/src/assistant_settings.rs b/crates/assistant/src/assistant_settings.rs index 5aa379bae3..2bab6a9624 100644 --- a/crates/assistant/src/assistant_settings.rs +++ b/crates/assistant/src/assistant_settings.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use ::open_ai::Model as OpenAiModel; use anthropic::Model as AnthropicModel; +use feature_flags::FeatureFlagAppExt; use fs::Fs; use gpui::{AppContext, Pixels}; use language_model::provider::open_ai; @@ -61,6 +62,13 @@ pub struct AssistantSettings { pub default_model: LanguageModelSelection, pub inline_alternatives: Vec, pub using_outdated_settings_version: bool, + pub enable_experimental_live_diffs: bool, +} + +impl AssistantSettings { + pub fn are_live_diffs_enabled(&self, cx: &AppContext) -> bool { + cx.is_staff() || self.enable_experimental_live_diffs + } } /// Assistant panel settings @@ -238,6 +246,7 @@ impl AssistantSettingsContent { } }), inline_alternatives: None, + enable_experimental_live_diffs: None, }, VersionedAssistantSettingsContent::V2(settings) => settings.clone(), }, @@ -257,6 +266,7 @@ impl AssistantSettingsContent { .to_string(), }), inline_alternatives: None, + enable_experimental_live_diffs: None, }, } } @@ -373,6 +383,7 @@ impl Default for VersionedAssistantSettingsContent { default_height: None, default_model: None, inline_alternatives: None, + enable_experimental_live_diffs: None, }) } } @@ -403,6 +414,10 @@ pub struct AssistantSettingsContentV2 { default_model: Option, /// Additional models with which to generate alternatives when performing inline assists. inline_alternatives: Option>, + /// Enable experimental live diffs in the assistant panel. + /// + /// Default: false + enable_experimental_live_diffs: Option, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -525,7 +540,10 @@ impl Settings for AssistantSettings { ); merge(&mut settings.default_model, value.default_model); merge(&mut settings.inline_alternatives, value.inline_alternatives); - // merge(&mut settings.infer_context, value.infer_context); TODO re-enable this once we ship context inference + merge( + &mut settings.enable_experimental_live_diffs, + value.enable_experimental_live_diffs, + ); } Ok(settings) @@ -584,6 +602,7 @@ mod tests { dock: None, default_width: None, default_height: None, + enable_experimental_live_diffs: None, }), ) }, diff --git a/crates/assistant/src/context.rs b/crates/assistant/src/context.rs index 610652a371..2818411d0d 100644 --- a/crates/assistant/src/context.rs +++ b/crates/assistant/src/context.rs @@ -2,8 +2,8 @@ mod context_tests; use crate::{ - prompts::PromptBuilder, slash_command::SlashCommandLine, MessageId, MessageStatus, - WorkflowStep, WorkflowStepEdit, WorkflowStepResolution, WorkflowSuggestionGroup, + prompts::PromptBuilder, slash_command::SlashCommandLine, AssistantEdit, AssistantPatch, + AssistantPatchStatus, MessageId, MessageStatus, }; use anyhow::{anyhow, Context as _, Result}; use assistant_slash_command::{ @@ -15,13 +15,10 @@ use clock::ReplicaId; use collections::{HashMap, HashSet}; use feature_flags::{FeatureFlag, FeatureFlagAppExt}; use fs::{Fs, RemoveOptions}; -use futures::{ - future::{self, Shared}, - FutureExt, StreamExt, -}; +use futures::{future::Shared, FutureExt, StreamExt}; use gpui::{ - AppContext, AsyncAppContext, Context as _, EventEmitter, Model, ModelContext, RenderImage, - SharedString, Subscription, Task, + AppContext, Context as _, EventEmitter, Model, ModelContext, RenderImage, SharedString, + Subscription, Task, }; use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset}; @@ -38,7 +35,7 @@ use project::Project; use serde::{Deserialize, Serialize}; use smallvec::SmallVec; use std::{ - cmp::{self, max, Ordering}, + cmp::{max, Ordering}, fmt::Debug, iter, mem, ops::Range, @@ -300,7 +297,7 @@ pub enum ContextEvent { MessagesEdited, SummaryChanged, StreamedCompletion, - WorkflowStepsUpdated { + PatchesUpdated { removed: Vec>, updated: Vec>, }, @@ -454,13 +451,14 @@ pub struct XmlTag { #[derive(Copy, Clone, Debug, strum::EnumString, PartialEq, Eq, strum::AsRefStr)] #[strum(serialize_all = "snake_case")] pub enum XmlTagKind { - Step, + Patch, + Title, Edit, Path, - Search, - Within, - Operation, Description, + OldText, + NewText, + Operation, } pub struct Context { @@ -490,7 +488,7 @@ pub struct Context { _subscriptions: Vec, telemetry: Option>, language_registry: Arc, - workflow_steps: Vec, + patches: Vec, xml_tags: Vec, project: Option>, prompt_builder: Arc, @@ -506,7 +504,7 @@ impl ContextAnnotation for PendingSlashCommand { } } -impl ContextAnnotation for WorkflowStep { +impl ContextAnnotation for AssistantPatch { fn range(&self) -> &Range { &self.range } @@ -591,7 +589,7 @@ impl Context { telemetry, project, language_registry, - workflow_steps: Vec::new(), + patches: Vec::new(), xml_tags: Vec::new(), prompt_builder, }; @@ -929,48 +927,49 @@ impl Context { self.summary.as_ref() } - pub(crate) fn workflow_step_containing( + pub(crate) fn patch_containing( &self, - offset: usize, + position: Point, cx: &AppContext, - ) -> Option<&WorkflowStep> { + ) -> Option<&AssistantPatch> { let buffer = self.buffer.read(cx); - let index = self - .workflow_steps - .binary_search_by(|step| { - let step_range = step.range.to_offset(&buffer); - if offset < step_range.start { - Ordering::Greater - } else if offset > step_range.end { - Ordering::Less - } else { - Ordering::Equal - } - }) - .ok()?; - Some(&self.workflow_steps[index]) + let index = self.patches.binary_search_by(|patch| { + let patch_range = patch.range.to_point(&buffer); + if position < patch_range.start { + Ordering::Greater + } else if position > patch_range.end { + Ordering::Less + } else { + Ordering::Equal + } + }); + if let Ok(ix) = index { + Some(&self.patches[ix]) + } else { + None + } } - pub fn workflow_step_ranges(&self) -> impl Iterator> + '_ { - self.workflow_steps.iter().map(|step| step.range.clone()) + pub fn patch_ranges(&self) -> impl Iterator> + '_ { + self.patches.iter().map(|patch| patch.range.clone()) } - pub(crate) fn workflow_step_for_range( + pub(crate) fn patch_for_range( &self, range: &Range, cx: &AppContext, - ) -> Option<&WorkflowStep> { + ) -> Option<&AssistantPatch> { let buffer = self.buffer.read(cx); - let index = self.workflow_step_index_for_range(range, buffer).ok()?; - Some(&self.workflow_steps[index]) + let index = self.patch_index_for_range(range, buffer).ok()?; + Some(&self.patches[index]) } - fn workflow_step_index_for_range( + fn patch_index_for_range( &self, tagged_range: &Range, buffer: &text::BufferSnapshot, ) -> Result { - self.workflow_steps + self.patches .binary_search_by(|probe| probe.range.cmp(&tagged_range, buffer)) } @@ -1018,8 +1017,6 @@ impl Context { language::BufferEvent::Edited => { self.count_remaining_tokens(cx); self.reparse(cx); - // Use `inclusive = true` to invalidate a step when an edit occurs - // at the start/end of a parsed step. cx.emit(ContextEvent::MessagesEdited); } _ => {} @@ -1248,8 +1245,8 @@ impl Context { let mut removed_slash_command_ranges = Vec::new(); let mut updated_slash_commands = Vec::new(); - let mut removed_steps = Vec::new(); - let mut updated_steps = Vec::new(); + let mut removed_patches = Vec::new(); + let mut updated_patches = Vec::new(); while let Some(mut row_range) = row_ranges.next() { while let Some(next_row_range) = row_ranges.peek() { if row_range.end >= next_row_range.start { @@ -1273,11 +1270,11 @@ impl Context { &mut removed_slash_command_ranges, cx, ); - self.reparse_workflow_steps_in_range( + self.reparse_patches_in_range( start..end, &buffer, - &mut updated_steps, - &mut removed_steps, + &mut updated_patches, + &mut removed_patches, cx, ); } @@ -1289,10 +1286,10 @@ impl Context { }); } - if !updated_steps.is_empty() || !removed_steps.is_empty() { - cx.emit(ContextEvent::WorkflowStepsUpdated { - removed: removed_steps, - updated: updated_steps, + if !updated_patches.is_empty() || !removed_patches.is_empty() { + cx.emit(ContextEvent::PatchesUpdated { + removed: removed_patches, + updated: updated_patches, }); } } @@ -1354,7 +1351,7 @@ impl Context { removed.extend(removed_commands.map(|command| command.source_range)); } - fn reparse_workflow_steps_in_range( + fn reparse_patches_in_range( &mut self, range: Range, buffer: &BufferSnapshot, @@ -1369,41 +1366,32 @@ impl Context { self.xml_tags .splice(intersecting_tags_range.clone(), new_tags); - // Find which steps intersect the changed range. - let intersecting_steps_range = - self.indices_intersecting_buffer_range(&self.workflow_steps, range.clone(), cx); + // Find which patches intersect the changed range. + let intersecting_patches_range = + self.indices_intersecting_buffer_range(&self.patches, range.clone(), cx); - // Reparse all tags after the last unchanged step before the change. + // Reparse all tags after the last unchanged patch before the change. let mut tags_start_ix = 0; - if let Some(preceding_unchanged_step) = - self.workflow_steps[..intersecting_steps_range.start].last() + if let Some(preceding_unchanged_patch) = + self.patches[..intersecting_patches_range.start].last() { tags_start_ix = match self.xml_tags.binary_search_by(|tag| { tag.range .start - .cmp(&preceding_unchanged_step.range.end, buffer) + .cmp(&preceding_unchanged_patch.range.end, buffer) .then(Ordering::Less) }) { Ok(ix) | Err(ix) => ix, }; } - // Rebuild the edit suggestions in the range. - let mut new_steps = self.parse_steps(tags_start_ix, range.end, buffer); - - if let Some(project) = self.project() { - for step in &mut new_steps { - Self::resolve_workflow_step_internal(step, &project, cx); - } - } - - updated.extend(new_steps.iter().map(|step| step.range.clone())); - let removed_steps = self - .workflow_steps - .splice(intersecting_steps_range, new_steps); + // Rebuild the patches in the range. + let new_patches = self.parse_patches(tags_start_ix, range.end, buffer, cx); + updated.extend(new_patches.iter().map(|patch| patch.range.clone())); + let removed_patches = self.patches.splice(intersecting_patches_range, new_patches); removed.extend( - removed_steps - .map(|step| step.range) + removed_patches + .map(|patch| patch.range) .filter(|range| !updated.contains(&range)), ); } @@ -1464,60 +1452,95 @@ impl Context { tags } - fn parse_steps( + fn parse_patches( &mut self, tags_start_ix: usize, buffer_end: text::Anchor, buffer: &BufferSnapshot, - ) -> Vec { - let mut new_steps = Vec::new(); - let mut pending_step = None; - let mut edit_step_depth = 0; + cx: &AppContext, + ) -> Vec { + let mut new_patches = Vec::new(); + let mut pending_patch = None; + let mut patch_tag_depth = 0; let mut tags = self.xml_tags[tags_start_ix..].iter().peekable(); 'tags: while let Some(tag) = tags.next() { - if tag.range.start.cmp(&buffer_end, buffer).is_gt() && edit_step_depth == 0 { + if tag.range.start.cmp(&buffer_end, buffer).is_gt() && patch_tag_depth == 0 { break; } - if tag.kind == XmlTagKind::Step && tag.is_open_tag { - edit_step_depth += 1; - let edit_start = tag.range.start; - let mut edits = Vec::new(); - let mut step = WorkflowStep { - range: edit_start..edit_start, - leading_tags_end: tag.range.end, - trailing_tag_start: None, + if tag.kind == XmlTagKind::Patch && tag.is_open_tag { + patch_tag_depth += 1; + let patch_start = tag.range.start; + let mut edits = Vec::>::new(); + let mut patch = AssistantPatch { + range: patch_start..patch_start, + title: String::new().into(), edits: Default::default(), - resolution: None, - resolution_task: None, + status: crate::AssistantPatchStatus::Pending, }; while let Some(tag) = tags.next() { - step.trailing_tag_start.get_or_insert(tag.range.start); + if tag.kind == XmlTagKind::Patch && !tag.is_open_tag { + patch_tag_depth -= 1; + if patch_tag_depth == 0 { + patch.range.end = tag.range.end; - if tag.kind == XmlTagKind::Step && !tag.is_open_tag { - // step.trailing_tag_start = Some(tag.range.start); - edit_step_depth -= 1; - if edit_step_depth == 0 { - step.range.end = tag.range.end; - step.edits = edits.into(); - new_steps.push(step); + // Include the line immediately after this tag if it's empty. + let patch_end_offset = patch.range.end.to_offset(buffer); + let mut patch_end_chars = buffer.chars_at(patch_end_offset); + if patch_end_chars.next() == Some('\n') + && patch_end_chars.next().map_or(true, |ch| ch == '\n') + { + let messages = self.messages_for_offsets( + [patch_end_offset, patch_end_offset + 1], + cx, + ); + if messages.len() == 1 { + patch.range.end = buffer.anchor_before(patch_end_offset + 1); + } + } + + edits.sort_unstable_by(|a, b| { + if let (Ok(a), Ok(b)) = (a, b) { + a.path.cmp(&b.path) + } else { + Ordering::Equal + } + }); + patch.edits = edits.into(); + patch.status = AssistantPatchStatus::Ready; + new_patches.push(patch); continue 'tags; } } + if tag.kind == XmlTagKind::Title && tag.is_open_tag { + let content_start = tag.range.end; + while let Some(tag) = tags.next() { + if tag.kind == XmlTagKind::Title && !tag.is_open_tag { + let content_end = tag.range.start; + patch.title = + trimmed_text_in_range(buffer, content_start..content_end) + .into(); + break; + } + } + } + if tag.kind == XmlTagKind::Edit && tag.is_open_tag { let mut path = None; - let mut search = None; + let mut old_text = None; + let mut new_text = None; let mut operation = None; let mut description = None; while let Some(tag) = tags.next() { if tag.kind == XmlTagKind::Edit && !tag.is_open_tag { - edits.push(WorkflowStepEdit::new( + edits.push(AssistantEdit::new( path, operation, - search, + old_text, + new_text, description, )); break; @@ -1526,7 +1549,8 @@ impl Context { if tag.is_open_tag && [ XmlTagKind::Path, - XmlTagKind::Search, + XmlTagKind::OldText, + XmlTagKind::NewText, XmlTagKind::Operation, XmlTagKind::Description, ] @@ -1538,15 +1562,18 @@ impl Context { if tag.kind == kind && !tag.is_open_tag { let tag = tags.next().unwrap(); let content_end = tag.range.start; - let mut content = buffer - .text_for_range(content_start..content_end) - .collect::(); - content.truncate(content.trim_end().len()); + let content = trimmed_text_in_range( + buffer, + content_start..content_end, + ); match kind { XmlTagKind::Path => path = Some(content), XmlTagKind::Operation => operation = Some(content), - XmlTagKind::Search => { - search = Some(content).filter(|s| !s.is_empty()) + XmlTagKind::OldText => { + old_text = Some(content).filter(|s| !s.is_empty()) + } + XmlTagKind::NewText => { + new_text = Some(content).filter(|s| !s.is_empty()) } XmlTagKind::Description => { description = @@ -1561,162 +1588,28 @@ impl Context { } } - pending_step = Some(step); + patch.edits = edits.into(); + pending_patch = Some(patch); } } - if let Some(mut pending_step) = pending_step { - pending_step.range.end = text::Anchor::MAX; - new_steps.push(pending_step); - } - - new_steps - } - - pub fn resolve_workflow_step( - &mut self, - tagged_range: Range, - cx: &mut ModelContext, - ) -> Option<()> { - let index = self - .workflow_step_index_for_range(&tagged_range, self.buffer.read(cx)) - .ok()?; - let step = &mut self.workflow_steps[index]; - let project = self.project.as_ref()?; - step.resolution.take(); - Self::resolve_workflow_step_internal(step, project, cx); - None - } - - fn resolve_workflow_step_internal( - step: &mut WorkflowStep, - project: &Model, - cx: &mut ModelContext<'_, Context>, - ) { - step.resolution_task = Some(cx.spawn({ - let range = step.range.clone(); - let edits = step.edits.clone(); - let project = project.clone(); - |this, mut cx| async move { - let suggestion_groups = - Self::compute_step_resolution(project, edits, &mut cx).await; - - this.update(&mut cx, |this, cx| { - let buffer = this.buffer.read(cx).text_snapshot(); - let ix = this.workflow_step_index_for_range(&range, &buffer).ok(); - if let Some(ix) = ix { - let step = &mut this.workflow_steps[ix]; - - let resolution = suggestion_groups.map(|suggestion_groups| { - let mut title = String::new(); - for mut chunk in buffer.text_for_range( - step.leading_tags_end - ..step.trailing_tag_start.unwrap_or(step.range.end), - ) { - if title.is_empty() { - chunk = chunk.trim_start(); - } - if let Some((prefix, _)) = chunk.split_once('\n') { - title.push_str(prefix); - break; - } else { - title.push_str(chunk); - } - } - - WorkflowStepResolution { - title, - suggestion_groups, - } - }); - - step.resolution = Some(Arc::new(resolution)); - cx.emit(ContextEvent::WorkflowStepsUpdated { - removed: vec![], - updated: vec![range], - }) - } - }) - .ok(); - } - })); - } - - async fn compute_step_resolution( - project: Model, - edits: Arc<[Result]>, - cx: &mut AsyncAppContext, - ) -> Result, Vec>> { - let mut suggestion_tasks = Vec::new(); - for edit in edits.iter() { - let edit = edit.as_ref().map_err(|e| anyhow!("{e}"))?; - suggestion_tasks.push(edit.resolve(project.clone(), cx.clone())); - } - - // Expand the context ranges of each suggestion and group suggestions with overlapping context ranges. - let suggestions = future::try_join_all(suggestion_tasks).await?; - - let mut suggestions_by_buffer = HashMap::default(); - for (buffer, suggestion) in suggestions { - suggestions_by_buffer - .entry(buffer) - .or_insert_with(Vec::new) - .push(suggestion); - } - - let mut suggestion_groups_by_buffer = HashMap::default(); - for (buffer, mut suggestions) in suggestions_by_buffer { - let mut suggestion_groups = Vec::::new(); - let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?; - // Sort suggestions by their range so that earlier, larger ranges come first - suggestions.sort_by(|a, b| a.range().cmp(&b.range(), &snapshot)); - - // Merge overlapping suggestions - suggestions.dedup_by(|a, b| b.try_merge(a, &snapshot)); - - // Create context ranges for each suggestion - for suggestion in suggestions { - let context_range = { - let suggestion_point_range = suggestion.range().to_point(&snapshot); - let start_row = suggestion_point_range.start.row.saturating_sub(5); - let end_row = - cmp::min(suggestion_point_range.end.row + 5, snapshot.max_point().row); - let start = snapshot.anchor_before(Point::new(start_row, 0)); - let end = - snapshot.anchor_after(Point::new(end_row, snapshot.line_len(end_row))); - start..end - }; - - if let Some(last_group) = suggestion_groups.last_mut() { - if last_group - .context_range - .end - .cmp(&context_range.start, &snapshot) - .is_ge() - { - // Merge with the previous group if context ranges overlap - last_group.context_range.end = context_range.end; - last_group.suggestions.push(suggestion); - } else { - // Create a new group - suggestion_groups.push(WorkflowSuggestionGroup { - context_range, - suggestions: vec![suggestion], - }); - } + if let Some(mut pending_patch) = pending_patch { + let patch_start = pending_patch.range.start.to_offset(buffer); + if let Some(message) = self.message_for_offset(patch_start, cx) { + if message.anchor_range.end == text::Anchor::MAX { + pending_patch.range.end = text::Anchor::MAX; } else { - // Create the first group - suggestion_groups.push(WorkflowSuggestionGroup { - context_range, - suggestions: vec![suggestion], - }); + let message_end = buffer.anchor_after(message.offset_range.end - 1); + pending_patch.range.end = message_end; } + } else { + pending_patch.range.end = text::Anchor::MAX; } - suggestion_groups_by_buffer.insert(buffer, suggestion_groups); + new_patches.push(pending_patch); } - Ok(suggestion_groups_by_buffer) + new_patches } pub fn pending_command_for_position( @@ -2315,11 +2208,11 @@ impl Context { let mut updated = Vec::new(); let mut removed = Vec::new(); for range in ranges { - self.reparse_workflow_steps_in_range(range, &buffer, &mut updated, &mut removed, cx); + self.reparse_patches_in_range(range, &buffer, &mut updated, &mut removed, cx); } if !updated.is_empty() || !removed.is_empty() { - cx.emit(ContextEvent::WorkflowStepsUpdated { removed, updated }) + cx.emit(ContextEvent::PatchesUpdated { removed, updated }) } } @@ -2825,6 +2718,24 @@ impl Context { } } +fn trimmed_text_in_range(buffer: &BufferSnapshot, range: Range) -> String { + let mut is_start = true; + let mut content = buffer + .text_for_range(range) + .map(|mut chunk| { + if is_start { + chunk = chunk.trim_start_matches('\n'); + if !chunk.is_empty() { + is_start = false; + } + } + chunk + }) + .collect::(); + content.truncate(content.trim_end().len()); + content +} + #[derive(Debug, Default)] pub struct ContextVersion { context: clock::Global, diff --git a/crates/assistant/src/context/context_tests.rs b/crates/assistant/src/context/context_tests.rs index 2d6a2894c9..a11cfc375d 100644 --- a/crates/assistant/src/context/context_tests.rs +++ b/crates/assistant/src/context/context_tests.rs @@ -1,8 +1,7 @@ -use super::{MessageCacheMetadata, WorkflowStepEdit}; +use super::{AssistantEdit, MessageCacheMetadata}; use crate::{ - assistant_panel, prompt_library, slash_command::file_command, CacheStatus, Context, - ContextEvent, ContextId, ContextOperation, MessageId, MessageStatus, PromptBuilder, - WorkflowStepEditKind, + assistant_panel, prompt_library, slash_command::file_command, AssistantEditKind, CacheStatus, + Context, ContextEvent, ContextId, ContextOperation, MessageId, MessageStatus, PromptBuilder, }; use anyhow::Result; use assistant_slash_command::{ @@ -15,6 +14,7 @@ use gpui::{AppContext, Model, SharedString, Task, TestAppContext, WeakView}; use language::{Buffer, BufferSnapshot, LanguageRegistry, LspAdapterDelegate}; use language_model::{LanguageModelCacheConfiguration, LanguageModelRegistry, Role}; use parking_lot::Mutex; +use pretty_assertions::assert_eq; use project::Project; use rand::prelude::*; use serde_json::json; @@ -478,7 +478,15 @@ async fn test_slash_commands(cx: &mut TestAppContext) { #[gpui::test] async fn test_workflow_step_parsing(cx: &mut TestAppContext) { cx.update(prompt_library::init); - let settings_store = cx.update(SettingsStore::test); + let mut settings_store = cx.update(SettingsStore::test); + cx.update(|cx| { + settings_store + .set_user_settings( + r#"{ "assistant": { "enable_experimental_live_diffs": true } }"#, + cx, + ) + .unwrap() + }); cx.set_global(settings_store); cx.update(language::init); cx.update(Project::init_settings); @@ -520,7 +528,7 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) { »", cx, ); - expect_steps( + expect_patches( &context, " @@ -539,17 +547,17 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) { one two « - - Add a second function - - ```rust - fn two() {} - ``` - + »", cx, ); - expect_steps( + expect_patches( &context, " one two - « - Add a second function - - ```rust - fn two() {} - ``` - + « »", &[&[]], cx, ); - // The full suggestion is added + // The full patch is added edit( &context, " @@ -600,51 +596,46 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) { one two - - Add a second function - - ```rust - fn two() {} - ``` - + « + add a `two` function src/lib.rs insert_after - fn one - add a `two` function + fn one + + fn two() {} + - + also,»", cx, ); - expect_steps( + expect_patches( &context, " one two - « - Add a second function - - ```rust - fn two() {} - ``` - + « + add a `two` function src/lib.rs insert_after - fn one - add a `two` function + fn one + + fn two() {} + - » - + + » also,", - &[&[WorkflowStepEdit { + &[&[AssistantEdit { path: "src/lib.rs".into(), - kind: WorkflowStepEditKind::InsertAfter { - search: "fn one".into(), + kind: AssistantEditKind::InsertAfter { + old_text: "fn one".into(), + new_text: "fn two() {}".into(), description: "add a `two` function".into(), }, }]], @@ -659,51 +650,46 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) { one two - - Add a second function - - ```rust - fn two() {} - ``` - + + add a `two` function src/lib.rs insert_after - «fn zero» - add a `two` function + «fn zero» + + fn two() {} + - + also,", cx, ); - expect_steps( + expect_patches( &context, " one two - « - Add a second function - - ```rust - fn two() {} - ``` - + « + add a `two` function src/lib.rs insert_after - fn zero - add a `two` function + fn zero + + fn two() {} + - » - + + » also,", - &[&[WorkflowStepEdit { + &[&[AssistantEdit { path: "src/lib.rs".into(), - kind: WorkflowStepEditKind::InsertAfter { - search: "fn zero".into(), + kind: AssistantEditKind::InsertAfter { + old_text: "fn zero".into(), + new_text: "fn two() {}".into(), description: "add a `two` function".into(), }, }]], @@ -715,27 +701,24 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) { context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx); context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx); }); - expect_steps( + expect_patches( &context, " one two - - Add a second function - - ```rust - fn two() {} - ``` - + + add a `two` function src/lib.rs insert_after - fn zero - add a `two` function + fn zero + + fn two() {} + - + also,", &[], @@ -746,33 +729,31 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) { context.update(cx, |context, cx| { context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx); }); - expect_steps( + expect_patches( &context, " one two - « - Add a second function - - ```rust - fn two() {} - ``` - + « + add a `two` function src/lib.rs insert_after - fn zero - add a `two` function + fn zero + + fn two() {} + - » - + + » also,", - &[&[WorkflowStepEdit { + &[&[AssistantEdit { path: "src/lib.rs".into(), - kind: WorkflowStepEditKind::InsertAfter { - search: "fn zero".into(), + kind: AssistantEditKind::InsertAfter { + old_text: "fn zero".into(), + new_text: "fn two() {}".into(), description: "add a `two` function".into(), }, }]], @@ -792,33 +773,31 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) { cx, ) }); - expect_steps( + expect_patches( &deserialized_context, " one two - « - Add a second function - - ```rust - fn two() {} - ``` - + « + add a `two` function src/lib.rs insert_after - fn zero - add a `two` function + fn zero + + fn two() {} + - » - + + » also,", - &[&[WorkflowStepEdit { + &[&[AssistantEdit { path: "src/lib.rs".into(), - kind: WorkflowStepEditKind::InsertAfter { - search: "fn zero".into(), + kind: AssistantEditKind::InsertAfter { + old_text: "fn zero".into(), + new_text: "fn two() {}".into(), description: "add a `two` function".into(), }, }]], @@ -834,48 +813,58 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) { cx.executor().run_until_parked(); } - fn expect_steps( + #[track_caller] + fn expect_patches( context: &Model, expected_marked_text: &str, - expected_suggestions: &[&[WorkflowStepEdit]], + expected_suggestions: &[&[AssistantEdit]], cx: &mut TestAppContext, ) { - context.update(cx, |context, cx| { - let expected_marked_text = expected_marked_text.unindent(); - let (expected_text, expected_ranges) = marked_text_ranges(&expected_marked_text, false); + let expected_marked_text = expected_marked_text.unindent(); + let (expected_text, _) = marked_text_ranges(&expected_marked_text, false); + + let (buffer_text, ranges, patches) = context.update(cx, |context, cx| { context.buffer.read_with(cx, |buffer, _| { - assert_eq!(buffer.text(), expected_text); let ranges = context - .workflow_steps + .patches .iter() .map(|entry| entry.range.to_offset(buffer)) .collect::>(); - let marked = generate_marked_text(&expected_text, &ranges, false); - assert_eq!( - marked, - expected_marked_text, - "unexpected suggestion ranges. actual: {ranges:?}, expected: {expected_ranges:?}" - ); - let suggestions = context - .workflow_steps - .iter() - .map(|step| { - step.edits - .iter() - .map(|edit| { - let edit = edit.as_ref().unwrap(); - WorkflowStepEdit { - path: edit.path.clone(), - kind: edit.kind.clone(), - } - }) - .collect::>() - }) - .collect::>(); - - assert_eq!(suggestions, expected_suggestions); - }); + ( + buffer.text(), + ranges, + context + .patches + .iter() + .map(|step| step.edits.clone()) + .collect::>(), + ) + }) }); + + assert_eq!(buffer_text, expected_text); + + let actual_marked_text = generate_marked_text(&expected_text, &ranges, false); + assert_eq!(actual_marked_text, expected_marked_text); + + assert_eq!( + patches + .iter() + .map(|patch| { + patch + .iter() + .map(|edit| { + let edit = edit.as_ref().unwrap(); + AssistantEdit { + path: edit.path.clone(), + kind: edit.kind.clone(), + } + }) + .collect::>() + }) + .collect::>(), + expected_suggestions + ); } } diff --git a/crates/assistant/src/inline_assistant.rs b/crates/assistant/src/inline_assistant.rs index ce01e63b51..a11d4113d8 100644 --- a/crates/assistant/src/inline_assistant.rs +++ b/crates/assistant/src/inline_assistant.rs @@ -82,13 +82,6 @@ pub struct InlineAssistant { assists: HashMap, assists_by_editor: HashMap, EditorInlineAssists>, assist_groups: HashMap, - assist_observations: HashMap< - InlineAssistId, - ( - async_watch::Sender, - async_watch::Receiver, - ), - >, confirmed_assists: HashMap>, prompt_history: VecDeque, prompt_builder: Arc, @@ -96,19 +89,6 @@ pub struct InlineAssistant { fs: Arc, } -pub enum AssistStatus { - Idle, - Started, - Stopped, - Finished, -} - -impl AssistStatus { - pub fn is_done(&self) -> bool { - matches!(self, Self::Stopped | Self::Finished) - } -} - impl Global for InlineAssistant {} impl InlineAssistant { @@ -123,7 +103,6 @@ impl InlineAssistant { assists: HashMap::default(), assists_by_editor: HashMap::default(), assist_groups: HashMap::default(), - assist_observations: HashMap::default(), confirmed_assists: HashMap::default(), prompt_history: VecDeque::default(), prompt_builder, @@ -835,17 +814,6 @@ impl InlineAssistant { .insert(assist_id, confirmed_alternative); } } - - // Remove the assist from the status updates map - self.assist_observations.remove(&assist_id); - } - - pub fn undo_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool { - let Some(codegen) = self.confirmed_assists.remove(&assist_id) else { - return false; - }; - codegen.update(cx, |this, cx| this.undo(cx)); - true } fn dismiss_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool { @@ -1039,10 +1007,6 @@ impl InlineAssistant { codegen.start(user_prompt, assistant_panel_context, cx) }) .log_err(); - - if let Some((tx, _)) = self.assist_observations.get(&assist_id) { - tx.send(AssistStatus::Started).ok(); - } } pub fn stop_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) { @@ -1053,25 +1017,6 @@ impl InlineAssistant { }; assist.codegen.update(cx, |codegen, cx| codegen.stop(cx)); - - if let Some((tx, _)) = self.assist_observations.get(&assist_id) { - tx.send(AssistStatus::Stopped).ok(); - } - } - - pub fn assist_status(&self, assist_id: InlineAssistId, cx: &AppContext) -> InlineAssistStatus { - if let Some(assist) = self.assists.get(&assist_id) { - match assist.codegen.read(cx).status(cx) { - CodegenStatus::Idle => InlineAssistStatus::Idle, - CodegenStatus::Pending => InlineAssistStatus::Pending, - CodegenStatus::Done => InlineAssistStatus::Done, - CodegenStatus::Error(_) => InlineAssistStatus::Error, - } - } else if self.confirmed_assists.contains_key(&assist_id) { - InlineAssistStatus::Confirmed - } else { - InlineAssistStatus::Canceled - } } fn update_editor_highlights(&self, editor: &View, cx: &mut WindowContext) { @@ -1257,42 +1202,6 @@ impl InlineAssistant { .collect(); }) } - - pub fn observe_assist( - &mut self, - assist_id: InlineAssistId, - ) -> async_watch::Receiver { - if let Some((_, rx)) = self.assist_observations.get(&assist_id) { - rx.clone() - } else { - let (tx, rx) = async_watch::channel(AssistStatus::Idle); - self.assist_observations.insert(assist_id, (tx, rx.clone())); - rx - } - } -} - -pub enum InlineAssistStatus { - Idle, - Pending, - Done, - Error, - Confirmed, - Canceled, -} - -impl InlineAssistStatus { - pub(crate) fn is_pending(&self) -> bool { - matches!(self, Self::Pending) - } - - pub(crate) fn is_confirmed(&self) -> bool { - matches!(self, Self::Confirmed) - } - - pub(crate) fn is_done(&self) -> bool { - matches!(self, Self::Done) - } } struct EditorInlineAssists { @@ -2290,8 +2199,6 @@ impl InlineAssist { if assist.decorations.is_none() { this.finish_assist(assist_id, false, cx); - } else if let Some(tx) = this.assist_observations.get(&assist_id) { - tx.0.send(AssistStatus::Finished).ok(); } } }) diff --git a/crates/assistant/src/patch.rs b/crates/assistant/src/patch.rs new file mode 100644 index 0000000000..82c81d3b86 --- /dev/null +++ b/crates/assistant/src/patch.rs @@ -0,0 +1,746 @@ +use anyhow::{anyhow, Context as _, Result}; +use collections::HashMap; +use editor::ProposedChangesEditor; +use futures::{future, TryFutureExt as _}; +use gpui::{AppContext, AsyncAppContext, Model, SharedString}; +use language::{AutoindentMode, Buffer, BufferSnapshot}; +use project::{Project, ProjectPath}; +use std::{cmp, ops::Range, path::Path, sync::Arc}; +use text::{AnchorRangeExt as _, Bias, OffsetRangeExt as _, Point}; + +#[derive(Clone, Debug)] +pub(crate) struct AssistantPatch { + pub range: Range, + pub title: SharedString, + pub edits: Arc<[Result]>, + pub status: AssistantPatchStatus, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub(crate) enum AssistantPatchStatus { + Pending, + Ready, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AssistantEdit { + pub path: String, + pub kind: AssistantEditKind, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AssistantEditKind { + Update { + old_text: String, + new_text: String, + description: String, + }, + Create { + new_text: String, + description: String, + }, + InsertBefore { + old_text: String, + new_text: String, + description: String, + }, + InsertAfter { + old_text: String, + new_text: String, + description: String, + }, + Delete { + old_text: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedPatch { + pub edit_groups: HashMap, Vec>, + pub errors: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedEditGroup { + pub context_range: Range, + pub edits: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedEdit { + range: Range, + new_text: String, + description: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AssistantPatchResolutionError { + pub edit_ix: usize, + pub message: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum SearchDirection { + Up, + Left, + Diagonal, +} + +// A measure of the currently quality of an in-progress fuzzy search. +// +// Uses 60 bits to store a numeric cost, and 4 bits to store the preceding +// operation in the search. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct SearchState { + score: u32, + direction: SearchDirection, +} + +impl SearchState { + fn new(score: u32, direction: SearchDirection) -> Self { + Self { score, direction } + } +} + +impl ResolvedPatch { + pub fn apply(&self, editor: &ProposedChangesEditor, cx: &mut AppContext) { + for (buffer, groups) in &self.edit_groups { + let branch = editor.branch_buffer_for_base(buffer).unwrap(); + Self::apply_edit_groups(groups, &branch, cx); + } + editor.recalculate_all_buffer_diffs(); + } + + fn apply_edit_groups( + groups: &Vec, + buffer: &Model, + cx: &mut AppContext, + ) { + let mut edits = Vec::new(); + for group in groups { + for suggestion in &group.edits { + edits.push((suggestion.range.clone(), suggestion.new_text.clone())); + } + } + buffer.update(cx, |buffer, cx| { + buffer.edit( + edits, + Some(AutoindentMode::Block { + original_indent_columns: Vec::new(), + }), + cx, + ); + }); + } +} + +impl ResolvedEdit { + pub fn try_merge(&mut self, other: &Self, buffer: &text::BufferSnapshot) -> bool { + let range = &self.range; + let other_range = &other.range; + + // Don't merge if we don't contain the other suggestion. + if range.start.cmp(&other_range.start, buffer).is_gt() + || range.end.cmp(&other_range.end, buffer).is_lt() + { + return false; + } + + if let Some(description) = &mut self.description { + if let Some(other_description) = &other.description { + description.push('\n'); + description.push_str(other_description); + } + } + true + } +} + +impl AssistantEdit { + pub fn new( + path: Option, + operation: Option, + old_text: Option, + new_text: Option, + description: Option, + ) -> Result { + let path = path.ok_or_else(|| anyhow!("missing path"))?; + let operation = operation.ok_or_else(|| anyhow!("missing operation"))?; + + let kind = match operation.as_str() { + "update" => AssistantEditKind::Update { + old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?, + new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?, + description: description.ok_or_else(|| anyhow!("missing description"))?, + }, + "insert_before" => AssistantEditKind::InsertBefore { + old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?, + new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?, + description: description.ok_or_else(|| anyhow!("missing description"))?, + }, + "insert_after" => AssistantEditKind::InsertAfter { + old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?, + new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?, + description: description.ok_or_else(|| anyhow!("missing description"))?, + }, + "delete" => AssistantEditKind::Delete { + old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?, + }, + "create" => AssistantEditKind::Create { + description: description.ok_or_else(|| anyhow!("missing description"))?, + new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?, + }, + _ => Err(anyhow!("unknown operation {operation:?}"))?, + }; + + Ok(Self { path, kind }) + } + + pub async fn resolve( + &self, + project: Model, + mut cx: AsyncAppContext, + ) -> Result<(Model, ResolvedEdit)> { + let path = self.path.clone(); + let kind = self.kind.clone(); + let buffer = project + .update(&mut cx, |project, cx| { + let project_path = project + .find_project_path(Path::new(&path), cx) + .or_else(|| { + // If we couldn't find a project path for it, put it in the active worktree + // so that when we create the buffer, it can be saved. + let worktree = project + .active_entry() + .and_then(|entry_id| project.worktree_for_entry(entry_id, cx)) + .or_else(|| project.worktrees(cx).next())?; + let worktree = worktree.read(cx); + + Some(ProjectPath { + worktree_id: worktree.id(), + path: Arc::from(Path::new(&path)), + }) + }) + .with_context(|| format!("worktree not found for {:?}", path))?; + anyhow::Ok(project.open_buffer(project_path, cx)) + })?? + .await?; + + let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?; + let suggestion = cx + .background_executor() + .spawn(async move { kind.resolve(&snapshot) }) + .await; + + Ok((buffer, suggestion)) + } +} + +impl AssistantEditKind { + fn resolve(self, snapshot: &BufferSnapshot) -> ResolvedEdit { + match self { + Self::Update { + old_text, + new_text, + description, + } => { + let range = Self::resolve_location(&snapshot, &old_text); + ResolvedEdit { + range, + new_text, + description: Some(description), + } + } + Self::Create { + new_text, + description, + } => ResolvedEdit { + range: text::Anchor::MIN..text::Anchor::MAX, + description: Some(description), + new_text, + }, + Self::InsertBefore { + old_text, + mut new_text, + description, + } => { + let range = Self::resolve_location(&snapshot, &old_text); + new_text.push('\n'); + ResolvedEdit { + range: range.start..range.start, + new_text, + description: Some(description), + } + } + Self::InsertAfter { + old_text, + mut new_text, + description, + } => { + let range = Self::resolve_location(&snapshot, &old_text); + new_text.insert(0, '\n'); + ResolvedEdit { + range: range.end..range.end, + new_text, + description: Some(description), + } + } + Self::Delete { old_text } => { + let range = Self::resolve_location(&snapshot, &old_text); + ResolvedEdit { + range, + new_text: String::new(), + description: None, + } + } + } + } + + fn resolve_location(buffer: &text::BufferSnapshot, search_query: &str) -> Range { + const INSERTION_COST: u32 = 3; + const WHITESPACE_INSERTION_COST: u32 = 1; + const DELETION_COST: u32 = 3; + const WHITESPACE_DELETION_COST: u32 = 1; + const EQUALITY_BONUS: u32 = 5; + + struct Matrix { + cols: usize, + data: Vec, + } + + impl Matrix { + fn new(rows: usize, cols: usize) -> Self { + Matrix { + cols, + data: vec![SearchState::new(0, SearchDirection::Diagonal); rows * cols], + } + } + + fn get(&self, row: usize, col: usize) -> SearchState { + self.data[row * self.cols + col] + } + + fn set(&mut self, row: usize, col: usize, cost: SearchState) { + self.data[row * self.cols + col] = cost; + } + } + + let buffer_len = buffer.len(); + let query_len = search_query.len(); + let mut matrix = Matrix::new(query_len + 1, buffer_len + 1); + + for (row, query_byte) in search_query.bytes().enumerate() { + for (col, buffer_byte) in buffer.bytes_in_range(0..buffer.len()).flatten().enumerate() { + let deletion_cost = if query_byte.is_ascii_whitespace() { + WHITESPACE_DELETION_COST + } else { + DELETION_COST + }; + let insertion_cost = if buffer_byte.is_ascii_whitespace() { + WHITESPACE_INSERTION_COST + } else { + INSERTION_COST + }; + + let up = SearchState::new( + matrix.get(row, col + 1).score.saturating_sub(deletion_cost), + SearchDirection::Up, + ); + let left = SearchState::new( + matrix + .get(row + 1, col) + .score + .saturating_sub(insertion_cost), + SearchDirection::Left, + ); + let diagonal = SearchState::new( + if query_byte == *buffer_byte { + matrix.get(row, col).score.saturating_add(EQUALITY_BONUS) + } else { + matrix + .get(row, col) + .score + .saturating_sub(deletion_cost + insertion_cost) + }, + SearchDirection::Diagonal, + ); + matrix.set(row + 1, col + 1, up.max(left).max(diagonal)); + } + } + + // Traceback to find the best match + let mut best_buffer_end = buffer_len; + let mut best_score = 0; + for col in 1..=buffer_len { + let score = matrix.get(query_len, col).score; + if score > best_score { + best_score = score; + best_buffer_end = col; + } + } + + let mut query_ix = query_len; + let mut buffer_ix = best_buffer_end; + while query_ix > 0 && buffer_ix > 0 { + let current = matrix.get(query_ix, buffer_ix); + match current.direction { + SearchDirection::Diagonal => { + query_ix -= 1; + buffer_ix -= 1; + } + SearchDirection::Up => { + query_ix -= 1; + } + SearchDirection::Left => { + buffer_ix -= 1; + } + } + } + + let mut start = buffer.offset_to_point(buffer.clip_offset(buffer_ix, Bias::Left)); + start.column = 0; + let mut end = buffer.offset_to_point(buffer.clip_offset(best_buffer_end, Bias::Right)); + if end.column > 0 { + end.column = buffer.line_len(end.row); + } + + buffer.anchor_after(start)..buffer.anchor_before(end) + } +} + +impl AssistantPatch { + pub(crate) async fn resolve( + &self, + project: Model, + cx: &mut AsyncAppContext, + ) -> ResolvedPatch { + let mut resolve_tasks = Vec::new(); + for (ix, edit) in self.edits.iter().enumerate() { + if let Ok(edit) = edit.as_ref() { + resolve_tasks.push( + edit.resolve(project.clone(), cx.clone()) + .map_err(move |error| (ix, error)), + ); + } + } + + let edits = future::join_all(resolve_tasks).await; + let mut errors = Vec::new(); + let mut edits_by_buffer = HashMap::default(); + for entry in edits { + match entry { + Ok((buffer, edit)) => { + edits_by_buffer + .entry(buffer) + .or_insert_with(Vec::new) + .push(edit); + } + Err((edit_ix, error)) => errors.push(AssistantPatchResolutionError { + edit_ix, + message: error.to_string(), + }), + } + } + + // Expand the context ranges of each edit and group edits with overlapping context ranges. + let mut edit_groups_by_buffer = HashMap::default(); + for (buffer, edits) in edits_by_buffer { + if let Ok(snapshot) = buffer.update(cx, |buffer, _| buffer.text_snapshot()) { + edit_groups_by_buffer.insert(buffer, Self::group_edits(edits, &snapshot)); + } + } + + ResolvedPatch { + edit_groups: edit_groups_by_buffer, + errors, + } + } + + fn group_edits( + mut edits: Vec, + snapshot: &text::BufferSnapshot, + ) -> Vec { + let mut edit_groups = Vec::::new(); + // Sort edits by their range so that earlier, larger ranges come first + edits.sort_by(|a, b| a.range.cmp(&b.range, &snapshot)); + + // Merge overlapping edits + edits.dedup_by(|a, b| b.try_merge(a, &snapshot)); + + // Create context ranges for each edit + for edit in edits { + let context_range = { + let edit_point_range = edit.range.to_point(&snapshot); + let start_row = edit_point_range.start.row.saturating_sub(5); + let end_row = cmp::min(edit_point_range.end.row + 5, snapshot.max_point().row); + let start = snapshot.anchor_before(Point::new(start_row, 0)); + let end = snapshot.anchor_after(Point::new(end_row, snapshot.line_len(end_row))); + start..end + }; + + if let Some(last_group) = edit_groups.last_mut() { + if last_group + .context_range + .end + .cmp(&context_range.start, &snapshot) + .is_ge() + { + // Merge with the previous group if context ranges overlap + last_group.context_range.end = context_range.end; + last_group.edits.push(edit); + } else { + // Create a new group + edit_groups.push(ResolvedEditGroup { + context_range, + edits: vec![edit], + }); + } + } else { + // Create the first group + edit_groups.push(ResolvedEditGroup { + context_range, + edits: vec![edit], + }); + } + } + + edit_groups + } + + pub fn path_count(&self) -> usize { + self.paths().count() + } + + pub fn paths(&self) -> impl '_ + Iterator { + let mut prev_path = None; + self.edits.iter().filter_map(move |edit| { + if let Ok(edit) = edit { + let path = Some(edit.path.as_str()); + if path != prev_path { + prev_path = path; + return path; + } + } + None + }) + } +} + +impl PartialEq for AssistantPatch { + fn eq(&self, other: &Self) -> bool { + self.range == other.range + && self.title == other.title + && Arc::ptr_eq(&self.edits, &other.edits) + } +} + +impl Eq for AssistantPatch {} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, Context}; + use language::{ + language_settings::AllLanguageSettings, Language, LanguageConfig, LanguageMatcher, + }; + use settings::SettingsStore; + use text::{OffsetRangeExt, Point}; + use ui::BorrowAppContext; + use unindent::Unindent as _; + + #[gpui::test] + fn test_resolve_location(cx: &mut AppContext) { + { + let buffer = cx.new_model(|cx| { + Buffer::local( + concat!( + " Lorem\n", + " ipsum\n", + " dolor sit amet\n", + " consecteur", + ), + cx, + ) + }); + let snapshot = buffer.read(cx).snapshot(); + assert_eq!( + AssistantEditKind::resolve_location(&snapshot, "ipsum\ndolor").to_point(&snapshot), + Point::new(1, 0)..Point::new(2, 18) + ); + } + + { + let buffer = cx.new_model(|cx| { + Buffer::local( + concat!( + "fn foo1(a: usize) -> usize {\n", + " 40\n", + "}\n", + "\n", + "fn foo2(b: usize) -> usize {\n", + " 42\n", + "}\n", + ), + cx, + ) + }); + let snapshot = buffer.read(cx).snapshot(); + assert_eq!( + AssistantEditKind::resolve_location(&snapshot, "fn foo1(b: usize) {\n40\n}") + .to_point(&snapshot), + Point::new(0, 0)..Point::new(2, 1) + ); + } + + { + let buffer = cx.new_model(|cx| { + Buffer::local( + concat!( + "fn main() {\n", + " Foo\n", + " .bar()\n", + " .baz()\n", + " .qux()\n", + "}\n", + "\n", + "fn foo2(b: usize) -> usize {\n", + " 42\n", + "}\n", + ), + cx, + ) + }); + let snapshot = buffer.read(cx).snapshot(); + assert_eq!( + AssistantEditKind::resolve_location(&snapshot, "Foo.bar.baz.qux()") + .to_point(&snapshot), + Point::new(1, 0)..Point::new(4, 14) + ); + } + } + + #[gpui::test] + fn test_resolve_edits(cx: &mut AppContext) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + cx.update_global::(|settings, cx| { + settings.update_user_settings::(cx, |_| {}); + }); + + assert_edits( + " + /// A person + struct Person { + name: String, + age: usize, + } + + /// A dog + struct Dog { + weight: f32, + } + + impl Person { + fn name(&self) -> &str { + &self.name + } + } + " + .unindent(), + vec![ + AssistantEditKind::Update { + old_text: " + name: String, + " + .unindent(), + new_text: " + first_name: String, + last_name: String, + " + .unindent(), + description: "".into(), + }, + AssistantEditKind::Update { + old_text: " + fn name(&self) -> &str { + &self.name + } + " + .unindent(), + new_text: " + fn name(&self) -> String { + format!(\"{} {}\", self.first_name, self.last_name) + } + " + .unindent(), + description: "".into(), + }, + ], + " + /// A person + struct Person { + first_name: String, + last_name: String, + age: usize, + } + + /// A dog + struct Dog { + weight: f32, + } + + impl Person { + fn name(&self) -> String { + format!(\"{} {}\", self.first_name, self.last_name) + } + } + " + .unindent(), + cx, + ); + } + + #[track_caller] + fn assert_edits( + old_text: String, + edits: Vec, + new_text: String, + cx: &mut AppContext, + ) { + let buffer = + cx.new_model(|cx| Buffer::local(old_text, cx).with_language(Arc::new(rust_lang()), cx)); + let snapshot = buffer.read(cx).snapshot(); + let resolved_edits = edits + .into_iter() + .map(|kind| kind.resolve(&snapshot)) + .collect(); + let edit_groups = AssistantPatch::group_edits(resolved_edits, &snapshot); + ResolvedPatch::apply_edit_groups(&edit_groups, &buffer, cx); + let actual_new_text = buffer.read(cx).text(); + pretty_assertions::assert_eq!(actual_new_text, new_text); + } + + fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(language::tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (call_expression) @indent + (field_expression) @indent + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap() + } +} diff --git a/crates/assistant/src/prompts.rs b/crates/assistant/src/prompts.rs index 106935cb88..132b3df68f 100644 --- a/crates/assistant/src/prompts.rs +++ b/crates/assistant/src/prompts.rs @@ -45,15 +45,6 @@ pub struct ProjectSlashCommandPromptContext { pub context_buffer: String, } -/// Context required to generate a workflow step resolution prompt. -#[derive(Debug, Serialize)] -pub struct StepResolutionContext { - /// The full context, including ... tags - pub workflow_context: String, - /// The text of the specific step from the context to resolve - pub step_to_resolve: String, -} - pub struct PromptLoadingParams<'a> { pub fs: Arc, pub repo_path: Option, diff --git a/crates/assistant/src/slash_command/workflow_command.rs b/crates/assistant/src/slash_command/workflow_command.rs index 071b4feaf4..50c0e6cbc6 100644 --- a/crates/assistant/src/slash_command/workflow_command.rs +++ b/crates/assistant/src/slash_command/workflow_command.rs @@ -18,6 +18,8 @@ pub(crate) struct WorkflowSlashCommand { } impl WorkflowSlashCommand { + pub const NAME: &'static str = "workflow"; + pub fn new(prompt_builder: Arc) -> Self { Self { prompt_builder } } @@ -25,7 +27,7 @@ impl WorkflowSlashCommand { impl SlashCommand for WorkflowSlashCommand { fn name(&self) -> String { - "workflow".into() + Self::NAME.into() } fn description(&self) -> String { diff --git a/crates/assistant/src/workflow.rs b/crates/assistant/src/workflow.rs deleted file mode 100644 index 8a770e21aa..0000000000 --- a/crates/assistant/src/workflow.rs +++ /dev/null @@ -1,507 +0,0 @@ -use crate::{AssistantPanel, InlineAssistId, InlineAssistant}; -use anyhow::{anyhow, Context as _, Result}; -use collections::HashMap; -use editor::Editor; -use gpui::AsyncAppContext; -use gpui::{Model, Task, UpdateGlobal as _, View, WeakView, WindowContext}; -use language::{Buffer, BufferSnapshot}; -use project::{Project, ProjectPath}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{ops::Range, path::Path, sync::Arc}; -use text::Bias; -use workspace::Workspace; - -#[derive(Debug)] -pub(crate) struct WorkflowStep { - pub range: Range, - pub leading_tags_end: text::Anchor, - pub trailing_tag_start: Option, - pub edits: Arc<[Result]>, - pub resolution_task: Option>, - pub resolution: Option>>, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct WorkflowStepEdit { - pub path: String, - pub kind: WorkflowStepEditKind, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct WorkflowStepResolution { - pub title: String, - pub suggestion_groups: HashMap, Vec>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct WorkflowSuggestionGroup { - pub context_range: Range, - pub suggestions: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum WorkflowSuggestion { - Update { - range: Range, - description: String, - }, - CreateFile { - description: String, - }, - InsertBefore { - position: language::Anchor, - description: String, - }, - InsertAfter { - position: language::Anchor, - description: String, - }, - Delete { - range: Range, - }, -} - -impl WorkflowSuggestion { - pub fn range(&self) -> Range { - match self { - Self::Update { range, .. } => range.clone(), - Self::CreateFile { .. } => language::Anchor::MIN..language::Anchor::MAX, - Self::InsertBefore { position, .. } | Self::InsertAfter { position, .. } => { - *position..*position - } - Self::Delete { range, .. } => range.clone(), - } - } - - pub fn description(&self) -> Option<&str> { - match self { - Self::Update { description, .. } - | Self::CreateFile { description } - | Self::InsertBefore { description, .. } - | Self::InsertAfter { description, .. } => Some(description), - Self::Delete { .. } => None, - } - } - - fn description_mut(&mut self) -> Option<&mut String> { - match self { - Self::Update { description, .. } - | Self::CreateFile { description } - | Self::InsertBefore { description, .. } - | Self::InsertAfter { description, .. } => Some(description), - Self::Delete { .. } => None, - } - } - - pub fn try_merge(&mut self, other: &Self, buffer: &BufferSnapshot) -> bool { - let range = self.range(); - let other_range = other.range(); - - // Don't merge if we don't contain the other suggestion. - if range.start.cmp(&other_range.start, buffer).is_gt() - || range.end.cmp(&other_range.end, buffer).is_lt() - { - return false; - } - - if let Some(description) = self.description_mut() { - if let Some(other_description) = other.description() { - description.push('\n'); - description.push_str(other_description); - } - } - true - } - - pub fn show( - &self, - editor: &View, - excerpt_id: editor::ExcerptId, - workspace: &WeakView, - assistant_panel: &View, - cx: &mut WindowContext, - ) -> Option { - let mut initial_transaction_id = None; - let initial_prompt; - let suggestion_range; - let buffer = editor.read(cx).buffer().clone(); - let snapshot = buffer.read(cx).snapshot(cx); - - match self { - Self::Update { - range, description, .. - } => { - initial_prompt = description.clone(); - suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)? - ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?; - } - Self::CreateFile { description } => { - initial_prompt = description.clone(); - suggestion_range = editor::Anchor::min()..editor::Anchor::min(); - } - Self::InsertBefore { - position, - description, - .. - } => { - let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?; - initial_prompt = description.clone(); - suggestion_range = buffer.update(cx, |buffer, cx| { - buffer.start_transaction(cx); - let line_start = buffer.insert_empty_line(position, true, true, cx); - initial_transaction_id = buffer.end_transaction(cx); - buffer.refresh_preview(cx); - - let line_start = buffer.read(cx).anchor_before(line_start); - line_start..line_start - }); - } - Self::InsertAfter { - position, - description, - .. - } => { - let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?; - initial_prompt = description.clone(); - suggestion_range = buffer.update(cx, |buffer, cx| { - buffer.start_transaction(cx); - let line_start = buffer.insert_empty_line(position, true, true, cx); - initial_transaction_id = buffer.end_transaction(cx); - buffer.refresh_preview(cx); - - let line_start = buffer.read(cx).anchor_before(line_start); - line_start..line_start - }); - } - Self::Delete { range, .. } => { - initial_prompt = "Delete".to_string(); - suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)? - ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?; - } - } - - InlineAssistant::update_global(cx, |inline_assistant, cx| { - Some(inline_assistant.suggest_assist( - editor, - suggestion_range, - initial_prompt, - initial_transaction_id, - false, - Some(workspace.clone()), - Some(assistant_panel), - cx, - )) - }) - } -} - -impl WorkflowStepEdit { - pub fn new( - path: Option, - operation: Option, - search: Option, - description: Option, - ) -> Result { - let path = path.ok_or_else(|| anyhow!("missing path"))?; - let operation = operation.ok_or_else(|| anyhow!("missing operation"))?; - - let kind = match operation.as_str() { - "update" => WorkflowStepEditKind::Update { - search: search.ok_or_else(|| anyhow!("missing search"))?, - description: description.ok_or_else(|| anyhow!("missing description"))?, - }, - "insert_before" => WorkflowStepEditKind::InsertBefore { - search: search.ok_or_else(|| anyhow!("missing search"))?, - description: description.ok_or_else(|| anyhow!("missing description"))?, - }, - "insert_after" => WorkflowStepEditKind::InsertAfter { - search: search.ok_or_else(|| anyhow!("missing search"))?, - description: description.ok_or_else(|| anyhow!("missing description"))?, - }, - "delete" => WorkflowStepEditKind::Delete { - search: search.ok_or_else(|| anyhow!("missing search"))?, - }, - "create" => WorkflowStepEditKind::Create { - description: description.ok_or_else(|| anyhow!("missing description"))?, - }, - _ => Err(anyhow!("unknown operation {operation:?}"))?, - }; - - Ok(Self { path, kind }) - } - - pub async fn resolve( - &self, - project: Model, - mut cx: AsyncAppContext, - ) -> Result<(Model, super::WorkflowSuggestion)> { - let path = self.path.clone(); - let kind = self.kind.clone(); - let buffer = project - .update(&mut cx, |project, cx| { - let project_path = project - .find_project_path(Path::new(&path), cx) - .or_else(|| { - // If we couldn't find a project path for it, put it in the active worktree - // so that when we create the buffer, it can be saved. - let worktree = project - .active_entry() - .and_then(|entry_id| project.worktree_for_entry(entry_id, cx)) - .or_else(|| project.worktrees(cx).next())?; - let worktree = worktree.read(cx); - - Some(ProjectPath { - worktree_id: worktree.id(), - path: Arc::from(Path::new(&path)), - }) - }) - .with_context(|| format!("worktree not found for {:?}", path))?; - anyhow::Ok(project.open_buffer(project_path, cx)) - })?? - .await?; - - let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?; - let suggestion = cx - .background_executor() - .spawn(async move { - match kind { - WorkflowStepEditKind::Update { - search, - description, - } => { - let range = Self::resolve_location(&snapshot, &search); - WorkflowSuggestion::Update { range, description } - } - WorkflowStepEditKind::Create { description } => { - WorkflowSuggestion::CreateFile { description } - } - WorkflowStepEditKind::InsertBefore { - search, - description, - } => { - let range = Self::resolve_location(&snapshot, &search); - WorkflowSuggestion::InsertBefore { - position: range.start, - description, - } - } - WorkflowStepEditKind::InsertAfter { - search, - description, - } => { - let range = Self::resolve_location(&snapshot, &search); - WorkflowSuggestion::InsertAfter { - position: range.end, - description, - } - } - WorkflowStepEditKind::Delete { search } => { - let range = Self::resolve_location(&snapshot, &search); - WorkflowSuggestion::Delete { range } - } - } - }) - .await; - - Ok((buffer, suggestion)) - } - - fn resolve_location(buffer: &text::BufferSnapshot, search_query: &str) -> Range { - const INSERTION_SCORE: f64 = -1.0; - const DELETION_SCORE: f64 = -1.0; - const REPLACEMENT_SCORE: f64 = -1.0; - const EQUALITY_SCORE: f64 = 5.0; - - struct Matrix { - cols: usize, - data: Vec, - } - - impl Matrix { - fn new(rows: usize, cols: usize) -> Self { - Matrix { - cols, - data: vec![0.0; rows * cols], - } - } - - fn get(&self, row: usize, col: usize) -> f64 { - self.data[row * self.cols + col] - } - - fn set(&mut self, row: usize, col: usize, value: f64) { - self.data[row * self.cols + col] = value; - } - } - - let buffer_len = buffer.len(); - let query_len = search_query.len(); - let mut matrix = Matrix::new(query_len + 1, buffer_len + 1); - - for (i, query_byte) in search_query.bytes().enumerate() { - for (j, buffer_byte) in buffer.bytes_in_range(0..buffer.len()).flatten().enumerate() { - let match_score = if query_byte == *buffer_byte { - EQUALITY_SCORE - } else { - REPLACEMENT_SCORE - }; - let up = matrix.get(i + 1, j) + DELETION_SCORE; - let left = matrix.get(i, j + 1) + INSERTION_SCORE; - let diagonal = matrix.get(i, j) + match_score; - let score = up.max(left.max(diagonal)).max(0.); - matrix.set(i + 1, j + 1, score); - } - } - - // Traceback to find the best match - let mut best_buffer_end = buffer_len; - let mut best_score = 0.0; - for col in 1..=buffer_len { - let score = matrix.get(query_len, col); - if score > best_score { - best_score = score; - best_buffer_end = col; - } - } - - let mut query_ix = query_len; - let mut buffer_ix = best_buffer_end; - while query_ix > 0 && buffer_ix > 0 { - let current = matrix.get(query_ix, buffer_ix); - let up = matrix.get(query_ix - 1, buffer_ix); - let left = matrix.get(query_ix, buffer_ix - 1); - if current == left + INSERTION_SCORE { - buffer_ix -= 1; - } else if current == up + DELETION_SCORE { - query_ix -= 1; - } else { - query_ix -= 1; - buffer_ix -= 1; - } - } - - let mut start = buffer.offset_to_point(buffer.clip_offset(buffer_ix, Bias::Left)); - start.column = 0; - let mut end = buffer.offset_to_point(buffer.clip_offset(best_buffer_end, Bias::Right)); - end.column = buffer.line_len(end.row); - - buffer.anchor_after(start)..buffer.anchor_before(end) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "operation")] -pub enum WorkflowStepEditKind { - /// Rewrites the specified text entirely based on the given description. - /// This operation completely replaces the given text. - Update { - /// A string in the source text to apply the update to. - search: String, - /// A brief description of the transformation to apply to the symbol. - description: String, - }, - /// Creates a new file with the given path based on the provided description. - /// This operation adds a new file to the codebase. - Create { - /// A brief description of the file to be created. - description: String, - }, - /// Inserts text before the specified text in the source file. - InsertBefore { - /// A string in the source text to insert text before. - search: String, - /// A brief description of how the new text should be generated. - description: String, - }, - /// Inserts text after the specified text in the source file. - InsertAfter { - /// A string in the source text to insert text after. - search: String, - /// A brief description of how the new text should be generated. - description: String, - }, - /// Deletes the specified symbol from the containing file. - Delete { - /// A string in the source text to delete. - search: String, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::{AppContext, Context}; - use text::{OffsetRangeExt, Point}; - - #[gpui::test] - fn test_resolve_location(cx: &mut AppContext) { - { - let buffer = cx.new_model(|cx| { - Buffer::local( - concat!( - " Lorem\n", - " ipsum\n", - " dolor sit amet\n", - " consecteur", - ), - cx, - ) - }); - let snapshot = buffer.read(cx).snapshot(); - assert_eq!( - WorkflowStepEdit::resolve_location(&snapshot, "ipsum\ndolor").to_point(&snapshot), - Point::new(1, 0)..Point::new(2, 18) - ); - } - - { - let buffer = cx.new_model(|cx| { - Buffer::local( - concat!( - "fn foo1(a: usize) -> usize {\n", - " 42\n", - "}\n", - "\n", - "fn foo2(b: usize) -> usize {\n", - " 42\n", - "}\n", - ), - cx, - ) - }); - let snapshot = buffer.read(cx).snapshot(); - assert_eq!( - WorkflowStepEdit::resolve_location(&snapshot, "fn foo1(b: usize) {\n42\n}") - .to_point(&snapshot), - Point::new(0, 0)..Point::new(2, 1) - ); - } - - { - let buffer = cx.new_model(|cx| { - Buffer::local( - concat!( - "fn main() {\n", - " Foo\n", - " .bar()\n", - " .baz()\n", - " .qux()\n", - "}\n", - "\n", - "fn foo2(b: usize) -> usize {\n", - " 42\n", - "}\n", - ), - cx, - ) - }); - let snapshot = buffer.read(cx).snapshot(); - assert_eq!( - WorkflowStepEdit::resolve_location(&snapshot, "Foo.bar.baz.qux()") - .to_point(&snapshot), - Point::new(1, 0)..Point::new(4, 14) - ); - } - } -} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 72205273f8..9d9cfde7b9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -100,7 +100,7 @@ use language::{ }; use linked_editing_ranges::refresh_linked_ranges; pub use proposed_changes_editor::{ - ProposedChangesBuffer, ProposedChangesEditor, ProposedChangesEditorToolbar, + ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar, }; use similar::{ChangeTag, TextDiff}; use task::{ResolvedTask, TaskTemplate, TaskVariables}; @@ -12363,10 +12363,15 @@ impl Editor { let proposed_changes_buffers = new_selections_by_buffer .into_iter() - .map(|(buffer, ranges)| ProposedChangesBuffer { buffer, ranges }) + .map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges }) .collect::>(); let proposed_changes_editor = cx.new_view(|cx| { - ProposedChangesEditor::new(proposed_changes_buffers, self.project.clone(), cx) + ProposedChangesEditor::new( + "Proposed changes", + proposed_changes_buffers, + self.project.clone(), + cx, + ) }); cx.window_context().defer(move |cx| { diff --git a/crates/editor/src/proposed_changes_editor.rs b/crates/editor/src/proposed_changes_editor.rs index 056daf28a8..2c99b9d277 100644 --- a/crates/editor/src/proposed_changes_editor.rs +++ b/crates/editor/src/proposed_changes_editor.rs @@ -16,16 +16,24 @@ use workspace::{ pub struct ProposedChangesEditor { editor: View, - _subscriptions: Vec, + multibuffer: Model, + title: SharedString, + buffer_entries: Vec, _recalculate_diffs_task: Task>, recalculate_diffs_tx: mpsc::UnboundedSender, } -pub struct ProposedChangesBuffer { +pub struct ProposedChangeLocation { pub buffer: Model, pub ranges: Vec>, } +struct BufferEntry { + base: Model, + branch: Model, + _subscription: Subscription, +} + pub struct ProposedChangesEditorToolbar { current_editor: Option>, } @@ -43,32 +51,14 @@ struct BranchBufferSemanticsProvider(Rc); impl ProposedChangesEditor { pub fn new( - buffers: Vec>, + title: impl Into, + locations: Vec>, project: Option>, cx: &mut ViewContext, ) -> Self { - let mut subscriptions = Vec::new(); let multibuffer = cx.new_model(|_| MultiBuffer::new(Capability::ReadWrite)); - - for buffer in buffers { - let branch_buffer = buffer.buffer.update(cx, |buffer, cx| buffer.branch(cx)); - subscriptions.push(cx.subscribe(&branch_buffer, Self::on_buffer_event)); - - multibuffer.update(cx, |multibuffer, cx| { - multibuffer.push_excerpts( - branch_buffer, - buffer.ranges.into_iter().map(|range| ExcerptRange { - context: range, - primary: None, - }), - cx, - ); - }); - } - let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded(); - - Self { + let mut this = Self { editor: cx.new_view(|cx| { let mut editor = Editor::for_multibuffer(multibuffer.clone(), project, true, cx); editor.set_expand_all_diff_hunks(); @@ -81,6 +71,9 @@ impl ProposedChangesEditor { ); editor }), + multibuffer, + title: title.into(), + buffer_entries: Vec::new(), recalculate_diffs_tx, _recalculate_diffs_task: cx.spawn(|_, mut cx| async move { let mut buffers_to_diff = HashSet::default(); @@ -112,7 +105,100 @@ impl ProposedChangesEditor { } None }), - _subscriptions: subscriptions, + }; + this.reset_locations(locations, cx); + this + } + + pub fn branch_buffer_for_base(&self, base_buffer: &Model) -> Option> { + self.buffer_entries.iter().find_map(|entry| { + if &entry.base == base_buffer { + Some(entry.branch.clone()) + } else { + None + } + }) + } + + pub fn set_title(&mut self, title: SharedString, cx: &mut ViewContext) { + self.title = title; + cx.notify(); + } + + pub fn reset_locations( + &mut self, + locations: Vec>, + cx: &mut ViewContext, + ) { + // Undo all branch changes + for entry in &self.buffer_entries { + let base_version = entry.base.read(cx).version(); + entry.branch.update(cx, |buffer, cx| { + let undo_counts = buffer + .operations() + .iter() + .filter_map(|(timestamp, _)| { + if !base_version.observed(*timestamp) { + Some((*timestamp, u32::MAX)) + } else { + None + } + }) + .collect(); + buffer.undo_operations(undo_counts, cx); + }); + } + + self.multibuffer.update(cx, |multibuffer, cx| { + multibuffer.clear(cx); + }); + + let mut buffer_entries = Vec::new(); + for location in locations { + let branch_buffer; + if let Some(ix) = self + .buffer_entries + .iter() + .position(|entry| entry.base == location.buffer) + { + let entry = self.buffer_entries.remove(ix); + branch_buffer = entry.branch.clone(); + buffer_entries.push(entry); + } else { + branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx)); + buffer_entries.push(BufferEntry { + branch: branch_buffer.clone(), + base: location.buffer.clone(), + _subscription: cx.subscribe(&branch_buffer, Self::on_buffer_event), + }); + } + + self.multibuffer.update(cx, |multibuffer, cx| { + multibuffer.push_excerpts( + branch_buffer, + location.ranges.into_iter().map(|range| ExcerptRange { + context: range, + primary: None, + }), + cx, + ); + }); + } + + self.buffer_entries = buffer_entries; + self.editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |selections| selections.refresh()) + }); + } + + pub fn recalculate_all_buffer_diffs(&self) { + for (ix, entry) in self.buffer_entries.iter().enumerate().rev() { + self.recalculate_diffs_tx + .unbounded_send(RecalculateDiff { + buffer: entry.branch.clone(), + debounce: ix > 0, + }) + .ok(); } } @@ -162,11 +248,11 @@ impl Item for ProposedChangesEditor { type Event = EditorEvent; fn tab_icon(&self, _cx: &ui::WindowContext) -> Option { - Some(Icon::new(IconName::Pencil)) + Some(Icon::new(IconName::Diff)) } fn tab_content_text(&self, _cx: &WindowContext) -> Option { - Some("Proposed changes".into()) + Some(self.title.clone()) } fn as_searchable(&self, _: &View) -> Option> { diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index 5f65cebdb2..4d5452acc0 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -434,12 +434,10 @@ impl Clone for Model { impl std::fmt::Debug for Model { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Model {{ entity_id: {:?}, entity_type: {:?} }}", - self.any_model.entity_id, - type_name::() - ) + f.debug_struct("Model") + .field("entity_id", &self.any_model.entity_id) + .field("entity_type", &type_name::()) + .finish() } } @@ -569,7 +567,10 @@ pub struct WeakModel { impl std::fmt::Debug for WeakModel { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct(type_name::>()).finish() + f.debug_struct(&type_name::()) + .field("entity_id", &self.any_model.entity_id) + .field("entity_type", &type_name::()) + .finish() } } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 28bbb25d1b..92cd84202a 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -20,6 +20,7 @@ use anyhow::{anyhow, Context, Result}; use async_watch as watch; use clock::Lamport; pub use clock::ReplicaId; +use collections::HashMap; use futures::channel::oneshot; use gpui::{ AnyElement, AppContext, Context as _, EventEmitter, HighlightStyle, Model, ModelContext, @@ -910,10 +911,8 @@ impl Buffer { self.apply_ops([operation.clone()], cx); if let Some(timestamp) = operation_to_undo { - let operation = self - .text - .undo_operations([(timestamp, u32::MAX)].into_iter().collect()); - self.send_operation(Operation::Buffer(operation), true, cx); + let counts = [(timestamp, u32::MAX)].into_iter().collect(); + self.undo_operations(counts, cx); } self.diff_base_version += 1; @@ -2331,6 +2330,18 @@ impl Buffer { undone } + pub fn undo_operations( + &mut self, + counts: HashMap, + cx: &mut ModelContext, + ) { + let was_dirty = self.is_dirty(); + let operation = self.text.undo_operations(counts); + let old_version = self.version.clone(); + self.send_operation(Operation::Buffer(operation), true, cx); + self.did_edit(&old_version, was_dirty, cx); + } + /// Manually redoes a specific transaction in the buffer's redo history. pub fn redo(&mut self, cx: &mut ModelContext) -> Option { let was_dirty = self.is_dirty(); diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index 33fa874ec8..c4f14d1354 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -9,7 +9,9 @@ license = "GPL-3.0-or-later" workspace = true [features] -test-support = [] +test-support = [ + "tree-sitter" +] load-grammars = [ "tree-sitter-bash", "tree-sitter-c", @@ -75,6 +77,7 @@ tree-sitter-yaml = { workspace = true, optional = true } util.workspace = true [dev-dependencies] +tree-sitter.workspace = true text.workspace = true theme = { workspace = true, features = ["test-support"] } unindent.workspace = true diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index b1e6be8a1c..380ced5253 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -1427,7 +1427,7 @@ impl Buffer { fn undo_or_redo(&mut self, transaction: Transaction) -> Operation { let mut counts = HashMap::default(); for edit_id in transaction.edit_ids { - counts.insert(edit_id, self.undo_map.undo_count(edit_id) + 1); + counts.insert(edit_id, self.undo_map.undo_count(edit_id).saturating_add(1)); } let operation = self.undo_operations(counts); diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 683bcec5a8..0727f7ed9d 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -170,6 +170,7 @@ pub enum IconName { Dash, DatabaseZap, Delete, + Diff, Disconnected, Download, Ellipsis,