Polish streaming slash commands (#20345)

This improves the experience in a few ways:

- It avoids merging slash command output sections that are adjacent.
- When hitting cmd-z, all the output from a command is undone at once.
- When deleting a pending command, it stops the command and prevents new
output from flowing in.

Release Notes:

- N/A
This commit is contained in:
Antonio Scandurra 2024-11-07 13:25:26 +01:00 committed by GitHub
parent e62d60c84c
commit 16cbff9118
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 140 additions and 106 deletions

View file

@ -77,8 +77,8 @@ use text::SelectionGoal;
use ui::{ use ui::{
prelude::*, prelude::*,
utils::{format_distance_from_now, DateTimeType}, utils::{format_distance_from_now, DateTimeType},
Avatar, ButtonLike, ContextMenu, Disclosure, ElevationIndex, IconButtonShape, KeyBinding, Avatar, ButtonLike, ContextMenu, Disclosure, ElevationIndex, KeyBinding, ListItem,
ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip,
}; };
use util::{maybe, ResultExt}; use util::{maybe, ResultExt};
use workspace::{ use workspace::{
@ -2111,7 +2111,6 @@ impl ContextEditor {
command_id: SlashCommandId, command_id: SlashCommandId,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
let context_editor = cx.view().downgrade();
self.editor.update(cx, |editor, cx| { self.editor.update(cx, |editor, cx| {
if let Some(invoked_slash_command) = if let Some(invoked_slash_command) =
self.context.read(cx).invoked_slash_command(&command_id) self.context.read(cx).invoked_slash_command(&command_id)
@ -2152,7 +2151,7 @@ impl ContextEditor {
.anchor_in_excerpt(excerpt_id, invoked_slash_command.range.end) .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.end)
.unwrap(); .unwrap();
let fold_placeholder = let fold_placeholder =
invoked_slash_command_fold_placeholder(command_id, context, context_editor); invoked_slash_command_fold_placeholder(command_id, context);
let crease_ids = editor.insert_creases( let crease_ids = editor.insert_creases(
[Crease::new( [Crease::new(
crease_start..crease_end, crease_start..crease_end,
@ -2352,6 +2351,7 @@ impl ContextEditor {
section.icon, section.icon,
section.label.clone(), section.label.clone(),
), ),
merge_adjacent: false,
..Default::default() ..Default::default()
}, },
render_slash_command_output_toggle, render_slash_command_output_toggle,
@ -4963,6 +4963,7 @@ fn quote_selection_fold_placeholder(title: String, editor: WeakView<Editor>) ->
.into_any_element() .into_any_element()
} }
}), }),
merge_adjacent: false,
..Default::default() ..Default::default()
} }
} }
@ -5096,7 +5097,6 @@ enum PendingSlashCommand {}
fn invoked_slash_command_fold_placeholder( fn invoked_slash_command_fold_placeholder(
command_id: SlashCommandId, command_id: SlashCommandId,
context: WeakModel<Context>, context: WeakModel<Context>,
context_editor: WeakView<ContextEditor>,
) -> FoldPlaceholder { ) -> FoldPlaceholder {
FoldPlaceholder { FoldPlaceholder {
constrain_width: false, constrain_width: false,
@ -5126,37 +5126,11 @@ fn invoked_slash_command_fold_placeholder(
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))), |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
)) ))
} }
InvokedSlashCommandStatus::Error(message) => parent InvokedSlashCommandStatus::Error(message) => parent.child(
.child( Label::new(format!("error: {message}"))
Label::new(format!("error: {message}")) .single_line()
.single_line() .color(Color::Error),
.color(Color::Error), ),
)
.child(
IconButton::new("dismiss-error", IconName::Close)
.shape(IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.on_click({
let context_editor = context_editor.clone();
move |_event, cx| {
context_editor
.update(cx, |context_editor, cx| {
context_editor.editor.update(cx, |editor, cx| {
editor.remove_creases(
HashSet::from_iter(
context_editor
.invoked_slash_command_creases
.remove(&command_id),
),
cx,
);
})
})
.log_err();
}
}),
),
InvokedSlashCommandStatus::Finished => parent, InvokedSlashCommandStatus::Finished => parent,
}) })
.into_any_element() .into_any_element()

View file

