Introduce a new /delta command (#17903)

Release Notes:

- Added a new `/delta` command to re-insert changed files that were
previously included in a context.

---------

Co-authored-by: Roy <roy@anthropic.com>
This commit is contained in:
Antonio Scandurra 2024-09-17 08:47:08 -06:00 committed by GitHub
parent a20c0eb626
commit 54b8232be2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 408 additions and 366 deletions

1
Cargo.lock generated
View file

@ -455,6 +455,7 @@ dependencies = [
"language",
"parking_lot",
"serde",
"serde_json",
"workspace",
]

View file

@ -41,9 +41,9 @@ use semantic_index::{CloudEmbeddingProvider, SemanticDb};
use serde::{Deserialize, Serialize};
use settings::{update_settings_file, Settings, SettingsStore};
use slash_command::{
auto_command, context_server_command, default_command, diagnostics_command, docs_command,
fetch_command, file_command, now_command, project_command, prompt_command, search_command,
symbols_command, tab_command, terminal_command, workflow_command,
auto_command, context_server_command, default_command, delta_command, diagnostics_command,
docs_command, fetch_command, file_command, now_command, project_command, prompt_command,
search_command, symbols_command, tab_command, terminal_command, workflow_command,
};
use std::path::PathBuf;
use std::sync::Arc;
@ -367,6 +367,7 @@ fn register_slash_commands(prompt_builder: Option<Arc<PromptBuilder>>, cx: &mut
let slash_command_registry = SlashCommandRegistry::global(cx);
slash_command_registry.register_command(file_command::FileSlashCommand, true);
slash_command_registry.register_command(delta_command::DeltaSlashCommand, true);
slash_command_registry.register_command(symbols_command::OutlineSlashCommand, true);
slash_command_registry.register_command(tab_command::TabSlashCommand, true);
slash_command_registry.register_command(project_command::ProjectSlashCommand, true);

View file

@ -1906,7 +1906,22 @@ impl ContextEditor {
cx: &mut ViewContext<Self>,
) {
if let Some(command) = SlashCommandRegistry::global(cx).command(name) {
let output = command.run(arguments, workspace, self.lsp_adapter_delegate.clone(), cx);
let context = self.context.read(cx);
let sections = context
.slash_command_output_sections()
.into_iter()
.filter(|section| section.is_valid(context.buffer().read(cx)))
.cloned()
.collect::<Vec<_>>();
let snapshot = context.buffer().read(cx).snapshot();
let output = command.run(
arguments,
&sections,
snapshot,
workspace,
self.lsp_adapter_delegate.clone(),
cx,
);
self.context.update(cx, |context, cx| {
context.insert_command_output(
command_range,

View file

@ -48,7 +48,7 @@ use std::{
};
use telemetry_events::AssistantKind;
use text::BufferSnapshot;
use util::{post_inc, TryFutureExt};
use util::{post_inc, ResultExt, TryFutureExt};
use uuid::Uuid;
#[derive(Clone, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
@ -162,6 +162,9 @@ impl ContextOperation {
)?,
icon: section.icon_name.parse()?,
label: section.label.into(),
metadata: section
.metadata
.and_then(|metadata| serde_json::from_str(&metadata).log_err()),
})
})
.collect::<Result<Vec<_>>>()?,
@ -242,6 +245,9 @@ impl ContextOperation {
)),
icon_name: icon_name.to_string(),
label: section.label.to_string(),
metadata: section.metadata.as_ref().and_then(|metadata| {
serde_json::to_string(metadata).log_err()
}),
}
})
.collect(),
@ -635,12 +641,13 @@ impl Context {
.slash_command_output_sections
.iter()
.filter_map(|section| {
let range = section.range.to_offset(buffer);
if section.range.start.is_valid(buffer) && !range.is_empty() {
if section.is_valid(buffer) {
let range = section.range.to_offset(buffer);
Some(assistant_slash_command::SlashCommandOutputSection {
range,
icon: section.icon,
label: section.label.clone(),
metadata: section.metadata.clone(),
})
} else {
None
@ -1825,6 +1832,7 @@ impl Context {
..buffer.anchor_before(start + section.range.end),
icon: section.icon,
label: section.label,
metadata: section.metadata,
})
.collect::<Vec<_>>();
sections.sort_by(|a, b| a.range.cmp(&b.range, buffer));
@ -2977,6 +2985,7 @@ impl SavedContext {
..buffer.anchor_before(section.range.end),
icon: section.icon,
label: section.label,
metadata: section.metadata,
}
})
.collect(),

View file

@ -12,7 +12,7 @@ use assistant_slash_command::{
use collections::HashSet;
use fs::FakeFs;
use gpui::{AppContext, Model, SharedString, Task, TestAppContext, WeakView};
use language::{Buffer, LanguageRegistry, LspAdapterDelegate};
use language::{Buffer, BufferSnapshot, LanguageRegistry, LspAdapterDelegate};
use language_model::{LanguageModelCacheConfiguration, LanguageModelRegistry, Role};
use parking_lot::Mutex;
use project::Project;
@ -1089,6 +1089,7 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std
range: section_start..section_end,
icon: ui::IconName::Ai,
label: "section".into(),
metadata: None,
});
}
@ -1425,6 +1426,8 @@ impl SlashCommand for FakeSlashCommand {
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
_cx: &mut WindowContext,

View file

@ -22,6 +22,7 @@ use workspace::Workspace;
pub mod auto_command;
pub mod context_server_command;
pub mod default_command;
pub mod delta_command;
pub mod diagnostics_command;
pub mod docs_command;
pub mod fetch_command;

View file

@ -1,7 +1,7 @@
use super::create_label_for_command;
use super::{SlashCommand, SlashCommandOutput};
use anyhow::{anyhow, Result};
use assistant_slash_command::ArgumentCompletion;
use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection};
use feature_flags::FeatureFlag;
use futures::StreamExt;
use gpui::{AppContext, AsyncAppContext, Task, WeakView};
@ -87,6 +87,8 @@ impl SlashCommand for AutoCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: language::BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,

