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, 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) ); } } }