workflow.rs

  1use crate::{AssistantPanel, InlineAssistId, InlineAssistant};
  2use anyhow::{anyhow, Context as _, Result};
  3use collections::HashMap;
  4use editor::Editor;
  5use gpui::AsyncAppContext;
  6use gpui::{Model, Task, UpdateGlobal as _, View, WeakView, WindowContext};
  7use language::{Anchor, Buffer, BufferSnapshot, Outline, OutlineItem, ParseStatus, SymbolPath};
  8use project::{Project, ProjectPath};
  9use rope::Point;
 10use schemars::JsonSchema;
 11use serde::{Deserialize, Serialize};
 12use std::{ops::Range, path::Path, sync::Arc};
 13use workspace::Workspace;
 14
 15const IMPORTS_SYMBOL: &str = "#imports";
 16
 17#[derive(Debug)]
 18pub(crate) struct WorkflowStep {
 19    pub range: Range<language::Anchor>,
 20    pub leading_tags_end: text::Anchor,
 21    pub trailing_tag_start: Option<text::Anchor>,
 22    pub edits: Arc<[Result<WorkflowStepEdit>]>,
 23    pub resolution_task: Option<Task<()>>,
 24    pub resolution: Option<Arc<Result<WorkflowStepResolution>>>,
 25}
 26
 27#[derive(Clone, Debug, PartialEq, Eq)]
 28pub(crate) struct WorkflowStepEdit {
 29    pub path: String,
 30    pub kind: WorkflowStepEditKind,
 31}
 32
 33#[derive(Clone, Debug, Eq, PartialEq)]
 34pub(crate) struct WorkflowStepResolution {
 35    pub title: String,
 36    pub suggestion_groups: HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>,
 37}
 38
 39#[derive(Clone, Debug, Eq, PartialEq)]
 40pub struct WorkflowSuggestionGroup {
 41    pub context_range: Range<language::Anchor>,
 42    pub suggestions: Vec<WorkflowSuggestion>,
 43}
 44
 45#[derive(Clone, Debug, Eq, PartialEq)]
 46pub enum WorkflowSuggestion {
 47    Update {
 48        symbol_path: SymbolPath,
 49        range: Range<language::Anchor>,
 50        description: String,
 51    },
 52    CreateFile {
 53        description: String,
 54    },
 55    InsertSiblingBefore {
 56        symbol_path: SymbolPath,
 57        position: language::Anchor,
 58        description: String,
 59    },
 60    InsertSiblingAfter {
 61        symbol_path: SymbolPath,
 62        position: language::Anchor,
 63        description: String,
 64    },
 65    PrependChild {
 66        symbol_path: Option<SymbolPath>,
 67        position: language::Anchor,
 68        description: String,
 69    },
 70    AppendChild {
 71        symbol_path: Option<SymbolPath>,
 72        position: language::Anchor,
 73        description: String,
 74    },
 75    Delete {
 76        symbol_path: SymbolPath,
 77        range: Range<language::Anchor>,
 78    },
 79}
 80
 81impl WorkflowSuggestion {
 82    pub fn range(&self) -> Range<language::Anchor> {
 83        match self {
 84            Self::Update { range, .. } => range.clone(),
 85            Self::CreateFile { .. } => language::Anchor::MIN..language::Anchor::MAX,
 86            Self::InsertSiblingBefore { position, .. }
 87            | Self::InsertSiblingAfter { position, .. }
 88            | Self::PrependChild { position, .. }
 89            | Self::AppendChild { position, .. } => *position..*position,
 90            Self::Delete { range, .. } => range.clone(),
 91        }
 92    }
 93
 94    pub fn description(&self) -> Option<&str> {
 95        match self {
 96            Self::Update { description, .. }
 97            | Self::CreateFile { description }
 98            | Self::InsertSiblingBefore { description, .. }
 99            | Self::InsertSiblingAfter { description, .. }
100            | Self::PrependChild { description, .. }
101            | Self::AppendChild { description, .. } => Some(description),
102            Self::Delete { .. } => None,
103        }
104    }
105
106    fn description_mut(&mut self) -> Option<&mut String> {
107        match self {
108            Self::Update { description, .. }
109            | Self::CreateFile { description }
110            | Self::InsertSiblingBefore { description, .. }
111            | Self::InsertSiblingAfter { description, .. }
112            | Self::PrependChild { description, .. }
113            | Self::AppendChild { description, .. } => Some(description),
114            Self::Delete { .. } => None,
115        }
116    }
117
118    pub fn try_merge(&mut self, other: &Self, buffer: &BufferSnapshot) -> bool {
119        let range = self.range();
120        let other_range = other.range();
121
122        // Don't merge if we don't contain the other suggestion.
123        if range.start.cmp(&other_range.start, buffer).is_gt()
124            || range.end.cmp(&other_range.end, buffer).is_lt()
125        {
126            return false;
127        }
128
129        if let Some(description) = self.description_mut() {
130            if let Some(other_description) = other.description() {
131                description.push('\n');
132                description.push_str(other_description);
133            }
134        }
135        true
136    }
137
138    pub fn show(
139        &self,
140        editor: &View<Editor>,
141        excerpt_id: editor::ExcerptId,
142        workspace: &WeakView<Workspace>,
143        assistant_panel: &View<AssistantPanel>,
144        cx: &mut WindowContext,
145    ) -> Option<InlineAssistId> {
146        let mut initial_transaction_id = None;
147        let initial_prompt;
148        let suggestion_range;
149        let buffer = editor.read(cx).buffer().clone();
150        let snapshot = buffer.read(cx).snapshot(cx);
151
152        match self {
153            Self::Update {
154                range, description, ..
155            } => {
156                initial_prompt = description.clone();
157                suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
158                    ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
159            }
160            Self::CreateFile { description } => {
161                initial_prompt = description.clone();
162                suggestion_range = editor::Anchor::min()..editor::Anchor::min();
163            }
164            Self::InsertSiblingBefore {
165                position,
166                description,
167                ..
168            } => {
169                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
170                initial_prompt = description.clone();
171                suggestion_range = buffer.update(cx, |buffer, cx| {
172                    buffer.start_transaction(cx);
173                    let line_start = buffer.insert_empty_line(position, true, true, cx);
174                    initial_transaction_id = buffer.end_transaction(cx);
175                    buffer.refresh_preview(cx);
176
177                    let line_start = buffer.read(cx).anchor_before(line_start);
178                    line_start..line_start
179                });
180            }
181            Self::InsertSiblingAfter {
182                position,
183                description,
184                ..
185            } => {
186                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
187                initial_prompt = description.clone();
188                suggestion_range = buffer.update(cx, |buffer, cx| {
189                    buffer.start_transaction(cx);
190                    let line_start = buffer.insert_empty_line(position, true, true, cx);
191                    initial_transaction_id = buffer.end_transaction(cx);
192                    buffer.refresh_preview(cx);
193
194                    let line_start = buffer.read(cx).anchor_before(line_start);
195                    line_start..line_start
196                });
197            }
198            Self::PrependChild {
199                position,
200                description,
201                ..
202            } => {
203                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
204                initial_prompt = description.clone();
205                suggestion_range = buffer.update(cx, |buffer, cx| {
206                    buffer.start_transaction(cx);
207                    let line_start = buffer.insert_empty_line(position, false, true, cx);
208                    initial_transaction_id = buffer.end_transaction(cx);
209                    buffer.refresh_preview(cx);
210
211                    let line_start = buffer.read(cx).anchor_before(line_start);
212                    line_start..line_start
213                });
214            }
215            Self::AppendChild {
216                position,
217                description,
218                ..
219            } => {
220                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
221                initial_prompt = description.clone();
222                suggestion_range = buffer.update(cx, |buffer, cx| {
223                    buffer.start_transaction(cx);
224                    let line_start = buffer.insert_empty_line(position, true, false, cx);
225                    initial_transaction_id = buffer.end_transaction(cx);
226                    buffer.refresh_preview(cx);
227
228                    let line_start = buffer.read(cx).anchor_before(line_start);
229                    line_start..line_start
230                });
231            }
232            Self::Delete { range, .. } => {
233                initial_prompt = "Delete".to_string();
234                suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
235                    ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
236            }
237        }
238
239        InlineAssistant::update_global(cx, |inline_assistant, cx| {
240            Some(inline_assistant.suggest_assist(
241                editor,
242                suggestion_range,
243                initial_prompt,
244                initial_transaction_id,
245                Some(workspace.clone()),
246                Some(assistant_panel),
247                cx,
248            ))
249        })
250    }
251}
252
253impl WorkflowStepEdit {
254    pub fn new(
255        path: Option<String>,
256        operation: Option<String>,
257        symbol: Option<String>,
258        description: Option<String>,
259    ) -> Result<Self> {
260        let path = path.ok_or_else(|| anyhow!("missing path"))?;
261        let operation = operation.ok_or_else(|| anyhow!("missing operation"))?;
262
263        let kind = match operation.as_str() {
264            "update" => WorkflowStepEditKind::Update {
265                symbol: symbol.ok_or_else(|| anyhow!("missing symbol"))?,
266                description: description.ok_or_else(|| anyhow!("missing description"))?,
267            },
268            "insert_sibling_before" => WorkflowStepEditKind::InsertSiblingBefore {
269                symbol: symbol.ok_or_else(|| anyhow!("missing symbol"))?,
270                description: description.ok_or_else(|| anyhow!("missing description"))?,
271            },
272            "insert_sibling_after" => WorkflowStepEditKind::InsertSiblingAfter {
273                symbol: symbol.ok_or_else(|| anyhow!("missing symbol"))?,
274                description: description.ok_or_else(|| anyhow!("missing description"))?,
275            },
276            "prepend_child" => WorkflowStepEditKind::PrependChild {
277                symbol,
278                description: description.ok_or_else(|| anyhow!("missing description"))?,
279            },
280            "append_child" => WorkflowStepEditKind::AppendChild {
281                symbol,
282                description: description.ok_or_else(|| anyhow!("missing description"))?,
283            },
284            "delete" => WorkflowStepEditKind::Delete {
285                symbol: symbol.ok_or_else(|| anyhow!("missing symbol"))?,
286            },
287            "create" => WorkflowStepEditKind::Create {
288                description: description.ok_or_else(|| anyhow!("missing description"))?,
289            },
290            _ => Err(anyhow!("unknown operation {operation:?}"))?,
291        };
292
293        Ok(Self { path, kind })
294    }
295
296    pub async fn resolve(
297        &self,
298        project: Model<Project>,
299        mut cx: AsyncAppContext,
300    ) -> Result<(Model<Buffer>, super::WorkflowSuggestion)> {
301        let path = self.path.clone();
302        let kind = self.kind.clone();
303        let buffer = project
304            .update(&mut cx, |project, cx| {
305                let project_path = project
306                    .find_project_path(Path::new(&path), cx)
307                    .or_else(|| {
308                        // If we couldn't find a project path for it, put it in the active worktree
309                        // so that when we create the buffer, it can be saved.
310                        let worktree = project
311                            .active_entry()
312                            .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
313                            .or_else(|| project.worktrees(cx).next())?;
314                        let worktree = worktree.read(cx);
315
316                        Some(ProjectPath {
317                            worktree_id: worktree.id(),
318                            path: Arc::from(Path::new(&path)),
319                        })
320                    })
321                    .with_context(|| format!("worktree not found for {:?}", path))?;
322                anyhow::Ok(project.open_buffer(project_path, cx))
323            })??
324            .await?;
325
326        let mut parse_status = buffer.read_with(&cx, |buffer, _cx| buffer.parse_status())?;
327        while *parse_status.borrow() != ParseStatus::Idle {
328            parse_status.changed().await?;
329        }
330
331        let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
332        let outline = snapshot.outline(None).context("no outline for buffer")?;
333
334        let suggestion = match kind {
335            WorkflowStepEditKind::Update {
336                symbol,
337                description,
338            } => {
339                let (symbol_path, symbol) = Self::resolve_symbol(&snapshot, &outline, &symbol)?;
340                let start = symbol
341                    .annotation_range
342                    .map_or(symbol.range.start, |range| range.start);
343                let start = Point::new(start.row, 0);
344                let end = Point::new(
345                    symbol.range.end.row,
346                    snapshot.line_len(symbol.range.end.row),
347                );
348                let range = snapshot.anchor_before(start)..snapshot.anchor_after(end);
349                WorkflowSuggestion::Update {
350                    range,
351                    description,
352                    symbol_path,
353                }
354            }
355            WorkflowStepEditKind::Create { description } => {
356                WorkflowSuggestion::CreateFile { description }
357            }
358            WorkflowStepEditKind::InsertSiblingBefore {
359                symbol,
360                description,
361            } => {
362                let (symbol_path, symbol) = Self::resolve_symbol(&snapshot, &outline, &symbol)?;
363                let position = snapshot.anchor_before(
364                    symbol
365                        .annotation_range
366                        .map_or(symbol.range.start, |annotation_range| {
367                            annotation_range.start
368                        }),
369                );
370                WorkflowSuggestion::InsertSiblingBefore {
371                    position,
372                    description,
373                    symbol_path,
374                }
375            }
376            WorkflowStepEditKind::InsertSiblingAfter {
377                symbol,
378                description,
379            } => {
380                let (symbol_path, symbol) = Self::resolve_symbol(&snapshot, &outline, &symbol)?;
381                let position = snapshot.anchor_after(symbol.range.end);
382                WorkflowSuggestion::InsertSiblingAfter {
383                    position,
384                    description,
385                    symbol_path,
386                }
387            }
388            WorkflowStepEditKind::PrependChild {
389                symbol,
390                description,
391            } => {
392                if let Some(symbol) = symbol {
393                    let (symbol_path, symbol) = Self::resolve_symbol(&snapshot, &outline, &symbol)?;
394
395                    let position = snapshot.anchor_after(
396                        symbol
397                            .body_range
398                            .map_or(symbol.range.start, |body_range| body_range.start),
399                    );
400                    WorkflowSuggestion::PrependChild {
401                        position,
402                        description,
403                        symbol_path: Some(symbol_path),
404                    }
405                } else {
406                    WorkflowSuggestion::PrependChild {
407                        position: language::Anchor::MIN,
408                        description,
409                        symbol_path: None,
410                    }
411                }
412            }
413            WorkflowStepEditKind::AppendChild {
414                symbol,
415                description,
416            } => {
417                if let Some(symbol) = symbol {
418                    let (symbol_path, symbol) = Self::resolve_symbol(&snapshot, &outline, &symbol)?;
419
420                    let position = snapshot.anchor_before(
421                        symbol
422                            .body_range
423                            .map_or(symbol.range.end, |body_range| body_range.end),
424                    );
425                    WorkflowSuggestion::AppendChild {
426                        position,
427                        description,
428                        symbol_path: Some(symbol_path),
429                    }
430                } else {
431                    WorkflowSuggestion::PrependChild {
432                        position: language::Anchor::MAX,
433                        description,
434                        symbol_path: None,
435                    }
436                }
437            }
438            WorkflowStepEditKind::Delete { symbol } => {
439                let (symbol_path, symbol) = Self::resolve_symbol(&snapshot, &outline, &symbol)?;
440                let start = symbol
441                    .annotation_range
442                    .map_or(symbol.range.start, |range| range.start);
443                let start = Point::new(start.row, 0);
444                let end = Point::new(
445                    symbol.range.end.row,
446                    snapshot.line_len(symbol.range.end.row),
447                );
448                let range = snapshot.anchor_before(start)..snapshot.anchor_after(end);
449                WorkflowSuggestion::Delete { range, symbol_path }
450            }
451        };
452
453        Ok((buffer, suggestion))
454    }
455
456    fn resolve_symbol(
457        snapshot: &BufferSnapshot,
458        outline: &Outline<Anchor>,
459        symbol: &str,
460    ) -> Result<(SymbolPath, OutlineItem<Point>)> {
461        if symbol == IMPORTS_SYMBOL {
462            let target_row = find_first_non_comment_line(snapshot);
463            Ok((
464                SymbolPath(IMPORTS_SYMBOL.to_string()),
465                OutlineItem {
466                    range: Point::new(target_row, 0)..Point::new(target_row + 1, 0),
467                    ..Default::default()
468                },
469            ))
470        } else {
471            let (symbol_path, symbol) = outline
472                .find_most_similar(symbol)
473                .with_context(|| format!("symbol not found: {symbol}"))?;
474            Ok((symbol_path, symbol.to_point(snapshot)))
475        }
476    }
477}
478
479fn find_first_non_comment_line(snapshot: &BufferSnapshot) -> u32 {
480    let Some(language) = snapshot.language() else {
481        return 0;
482    };
483
484    let scope = language.default_scope();
485    let comment_prefixes = scope.line_comment_prefixes();
486
487    let mut chunks = snapshot.as_rope().chunks();
488    let mut target_row = 0;
489    loop {
490        let starts_with_comment = chunks
491            .peek()
492            .map(|chunk| {
493                comment_prefixes
494                    .iter()
495                    .any(|s| chunk.starts_with(s.as_ref().trim_end()))
496            })
497            .unwrap_or(false);
498
499        if !starts_with_comment {
500            break;
501        }
502
503        target_row += 1;
504        if !chunks.next_line() {
505            break;
506        }
507    }
508    target_row
509}
510
511#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
512#[serde(tag = "operation")]
513pub enum WorkflowStepEditKind {
514    /// Rewrites the specified symbol entirely based on the given description.
515    /// This operation completely replaces the existing symbol with new content.
516    Update {
517        /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
518        /// The path should uniquely identify the symbol within the containing file.
519        symbol: String,
520        /// A brief description of the transformation to apply to the symbol.
521        description: String,
522    },
523    /// Creates a new file with the given path based on the provided description.
524    /// This operation adds a new file to the codebase.
525    Create {
526        /// A brief description of the file to be created.
527        description: String,
528    },
529    /// Inserts a new symbol based on the given description before the specified symbol.
530    /// This operation adds new content immediately preceding an existing symbol.
531    InsertSiblingBefore {
532        /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
533        /// The new content will be inserted immediately before this symbol.
534        symbol: String,
535        /// A brief description of the new symbol to be inserted.
536        description: String,
537    },
538    /// Inserts a new symbol based on the given description after the specified symbol.
539    /// This operation adds new content immediately following an existing symbol.
540    InsertSiblingAfter {
541        /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
542        /// The new content will be inserted immediately after this symbol.
543        symbol: String,
544        /// A brief description of the new symbol to be inserted.
545        description: String,
546    },
547    /// Inserts a new symbol as a child of the specified symbol at the start.
548    /// This operation adds new content as the first child of an existing symbol (or file if no symbol is provided).
549    PrependChild {
550        /// An optional fully-qualified reference to the symbol after the code you want to insert, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
551        /// If provided, the new content will be inserted as the first child of this symbol.
552        /// If not provided, the new content will be inserted at the top of the file.
553        symbol: Option<String>,
554        /// A brief description of the new symbol to be inserted.
555        description: String,
556    },
557    /// Inserts a new symbol as a child of the specified symbol at the end.
558    /// This operation adds new content as the last child of an existing symbol (or file if no symbol is provided).
559    AppendChild {
560        /// An optional fully-qualified reference to the symbol before the code you want to insert, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
561        /// If provided, the new content will be inserted as the last child of this symbol.
562        /// If not provided, the new content will be applied at the bottom of the file.
563        symbol: Option<String>,
564        /// A brief description of the new symbol to be inserted.
565        description: String,
566    },
567    /// Deletes the specified symbol from the containing file.
568    Delete {
569        /// An fully-qualified reference to the symbol to be deleted, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
570        symbol: String,
571    },
572}