View file

@ -9,7 +9,7 @@ use context_servers::{
protocol::PromptInfo,
};
use gpui::{Task, WeakView, WindowContext};
use language::{CodeLabel, LspAdapterDelegate};
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use text::LineEnding;
@ -96,7 +96,6 @@ impl SlashCommand for ContextServerSlashCommand {
replace_previous_arguments: false,
})
.collect();
Ok(completions)
})
} else {
@ -107,6 +106,8 @@ impl SlashCommand for ContextServerSlashCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -141,6 +142,7 @@ impl SlashCommand for ContextServerSlashCommand {
.description
.unwrap_or(format!("Result from {}", prompt_name)),
),
metadata: None,
}],
text: prompt,
run_commands_in_text: false,

View file

@ -3,7 +3,7 @@ use crate::prompt_library::PromptStore;
use anyhow::{anyhow, Result};
use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection};
use gpui::{Task, WeakView};
use language::LspAdapterDelegate;
use language::{BufferSnapshot, LspAdapterDelegate};
use std::{
fmt::Write,
sync::{atomic::AtomicBool, Arc},
@ -43,6 +43,8 @@ impl SlashCommand for DefaultSlashCommand {
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -70,6 +72,7 @@ impl SlashCommand for DefaultSlashCommand {
range: 0..text.len(),
icon: IconName::Library,
label: "Default".into(),
metadata: None,
}],
text,
run_commands_in_text: true,

View file

@ -0,0 +1,109 @@
use crate::slash_command::file_command::{FileCommandMetadata, FileSlashCommand};
use anyhow::Result;
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
};
use collections::HashSet;
use futures::future;
use gpui::{Task, WeakView, WindowContext};
use language::{BufferSnapshot, LspAdapterDelegate};
use std::sync::{atomic::AtomicBool, Arc};
use text::OffsetRangeExt;
use workspace::Workspace;
pub(crate) struct DeltaSlashCommand;
impl SlashCommand for DeltaSlashCommand {
fn name(&self) -> String {
"delta".into()
}
fn description(&self) -> String {
"re-insert changed files".into()
}
fn menu_text(&self) -> String {
"Re-insert Changed Files".into()
}
fn requires_argument(&self) -> bool {
false
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancellation_flag: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
unimplemented!()
}
fn run(
self: Arc<Self>,
_arguments: &[String],
context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<Result<SlashCommandOutput>> {
let mut paths = HashSet::default();
let mut file_command_old_outputs = Vec::new();
let mut file_command_new_outputs = Vec::new();
for section in context_slash_command_output_sections.iter().rev() {
if let Some(metadata) = section
.metadata
.as_ref()
.and_then(|value| serde_json::from_value::<FileCommandMetadata>(value.clone()).ok())
{
if paths.insert(metadata.path.clone()) {
file_command_old_outputs.push(
context_buffer
.as_rope()
.slice(section.range.to_offset(&context_buffer)),
);
file_command_new_outputs.push(Arc::new(FileSlashCommand).run(
&[metadata.path.clone()],
context_slash_command_output_sections,
context_buffer.clone(),
workspace.clone(),
delegate.clone(),
cx,
));
}
}
}
cx.background_executor().spawn(async move {
let mut output = SlashCommandOutput::default();
let file_command_new_outputs = future::join_all(file_command_new_outputs).await;
for (old_text, new_output) in file_command_old_outputs
.into_iter()
.zip(file_command_new_outputs)
{
if let Ok(new_output) = new_output {
if let Some(file_command_range) = new_output.sections.first() {
let new_text = &new_output.text[file_command_range.range.clone()];
if old_text.chars().ne(new_text.chars()) {
output.sections.extend(new_output.sections.into_iter().map(
|section| SlashCommandOutputSection {
range: output.text.len() + section.range.start
..output.text.len() + section.range.end,
icon: section.icon,
label: section.label,
metadata: section.metadata,
},
));
output.text.push_str(&new_output.text);
}
}
}
}
Ok(output)
})
}
}

View file

@ -9,10 +9,9 @@ use language::{
};
use project::{DiagnosticSummary, PathMatchCandidateSet, Project};
use rope::Point;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::{
ops::Range,
fmt::Write,
path::{Path, PathBuf},
sync::{atomic::AtomicBool, Arc},
};
use ui::prelude::*;
@ -163,6 +162,8 @@ impl SlashCommand for DiagnosticsSlashCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -175,68 +176,7 @@ impl SlashCommand for DiagnosticsSlashCommand {
let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
cx.spawn(move |_| async move {
let Some((text, sections)) = task.await? else {
return Ok(SlashCommandOutput {
sections: vec![SlashCommandOutputSection {
range: 0..1,
icon: IconName::Library,
label: "No Diagnostics".into(),
}],
text: "\n".to_string(),
run_commands_in_text: true,
});
};
let sections = sections
.into_iter()
.map(|(range, placeholder_type)| SlashCommandOutputSection {
range,
icon: match placeholder_type {
PlaceholderType::Root(_, _) => IconName::Warning,
PlaceholderType::File(_) => IconName::File,
PlaceholderType::Diagnostic(DiagnosticType::Error, _) => IconName::XCircle,
PlaceholderType::Diagnostic(DiagnosticType::Warning, _) => {
IconName::Warning
}
},
label: match placeholder_type {
PlaceholderType::Root(summary, source) => {
let mut label = String::new();
label.push_str("Diagnostics");
if let Some(source) = source {
write!(label, " ({})", source).unwrap();
}
if summary.error_count > 0 || summary.warning_count > 0 {
label.push(':');
if summary.error_count > 0 {
write!(label, " {} errors", summary.error_count).unwrap();
if summary.warning_count > 0 {
label.push_str(",");
}
}
if summary.warning_count > 0 {
write!(label, " {} warnings", summary.warning_count).unwrap();
}
}
label.into()
}
PlaceholderType::File(file_path) => file_path.into(),
PlaceholderType::Diagnostic(_, message) => message.into(),
},
})
.collect();
Ok(SlashCommandOutput {
text,
sections,
run_commands_in_text: false,
})
})
cx.spawn(move |_| async move { task.await?.ok_or_else(|| anyhow!("No diagnostics found")) })
}
}
@ -277,7 +217,7 @@ fn collect_diagnostics(
project: Model<Project>,
options: Options,
cx: &mut AppContext,
) -> Task<Result<Option<(String, Vec<(Range<usize>, PlaceholderType)>)>>> {
) -> Task<Result<Option<SlashCommandOutput>>> {
let error_source = if let Some(path_matcher) = &options.path_matcher {
debug_assert_eq!(path_matcher.sources().len(), 1);
Some(path_matcher.sources().first().cloned().unwrap_or_default())
@ -318,13 +258,13 @@ fn collect_diagnostics(
.collect();
cx.spawn(|mut cx| async move {
let mut text = String::new();
let mut output = SlashCommandOutput::default();
if let Some(error_source) = error_source.as_ref() {
writeln!(text, "diagnostics: {}", error_source).unwrap();
writeln!(output.text, "diagnostics: {}", error_source).unwrap();
} else {
writeln!(text, "diagnostics").unwrap();
writeln!(output.text, "diagnostics").unwrap();
}
let mut sections: Vec<(Range<usize>, PlaceholderType)> = Vec::new();
let mut project_summary = DiagnosticSummary::default();
for (project_path, path, summary) in diagnostic_summaries {
@ -341,10 +281,10 @@ fn collect_diagnostics(
continue;
}
let last_end = text.len();
let last_end = output.text.len();
let file_path = path.to_string_lossy().to_string();
if !glob_is_exact_file_match {
writeln!(&mut text, "{file_path}").unwrap();
writeln!(&mut output.text, "{file_path}").unwrap();
}
if let Some(buffer) = project_handle
@ -352,75 +292,73 @@ fn collect_diagnostics(
.await
.log_err()
{
collect_buffer_diagnostics(
&mut text,
&mut sections,
cx.read_model(&buffer, |buffer, _| buffer.snapshot())?,
options.include_warnings,
);
let snapshot = cx.read_model(&buffer, |buffer, _| buffer.snapshot())?;
collect_buffer_diagnostics(&mut output, &snapshot, options.include_warnings);
}
if !glob_is_exact_file_match {
sections.push((
last_end..text.len().saturating_sub(1),
PlaceholderType::File(file_path),
))
output.sections.push(SlashCommandOutputSection {
range: last_end..output.text.len().saturating_sub(1),
icon: IconName::File,
label: file_path.into(),
metadata: None,
});
}
}
// No diagnostics found
if sections.is_empty() {
if output.sections.is_empty() {
return Ok(None);
}
sections.push((
0..text.len(),
PlaceholderType::Root(project_summary, error_source),
));
Ok(Some((text, sections)))
let mut label = String::new();
label.push_str("Diagnostics");
if let Some(source) = error_source {
write!(label, " ({})", source).unwrap();
}
if project_summary.error_count > 0 || project_summary.warning_count > 0 {
label.push(':');
if project_summary.error_count > 0 {
write!(label, " {} errors", project_summary.error_count).unwrap();
if project_summary.warning_count > 0 {
label.push_str(",");
}
}
if project_summary.warning_count > 0 {
write!(label, " {} warnings", project_summary.warning_count).unwrap();
}
}
output.sections.insert(
0,
SlashCommandOutputSection {
range: 0..output.text.len(),
icon: IconName::Warning,
label: label.into(),
metadata: None,
},
);
Ok(Some(output))
})
}
pub fn buffer_has_error_diagnostics(snapshot: &BufferSnapshot) -> bool {
for (_, group) in snapshot.diagnostic_groups(None) {
let entry = &group.entries[group.primary_ix];
if entry.diagnostic.severity == DiagnosticSeverity::ERROR {
return true;
}
}
false
}
pub fn write_single_file_diagnostics(
output: &mut String,
path: Option<&Path>,
pub fn collect_buffer_diagnostics(
output: &mut SlashCommandOutput,
snapshot: &BufferSnapshot,
) -> bool {
if let Some(path) = path {
if buffer_has_error_diagnostics(&snapshot) {
output.push_str("/diagnostics ");
output.push_str(&path.to_string_lossy());
return true;
}
}
false
}
fn collect_buffer_diagnostics(
text: &mut String,
sections: &mut Vec<(Range<usize>, PlaceholderType)>,
snapshot: BufferSnapshot,
include_warnings: bool,
) {
for (_, group) in snapshot.diagnostic_groups(None) {
let entry = &group.entries[group.primary_ix];
collect_diagnostic(text, sections, entry, &snapshot, include_warnings)
collect_diagnostic(output, entry, &snapshot, include_warnings)
}
}
fn collect_diagnostic(
text: &mut String,
sections: &mut Vec<(Range<usize>, PlaceholderType)>,
output: &mut SlashCommandOutput,
entry: &DiagnosticEntry<Anchor>,
snapshot: &BufferSnapshot,
include_warnings: bool,
@ -428,17 +366,17 @@ fn collect_diagnostic(
const EXCERPT_EXPANSION_SIZE: u32 = 2;
const MAX_MESSAGE_LENGTH: usize = 2000;
let ty = match entry.diagnostic.severity {
let (ty, icon) = match entry.diagnostic.severity {
DiagnosticSeverity::WARNING => {
if !include_warnings {
return;
}
DiagnosticType::Warning
("warning", IconName::Warning)
}
DiagnosticSeverity::ERROR => DiagnosticType::Error,
DiagnosticSeverity::ERROR => ("error", IconName::XCircle),
_ => return,
};
let prev_len = text.len();
let prev_len = output.text.len();
let range = entry.range.to_point(snapshot);
let diagnostic_row_number = range.start.row + 1;
@ -448,11 +386,11 @@ fn collect_diagnostic(
let excerpt_range =
Point::new(start_row, 0).to_offset(&snapshot)..Point::new(end_row, 0).to_offset(&snapshot);
text.push_str("```");
output.text.push_str("```");
if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
text.push_str(&language_name);
output.text.push_str(&language_name);
}
text.push('\n');
output.text.push('\n');
let mut buffer_text = String::new();
for chunk in snapshot.text_for_range(excerpt_range) {
@ -461,46 +399,26 @@ fn collect_diagnostic(
for (i, line) in buffer_text.lines().enumerate() {
let line_number = start_row + i as u32 + 1;
writeln!(text, "{}", line).unwrap();
writeln!(output.text, "{}", line).unwrap();
if line_number == diagnostic_row_number {
text.push_str("//");
let prev_len = text.len();
write!(text, " {}: ", ty.as_str()).unwrap();
let padding = text.len() - prev_len;
output.text.push_str("//");
let prev_len = output.text.len();
write!(output.text, " {}: ", ty).unwrap();
let padding = output.text.len() - prev_len;
let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
.replace('\n', format!("\n//{:padding$}", "").as_str());
writeln!(text, "{message}").unwrap();
writeln!(output.text, "{message}").unwrap();
}
}
writeln!(text, "```").unwrap();
sections.push((
prev_len..text.len().saturating_sub(1),
PlaceholderType::Diagnostic(ty, entry.diagnostic.message.clone()),
))
}
#[derive(Clone)]
pub enum PlaceholderType {
Root(DiagnosticSummary, Option<String>),
File(String),
Diagnostic(DiagnosticType, String),
}
#[derive(Copy, Clone)]
pub enum DiagnosticType {
Warning,
Error,
}
impl DiagnosticType {
pub fn as_str(&self) -> &'static str {
match self {
DiagnosticType::Warning => "warning",
DiagnosticType::Error => "error",
}
}
writeln!(output.text, "```").unwrap();
output.sections.push(SlashCommandOutputSection {
range: prev_len..output.text.len().saturating_sub(1),
icon,
label: entry.diagnostic.message.clone().into(),
metadata: None,
});
}

View file

@ -12,7 +12,7 @@ use indexed_docs::{
DocsDotRsProvider, IndexedDocsRegistry, IndexedDocsStore, LocalRustdocProvider, PackageName,
ProviderId,
};
use language::LspAdapterDelegate;
use language::{BufferSnapshot, LspAdapterDelegate};
use project::{Project, ProjectPath};
use ui::prelude::*;
use util::{maybe, ResultExt};
@ -269,6 +269,8 @@ impl SlashCommand for DocsSlashCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -349,6 +351,7 @@ impl SlashCommand for DocsSlashCommand {
range,
icon: IconName::FileDoc,
label: format!("docs ({provider}): {key}",).into(),
metadata: None,
})
.collect(),
run_commands_in_text: false,

View file

@ -11,7 +11,7 @@ use futures::AsyncReadExt;
use gpui::{Task, WeakView};
use html_to_markdown::{convert_html_to_markdown, markdown, TagHandler};
use http_client::{AsyncBody, HttpClient, HttpClientWithUrl};
use language::LspAdapterDelegate;
use language::{BufferSnapshot, LspAdapterDelegate};
use ui::prelude::*;
use workspace::Workspace;
@ -128,6 +128,8 @@ impl SlashCommand for FetchSlashCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -161,6 +163,7 @@ impl SlashCommand for FetchSlashCommand {
range,
icon: IconName::AtSign,
label: format!("fetch {}", url).into(),
metadata: None,
}],
run_commands_in_text: false,
})

View file

@ -1,10 +1,11 @@
use super::{diagnostics_command::write_single_file_diagnostics, SlashCommand, SlashCommandOutput};
use super::{diagnostics_command::collect_buffer_diagnostics, SlashCommand, SlashCommandOutput};
use anyhow::{anyhow, Context as _, Result};
use assistant_slash_command::{AfterCompletion, ArgumentCompletion, SlashCommandOutputSection};
use fuzzy::PathMatch;
use gpui::{AppContext, Model, Task, View, WeakView};
use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
use project::{PathMatchCandidateSet, Project};
use serde::{Deserialize, Serialize};
use std::{
fmt::Write,
ops::Range,
@ -175,6 +176,8 @@ impl SlashCommand for FileSlashCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -187,54 +190,15 @@ impl SlashCommand for FileSlashCommand {
return Task::ready(Err(anyhow!("missing path")));
};
let task = collect_files(workspace.read(cx).project().clone(), arguments, cx);
cx.foreground_executor().spawn(async move {
let output = task.await?;
Ok(SlashCommandOutput {
text: output.completion_text,
sections: output
.files
.into_iter()
.map(|file| {
build_entry_output_section(
file.range_in_text,
Some(&file.path),
file.entry_type == EntryType::Directory,
None,
)
})
.collect(),
run_commands_in_text: true,
})
})
collect_files(workspace.read(cx).project().clone(), arguments, cx)
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum EntryType {
File,
Directory,
}
#[derive(Clone, PartialEq, Debug)]
struct FileCommandOutput {
completion_text: String,
files: Vec<OutputFile>,
}
#[derive(Clone, PartialEq, Debug)]
struct OutputFile {
range_in_text: Range<usize>,
path: PathBuf,
entry_type: EntryType,
}
fn collect_files(
project: Model<Project>,
glob_inputs: &[String],
cx: &mut AppContext,
) -> Task<Result<FileCommandOutput>> {
) -> Task<Result<SlashCommandOutput>> {
let Ok(matchers) = glob_inputs
.into_iter()
.map(|glob_input| {
@ -254,8 +218,7 @@ fn collect_files(
.collect::<Vec<_>>();
cx.spawn(|mut cx| async move {
let mut text = String::new();
let mut ranges = Vec::new();
let mut output = SlashCommandOutput::default();
for snapshot in snapshots {
let worktree_id = snapshot.id();
let mut directory_stack: Vec<(Arc<Path>, String, usize)> = Vec::new();
@ -279,11 +242,12 @@ fn collect_files(
break;
}
let (_, entry_name, start) = directory_stack.pop().unwrap();
ranges.push(OutputFile {
range_in_text: start..text.len().saturating_sub(1),
path: PathBuf::from(entry_name),
entry_type: EntryType::Directory,
});
output.sections.push(build_entry_output_section(
start..output.text.len().saturating_sub(1),
Some(&PathBuf::from(entry_name)),
true,
None,
));
}
let filename = entry
@ -315,21 +279,23 @@ fn collect_files(
continue;
}
let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
let entry_start = text.len();
let entry_start = output.text.len();
if prefix_paths.is_empty() {
if is_top_level_directory {
text.push_str(&path_including_worktree_name.to_string_lossy());
output
.text
.push_str(&path_including_worktree_name.to_string_lossy());
is_top_level_directory = false;
} else {
text.push_str(&filename);
output.text.push_str(&filename);
}
directory_stack.push((entry.path.clone(), filename, entry_start));
} else {
let entry_name = format!("{}/{}", prefix_paths, &filename);
text.push_str(&entry_name);
output.text.push_str(&entry_name);
directory_stack.push((entry.path.clone(), entry_name, entry_start));
}
text.push('\n');
output.text.push('\n');
} else if entry.is_file() {
let Some(open_buffer_task) = project_handle
.update(&mut cx, |project, cx| {
@ -340,28 +306,13 @@ fn collect_files(
continue;
};
if let Some(buffer) = open_buffer_task.await.log_err() {
let buffer_snapshot =
cx.read_model(&buffer, |buffer, _| buffer.snapshot())?;
let prev_len = text.len();
collect_file_content(
&mut text,
&buffer_snapshot,
path_including_worktree_name.to_string_lossy().to_string(),
);
text.push('\n');
if !write_single_file_diagnostics(
&mut text,
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
append_buffer_to_output(
&snapshot,
Some(&path_including_worktree_name),
&buffer_snapshot,
) {
text.pop();
}
ranges.push(OutputFile {
range_in_text: prev_len..text.len(),
path: path_including_worktree_name,
entry_type: EntryType::File,
});
text.push('\n');
&mut output,
)
.log_err();
}
}
}
@ -371,42 +322,26 @@ fn collect_files(
let mut root_path = PathBuf::new();
root_path.push(snapshot.root_name());
root_path.push(&dir);
ranges.push(OutputFile {
range_in_text: start..text.len(),
path: root_path,
entry_type: EntryType::Directory,
});
output.sections.push(build_entry_output_section(
start..output.text.len(),
Some(&root_path),
true,
None,
));
} else {
ranges.push(OutputFile {
range_in_text: start..text.len(),
path: PathBuf::from(entry.as_str()),
entry_type: EntryType::Directory,
});
output.sections.push(build_entry_output_section(
start..output.text.len(),
Some(&PathBuf::from(entry.as_str())),
true,
None,
));
}
}
}
Ok(FileCommandOutput {
completion_text: text,
files: ranges,
})
Ok(output)
})
}
fn collect_file_content(buffer: &mut String, snapshot: &BufferSnapshot, filename: String) {
let mut content = snapshot.text();
LineEnding::normalize(&mut content);
buffer.reserve(filename.len() + content.len() + 9);
buffer.push_str(&codeblock_fence_for_path(
Some(&PathBuf::from(filename)),
None,
));
buffer.push_str(&content);
if !buffer.ends_with('\n') {
buffer.push('\n');
}
buffer.push_str("```");
}
pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<Range<u32>>) -> String {
let mut text = String::new();
write!(text, "```").unwrap();
@ -429,6 +364,11 @@ pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<Range<u32
text
}
#[derive(Serialize, Deserialize)]
pub struct FileCommandMetadata {
pub path: String,
}
pub fn build_entry_output_section(
range: Range<usize>,
path: Option<&Path>,
@ -454,6 +394,16 @@ pub fn build_entry_output_section(
range,
icon,
label: label.into(),
metadata: if is_directory {
None
} else {
path.and_then(|path| {
serde_json::to_value(FileCommandMetadata {
path: path.to_string_lossy().to_string(),
})
.ok()
})
},
}
}
@ -539,6 +489,36 @@ mod custom_path_matcher {
}
}
pub fn append_buffer_to_output(
buffer: &BufferSnapshot,
path: Option<&Path>,
output: &mut SlashCommandOutput,
) -> Result<()> {
let prev_len = output.text.len();
let mut content = buffer.text();
LineEnding::normalize(&mut content);
output.text.push_str(&codeblock_fence_for_path(path, None));
output.text.push_str(&content);
if !output.text.ends_with('\n') {
output.text.push('\n');
}
output.text.push_str("```");
output.text.push('\n');
let section_ix = output.sections.len();
collect_buffer_diagnostics(output, buffer, false);
output.sections.insert(
section_ix,
build_entry_output_section(prev_len..output.text.len(), path, false, None),
);
output.text.push('\n');
Ok(())
}
#[cfg(test)]
mod test {
use fs::FakeFs;
@ -591,9 +571,9 @@ mod test {
.await
.unwrap();
assert!(result_1.completion_text.starts_with("root/dir"));
assert!(result_1.text.starts_with("root/dir"));
// 4 files + 2 directories
assert_eq!(6, result_1.files.len());
assert_eq!(result_1.sections.len(), 6);
let result_2 = cx
.update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx))
@ -607,9 +587,9 @@ mod test {
.await
.unwrap();
assert!(result.completion_text.starts_with("root/dir"));
assert!(result.text.starts_with("root/dir"));
// 5 files + 2 directories
assert_eq!(7, result.files.len());
assert_eq!(result.sections.len(), 7);
// Ensure that the project lasts until after the last await
drop(project);
@ -654,36 +634,27 @@ mod test {
.unwrap();
// Sanity check
assert!(result.completion_text.starts_with("zed/assets/themes\n"));
assert_eq!(7, result.files.len());
assert!(result.text.starts_with("zed/assets/themes\n"));
assert_eq!(result.sections.len(), 7);
// Ensure that full file paths are included in the real output
assert!(result
.completion_text
.contains("zed/assets/themes/andromeda/LICENSE"));
assert!(result
.completion_text
.contains("zed/assets/themes/ayu/LICENSE"));
assert!(result
.completion_text
.contains("zed/assets/themes/summercamp/LICENSE"));
assert!(result.text.contains("zed/assets/themes/andromeda/LICENSE"));
assert!(result.text.contains("zed/assets/themes/ayu/LICENSE"));
assert!(result.text.contains("zed/assets/themes/summercamp/LICENSE"));
assert_eq!("summercamp", result.files[5].path.to_string_lossy());
assert_eq!(result.sections[5].label, "summercamp");
// Ensure that things are in descending order, with properly relativized paths
assert_eq!(
"zed/assets/themes/andromeda/LICENSE",
result.files[0].path.to_string_lossy()
result.sections[0].label,
"zed/assets/themes/andromeda/LICENSE"
);
assert_eq!("andromeda", result.files[1].path.to_string_lossy());
assert_eq!(result.sections[1].label, "andromeda");
assert_eq!(result.sections[2].label, "zed/assets/themes/ayu/LICENSE");
assert_eq!(result.sections[3].label, "ayu");
assert_eq!(
"zed/assets/themes/ayu/LICENSE",
result.files[2].path.to_string_lossy()
);
assert_eq!("ayu", result.files[3].path.to_string_lossy());
assert_eq!(
"zed/assets/themes/summercamp/LICENSE",
result.files[4].path.to_string_lossy()
result.sections[4].label,
"zed/assets/themes/summercamp/LICENSE"
);
// Ensure that the project lasts until after the last await
@ -723,27 +694,24 @@ mod test {
.await
.unwrap();
assert!(result.completion_text.starts_with("zed/assets/themes\n"));
assert!(result.text.starts_with("zed/assets/themes\n"));
assert_eq!(result.sections[0].label, "zed/assets/themes/LICENSE");
assert_eq!(
"zed/assets/themes/LICENSE",
result.files[0].path.to_string_lossy()
result.sections[1].label,
"zed/assets/themes/summercamp/LICENSE"
);
assert_eq!(
"zed/assets/themes/summercamp/LICENSE",
result.files[1].path.to_string_lossy()
result.sections[2].label,
"zed/assets/themes/summercamp/subdir/LICENSE"
);
assert_eq!(
"zed/assets/themes/summercamp/subdir/LICENSE",
result.files[2].path.to_string_lossy()
result.sections[3].label,
"zed/assets/themes/summercamp/subdir/subsubdir/LICENSE"
);
assert_eq!(
"zed/assets/themes/summercamp/subdir/subsubdir/LICENSE",
result.files[3].path.to_string_lossy()
);
assert_eq!("subsubdir", result.files[4].path.to_string_lossy());
assert_eq!("subdir", result.files[5].path.to_string_lossy());
assert_eq!("summercamp", result.files[6].path.to_string_lossy());
assert_eq!("zed/assets/themes", result.files[7].path.to_string_lossy());
assert_eq!(result.sections[4].label, "subsubdir");
assert_eq!(result.sections[5].label, "subdir");
assert_eq!(result.sections[6].label, "summercamp");
assert_eq!(result.sections[7].label, "zed/assets/themes");
// Ensure that the project lasts until after the last await
drop(project);

View file

@ -7,7 +7,7 @@ use assistant_slash_command::{
};
use chrono::Local;
use gpui::{Task, WeakView};
use language::LspAdapterDelegate;
use language::{BufferSnapshot, LspAdapterDelegate};
use ui::prelude::*;
use workspace::Workspace;
@ -43,6 +43,8 @@ impl SlashCommand for NowSlashCommand {
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
_cx: &mut WindowContext,
@ -57,6 +59,7 @@ impl SlashCommand for NowSlashCommand {
range,
icon: IconName::CountdownTimer,
label: now.to_rfc2822().into(),
metadata: None,
}],
run_commands_in_text: false,
}))

View file

@ -3,7 +3,7 @@ use anyhow::{anyhow, Context, Result};
use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection};
use fs::Fs;
use gpui::{AppContext, Model, Task, WeakView};
use language::LspAdapterDelegate;
use language::{BufferSnapshot, LspAdapterDelegate};
use project::{Project, ProjectPath};
use std::{
fmt::Write,
@ -118,6 +118,8 @@ impl SlashCommand for ProjectSlashCommand {
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -140,6 +142,7 @@ impl SlashCommand for ProjectSlashCommand {
range,
icon: IconName::FileTree,
label: "Project".into(),
metadata: None,
}],
run_commands_in_text: false,
})

View file

@ -3,7 +3,7 @@ use crate::prompt_library::PromptStore;
use anyhow::{anyhow, Context, Result};
use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection};
use gpui::{Task, WeakView};
use language::LspAdapterDelegate;
use language::{BufferSnapshot, LspAdapterDelegate};
use std::sync::{atomic::AtomicBool, Arc};
use ui::prelude::*;
use workspace::Workspace;
@ -56,6 +56,8 @@ impl SlashCommand for PromptSlashCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -95,6 +97,7 @@ impl SlashCommand for PromptSlashCommand {
range,
icon: IconName::Library,
label: title,
metadata: None,
}],
run_commands_in_text: true,
})

