Cargo.lock ๐
@@ -348,6 +348,7 @@ dependencies = [
"file_icons",
"fs",
"futures 0.3.28",
+ "fuzzy",
"gpui",
"http 0.1.0",
"indoc",
Max Brunsfeld , Marshall , and Antonio Scandurra created
Tasks
* [x] remove old flaps and output when editing a slash command
* [x] the completing a command name that takes args, insert a space to
prepare for typing an arg
* [x] always trigger completions when typing in a slash command
* [x] don't show line numbers
* [x] implement `prompt` command
* [x] `current-file` command
* [x] state gets corrupted on `duplicate line up` on a slash command
* [x] exclude slash command source from completion request
Next steps:
* show output token count in flap trailer
* add `/project` command that matches project ambient context
* delete ambient context
Release Notes:
- N/A
---------
Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Cargo.lock | 1
crates/assistant/Cargo.toml | 1
crates/assistant/src/ambient_context/current_project.rs | 10
crates/assistant/src/ambient_context/recent_buffers.rs | 10
crates/assistant/src/assistant.rs | 2
crates/assistant/src/assistant_panel.rs | 737 +++++++
crates/assistant/src/completion_provider.rs | 2
crates/assistant/src/prompt_library.rs | 4
crates/assistant/src/slash_command.rs | 319 +++
crates/assistant/src/slash_command/current_file_command.rs | 135 +
crates/assistant/src/slash_command/file_command.rs | 145 +
crates/assistant/src/slash_command/prompt_command.rs | 88
crates/collab_ui/src/chat_panel/message_editor.rs | 11
crates/editor/src/editor.rs | 151 +
crates/editor/src/element.rs | 102
crates/gpui/src/window.rs | 3
crates/multi_buffer/src/multi_buffer.rs | 41
crates/text/src/text.rs | 25
18 files changed, 1,641 insertions(+), 146 deletions(-)
@@ -348,6 +348,7 @@ dependencies = [
"file_icons",
"fs",
"futures 0.3.28",
+ "fuzzy",
"gpui",
"http 0.1.0",
"indoc",
@@ -21,6 +21,7 @@ editor.workspace = true
file_icons.workspace = true
fs.workspace = true
futures.workspace = true
+fuzzy.workspace = true
gpui.workspace = true
http.workspace = true
indoc.workspace = true
@@ -34,10 +34,12 @@ impl Default for CurrentProjectContext {
impl CurrentProjectContext {
/// Returns the [`CurrentProjectContext`] as a message to the language model.
pub fn to_message(&self) -> Option<LanguageModelRequestMessage> {
- self.enabled.then(|| LanguageModelRequestMessage {
- role: Role::System,
- content: self.message.clone(),
- })
+ self.enabled
+ .then(|| LanguageModelRequestMessage {
+ role: Role::System,
+ content: self.message.clone(),
+ })
+ .filter(|message| !message.content.is_empty())
}
/// Updates the [`CurrentProjectContext`] for the given [`Project`].
@@ -87,10 +87,12 @@ impl RecentBuffersContext {
/// Returns the [`RecentBuffersContext`] as a message to the language model.
pub fn to_message(&self) -> Option<LanguageModelRequestMessage> {
- self.enabled.then(|| LanguageModelRequestMessage {
- role: Role::System,
- content: self.snapshot.message.to_string(),
- })
+ self.enabled
+ .then(|| LanguageModelRequestMessage {
+ role: Role::System,
+ content: self.snapshot.message.to_string(),
+ })
+ .filter(|message| !message.content.is_empty())
}
}
@@ -7,6 +7,7 @@ mod prompt_library;
mod prompts;
mod saved_conversation;
mod search;
+mod slash_command;
mod streaming_diff;
use ambient_context::AmbientContextSnapshot;
@@ -16,6 +17,7 @@ use client::{proto, Client};
use command_palette_hooks::CommandPaletteFilter;
pub(crate) use completion_provider::*;
use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal};
+pub(crate) use prompt_library::*;
pub(crate) use saved_conversation::*;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
@@ -5,6 +5,9 @@ use crate::{
prompt_library::{PromptLibrary, PromptManager},
prompts::generate_content_prompt,
search::*,
+ slash_command::{
+ SlashCommandCleanup, SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry,
+ },
ApplyEdit, Assist, CompletionProvider, CycleMessageRole, InlineAssist, InsertActivePrompt,
LanguageModel, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata,
MessageStatus, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata,
@@ -14,9 +17,10 @@ use anyhow::{anyhow, Result};
use client::telemetry::Telemetry;
use collections::{hash_map, HashMap, HashSet, VecDeque};
use editor::{
- actions::{MoveDown, MoveUp},
+ actions::{FoldAt, MoveDown, MoveUp},
display_map::{
- BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint,
+ BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Flap, FlapId,
+ ToDisplayPoint,
},
scroll::{Autoscroll, AutoscrollStrategy},
Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, RowExt,
@@ -26,16 +30,17 @@ use file_icons::FileIcons;
use fs::Fs;
use futures::StreamExt;
use gpui::{
- canvas, div, point, relative, rems, uniform_list, Action, AnyView, AppContext, AsyncAppContext,
- AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
- FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement, IntoElement, Model,
- ModelContext, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement, Styled,
- Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext,
- WeakModel, WeakView, WhiteSpace, WindowContext,
+ canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext,
+ AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Entity,
+ EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle,
+ InteractiveElement, IntoElement, Model, ModelContext, ParentElement, Pixels, Render,
+ SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextStyle,
+ UniformListScrollHandle, View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace,
+ WindowContext,
};
use language::{
language_settings::SoftWrap, AutoindentMode, Buffer, BufferSnapshot, LanguageRegistry,
- OffsetRangeExt as _, Point, ToOffset as _,
+ OffsetRangeExt as _, Point, ToOffset as _, ToPoint as _,
};
use multi_buffer::MultiBufferRow;
use parking_lot::Mutex;
@@ -45,7 +50,7 @@ use settings::Settings;
use std::{
cmp::{self, Ordering},
fmt::Write,
- iter,
+ iter, mem,
ops::Range,
path::PathBuf,
sync::Arc,
@@ -64,6 +69,7 @@ use workspace::{
use workspace::{notifications::NotificationId, NewFile};
const MAX_RECENT_BUFFERS: usize = 3;
+const SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(200);
pub fn init(cx: &mut AppContext) {
cx.observe_new_views(
@@ -98,6 +104,7 @@ pub struct AssistantPanel {
focus_handle: FocusHandle,
toolbar: View<Toolbar>,
languages: Arc<LanguageRegistry>,
+ slash_commands: Arc<SlashCommandRegistry>,
prompt_library: Arc<PromptLibrary>,
fs: Arc<dyn Fs>,
telemetry: Arc<Telemetry>,
@@ -190,6 +197,12 @@ impl AssistantPanel {
})
.detach();
+ let slash_command_registry = SlashCommandRegistry::new(
+ workspace.project().clone(),
+ prompt_library.clone(),
+ cx.window_handle().downcast::<Workspace>(),
+ );
+
Self {
workspace: workspace_handle,
active_conversation_editor: None,
@@ -200,6 +213,7 @@ impl AssistantPanel {
focus_handle,
toolbar,
languages: workspace.app_state().languages.clone(),
+ slash_commands: slash_command_registry,
prompt_library,
fs: workspace.app_state().fs.clone(),
telemetry: workspace.client().telemetry().clone(),
@@ -785,6 +799,7 @@ impl AssistantPanel {
ConversationEditor::new(
self.model.clone(),
self.languages.clone(),
+ self.slash_commands.clone(),
self.fs.clone(),
workspace,
cx,
@@ -1083,6 +1098,7 @@ impl AssistantPanel {
let fs = self.fs.clone();
let workspace = self.workspace.clone();
+ let slash_commands = self.slash_commands.clone();
let languages = self.languages.clone();
let telemetry = self.telemetry.clone();
cx.spawn(|this, mut cx| async move {
@@ -1093,6 +1109,7 @@ impl AssistantPanel {
model,
path.clone(),
languages,
+ slash_commands,
Some(telemetry),
&mut cx,
)
@@ -1380,11 +1397,15 @@ impl FocusableView for AssistantPanel {
}
}
+#[derive(Clone)]
enum ConversationEvent {
MessagesEdited,
SummaryChanged,
EditSuggestionsChanged,
StreamedCompletion,
+ SlashCommandsChanged,
+ SlashCommandOutputAdded(Range<language::Anchor>),
+ SlashCommandOutputRemoved(Range<language::Anchor>),
}
#[derive(Default)]
@@ -1398,6 +1419,7 @@ pub struct Conversation {
buffer: Model<Buffer>,
pub(crate) ambient_context: AmbientContext,
edit_suggestions: Vec<EditSuggestion>,
+ slash_command_calls: Vec<SlashCommandCall>,
message_anchors: Vec<MessageAnchor>,
messages_metadata: HashMap<MessageId, MessageMetadata>,
next_message_id: MessageId,
@@ -1409,10 +1431,12 @@ pub struct Conversation {
token_count: Option<usize>,
pending_token_count: Task<Option<()>>,
pending_edit_suggestion_parse: Option<Task<()>>,
+ pending_command_invocation_parse: Option<Task<()>>,
pending_save: Task<Result<()>>,
path: Option<PathBuf>,
_subscriptions: Vec<Subscription>,
telemetry: Option<Arc<Telemetry>>,
+ slash_command_registry: Arc<SlashCommandRegistry>,
language_registry: Arc<LanguageRegistry>,
}
@@ -1422,6 +1446,7 @@ impl Conversation {
fn new(
model: LanguageModel,
language_registry: Arc<LanguageRegistry>,
+ slash_command_registry: Arc<SlashCommandRegistry>,
telemetry: Option<Arc<Telemetry>>,
cx: &mut ModelContext<Self>,
) -> Self {
@@ -1438,6 +1463,7 @@ impl Conversation {
next_message_id: Default::default(),
ambient_context: AmbientContext::default(),
edit_suggestions: Vec::new(),
+ slash_command_calls: Vec::new(),
summary: None,
pending_summary: Task::ready(None),
completion_count: Default::default(),
@@ -1445,12 +1471,14 @@ impl Conversation {
token_count: None,
pending_token_count: Task::ready(None),
pending_edit_suggestion_parse: None,
+ pending_command_invocation_parse: None,
model,
_subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
pending_save: Task::ready(Ok(())),
path: None,
buffer,
telemetry,
+ slash_command_registry,
language_registry,
};
@@ -1500,6 +1528,7 @@ impl Conversation {
model: LanguageModel,
path: PathBuf,
language_registry: Arc<LanguageRegistry>,
+ slash_command_registry: Arc<SlashCommandRegistry>,
telemetry: Option<Arc<Telemetry>>,
cx: &mut AsyncAppContext,
) -> Result<Model<Self>> {
@@ -1540,6 +1569,7 @@ impl Conversation {
next_message_id,
ambient_context: AmbientContext::default(),
edit_suggestions: Vec::new(),
+ slash_command_calls: Vec::new(),
summary: Some(Summary {
text: saved_conversation.summary,
done: true,
@@ -1549,6 +1579,7 @@ impl Conversation {
pending_completions: Default::default(),
token_count: None,
pending_edit_suggestion_parse: None,
+ pending_command_invocation_parse: None,
pending_token_count: Task::ready(None),
model,
_subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
@@ -1557,6 +1588,7 @@ impl Conversation {
buffer,
telemetry,
language_registry,
+ slash_command_registry,
};
this.set_language(cx);
this.reparse_edit_suggestions(cx);
@@ -1640,6 +1672,7 @@ impl Conversation {
if *event == language::Event::Edited {
self.count_remaining_tokens(cx);
self.reparse_edit_suggestions(cx);
+ self.reparse_slash_command_calls(cx);
cx.emit(ConversationEvent::MessagesEdited);
}
}
@@ -1725,6 +1758,220 @@ impl Conversation {
cx.notify();
}
+ fn reparse_slash_command_calls(&mut self, cx: &mut ModelContext<Self>) {
+ self.pending_command_invocation_parse = Some(cx.spawn(|this, mut cx| async move {
+ cx.background_executor().timer(SLASH_COMMAND_DEBOUNCE).await;
+
+ this.update(&mut cx, |this, cx| {
+ let buffer = this.buffer.read(cx).snapshot();
+
+ let mut changed = false;
+ let mut new_calls = Vec::new();
+ let mut old_calls = mem::take(&mut this.slash_command_calls)
+ .into_iter()
+ .peekable();
+ let mut lines = buffer.as_rope().chunks().lines();
+ let mut offset = 0;
+ while let Some(line) = lines.next() {
+ let line_end_offset = offset + line.len();
+ if let Some(call) = SlashCommandLine::parse(line) {
+ let mut unchanged_call = None;
+ while let Some(old_call) = old_calls.peek() {
+ match old_call.source_range.start.to_offset(&buffer).cmp(&offset) {
+ Ordering::Greater => break,
+ Ordering::Equal
+ if this.slash_command_is_unchanged(
+ old_call, &call, line, &buffer,
+ ) =>
+ {
+ unchanged_call = old_calls.next();
+ }
+ _ => {
+ changed = true;
+ let old_call = old_calls.next().unwrap();
+ this.slash_command_call_removed(old_call, cx);
+ }
+ }
+ }
+
+ let name = &line[call.name];
+ if let Some(call) = unchanged_call {
+ new_calls.push(call);
+ } else if let Some(command) = this.slash_command_registry.command(name) {
+ changed = true;
+ let name = name.to_string();
+ let source_range =
+ buffer.anchor_after(offset)..buffer.anchor_before(line_end_offset);
+
+ let argument = call.argument.map(|range| &line[range]);
+ let invocation = command.run(argument, cx);
+
+ new_calls.push(SlashCommandCall {
+ name,
+ argument: argument.map(|s| s.to_string()),
+ source_range: source_range.clone(),
+ output_range: None,
+ should_rerun: false,
+ _invalidate: cx.spawn(|this, mut cx| {
+ let source_range = source_range.clone();
+ let invalidated = invocation.invalidated;
+ async move {
+ if invalidated.await.is_ok() {
+ _ = this.update(&mut cx, |this, cx| {
+ let buffer = this.buffer.read(cx);
+ let call_ix = this
+ .slash_command_calls
+ .binary_search_by(|probe| {
+ probe
+ .source_range
+ .start
+ .cmp(&source_range.start, buffer)
+ });
+ if let Ok(call_ix) = call_ix {
+ this.slash_command_calls[call_ix]
+ .should_rerun = true;
+ this.reparse_slash_command_calls(cx);
+ }
+ });
+ }
+ }
+ }),
+ _command_cleanup: invocation.cleanup,
+ });
+
+ cx.spawn(|this, mut cx| async move {
+ let output = invocation.output.await;
+ this.update(&mut cx, |this, cx| {
+ let output_range = this.buffer.update(cx, |buffer, cx| {
+ let call_ix = this
+ .slash_command_calls
+ .binary_search_by(|probe| {
+ probe
+ .source_range
+ .start
+ .cmp(&source_range.start, buffer)
+ })
+ .ok()?;
+
+ let mut output = output.log_err()?;
+ output.truncate(output.trim_end().len());
+
+ let source_end = source_range.end.to_offset(buffer);
+ let output_start = source_end + '\n'.len_utf8();
+ let output_end = output_start + output.len();
+
+ if buffer
+ .chars_at(source_end)
+ .next()
+ .map_or(false, |c| c != '\n')
+ {
+ output.push('\n');
+ }
+
+ buffer.edit(
+ [
+ (source_end..source_end, "\n".to_string()),
+ (source_end..source_end, output),
+ ],
+ None,
+ cx,
+ );
+
+ let output_start = buffer.anchor_after(output_start);
+ let output_end = buffer.anchor_before(output_end);
+ this.slash_command_calls[call_ix].output_range =
+ Some(output_start..output_end);
+ Some(source_range.end..output_end)
+ });
+ if let Some(output_range) = output_range {
+ cx.emit(ConversationEvent::SlashCommandOutputAdded(
+ output_range,
+ ));
+ cx.emit(ConversationEvent::SlashCommandsChanged);
+ }
+ })
+ .ok();
+ })
+ .detach();
+ }
+ }
+ offset = lines.offset();
+ }
+
+ for old_call in old_calls {
+ changed = true;
+ this.slash_command_call_removed(old_call, cx);
+ }
+
+ if changed {
+ cx.emit(ConversationEvent::SlashCommandsChanged);
+ }
+
+ this.slash_command_calls = new_calls;
+ })
+ .ok();
+ }));
+ }
+
+ fn slash_command_is_unchanged(
+ &self,
+ old_call: &SlashCommandCall,
+ new_call: &SlashCommandLine,
+ new_text: &str,
+ buffer: &BufferSnapshot,
+ ) -> bool {
+ if old_call.name != new_text[new_call.name.clone()] {
+ return false;
+ }
+
+ if old_call.argument.as_deref() != new_call.argument.clone().map(|range| &new_text[range]) {
+ return false;
+ }
+
+ if old_call.should_rerun {
+ return false;
+ }
+
+ if let Some(output_range) = &old_call.output_range {
+ let source_range = old_call.source_range.to_point(buffer);
+ let output_start = output_range.start.to_point(buffer);
+ if source_range.start.column != 0 {
+ return false;
+ }
+ if source_range.end.column != new_text.len() as u32 {
+ return false;
+ }
+ if output_start != Point::new(source_range.end.row + 1, 0) {
+ return false;
+ }
+ if let Some(next_char) = buffer.chars_at(output_range.end).next() {
+ if next_char != '\n' {
+ return false;
+ }
+ }
+ }
+ true
+ }
+
+ fn slash_command_call_removed(
+ &self,
+ old_call: SlashCommandCall,
+ cx: &mut ModelContext<Conversation>,
+ ) {
+ if let Some(output_range) = old_call.output_range {
+ self.buffer.update(cx, |buffer, cx| {
+ buffer.edit(
+ [(old_call.source_range.end..output_range.end, "")],
+ None,
+ cx,
+ );
+ });
+ cx.emit(ConversationEvent::SlashCommandOutputRemoved(
+ old_call.source_range.end..output_range.end,
+ ))
+ }
+ }
+
fn remaining_tokens(&self) -> Option<isize> {
Some(self.model.max_token_count() as isize - self.token_count? as isize)
}
@@ -2183,6 +2430,17 @@ impl Conversation {
fn messages<'a>(&'a self, cx: &'a AppContext) -> impl 'a + Iterator<Item = Message> {
let buffer = self.buffer.read(cx);
+ let mut slash_command_calls = self
+ .slash_command_calls
+ .iter()
+ .map(|call| {
+ if let Some(output) = &call.output_range {
+ call.source_range.start.to_offset(buffer)..output.start.to_offset(buffer)
+ } else {
+ call.source_range.to_offset(buffer)
+ }
+ })
+ .peekable();
let mut message_anchors = self.message_anchors.iter().enumerate().peekable();
iter::from_fn(move || {
if let Some((start_ix, message_anchor)) = message_anchors.next() {
@@ -2202,6 +2460,16 @@ impl Conversation {
let message_end = message_end
.unwrap_or(language::Anchor::MAX)
.to_offset(buffer);
+
+ let mut slash_command_ranges = Vec::new();
+ while let Some(call_range) = slash_command_calls.peek() {
+ if call_range.end <= message_end {
+ slash_command_ranges.push(slash_command_calls.next().unwrap());
+ } else {
+ break;
+ }
+ }
+
return Some(Message {
index_range: start_ix..end_ix,
offset_range: message_start..message_end,
@@ -2209,6 +2477,7 @@ impl Conversation {
anchor: message_anchor.start,
role: metadata.role,
status: metadata.status.clone(),
+ slash_command_ranges,
ambient_context: metadata.ambient_context.clone(),
});
}
@@ -2367,6 +2636,16 @@ fn parse_next_edit_suggestion(lines: &mut rope::Lines) -> Option<ParsedEditSugge
}
}
+struct SlashCommandCall {
+ source_range: Range<language::Anchor>,
+ output_range: Option<Range<language::Anchor>>,
+ name: String,
+ argument: Option<String>,
+ should_rerun: bool,
+ _invalidate: Task<()>,
+ _command_cleanup: SlashCommandCleanup,
+}
+
struct PendingCompletion {
id: usize,
_task: Task<()>,
@@ -2387,6 +2666,7 @@ struct ConversationEditor {
fs: Arc<dyn Fs>,
workspace: WeakView<Workspace>,
editor: View<Editor>,
+ flap_ids: HashMap<Range<language::Anchor>, FlapId>,
blocks: HashSet<BlockId>,
scroll_position: Option<ScrollPosition>,
_subscriptions: Vec<Subscription>,
@@ -2396,13 +2676,21 @@ impl ConversationEditor {
fn new(
model: LanguageModel,
language_registry: Arc<LanguageRegistry>,
+ slash_command_registry: Arc<SlashCommandRegistry>,
fs: Arc<dyn Fs>,
workspace: View<Workspace>,
cx: &mut ViewContext<Self>,
) -> Self {
let telemetry = workspace.read(cx).client().telemetry().clone();
- let conversation =
- cx.new_model(|cx| Conversation::new(model, language_registry, Some(telemetry), cx));
+ let conversation = cx.new_model(|cx| {
+ Conversation::new(
+ model,
+ language_registry,
+ slash_command_registry,
+ Some(telemetry),
+ cx,
+ )
+ });
Self::for_conversation(conversation, fs, workspace, cx)
}
@@ -2412,11 +2700,17 @@ impl ConversationEditor {
workspace: View<Workspace>,
cx: &mut ViewContext<Self>,
) -> Self {
+ let command_registry = conversation.read(cx).slash_command_registry.clone();
+ let completion_provider = SlashCommandCompletionProvider::new(command_registry);
+
let editor = cx.new_view(|cx| {
let mut editor = Editor::for_buffer(conversation.read(cx).buffer.clone(), None, cx);
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
- editor.set_show_gutter(false, cx);
+ editor.set_show_line_numbers(false, cx);
+ editor.set_show_git_diff_gutter(false, cx);
+ editor.set_show_code_actions(false, cx);
editor.set_show_wrap_guides(false, cx);
+ editor.set_completion_provider(Box::new(completion_provider));
editor
});
@@ -2432,6 +2726,7 @@ impl ConversationEditor {
editor,
blocks: Default::default(),
scroll_position: None,
+ flap_ids: Default::default(),
fs,
workspace: workspace.downgrade(),
_subscriptions,
@@ -2570,6 +2865,68 @@ impl ConversationEditor {
}
});
}
+ ConversationEvent::SlashCommandsChanged => {
+ self.editor.update(cx, |editor, cx| {
+ let buffer = editor.buffer().read(cx).snapshot(cx);
+ let excerpt_id = *buffer.as_singleton().unwrap().0;
+ let conversation = self.conversation.read(cx);
+ let colors = cx.theme().colors();
+ let highlighted_rows = conversation
+ .slash_command_calls
+ .iter()
+ .map(|call| {
+ let start = call.source_range.start;
+ let end = if let Some(output) = &call.output_range {
+ output.end
+ } else {
+ call.source_range.end
+ };
+ let start = buffer.anchor_in_excerpt(excerpt_id, start).unwrap();
+ let end = buffer.anchor_in_excerpt(excerpt_id, end).unwrap();
+ (
+ start..=end,
+ Some(colors.editor_document_highlight_read_background),
+ )
+ })
+ .collect::<Vec<_>>();
+
+ editor.clear_row_highlights::<SlashCommandCall>();
+ for (range, color) in highlighted_rows {
+ editor.highlight_rows::<SlashCommandCall>(range, color, false, cx);
+ }
+ });
+ }
+ ConversationEvent::SlashCommandOutputAdded(range) => {
+ self.editor.update(cx, |editor, cx| {
+ let buffer = editor.buffer().read(cx).snapshot(cx);
+ let excerpt_id = *buffer.as_singleton().unwrap().0;
+ let start = buffer.anchor_in_excerpt(excerpt_id, range.start).unwrap();
+ let end = buffer.anchor_in_excerpt(excerpt_id, range.end).unwrap();
+ let buffer_row = MultiBufferRow(start.to_point(&buffer).row);
+
+ let flap_id = editor
+ .insert_flaps(
+ [Flap::new(
+ start..end,
+ render_slash_command_output_toggle,
+ render_slash_command_output_trailer,
+ )],
+ cx,
+ )
+ .into_iter()
+ .next()
+ .unwrap();
+ self.flap_ids.insert(range.clone(), flap_id);
+ editor.fold_at(&FoldAt { buffer_row }, cx);
+ });
+ }
+ ConversationEvent::SlashCommandOutputRemoved(range) => {
+ if let Some(flap_id) = self.flap_ids.remove(range) {
+ self.editor.update(cx, |editor, cx| {
+ editor.remove_flaps([flap_id], cx);
+ });
+ }
+ }
}
}
@@ -2732,6 +3089,7 @@ impl ConversationEditor {
h_flex()
.id(("message_header", message_id.0))
+ .pl(cx.gutter_dimensions.width)
.h_11()
.w_full()
.relative()
@@ -3157,7 +3515,6 @@ impl Render for ConversationEditor {
.child(
div()
.flex_grow()
- .pl_4()
.bg(cx.theme().colors().editor_background)
.child(self.editor.clone()),
)
@@ -3184,14 +3541,41 @@ pub struct Message {
anchor: language::Anchor,
role: Role,
status: MessageStatus,
+ slash_command_ranges: Vec<Range<usize>>,
ambient_context: AmbientContextSnapshot,
}
impl Message {
fn to_request_message(&self, buffer: &Buffer) -> LanguageModelRequestMessage {
- let content = buffer
- .text_for_range(self.offset_range.clone())
- .collect::<String>();
+ let mut slash_command_ranges = self.slash_command_ranges.iter().peekable();
+ let mut content = String::with_capacity(self.offset_range.len());
+ let mut offset = self.offset_range.start;
+ let mut chunks = buffer.text_for_range(self.offset_range.clone());
+ while let Some(chunk) = chunks.next() {
+ if let Some(slash_command_range) = slash_command_ranges.peek() {
+ match offset.cmp(&slash_command_range.start) {
+ Ordering::Less => {
+ let max_len = slash_command_range.start - offset;
+ if chunk.len() < max_len {
+ content.push_str(chunk);
+ offset += chunk.len();
+ } else {
+ content.push_str(&chunk[..max_len]);
+ offset += max_len;
+ chunks.seek(slash_command_range.end);
+ slash_command_ranges.next();
+ }
+ }
+ Ordering::Equal | Ordering::Greater => {
+ chunks.seek(slash_command_range.end);
+ offset = slash_command_range.end;
+ slash_command_ranges.next();
+ }
+ }
+ } else {
+ content.push_str(chunk);
+ }
+ }
LanguageModelRequestMessage {
role: self.role,
content: content.trim_end().into(),
@@ -3470,6 +3854,35 @@ struct PendingInlineAssist {
project: WeakModel<Project>,
}
+type ToggleFold = Arc<dyn Fn(bool, &mut WindowContext) + Send + Sync>;
+
+fn render_slash_command_output_toggle(
+ row: MultiBufferRow,
+ is_folded: bool,
+ fold: ToggleFold,
+ _cx: &mut WindowContext,
+) -> AnyElement {
+ IconButton::new(
+ ("slash-command-output-fold-indicator", row.0),
+ ui::IconName::ChevronDown,
+ )
+ .on_click(move |_e, cx| fold(!is_folded, cx))
+ .icon_color(ui::Color::Muted)
+ .icon_size(ui::IconSize::Small)
+ .selected(is_folded)
+ .selected_icon(ui::IconName::ChevronRight)
+ .size(ui::ButtonSize::None)
+ .into_any_element()
+}
+
+fn render_slash_command_output_trailer(
+ _row: MultiBufferRow,
+ _is_folded: bool,
+ _cx: &mut WindowContext,
+) -> AnyElement {
+ div().into_any_element()
+}
+
fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
ranges.sort_unstable_by(|a, b| {
a.start
@@ -3494,14 +3907,17 @@ fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
#[cfg(test)]
mod tests {
- use std::path::Path;
+ use std::{cell::RefCell, path::Path, rc::Rc};
use super::*;
use crate::{FakeCompletionProvider, MessageId};
+ use fs::FakeFs;
use gpui::{AppContext, TestAppContext};
use rope::Rope;
+ use serde_json::json;
use settings::SettingsStore;
use unindent::Unindent;
+ use util::test::marked_text_ranges;
#[gpui::test]
fn test_inserting_and_removing_messages(cx: &mut AppContext) {
@@ -3511,8 +3927,15 @@ mod tests {
init(cx);
let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
- let conversation =
- cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, None, cx));
+ let conversation = cx.new_model(|cx| {
+ Conversation::new(
+ LanguageModel::default(),
+ registry,
+ Default::default(),
+ None,
+ cx,
+ )
+ });
let buffer = conversation.read(cx).buffer.clone();
let message_1 = conversation.read(cx).message_anchors[0].clone();
@@ -3643,8 +4066,15 @@ mod tests {
init(cx);
let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
- let conversation =
- cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, None, cx));
+ let conversation = cx.new_model(|cx| {
+ Conversation::new(
+ LanguageModel::default(),
+ registry,
+ Default::default(),
+ None,
+ cx,
+ )
+ });
let buffer = conversation.read(cx).buffer.clone();
let message_1 = conversation.read(cx).message_anchors[0].clone();
@@ -3742,8 +4172,15 @@ mod tests {
cx.set_global(settings_store);
init(cx);
let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
- let conversation =
- cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, None, cx));
+ let conversation = cx.new_model(|cx| {
+ Conversation::new(
+ LanguageModel::default(),
+ registry,
+ Default::default(),
+ None,
+ cx,
+ )
+ });
let buffer = conversation.read(cx).buffer.clone();
let message_1 = conversation.read(cx).message_anchors[0].clone();
@@ -3820,6 +4257,258 @@ mod tests {
}
}
+ #[gpui::test]
+ async fn test_slash_commands(cx: &mut TestAppContext) {
+ let settings_store = cx.update(SettingsStore::test);
+ cx.set_global(settings_store);
+ cx.set_global(CompletionProvider::Fake(FakeCompletionProvider::default()));
+ cx.update(Project::init_settings);
+ cx.update(init);
+ let fs = FakeFs::new(cx.background_executor.clone());
+
+ fs.insert_tree(
+ "/test",
+ json!({
+ "src": {
+ "lib.rs": "fn one() -> usize { 1 }",
+ "main.rs": "
+ use crate::one;
+ fn main() { one(); }
+ ".unindent(),
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
+ let prompt_library = Arc::new(PromptLibrary::default());
+ let slash_command_registry =
+ SlashCommandRegistry::new(project.clone(), prompt_library, None);
+
+ let registry = Arc::new(LanguageRegistry::test(cx.executor()));
+ let conversation = cx.new_model(|cx| {
+ Conversation::new(
+ LanguageModel::default(),
+ registry.clone(),
+ slash_command_registry,
+ None,
+ cx,
+ )
+ });
+
+ let output_ranges = Rc::new(RefCell::new(HashSet::default()));
+ conversation.update(cx, |_, cx| {
+ cx.subscribe(&conversation, {
+ let ranges = output_ranges.clone();
+ move |_, _, event, _| match event {
+ ConversationEvent::SlashCommandOutputAdded(range) => {
+ ranges.borrow_mut().insert(range.clone());
+ }
+ ConversationEvent::SlashCommandOutputRemoved(range) => {
+ ranges.borrow_mut().remove(range);
+ }
+ _ => {}
+ }
+ })
+ .detach();
+ });
+
+ let buffer = conversation.read_with(cx, |conversation, _| conversation.buffer.clone());
+
+ // Insert a slash command
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..0, "/file src/lib.rs")], None, cx);
+ });
+ assert_text_and_output_ranges(
+ &buffer,
+ &output_ranges.borrow(),
+ "
+ /file src/lib.rs
+ "
+ .unindent()
+ .trim_end(),
+ cx,
+ );
+
+ // The slash command runs
+ cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE);
+ assert_text_and_output_ranges(
+ &buffer,
+ &output_ranges.borrow(),
+ &"
+ /file src/lib.rsยซ
+ ```src/lib.rs
+ fn one() -> usize { 1 }
+ ```ยป"
+ .unindent(),
+ cx,
+ );
+
+ // Edit the slash command
+ buffer.update(cx, |buffer, cx| {
+ let edit_offset = buffer.text().find("lib.rs").unwrap();
+ buffer.edit([(edit_offset..edit_offset + "lib".len(), "main")], None, cx);
+ });
+ assert_text_and_output_ranges(
+ &buffer,
+ &output_ranges.borrow(),
+ &"
+ /file src/main.rsยซ
+ ```src/lib.rs
+ fn one() -> usize { 1 }
+ ```ยป"
+ .unindent(),
+ cx,
+ );
+
+ cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE);
+ assert_text_and_output_ranges(
+ &buffer,
+ &output_ranges.borrow(),
+ &"
+ /file src/main.rsยซ
+ ```src/main.rs
+ use crate::one;
+ fn main() { one(); }
+ ```ยป"
+ .unindent(),
+ cx,
+ );
+
+ // Insert newlines between the slash command and its output
+ buffer.update(cx, |buffer, cx| {
+ let edit_offset = buffer.text().find("\n```src/main.rs").unwrap();
+ buffer.edit([(edit_offset..edit_offset, "\n")], None, cx);
+ });
+ assert_text_and_output_ranges(
+ &buffer,
+ &output_ranges.borrow(),
+ &"
+ /file src/main.rsยซ
+
+ ```src/main.rs
+ use crate::one;
+ fn main() { one(); }
+ ```ยป"
+ .unindent(),
+ cx,
+ );
+
+ cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE);
+ assert_text_and_output_ranges(
+ &buffer,
+ &output_ranges.borrow(),
+ &"
+ /file src/main.rsยซ
+ ```src/main.rs
+ use crate::one;
+ fn main() { one(); }
+ ```ยป"
+ .unindent(),
+ cx,
+ );
+
+ // Insert text at the beginning of the output
+ buffer.update(cx, |buffer, cx| {
+ let edit_offset = buffer.text().find("```src/main.rs").unwrap();
+ buffer.edit([(edit_offset..edit_offset, "!")], None, cx);
+ });
+ assert_text_and_output_ranges(
+ &buffer,
+ &output_ranges.borrow(),
+ &"
+ /file src/main.rsยซ
+ !```src/main.rs
+ use crate::one;
+ fn main() { one(); }
+ ```ยป"
+ .unindent(),
+ cx,
+ );
+
+ cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE);
+ assert_text_and_output_ranges(
+ &buffer,
+ &output_ranges.borrow(),
+ &"
+ /file src/main.rsยซ
+ ```src/main.rs
+ use crate::one;
+ fn main() { one(); }
+ ```ยป"
+ .unindent(),
+ cx,
+ );
+
+ // Slash commands are omitted from completion requests. Only their
+ // output is included.
+ let request = conversation.update(cx, |conversation, cx| {
+ conversation.to_completion_request(cx)
+ });
+ assert_eq!(
+ &request.messages[1..],
+ &[LanguageModelRequestMessage {
+ role: Role::User,
+ content: "
+ ```src/main.rs
+ use crate::one;
+ fn main() { one(); }
+ ```"
+ .unindent()
+ }]
+ );
+
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..0, "hello\n")], None, cx);
+ });
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit(
+ [(buffer.len()..buffer.len(), "\ngoodbye\nfarewell\n")],
+ None,
+ cx,
+ );
+ });
+ let request = conversation.update(cx, |conversation, cx| {
+ conversation.to_completion_request(cx)
+ });
+ assert_eq!(
+ &request.messages[1..],
+ &[LanguageModelRequestMessage {
+ role: Role::User,
+ content: "
+ hello
+ ```src/main.rs
+ use crate::one;
+ fn main() { one(); }
+ ```
+ goodbye
+ farewell"
+ .unindent()
+ }]
+ );
+
+ #[track_caller]
+ fn assert_text_and_output_ranges(
+ buffer: &Model<Buffer>,
+ ranges: &HashSet<Range<language::Anchor>>,
+ expected_marked_text: &str,
+ cx: &mut TestAppContext,
+ ) {
+ let (expected_text, expected_ranges) = marked_text_ranges(expected_marked_text, false);
+ let (actual_text, actual_ranges) = buffer.update(cx, |buffer, _| {
+ let mut ranges = ranges
+ .iter()
+ .map(|range| range.to_offset(buffer))
+ .collect::<Vec<_>>();
+ ranges.sort_by_key(|a| a.start);
+ (buffer.text(), ranges)
+ });
+
+ assert_eq!(actual_text, expected_text);
+ assert_eq!(actual_ranges, expected_ranges);
+ }
+ }
+
#[test]
fn test_parse_next_edit_suggestion() {
let text = "
@@ -233,7 +233,7 @@ impl CompletionProvider {
CompletionProvider::Anthropic(provider) => provider.count_tokens(request, cx),
CompletionProvider::ZedDotDev(provider) => provider.count_tokens(request, cx),
#[cfg(test)]
- CompletionProvider::Fake(_) => unimplemented!(),
+ CompletionProvider::Fake(_) => futures::FutureExt::boxed(futures::future::ready(Ok(0))),
}
}
@@ -156,10 +156,10 @@ impl PromptLibrary {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct UserPrompt {
version: String,
- title: String,
+ pub title: String,
author: String,
languages: Vec<String>,
- prompt: String,
+ pub prompt: String,
}
impl UserPrompt {
@@ -0,0 +1,319 @@
+use anyhow::Result;
+use collections::HashMap;
+use editor::{CompletionProvider, Editor};
+use futures::channel::oneshot;
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{AppContext, Model, Task, ViewContext, WindowHandle};
+use language::{Anchor, Buffer, CodeLabel, Documentation, LanguageServerId, ToPoint};
+use parking_lot::{Mutex, RwLock};
+use project::Project;
+use rope::Point;
+use std::{
+ ops::Range,
+ sync::{
+ atomic::{AtomicBool, Ordering::SeqCst},
+ Arc,
+ },
+};
+use workspace::Workspace;
+
+use crate::PromptLibrary;
+
+mod current_file_command;
+mod file_command;
+mod prompt_command;
+
+pub(crate) struct SlashCommandCompletionProvider {
+ commands: Arc<SlashCommandRegistry>,
+ cancel_flag: Mutex<Arc<AtomicBool>>,
+}
+
+#[derive(Default)]
+pub(crate) struct SlashCommandRegistry {
+ commands: HashMap<String, Box<dyn SlashCommand>>,
+}
+
+pub(crate) trait SlashCommand: 'static + Send + Sync {
+ fn name(&self) -> String;
+ fn description(&self) -> String;
+ fn complete_argument(
+ &self,
+ query: String,
+ cancel: Arc<AtomicBool>,
+ cx: &mut AppContext,
+ ) -> Task<Result<Vec<String>>>;
+ fn requires_argument(&self) -> bool;
+ fn run(&self, argument: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation;
+}
+
+pub(crate) struct SlashCommandInvocation {
+ pub output: Task<Result<String>>,
+ pub invalidated: oneshot::Receiver<()>,
+ pub cleanup: SlashCommandCleanup,
+}
+
+#[derive(Default)]
+pub(crate) struct SlashCommandCleanup(Option<Box<dyn FnOnce()>>);
+
+impl SlashCommandCleanup {
+ pub fn new(cleanup: impl FnOnce() + 'static) -> Self {
+ Self(Some(Box::new(cleanup)))
+ }
+}
+
+impl Drop for SlashCommandCleanup {
+ fn drop(&mut self) {
+ if let Some(cleanup) = self.0.take() {
+ cleanup();
+ }
+ }
+}
+
+pub(crate) struct SlashCommandLine {
+ /// The range within the line containing the command name.
+ pub name: Range<usize>,
+ /// The range within the line containing the command argument.
+ pub argument: Option<Range<usize>>,
+}
+
+impl SlashCommandRegistry {
+ pub fn new(
+ project: Model<Project>,
+ prompt_library: Arc<PromptLibrary>,
+ window: Option<WindowHandle<Workspace>>,
+ ) -> Arc<Self> {
+ let mut this = Self {
+ commands: HashMap::default(),
+ };
+
+ this.register_command(file_command::FileSlashCommand::new(project));
+ this.register_command(prompt_command::PromptSlashCommand::new(prompt_library));
+ if let Some(window) = window {
+ this.register_command(current_file_command::CurrentFileSlashCommand::new(window));
+ }
+
+ Arc::new(this)
+ }
+
+ fn register_command(&mut self, command: impl SlashCommand) {
+ self.commands.insert(command.name(), Box::new(command));
+ }
+
+ fn command_names(&self) -> impl Iterator<Item = &String> {
+ self.commands.keys()
+ }
+
+ pub(crate) fn command(&self, name: &str) -> Option<&dyn SlashCommand> {
+ self.commands.get(name).map(|b| &**b)
+ }
+}
+
+impl SlashCommandCompletionProvider {
+ pub fn new(commands: Arc<SlashCommandRegistry>) -> Self {
+ Self {
+ cancel_flag: Mutex::new(Arc::new(AtomicBool::new(false))),
+ commands,
+ }
+ }
+
+ fn complete_command_name(
+ &self,
+ command_name: &str,
+ range: Range<Anchor>,
+ cx: &mut AppContext,
+ ) -> Task<Result<Vec<project::Completion>>> {
+ let candidates = self
+ .commands
+ .command_names()
+ .enumerate()
+ .map(|(ix, def)| StringMatchCandidate {
+ id: ix,
+ string: def.clone(),
+ char_bag: def.as_str().into(),
+ })
+ .collect::<Vec<_>>();
+ let commands = self.commands.clone();
+ let command_name = command_name.to_string();
+ let executor = cx.background_executor().clone();
+ executor.clone().spawn(async move {
+ let matches = match_strings(
+ &candidates,
+ &command_name,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor,
+ )
+ .await;
+
+ Ok(matches
+ .into_iter()
+ .filter_map(|mat| {
+ let command = commands.command(&mat.string)?;
+ let mut new_text = mat.string.clone();
+ if command.requires_argument() {
+ new_text.push(' ');
+ }
+
+ Some(project::Completion {
+ old_range: range.clone(),
+ documentation: Some(Documentation::SingleLine(command.description())),
+ new_text,
+ label: CodeLabel::plain(mat.string, None),
+ server_id: LanguageServerId(0),
+ lsp_completion: Default::default(),
+ })
+ })
+ .collect())
+ })
+ }
+
+ fn complete_command_argument(
+ &self,
+ command_name: &str,
+ argument: String,
+ range: Range<Anchor>,
+ cx: &mut AppContext,
+ ) -> Task<Result<Vec<project::Completion>>> {
+ let new_cancel_flag = Arc::new(AtomicBool::new(false));
+ let mut flag = self.cancel_flag.lock();
+ flag.store(true, SeqCst);
+ *flag = new_cancel_flag.clone();
+
+ if let Some(command) = self.commands.command(command_name) {
+ let completions = command.complete_argument(argument, new_cancel_flag.clone(), cx);
+ cx.background_executor().spawn(async move {
+ Ok(completions
+ .await?
+ .into_iter()
+ .map(|arg| project::Completion {
+ old_range: range.clone(),
+ label: CodeLabel::plain(arg.clone(), None),
+ new_text: arg.clone(),
+ documentation: None,
+ server_id: LanguageServerId(0),
+ lsp_completion: Default::default(),
+ })
+ .collect())
+ })
+ } else {
+ cx.background_executor()
+ .spawn(async move { Ok(Vec::new()) })
+ }
+ }
+}
+
+impl CompletionProvider for SlashCommandCompletionProvider {
+ fn completions(
+ &self,
+ buffer: &Model<Buffer>,
+ buffer_position: Anchor,
+ cx: &mut ViewContext<Editor>,
+ ) -> Task<Result<Vec<project::Completion>>> {
+ let task = buffer.update(cx, |buffer, cx| {
+ let position = buffer_position.to_point(buffer);
+ let line_start = Point::new(position.row, 0);
+ let mut lines = buffer.text_for_range(line_start..position).lines();
+ let line = lines.next()?;
+ let call = SlashCommandLine::parse(line)?;
+
+ let name = &line[call.name.clone()];
+ if let Some(argument) = call.argument {
+ let start = buffer.anchor_after(Point::new(position.row, argument.start as u32));
+ let argument = line[argument.clone()].to_string();
+ Some(self.complete_command_argument(name, argument, start..buffer_position, cx))
+ } else {
+ let start = buffer.anchor_after(Point::new(position.row, call.name.start as u32));
+ Some(self.complete_command_name(name, start..buffer_position, cx))
+ }
+ });
+
+ task.unwrap_or_else(|| Task::ready(Ok(Vec::new())))
+ }
+
+ fn resolve_completions(
+ &self,
+ _: Model<Buffer>,
+ _: Vec<usize>,
+ _: Arc<RwLock<Box<[project::Completion]>>>,
+ _: &mut ViewContext<Editor>,
+ ) -> Task<Result<bool>> {
+ Task::ready(Ok(true))
+ }
+
+ fn apply_additional_edits_for_completion(
+ &self,
+ _: Model<Buffer>,
+ _: project::Completion,
+ _: bool,
+ _: &mut ViewContext<Editor>,
+ ) -> Task<Result<Option<language::Transaction>>> {
+ Task::ready(Ok(None))
+ }
+
+ fn is_completion_trigger(
+ &self,
+ buffer: &Model<Buffer>,
+ position: language::Anchor,
+ _text: &str,
+ _trigger_in_words: bool,
+ cx: &mut ViewContext<Editor>,
+ ) -> bool {
+ let buffer = buffer.read(cx);
+ let position = position.to_point(buffer);
+ let line_start = Point::new(position.row, 0);
+ let mut lines = buffer.text_for_range(line_start..position).lines();
+ if let Some(line) = lines.next() {
+ SlashCommandLine::parse(line).is_some()
+ } else {
+ false
+ }
+ }
+}
+
+impl SlashCommandLine {
+ pub(crate) fn parse(line: &str) -> Option<Self> {
+ let mut call: Option<Self> = None;
+ let mut ix = 0;
+ for c in line.chars() {
+ let next_ix = ix + c.len_utf8();
+ if let Some(call) = &mut call {
+ // The command arguments start at the first non-whitespace character
+ // after the command name, and continue until the end of the line.
+ if let Some(argument) = &mut call.argument {
+ if (*argument).is_empty() && c.is_whitespace() {
+ argument.start = next_ix;
+ }
+ argument.end = next_ix;
+ }
+ // The command name ends at the first whitespace character.
+ else if !call.name.is_empty() {
+ if c.is_whitespace() {
+ call.argument = Some(next_ix..next_ix);
+ } else {
+ call.name.end = next_ix;
+ }
+ }
+ // The command name must begin with a letter.
+ else if c.is_alphabetic() {
+ call.name.end = next_ix;
+ } else {
+ return None;
+ }
+ }
+ // Commands start with a slash.
+ else if c == '/' {
+ call = Some(SlashCommandLine {
+ name: next_ix..next_ix,
+ argument: None,
+ });
+ }
+ // The line can't contain anything before the slash except for whitespace.
+ else if !c.is_whitespace() {
+ return None;
+ }
+ ix = next_ix;
+ }
+ call
+ }
+}
@@ -0,0 +1,135 @@
+use std::{borrow::Cow, cell::Cell, rc::Rc};
+
+use anyhow::{anyhow, Result};
+use collections::HashMap;
+use editor::Editor;
+use futures::channel::oneshot;
+use gpui::{AppContext, Entity, Subscription, Task, WindowHandle};
+use workspace::{Event as WorkspaceEvent, Workspace};
+
+use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
+
+pub(crate) struct CurrentFileSlashCommand {
+ workspace: WindowHandle<Workspace>,
+}
+
+impl CurrentFileSlashCommand {
+ pub fn new(workspace: WindowHandle<Workspace>) -> Self {
+ Self { workspace }
+ }
+}
+
+impl SlashCommand for CurrentFileSlashCommand {
+ fn name(&self) -> String {
+ "current_file".into()
+ }
+
+ fn description(&self) -> String {
+ "insert the current file".into()
+ }
+
+ fn complete_argument(
+ &self,
+ _query: String,
+ _cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
+ _cx: &mut AppContext,
+ ) -> Task<Result<Vec<String>>> {
+ Task::ready(Err(anyhow!("this command does not require argument")))
+ }
+
+ fn requires_argument(&self) -> bool {
+ false
+ }
+
+ fn run(&self, _argument: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation {
+ let (invalidate_tx, invalidate_rx) = oneshot::channel();
+ let invalidate_tx = Rc::new(Cell::new(Some(invalidate_tx)));
+ let mut subscriptions: Vec<Subscription> = Vec::new();
+ let output = self.workspace.update(cx, |workspace, cx| {
+ let mut timestamps_by_entity_id = HashMap::default();
+ for pane in workspace.panes() {
+ let pane = pane.read(cx);
+ for entry in pane.activation_history() {
+ timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp);
+ }
+ }
+
+ let mut most_recent_buffer = None;
+ for editor in workspace.items_of_type::<Editor>(cx) {
+ let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
+ continue;
+ };
+
+ let timestamp = timestamps_by_entity_id
+ .get(&editor.entity_id())
+ .copied()
+ .unwrap_or_default();
+ if most_recent_buffer
+ .as_ref()
+ .map_or(true, |(_, prev_timestamp)| timestamp > *prev_timestamp)
+ {
+ most_recent_buffer = Some((buffer, timestamp));
+ }
+ }
+
+ subscriptions.push({
+ let workspace_view = cx.view().clone();
+ let invalidate_tx = invalidate_tx.clone();
+ cx.window_context()
+ .subscribe(&workspace_view, move |_workspace, event, _cx| match event {
+ WorkspaceEvent::ActiveItemChanged
+ | WorkspaceEvent::ItemAdded
+ | WorkspaceEvent::ItemRemoved
+ | WorkspaceEvent::PaneAdded(_)
+ | WorkspaceEvent::PaneRemoved => {
+ if let Some(invalidate_tx) = invalidate_tx.take() {
+ _ = invalidate_tx.send(());
+ }
+ }
+ _ => {}
+ })
+ });
+
+ if let Some((buffer, _)) = most_recent_buffer {
+ subscriptions.push({
+ let invalidate_tx = invalidate_tx.clone();
+ cx.window_context().observe(&buffer, move |_buffer, _cx| {
+ if let Some(invalidate_tx) = invalidate_tx.take() {
+ _ = invalidate_tx.send(());
+ }
+ })
+ });
+
+ let snapshot = buffer.read(cx).snapshot();
+ let path = snapshot.resolve_file_path(cx, true);
+ cx.background_executor().spawn(async move {
+ let path = path
+ .as_ref()
+ .map(|path| path.to_string_lossy())
+ .unwrap_or_else(|| Cow::Borrowed("untitled"));
+
+ let mut output = String::with_capacity(path.len() + snapshot.len() + 9);
+ output.push_str("```");
+ output.push_str(&path);
+ output.push('\n');
+ for chunk in snapshot.as_rope().chunks() {
+ output.push_str(chunk);
+ }
+ if !output.ends_with('\n') {
+ output.push('\n');
+ }
+ output.push_str("```");
+ Ok(output)
+ })
+ } else {
+ Task::ready(Err(anyhow!("no recent buffer found")))
+ }
+ });
+
+ SlashCommandInvocation {
+ output: output.unwrap_or_else(|error| Task::ready(Err(error))),
+ invalidated: invalidate_rx,
+ cleanup: SlashCommandCleanup::new(move || drop(subscriptions)),
+ }
+ }
+}
@@ -0,0 +1,145 @@
+use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
+use anyhow::Result;
+use futures::channel::oneshot;
+use fuzzy::PathMatch;
+use gpui::{AppContext, Model, Task};
+use project::{PathMatchCandidateSet, Project};
+use std::{
+ path::Path,
+ sync::{atomic::AtomicBool, Arc},
+};
+
+pub(crate) struct FileSlashCommand {
+ project: Model<Project>,
+}
+
+impl FileSlashCommand {
+ pub fn new(project: Model<Project>) -> Self {
+ Self { project }
+ }
+
+ fn search_paths(
+ &self,
+ query: String,
+ cancellation_flag: Arc<AtomicBool>,
+ cx: &mut AppContext,
+ ) -> Task<Vec<PathMatch>> {
+ let worktrees = self
+ .project
+ .read(cx)
+ .visible_worktrees(cx)
+ .collect::<Vec<_>>();
+ let include_root_name = worktrees.len() > 1;
+ let candidate_sets = worktrees
+ .into_iter()
+ .map(|worktree| {
+ let worktree = worktree.read(cx);
+ PathMatchCandidateSet {
+ snapshot: worktree.snapshot(),
+ include_ignored: worktree
+ .root_entry()
+ .map_or(false, |entry| entry.is_ignored),
+ include_root_name,
+ directories_only: false,
+ }
+ })
+ .collect::<Vec<_>>();
+
+ let executor = cx.background_executor().clone();
+ cx.foreground_executor().spawn(async move {
+ fuzzy::match_path_sets(
+ candidate_sets.as_slice(),
+ query.as_str(),
+ None,
+ false,
+ 100,
+ &cancellation_flag,
+ executor,
+ )
+ .await
+ })
+ }
+}
+
+impl SlashCommand for FileSlashCommand {
+ fn name(&self) -> String {
+ "file".into()
+ }
+
+ fn description(&self) -> String {
+ "insert an entire file".into()
+ }
+
+ fn requires_argument(&self) -> bool {
+ true
+ }
+
+ fn complete_argument(
+ &self,
+ query: String,
+ cancellation_flag: Arc<AtomicBool>,
+ cx: &mut AppContext,
+ ) -> gpui::Task<Result<Vec<String>>> {
+ let paths = self.search_paths(query, cancellation_flag, cx);
+ cx.background_executor().spawn(async move {
+ Ok(paths
+ .await
+ .into_iter()
+ .map(|path_match| {
+ format!(
+ "{}{}",
+ path_match.path_prefix,
+ path_match.path.to_string_lossy()
+ )
+ })
+ .collect())
+ })
+ }
+
+ fn run(&self, argument: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation {
+ let project = self.project.read(cx);
+ let Some(argument) = argument else {
+ return SlashCommandInvocation {
+ output: Task::ready(Err(anyhow::anyhow!("missing path"))),
+ invalidated: oneshot::channel().1,
+ cleanup: SlashCommandCleanup::default(),
+ };
+ };
+
+ let path = Path::new(argument);
+ let abs_path = project.worktrees().find_map(|worktree| {
+ let worktree = worktree.read(cx);
+ worktree.entry_for_path(path)?;
+ worktree.absolutize(path).ok()
+ });
+
+ let Some(abs_path) = abs_path else {
+ return SlashCommandInvocation {
+ output: Task::ready(Err(anyhow::anyhow!("missing path"))),
+ invalidated: oneshot::channel().1,
+ cleanup: SlashCommandCleanup::default(),
+ };
+ };
+
+ let fs = project.fs().clone();
+ let argument = argument.to_string();
+ let output = cx.background_executor().spawn(async move {
+ let content = fs.load(&abs_path).await?;
+ let mut output = String::with_capacity(argument.len() + content.len() + 9);
+ output.push_str("```");
+ output.push_str(&argument);
+ output.push('\n');
+ output.push_str(&content);
+ if !output.ends_with('\n') {
+ output.push('\n');
+ }
+ output.push_str("```");
+ Ok(output)
+ });
+ SlashCommandInvocation {
+ output,
+ invalidated: oneshot::channel().1,
+ cleanup: SlashCommandCleanup::default(),
+ }
+ }
+}
@@ -0,0 +1,88 @@
+use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
+use crate::PromptLibrary;
+use anyhow::{anyhow, Context, Result};
+use futures::channel::oneshot;
+use fuzzy::StringMatchCandidate;
+use gpui::{AppContext, Task};
+use std::sync::{atomic::AtomicBool, Arc};
+
+pub(crate) struct PromptSlashCommand {
+ library: Arc<PromptLibrary>,
+}
+
+impl PromptSlashCommand {
+ pub fn new(library: Arc<PromptLibrary>) -> Self {
+ Self { library }
+ }
+}
+
+impl SlashCommand for PromptSlashCommand {
+ fn name(&self) -> String {
+ "prompt".into()
+ }
+
+ fn description(&self) -> String {
+ "insert a prompt from the library".into()
+ }
+
+ fn requires_argument(&self) -> bool {
+ true
+ }
+
+ fn complete_argument(
+ &self,
+ query: String,
+ cancellation_flag: Arc<AtomicBool>,
+ cx: &mut AppContext,
+ ) -> Task<Result<Vec<String>>> {
+ let library = self.library.clone();
+ let executor = cx.background_executor().clone();
+ cx.background_executor().spawn(async move {
+ let candidates = library
+ .prompts()
+ .into_iter()
+ .enumerate()
+ .map(|(ix, prompt)| StringMatchCandidate::new(ix, prompt.title))
+ .collect::<Vec<_>>();
+ let matches = fuzzy::match_strings(
+ &candidates,
+ &query,
+ false,
+ 100,
+ &cancellation_flag,
+ executor,
+ )
+ .await;
+ Ok(matches
+ .into_iter()
+ .map(|mat| candidates[mat.candidate_id].string.clone())
+ .collect())
+ })
+ }
+
+ fn run(&self, title: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation {
+ let Some(title) = title else {
+ return SlashCommandInvocation {
+ output: Task::ready(Err(anyhow!("missing prompt name"))),
+ invalidated: oneshot::channel().1,
+ cleanup: SlashCommandCleanup::default(),
+ };
+ };
+
+ let library = self.library.clone();
+ let title = title.to_string();
+ let output = cx.background_executor().spawn(async move {
+ let prompt = library
+ .prompts()
+ .into_iter()
+ .find(|prompt| prompt.title == title)
+ .with_context(|| format!("no prompt found with title {:?}", title))?;
+ Ok(prompt.prompt)
+ });
+ SlashCommandInvocation {
+ output,
+ invalidated: oneshot::channel().1,
+ cleanup: SlashCommandCleanup::default(),
+ }
+ }
+}
@@ -75,6 +75,17 @@ impl CompletionProvider for MessageEditorCompletionProvider {
) -> Task<Result<Option<language::Transaction>>> {
Task::ready(Ok(None))
}
+
+ fn is_completion_trigger(
+ &self,
+ _buffer: &Model<Buffer>,
+ _position: language::Anchor,
+ text: &str,
+ _trigger_in_words: bool,
+ _cx: &mut ViewContext<Editor>,
+ ) -> bool {
+ text == "@"
+ }
}
impl MessageEditor {
@@ -449,6 +449,9 @@ pub struct Editor {
mode: EditorMode,
show_breadcrumbs: bool,
show_gutter: bool,
+ show_line_numbers: Option<bool>,
+ show_git_diff_gutter: Option<bool>,
+ show_code_actions: Option<bool>,
show_wrap_guides: Option<bool>,
placeholder_text: Option<Arc<str>>,
highlight_order: usize,
@@ -517,6 +520,9 @@ pub struct Editor {
pub struct EditorSnapshot {
pub mode: EditorMode,
show_gutter: bool,
+ show_line_numbers: Option<bool>,
+ show_git_diff_gutter: Option<bool>,
+ show_code_actions: Option<bool>,
render_git_blame_gutter: bool,
pub display_snapshot: DisplaySnapshot,
pub placeholder_text: Option<Arc<str>>,
@@ -1646,6 +1652,9 @@ impl Editor {
mode,
show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs,
show_gutter: mode == EditorMode::Full,
+ show_line_numbers: None,
+ show_git_diff_gutter: None,
+ show_code_actions: None,
show_wrap_guides: None,
placeholder_text: None,
highlight_order: 0,
@@ -1881,6 +1890,9 @@ impl Editor {
EditorSnapshot {
mode: self.mode,
show_gutter: self.show_gutter,
+ show_line_numbers: self.show_line_numbers,
+ show_git_diff_gutter: self.show_git_diff_gutter,
+ show_code_actions: self.show_code_actions,
render_git_blame_gutter: self.render_git_blame_gutter(cx),
display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)),
scroll_anchor: self.scroll_manager.anchor(),
@@ -1933,8 +1945,8 @@ impl Editor {
self.custom_context_menu = Some(Box::new(f))
}
- pub fn set_completion_provider(&mut self, hub: Box<dyn CompletionProvider>) {
- self.completion_provider = Some(hub);
+ pub fn set_completion_provider(&mut self, provider: Box<dyn CompletionProvider>) {
+ self.completion_provider = Some(provider);
}
pub fn set_inline_completion_provider<T>(
@@ -3280,22 +3292,41 @@ impl Editor {
trigger_in_words: bool,
cx: &mut ViewContext<Self>,
) {
- if !EditorSettings::get_global(cx).show_completions_on_input {
- return;
- }
-
- let selection = self.selections.newest_anchor();
- if self
- .buffer
- .read(cx)
- .is_completion_trigger(selection.head(), text, trigger_in_words, cx)
- {
+ if self.is_completion_trigger(text, trigger_in_words, cx) {
self.show_completions(&ShowCompletions, cx);
} else {
self.hide_context_menu(cx);
}
}
+ fn is_completion_trigger(
+ &self,
+ text: &str,
+ trigger_in_words: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> bool {
+ let position = self.selections.newest_anchor().head();
+ let multibuffer = self.buffer.read(cx);
+ let Some(buffer) = position
+ .buffer_id
+ .and_then(|buffer_id| multibuffer.buffer(buffer_id).clone())
+ else {
+ return false;
+ };
+
+ if let Some(completion_provider) = &self.completion_provider {
+ completion_provider.is_completion_trigger(
+ &buffer,
+ position.text_anchor,
+ text,
+ trigger_in_words,
+ cx,
+ )
+ } else {
+ false
+ }
+ }
+
/// If any empty selections is touching the start of its innermost containing autoclose
/// region, expand it to select the brackets.
fn select_autoclose_pair(&mut self, cx: &mut ViewContext<Self>) {
@@ -9613,8 +9644,27 @@ impl Editor {
cx.notify();
}
- pub fn set_show_wrap_guides(&mut self, show_gutter: bool, cx: &mut ViewContext<Self>) {
- self.show_wrap_guides = Some(show_gutter);
+ pub fn set_show_line_numbers(&mut self, show_line_numbers: bool, cx: &mut ViewContext<Self>) {
+ self.show_line_numbers = Some(show_line_numbers);
+ cx.notify();
+ }
+
+ pub fn set_show_git_diff_gutter(
+ &mut self,
+ show_git_diff_gutter: bool,
+ cx: &mut ViewContext<Self>,
+ ) {
+ self.show_git_diff_gutter = Some(show_git_diff_gutter);
+ cx.notify();
+ }
+
+ pub fn set_show_code_actions(&mut self, show_code_actions: bool, cx: &mut ViewContext<Self>) {
+ self.show_code_actions = Some(show_code_actions);
+ cx.notify();
+ }
+
+ pub fn set_show_wrap_guides(&mut self, show_wrap_guides: bool, cx: &mut ViewContext<Self>) {
+ self.show_wrap_guides = Some(show_wrap_guides);
cx.notify();
}
@@ -10888,6 +10938,15 @@ pub trait CompletionProvider {
push_to_history: bool,
cx: &mut ViewContext<Editor>,
) -> Task<Result<Option<language::Transaction>>>;
+
+ fn is_completion_trigger(
+ &self,
+ buffer: &Model<Buffer>,
+ position: language::Anchor,
+ text: &str,
+ trigger_in_words: bool,
+ cx: &mut ViewContext<Editor>,
+ ) -> bool;
}
impl CompletionProvider for Model<Project> {
@@ -10925,6 +10984,40 @@ impl CompletionProvider for Model<Project> {
project.apply_additional_edits_for_completion(buffer, completion, push_to_history, cx)
})
}
+
+ fn is_completion_trigger(
+ &self,
+ buffer: &Model<Buffer>,
+ position: language::Anchor,
+ text: &str,
+ trigger_in_words: bool,
+ cx: &mut ViewContext<Editor>,
+ ) -> bool {
+ if !EditorSettings::get_global(cx).show_completions_on_input {
+ return false;
+ }
+
+ let mut chars = text.chars();
+ let char = if let Some(char) = chars.next() {
+ char
+ } else {
+ return false;
+ };
+ if chars.next().is_some() {
+ return false;
+ }
+
+ let buffer = buffer.read(cx);
+ let scope = buffer.snapshot().language_scope_at(position);
+ if trigger_in_words && char_kind(&scope, char) == CharKind::Word {
+ return true;
+ }
+
+ buffer
+ .completion_triggers()
+ .iter()
+ .any(|string| string == text)
+ }
}
fn inlay_hint_settings(
@@ -11030,13 +11123,17 @@ impl EditorSnapshot {
}
let descent = cx.text_system().descent(font_id, font_size);
- let show_git_gutter = matches!(
- ProjectSettings::get_global(cx).git.git_gutter,
- Some(GitGutterSetting::TrackedFiles)
- );
+ let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| {
+ matches!(
+ ProjectSettings::get_global(cx).git.git_gutter,
+ Some(GitGutterSetting::TrackedFiles)
+ )
+ });
let gutter_settings = EditorSettings::get_global(cx).gutter;
- let gutter_lines_enabled = gutter_settings.line_numbers;
- let line_gutter_width = if gutter_lines_enabled {
+ let show_line_numbers = self
+ .show_line_numbers
+ .unwrap_or_else(|| gutter_settings.line_numbers);
+ let line_gutter_width = if show_line_numbers {
// Avoid flicker-like gutter resizes when the line number gains another digit and only resize the gutter on files with N*10^5 lines.
let min_width_for_number_on_gutter = em_width * 4.0;
max_line_number_width.max(min_width_for_number_on_gutter)
@@ -11044,26 +11141,30 @@ impl EditorSnapshot {
0.0.into()
};
+ let show_code_actions = self
+ .show_code_actions
+ .unwrap_or_else(|| gutter_settings.code_actions);
+
let git_blame_entries_width = self
.render_git_blame_gutter
.then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS);
let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO);
- left_padding += if gutter_settings.code_actions {
+ left_padding += if show_code_actions {
em_width * 3.0
- } else if show_git_gutter && gutter_lines_enabled {
+ } else if show_git_gutter && show_line_numbers {
em_width * 2.0
- } else if show_git_gutter || gutter_lines_enabled {
+ } else if show_git_gutter || show_line_numbers {
em_width
} else {
px(0.)
};
- let right_padding = if gutter_settings.folds && gutter_lines_enabled {
+ let right_padding = if gutter_settings.folds && show_line_numbers {
em_width * 4.0
} else if gutter_settings.folds {
em_width * 3.0
- } else if gutter_lines_enabled {
+ } else if show_line_numbers {
em_width
} else {
px(0.)
@@ -1623,6 +1623,13 @@ impl EditorElement {
snapshot: &EditorSnapshot,
cx: &mut WindowContext,
) -> Vec<Option<ShapedLine>> {
+ let include_line_numbers = snapshot.show_line_numbers.unwrap_or_else(|| {
+ EditorSettings::get_global(cx).gutter.line_numbers && snapshot.mode == EditorMode::Full
+ });
+ if !include_line_numbers {
+ return Vec::new();
+ }
+
let editor = self.editor.read(cx);
let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
let newest = editor.selections.newest::<Point>(cx);
@@ -1638,54 +1645,47 @@ impl EditorElement {
.head
});
let font_size = self.style.text.font_size.to_pixels(cx.rem_size());
- let include_line_numbers =
- EditorSettings::get_global(cx).gutter.line_numbers && snapshot.mode == EditorMode::Full;
- let mut shaped_line_numbers = Vec::with_capacity(rows.len());
- let mut line_number = String::new();
+
let is_relative = EditorSettings::get_global(cx).relative_line_numbers;
let relative_to = if is_relative {
Some(newest_selection_head.row())
} else {
None
};
-
let relative_rows = self.calculate_relative_line_numbers(snapshot, &rows, relative_to);
-
- for (ix, row) in buffer_rows.into_iter().enumerate() {
- let display_row = DisplayRow(rows.start.0 + ix as u32);
- let color = if active_rows.contains_key(&display_row) {
- cx.theme().colors().editor_active_line_number
- } else {
- cx.theme().colors().editor_line_number
- };
- if let Some(multibuffer_row) = row {
- if include_line_numbers {
- line_number.clear();
- let default_number = multibuffer_row.0 + 1;
- let number = relative_rows
- .get(&DisplayRow(ix as u32 + rows.start.0))
- .unwrap_or(&default_number);
- write!(&mut line_number, "{number}").unwrap();
- let run = TextRun {
- len: line_number.len(),
- font: self.style.text.font(),
- color,
- background_color: None,
- underline: None,
- strikethrough: None,
- };
- let shaped_line = cx
- .text_system()
- .shape_line(line_number.clone().into(), font_size, &[run])
- .unwrap();
- shaped_line_numbers.push(Some(shaped_line));
- }
- } else {
- shaped_line_numbers.push(None);
- }
- }
-
- shaped_line_numbers
+ let mut line_number = String::new();
+ buffer_rows
+ .into_iter()
+ .enumerate()
+ .map(|(ix, multibuffer_row)| {
+ let multibuffer_row = multibuffer_row?;
+ let display_row = DisplayRow(rows.start.0 + ix as u32);
+ let color = if active_rows.contains_key(&display_row) {
+ cx.theme().colors().editor_active_line_number
+ } else {
+ cx.theme().colors().editor_line_number
+ };
+ line_number.clear();
+ let default_number = multibuffer_row.0 + 1;
+ let number = relative_rows
+ .get(&DisplayRow(ix as u32 + rows.start.0))
+ .unwrap_or(&default_number);
+ write!(&mut line_number, "{number}").unwrap();
+ let run = TextRun {
+ len: line_number.len(),
+ font: self.style.text.font(),
+ color,
+ background_color: None,
+ underline: None,
+ strikethrough: None,
+ };
+ let shaped_line = cx
+ .text_system()
+ .shape_line(line_number.clone().into(), font_size, &[run])
+ .unwrap();
+ Some(shaped_line)
+ })
+ .collect()
}
fn layout_gutter_fold_toggles(
@@ -2513,10 +2513,16 @@ impl EditorElement {
}
}
- let show_git_gutter = matches!(
- ProjectSettings::get_global(cx).git.git_gutter,
- Some(GitGutterSetting::TrackedFiles)
- );
+ let show_git_gutter = layout
+ .position_map
+ .snapshot
+ .show_git_diff_gutter
+ .unwrap_or_else(|| {
+ matches!(
+ ProjectSettings::get_global(cx).git.git_gutter,
+ Some(GitGutterSetting::TrackedFiles)
+ )
+ });
if show_git_gutter {
Self::paint_diff_hunks(layout.gutter_hitbox.bounds, layout, cx)
}
@@ -4281,7 +4287,11 @@ impl Element for EditorElement {
gutter_dimensions.width - gutter_dimensions.left_padding,
cx,
);
- if gutter_settings.code_actions {
+
+ let show_code_actions = snapshot
+ .show_code_actions
+ .unwrap_or_else(|| gutter_settings.code_actions);
+ if show_code_actions {
let newest_selection_point =
newest_selection_head.to_point(&snapshot.display_snapshot);
let buffer = snapshot.buffer_snapshot.buffer_line_for_row(
@@ -4443,6 +4443,9 @@ impl<V: 'static> From<WindowHandle<V>> for AnyWindowHandle {
}
}
+unsafe impl<V> Send for WindowHandle<V> {}
+unsafe impl<V> Sync for WindowHandle<V> {}
+
/// A handle to a window with any root view type, which can be downcast to a window with a specific root view type.
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct AnyWindowHandle {
@@ -110,6 +110,7 @@ impl MultiBufferRow {
pub const MIN: Self = Self(0);
pub const MAX: Self = Self(u32::MAX);
}
+
#[derive(Clone)]
struct History {
next_transaction_id: TransactionId,
@@ -1531,46 +1532,6 @@ impl MultiBuffer {
.map(|state| state.buffer.clone())
}
- pub fn is_completion_trigger(
- &self,
- position: Anchor,
- text: &str,
- trigger_in_words: bool,
- cx: &AppContext,
- ) -> bool {
- let mut chars = text.chars();
- let char = if let Some(char) = chars.next() {
- char
- } else {
- return false;
- };
- if chars.next().is_some() {
- return false;
- }
-
- let snapshot = self.snapshot(cx);
- let position = position.to_offset(&snapshot);
- let scope = snapshot.language_scope_at(position);
- if trigger_in_words && char_kind(&scope, char) == CharKind::Word {
- return true;
- }
-
- let anchor = snapshot.anchor_before(position);
- anchor
- .buffer_id
- .and_then(|buffer_id| {
- let buffer = self.buffers.borrow().get(&buffer_id)?.buffer.clone();
- Some(
- buffer
- .read(cx)
- .completion_triggers()
- .iter()
- .any(|string| string == text),
- )
- })
- .unwrap_or(false)
- }
-
pub fn language_at<T: ToOffset>(&self, point: T, cx: &AppContext) -> Option<Arc<Language>> {
self.point_to_buffer_offset(point, cx)
.and_then(|(buffer, offset, _)| buffer.read(cx).language_at(offset))
@@ -2166,6 +2166,31 @@ impl BufferSnapshot {
}
}
+ pub fn has_edits_since_in_range(&self, since: &clock::Global, range: Range<Anchor>) -> bool {
+ if *since != self.version {
+ let start_fragment_id = self.fragment_id_for_anchor(&range.start);
+ let end_fragment_id = self.fragment_id_for_anchor(&range.end);
+ let mut cursor = self
+ .fragments
+ .filter::<_, usize>(move |summary| !since.observed_all(&summary.max_version));
+ cursor.next(&None);
+ while let Some(fragment) = cursor.item() {
+ if fragment.id > *end_fragment_id {
+ break;
+ }
+ if fragment.id > *start_fragment_id {
+ let was_visible = fragment.was_visible(since, &self.undo_map);
+ let is_visible = fragment.visible;
+ if was_visible != is_visible {
+ return true;
+ }
+ }
+ cursor.next(&None);
+ }
+ }
+ false
+ }
+
pub fn has_edits_since(&self, since: &clock::Global) -> bool {
if *since != self.version {
let mut cursor = self