@ -545,7 +545,6 @@ pub struct Context {
parsed_slash_commands: Vec<ParsedSlashCommand>, parsed_slash_commands: Vec<ParsedSlashCommand>,
invoked_slash_commands: HashMap<SlashCommandId, InvokedSlashCommand>, invoked_slash_commands: HashMap<SlashCommandId, InvokedSlashCommand>,
edits_since_last_parse: language::Subscription, edits_since_last_parse: language::Subscription,
finished_slash_commands: HashSet<SlashCommandId>,
slash_command_output_sections: Vec<SlashCommandOutputSection<language::Anchor>>, slash_command_output_sections: Vec<SlashCommandOutputSection<language::Anchor>>,
pending_tool_uses_by_id: HashMap<Arc<str>, PendingToolUse>, pending_tool_uses_by_id: HashMap<Arc<str>, PendingToolUse>,
message_anchors: Vec<MessageAnchor>, message_anchors: Vec<MessageAnchor>,
@ -647,7 +646,6 @@ impl Context {
messages_metadata: Default::default(), messages_metadata: Default::default(),
parsed_slash_commands: Vec::new(), parsed_slash_commands: Vec::new(),
invoked_slash_commands: HashMap::default(), invoked_slash_commands: HashMap::default(),
finished_slash_commands: HashSet::default(),
pending_tool_uses_by_id: HashMap::default(), pending_tool_uses_by_id: HashMap::default(),
slash_command_output_sections: Vec::new(), slash_command_output_sections: Vec::new(),
edits_since_last_parse: edits_since_last_slash_command_parse, edits_since_last_parse: edits_since_last_slash_command_parse,
@ -905,6 +903,8 @@ impl Context {
name: name.into(), name: name.into(),
range: output_range, range: output_range,
status: InvokedSlashCommandStatus::Running(Task::ready(())), status: InvokedSlashCommandStatus::Running(Task::ready(())),
transaction: None,
timestamp: id.0,
}, },
); );
cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id }); cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id });
@ -921,10 +921,14 @@ impl Context {
} }
} }
ContextOperation::SlashCommandFinished { ContextOperation::SlashCommandFinished {
id, error_message, .. id,
error_message,
timestamp,
..
} => { } => {
if self.finished_slash_commands.insert(id) { if let Some(slash_command) = self.invoked_slash_commands.get_mut(&id) {
if let Some(slash_command) = self.invoked_slash_commands.get_mut(&id) { if timestamp > slash_command.timestamp {
slash_command.timestamp = timestamp;
match error_message { match error_message {
Some(message) => { Some(message) => {
slash_command.status = slash_command.status =
@ -934,9 +938,8 @@ impl Context {
slash_command.status = InvokedSlashCommandStatus::Finished; slash_command.status = InvokedSlashCommandStatus::Finished;
} }
} }
cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id });
} }
cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id });
} }
} }
ContextOperation::BufferOperation(_) => unreachable!(), ContextOperation::BufferOperation(_) => unreachable!(),
@ -1370,8 +1373,8 @@ impl Context {
}) })
.peekable(); .peekable();
let mut removed_slash_command_ranges = Vec::new(); let mut removed_parsed_slash_command_ranges = Vec::new();
let mut updated_slash_commands = Vec::new(); let mut updated_parsed_slash_commands = Vec::new();
let mut removed_patches = Vec::new(); let mut removed_patches = Vec::new();
let mut updated_patches = Vec::new(); let mut updated_patches = Vec::new();
while let Some(mut row_range) = row_ranges.next() { while let Some(mut row_range) = row_ranges.next() {
@ -1393,10 +1396,11 @@ impl Context {
self.reparse_slash_commands_in_range( self.reparse_slash_commands_in_range(
start..end, start..end,
&buffer, &buffer,
&mut updated_slash_commands, &mut updated_parsed_slash_commands,
&mut removed_slash_command_ranges, &mut removed_parsed_slash_command_ranges,
cx, cx,
); );
self.invalidate_pending_slash_commands(&buffer, cx);
self.reparse_patches_in_range( self.reparse_patches_in_range(
start..end, start..end,
&buffer, &buffer,
@ -1406,10 +1410,12 @@ impl Context {
); );
} }
if !updated_slash_commands.is_empty() || !removed_slash_command_ranges.is_empty() { if !updated_parsed_slash_commands.is_empty()
|| !removed_parsed_slash_command_ranges.is_empty()
{
cx.emit(ContextEvent::ParsedSlashCommandsUpdated { cx.emit(ContextEvent::ParsedSlashCommandsUpdated {
removed: removed_slash_command_ranges, removed: removed_parsed_slash_command_ranges,
updated: updated_slash_commands, updated: updated_parsed_slash_commands,
}); });
} }
@ -1478,6 +1484,37 @@ impl Context {
removed.extend(removed_commands.map(|command| command.source_range)); removed.extend(removed_commands.map(|command| command.source_range));
} }
fn invalidate_pending_slash_commands(
&mut self,
buffer: &BufferSnapshot,
cx: &mut ModelContext<Self>,
) {
let mut invalidated_command_ids = Vec::new();
for (&command_id, command) in self.invoked_slash_commands.iter_mut() {
if !matches!(command.status, InvokedSlashCommandStatus::Finished)
&& (!command.range.start.is_valid(buffer) || !command.range.end.is_valid(buffer))
{
command.status = InvokedSlashCommandStatus::Finished;
cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id });
invalidated_command_ids.push(command_id);
}
}
for command_id in invalidated_command_ids {
let version = self.version.clone();
let timestamp = self.next_timestamp();
self.push_op(
ContextOperation::SlashCommandFinished {
id: command_id,
timestamp,
error_message: None,
version: version.clone(),
},
cx,
);
}
}
fn reparse_patches_in_range( fn reparse_patches_in_range(
&mut self, &mut self,
range: Range<text::Anchor>, range: Range<text::Anchor>,
@ -1814,13 +1851,16 @@ impl Context {
const PENDING_OUTPUT_END_MARKER: &str = ""; const PENDING_OUTPUT_END_MARKER: &str = "";
let (command_range, command_source_range, insert_position) = let (command_range, command_source_range, insert_position, first_transaction) =
self.buffer.update(cx, |buffer, cx| { self.buffer.update(cx, |buffer, cx| {
let command_source_range = command_source_range.to_offset(buffer); let command_source_range = command_source_range.to_offset(buffer);
let mut insertion = format!("\n{PENDING_OUTPUT_END_MARKER}"); let mut insertion = format!("\n{PENDING_OUTPUT_END_MARKER}");
if ensure_trailing_newline { if ensure_trailing_newline {
insertion.push('\n'); insertion.push('\n');
} }
buffer.finalize_last_transaction();
buffer.start_transaction();
buffer.edit( buffer.edit(
[( [(
command_source_range.end..command_source_range.end, command_source_range.end..command_source_range.end,
@ -1829,14 +1869,22 @@ impl Context {
None, None,
cx, cx,
); );
let first_transaction = buffer.end_transaction(cx).unwrap();
buffer.finalize_last_transaction();
let insert_position = buffer.anchor_after(command_source_range.end + 1); let insert_position = buffer.anchor_after(command_source_range.end + 1);
let command_range = buffer.anchor_before(command_source_range.start) let command_range = buffer.anchor_after(command_source_range.start)
..buffer.anchor_after( ..buffer.anchor_before(
command_source_range.end + 1 + PENDING_OUTPUT_END_MARKER.len(), command_source_range.end + 1 + PENDING_OUTPUT_END_MARKER.len(),
); );
let command_source_range = buffer.anchor_before(command_source_range.start) let command_source_range = buffer.anchor_before(command_source_range.start)
..buffer.anchor_before(command_source_range.end + 1); ..buffer.anchor_before(command_source_range.end + 1);
(command_range, command_source_range, insert_position) (
command_range,
command_source_range,
insert_position,
first_transaction,
)
}); });
self.reparse(cx); self.reparse(cx);
@ -1858,13 +1906,18 @@ impl Context {
while let Some(event) = stream.next().await { while let Some(event) = stream.next().await {
let event = event?; let event = event?;
match event { this.update(&mut cx, |this, cx| {
SlashCommandEvent::StartMessage { this.buffer.update(cx, |buffer, _cx| {
role, buffer.finalize_last_transaction();
merge_same_roles, buffer.start_transaction()
} => { });
if !merge_same_roles && Some(role) != last_role {
this.update(&mut cx, |this, cx| { match event {
SlashCommandEvent::StartMessage {
role,
merge_same_roles,
} => {
if !merge_same_roles && Some(role) != last_role {
let offset = this.buffer.read_with(cx, |buffer, _cx| { let offset = this.buffer.read_with(cx, |buffer, _cx| {
insert_position.to_offset(buffer) insert_position.to_offset(buffer)
}); });
@ -1874,17 +1927,15 @@ impl Context {
MessageStatus::Pending, MessageStatus::Pending,
cx, cx,
); );
})?; }
}
last_role = Some(role); last_role = Some(role);
} }
SlashCommandEvent::StartSection { SlashCommandEvent::StartSection {
icon, icon,
label, label,
metadata, metadata,
} => { } => {
this.update(&mut cx, |this, cx| {
this.buffer.update(cx, |buffer, cx| { this.buffer.update(cx, |buffer, cx| {
let insert_point = insert_position.to_point(buffer); let insert_point = insert_position.to_point(buffer);
if insert_point.column > 0 { if insert_point.column > 0 {
@ -1898,16 +1949,14 @@ impl Context {
metadata, metadata,
}); });
}); });
})?; }
} SlashCommandEvent::Content(SlashCommandContent::Text {
SlashCommandEvent::Content(SlashCommandContent::Text { text,
text, run_commands_in_text,
run_commands_in_text, }) => {
}) => {
this.update(&mut cx, |this, cx| {
let start = this.buffer.read(cx).anchor_before(insert_position); let start = this.buffer.read(cx).anchor_before(insert_position);
let result = this.buffer.update(cx, |buffer, cx| { this.buffer.update(cx, |buffer, cx| {
buffer.edit( buffer.edit(
[(insert_position..insert_position, text)], [(insert_position..insert_position, text)],
None, None,
@ -1919,41 +1968,44 @@ impl Context {
if run_commands_in_text { if run_commands_in_text {
run_commands_in_ranges.push(start..end); run_commands_in_ranges.push(start..end);
} }
}
result SlashCommandEvent::EndSection { metadata } => {
})?; if let Some(pending_section) = pending_section_stack.pop() {
}
SlashCommandEvent::EndSection { metadata } => {
if let Some(pending_section) = pending_section_stack.pop() {
this.update(&mut cx, |this, cx| {
let offset_range = (pending_section.start..insert_position) let offset_range = (pending_section.start..insert_position)
.to_offset(this.buffer.read(cx)); .to_offset(this.buffer.read(cx));
if offset_range.is_empty() { if !offset_range.is_empty() {
return; let range = this.buffer.update(cx, |buffer, _cx| {
buffer.anchor_after(offset_range.start)
..buffer.anchor_before(offset_range.end)
});
this.insert_slash_command_output_section(
SlashCommandOutputSection {
range: range.clone(),
icon: pending_section.icon,
label: pending_section.label,
metadata: metadata.or(pending_section.metadata),
},
cx,
);
last_section_range = Some(range);
} }
}
let range = this.buffer.update(cx, |buffer, _cx| {
buffer.anchor_after(offset_range.start)
..buffer.anchor_before(offset_range.end)
});
this.insert_slash_command_output_section(
SlashCommandOutputSection {
range: range.clone(),
icon: pending_section.icon,
label: pending_section.label,
metadata: metadata.or(pending_section.metadata),
},
cx,
);
last_section_range = Some(range);
})?;
} }
} }
}
this.buffer.update(cx, |buffer, cx| {
if let Some(event_transaction) = buffer.end_transaction(cx) {
buffer.merge_transactions(event_transaction, first_transaction);
}
});
})?;
} }
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
this.buffer.update(cx, |buffer, cx| { this.buffer.update(cx, |buffer, cx| {
buffer.finalize_last_transaction();
buffer.start_transaction();
let mut deletions = vec![(command_source_range.to_offset(buffer), "")]; let mut deletions = vec![(command_source_range.to_offset(buffer), "")];
let insert_position = insert_position.to_offset(buffer); let insert_position = insert_position.to_offset(buffer);
let command_range_end = command_range.end.to_offset(buffer); let command_range_end = command_range.end.to_offset(buffer);
@ -1981,6 +2033,10 @@ impl Context {
} }
buffer.edit(deletions, None, cx); buffer.edit(deletions, None, cx);
if let Some(deletion_transaction) = buffer.end_transaction(cx) {
buffer.merge_transactions(deletion_transaction, first_transaction);
}
}); });
})?; })?;
@ -2031,6 +2087,8 @@ impl Context {
name: name.to_string().into(), name: name.to_string().into(),
range: command_range.clone(), range: command_range.clone(),
status: InvokedSlashCommandStatus::Running(insert_output_task), status: InvokedSlashCommandStatus::Running(insert_output_task),
transaction: Some(first_transaction),
timestamp: command_id.0,
}, },
); );
cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id }); cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id });
@ -3101,6 +3159,8 @@ pub struct InvokedSlashCommand {
pub name: SharedString, pub name: SharedString,
pub range: Range<language::Anchor>, pub range: Range<language::Anchor>,
pub status: InvokedSlashCommandStatus, pub status: InvokedSlashCommandStatus,
pub transaction: Option<language::TransactionId>,
timestamp: clock::Lamport,
} }
#[derive(Debug)] #[derive(Debug)]

View file

@ -1357,7 +1357,7 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std
let first_context = contexts[0].read(cx); let first_context = contexts[0].read(cx);
for context in &contexts[1..] { for context in &contexts[1..] {
let context = context.read(cx); let context = context.read(cx);
assert!(context.pending_ops.is_empty()); assert!(context.pending_ops.is_empty(), "pending ops: {:?}", context.pending_ops);
assert_eq!( assert_eq!(
context.buffer.read(cx).text(), context.buffer.read(cx).text(),
first_context.buffer.read(cx).text(), first_context.buffer.read(cx).text(),