View file

@ -60,6 +60,8 @@ impl SlashCommand for SearchSlashCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: language::BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -168,6 +170,7 @@ impl SlashCommand for SearchSlashCommand {
range: 0..text.len(),
icon: IconName::MagnifyingGlass,
label: query,
metadata: None,
});
SlashCommandOutput {

View file

@ -3,7 +3,7 @@ use anyhow::{anyhow, Context as _, Result};
use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection};
use editor::Editor;
use gpui::{Task, WeakView};
use language::LspAdapterDelegate;
use language::{BufferSnapshot, LspAdapterDelegate};
use std::sync::Arc;
use std::{path::Path, sync::atomic::AtomicBool};
use ui::{IconName, WindowContext};
@ -41,6 +41,8 @@ impl SlashCommand for OutlineSlashCommand {
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -77,6 +79,7 @@ impl SlashCommand for OutlineSlashCommand {
range: 0..outline_text.len(),
icon: IconName::ListTree,
label: path.to_string_lossy().to_string().into(),
metadata: None,
}],
text: outline_text,
run_commands_in_text: false,

View file

@ -1,21 +1,17 @@
use super::{
diagnostics_command::write_single_file_diagnostics,
file_command::{build_entry_output_section, codeblock_fence_for_path},
SlashCommand, SlashCommandOutput,
};
use super::{file_command::append_buffer_to_output, SlashCommand, SlashCommandOutput};
use anyhow::{Context, Result};
use assistant_slash_command::ArgumentCompletion;
use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection};
use collections::{HashMap, HashSet};
use editor::Editor;
use futures::future::join_all;
use gpui::{Entity, Task, WeakView};
use language::{BufferSnapshot, CodeLabel, HighlightId, LspAdapterDelegate};
use std::{
fmt::Write,
path::PathBuf,
sync::{atomic::AtomicBool, Arc},
};
use ui::{ActiveTheme, WindowContext};
use util::ResultExt;
use workspace::Workspace;
pub(crate) struct TabSlashCommand;
@ -131,6 +127,8 @@ impl SlashCommand for TabSlashCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -144,40 +142,11 @@ impl SlashCommand for TabSlashCommand {
);
cx.background_executor().spawn(async move {
let mut sections = Vec::new();
let mut text = String::new();
let mut has_diagnostics = false;
let mut output = SlashCommandOutput::default();
for (full_path, buffer, _) in tab_items_search.await? {
let section_start_ix = text.len();
text.push_str(&codeblock_fence_for_path(full_path.as_deref(), None));
for chunk in buffer.as_rope().chunks() {
text.push_str(chunk);
}
if !text.ends_with('\n') {
text.push('\n');
}
writeln!(text, "```").unwrap();
if write_single_file_diagnostics(&mut text, full_path.as_deref(), &buffer) {
has_diagnostics = true;
}
if !text.ends_with('\n') {
text.push('\n');
}
let section_end_ix = text.len() - 1;
sections.push(build_entry_output_section(
section_start_ix..section_end_ix,
full_path.as_deref(),
false,
None,
));
append_buffer_to_output(&buffer, full_path.as_deref(), &mut output).log_err();
}
Ok(SlashCommandOutput {
text,
sections,
run_commands_in_text: has_diagnostics,
})
Ok(output)
})
}
}

