Release Notes: - Added a new "Fix with Assistant" action on code with errors or warnings. --------- Co-authored-by: Nathan <nathan@zed.dev>
508 lines
18 KiB
Rust
508 lines
18 KiB
Rust
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<language::Anchor>,
|
|
pub leading_tags_end: text::Anchor,
|
|
pub trailing_tag_start: Option<text::Anchor>,
|
|
pub edits: Arc<[Result<WorkflowStepEdit>]>,
|
|
pub resolution_task: Option<Task<()>>,
|
|
pub resolution: Option<Arc<Result<WorkflowStepResolution>>>,
|
|
}
|
|
|
|
#[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<Model<Buffer>, Vec<WorkflowSuggestionGroup>>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct WorkflowSuggestionGroup {
|
|
pub context_range: Range<language::Anchor>,
|
|
pub suggestions: Vec<WorkflowSuggestion>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum WorkflowSuggestion {
|
|
Update {
|
|
range: Range<language::Anchor>,
|
|
description: String,
|
|
},
|
|
CreateFile {
|
|
description: String,
|
|
},
|
|
InsertBefore {
|
|
position: language::Anchor,
|
|
description: String,
|
|
},
|
|
InsertAfter {
|
|
position: language::Anchor,
|
|
description: String,
|
|
},
|
|
Delete {
|
|
range: Range<language::Anchor>,
|
|
},
|
|
}
|
|
|
|
impl WorkflowSuggestion {
|
|
pub fn range(&self) -> Range<language::Anchor> {
|
|
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<Editor>,
|
|
excerpt_id: editor::ExcerptId,
|
|
workspace: &WeakView<Workspace>,
|
|
assistant_panel: &View<AssistantPanel>,
|
|
cx: &mut WindowContext,
|
|
) -> Option<InlineAssistId> {
|
|
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<String>,
|
|
operation: Option<String>,
|
|
search: Option<String>,
|
|
description: Option<String>,
|
|
) -> Result<Self> {
|
|
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<Project>,
|
|
mut cx: AsyncAppContext,
|
|
) -> Result<(Model<Buffer>, 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<text::Anchor> {
|
|
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<f64>,
|
|
}
|
|
|
|
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)
|
|
);
|
|
}
|
|
}
|
|
}
|