View file

@ -6,7 +6,7 @@ use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
};
use gpui::{AppContext, Task, View, WeakView};
use language::{CodeLabel, LspAdapterDelegate};
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
use terminal_view::{terminal_panel::TerminalPanel, TerminalView};
use ui::prelude::*;
use workspace::{dock::Panel, Workspace};
@ -57,6 +57,8 @@ impl SlashCommand for TerminalSlashCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -91,6 +93,7 @@ impl SlashCommand for TerminalSlashCommand {
range,
icon: IconName::Terminal,
label: "Terminal".into(),
metadata: None,
}],
run_commands_in_text: false,
}))

View file

@ -8,7 +8,7 @@ use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
};
use gpui::{Task, WeakView};
use language::LspAdapterDelegate;
use language::{BufferSnapshot, LspAdapterDelegate};
use ui::prelude::*;
use workspace::Workspace;
@ -53,6 +53,8 @@ impl SlashCommand for WorkflowSlashCommand {
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -68,6 +70,7 @@ impl SlashCommand for WorkflowSlashCommand {
range,
icon: IconName::Route,
label: "Workflow".into(),
metadata: None,
}],
run_commands_in_text: false,
})

View file

@ -19,4 +19,5 @@ gpui.workspace = true
language.workspace = true
parking_lot.workspace = true
serde.workspace = true
serde_json.workspace = true
workspace.workspace = true

View file

@ -2,7 +2,7 @@ mod slash_command_registry;
use anyhow::Result;
use gpui::{AnyElement, AppContext, ElementId, SharedString, Task, WeakView, WindowContext};
use language::{CodeLabel, LspAdapterDelegate};
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt};
use serde::{Deserialize, Serialize};
pub use slash_command_registry::*;
use std::{
@ -77,6 +77,8 @@ pub trait SlashCommand: 'static + Send + Sync {
fn run(
self: Arc<Self>,
arguments: &[String],
context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
// TODO: We're just using the `LspAdapterDelegate` here because that is
// what the extension API is already expecting.
@ -94,7 +96,7 @@ pub type RenderFoldPlaceholder = Arc<
+ Fn(ElementId, Arc<dyn Fn(&mut WindowContext)>, &mut WindowContext) -> AnyElement,
>;
#[derive(Debug, Default)]
#[derive(Debug, Default, PartialEq)]
pub struct SlashCommandOutput {
pub text: String,
pub sections: Vec<SlashCommandOutputSection<usize>>,
@ -106,4 +108,11 @@ pub struct SlashCommandOutputSection<T> {
pub range: Range<T>,
pub icon: IconName,
pub label: SharedString,
pub metadata: Option<serde_json::Value>,
}
impl SlashCommandOutputSection<language::Anchor> {
pub fn is_valid(&self, buffer: &language::TextBuffer) -> bool {
self.range.start.is_valid(buffer) && !self.range.to_offset(buffer).is_empty()
}
}

View file

@ -6,7 +6,7 @@ use assistant_slash_command::{
};
use futures::FutureExt;
use gpui::{Task, WeakView, WindowContext};
use language::LspAdapterDelegate;
use language::{BufferSnapshot, LspAdapterDelegate};
use ui::prelude::*;
use wasmtime_wasi::WasiView;
use workspace::Workspace;
@ -82,6 +82,8 @@ impl SlashCommand for ExtensionSlashCommand {
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
@ -121,6 +123,7 @@ impl SlashCommand for ExtensionSlashCommand {
range: section.range.into(),
icon: IconName::Code,
label: section.label.into(),
metadata: None,
})
.collect(),
run_commands_in_text: false,

View file

@ -2390,6 +2390,7 @@ message SlashCommandOutputSection {
AnchorRange range = 1;
string icon_name = 2;
string label = 3;
optional string metadata = 4;
}
message ContextOperation {