Rework context insertion UX (#12360)

- Confirming a completion now runs the command immediately
- Hitting `enter` on a line with a command now runs it
- The output of commands gets folded away and replaced with a custom
placeholder
- Eliminated ambient context

<img width="1588" alt="image"
src="https://github.com/zed-industries/zed/assets/482957/b1927a45-52d6-4634-acc9-2ee539c1d89a">

Release Notes:

- N/A

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
This commit is contained in:
Antonio Scandurra 2024-05-28 01:44:54 +02:00 committed by GitHub
parent 20f37f0647
commit 7e3ab9acc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1148 additions and 1534 deletions

3
Cargo.lock generated
View File

@ -434,10 +434,10 @@ dependencies = [
"anyhow",
"collections",
"derive_more",
"futures 0.3.28",
"gpui",
"language",
"parking_lot",
"workspace",
]
[[package]]
@ -3823,6 +3823,7 @@ dependencies = [
"wasmtime",
"wasmtime-wasi",
"wit-component",
"workspace",
]
[[package]]

View File

@ -0,0 +1 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 11L6 4L10.5 7.5L6 11Z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 164 B

View File

@ -211,7 +211,9 @@
"ctrl-s": "workspace::Save",
"ctrl->": "assistant::QuoteSelection",
"shift-enter": "assistant::Split",
"ctrl-r": "assistant::CycleMessageRole"
"ctrl-r": "assistant::CycleMessageRole",
"enter": "assistant::ConfirmCommand",
"alt-enter": "editor::Newline"
}
},
{

View File

@ -227,7 +227,9 @@
"cmd-s": "workspace::Save",
"cmd->": "assistant::QuoteSelection",
"shift-enter": "assistant::Split",
"ctrl-r": "assistant::CycleMessageRole"
"ctrl-r": "assistant::CycleMessageRole",
"enter": "assistant::ConfirmCommand",
"alt-enter": "editor::Newline"
}
},
{

View File

@ -1,30 +0,0 @@
mod current_project;
mod recent_buffers;
pub use current_project::*;
pub use recent_buffers::*;
#[derive(Default)]
pub struct AmbientContext {
pub recent_buffers: RecentBuffersContext,
pub current_project: CurrentProjectContext,
}
impl AmbientContext {
pub fn snapshot(&self) -> AmbientContextSnapshot {
AmbientContextSnapshot {
recent_buffers: self.recent_buffers.snapshot.clone(),
}
}
}
#[derive(Clone, Default, Debug)]
pub struct AmbientContextSnapshot {
pub recent_buffers: RecentBuffersSnapshot,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
pub enum ContextUpdated {
Updating,
Disabled,
}

View File

@ -1,180 +0,0 @@
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Result};
use fs::Fs;
use gpui::{AsyncAppContext, ModelContext, Task, WeakModel};
use project::{Project, ProjectPath};
use util::ResultExt;
use crate::ambient_context::ContextUpdated;
use crate::assistant_panel::Conversation;
use crate::{LanguageModelRequestMessage, Role};
/// Ambient context about the current project.
pub struct CurrentProjectContext {
pub enabled: bool,
pub message: String,
pub pending_message: Option<Task<()>>,
}
#[allow(clippy::derivable_impls)]
impl Default for CurrentProjectContext {
fn default() -> Self {
Self {
enabled: false,
message: String::new(),
pending_message: None,
}
}
}
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(),
})
.filter(|message| !message.content.is_empty())
}
/// Updates the [`CurrentProjectContext`] for the given [`Project`].
pub fn update(
&mut self,
fs: Arc<dyn Fs>,
project: WeakModel<Project>,
cx: &mut ModelContext<Conversation>,
) -> ContextUpdated {
if !self.enabled {
self.message.clear();
self.pending_message = None;
cx.notify();
return ContextUpdated::Disabled;
}
self.pending_message = Some(cx.spawn(|conversation, mut cx| async move {
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
let Some(path_to_cargo_toml) = Self::path_to_cargo_toml(project, &mut cx).log_err()
else {
return;
};
let Some(path_to_cargo_toml) = path_to_cargo_toml
.ok_or_else(|| anyhow!("no Cargo.toml"))
.log_err()
else {
return;
};
let message_task = cx
.background_executor()
.spawn(async move { Self::build_message(fs, &path_to_cargo_toml).await });
if let Some(message) = message_task.await.log_err() {
conversation
.update(&mut cx, |conversation, cx| {
conversation.ambient_context.current_project.message = message;
conversation.count_remaining_tokens(cx);
cx.notify();
})
.log_err();
}
}));
ContextUpdated::Updating
}
async fn build_message(fs: Arc<dyn Fs>, path_to_cargo_toml: &Path) -> Result<String> {
let buffer = fs.load(path_to_cargo_toml).await?;
let cargo_toml: cargo_toml::Manifest = toml::from_str(&buffer)?;
let mut message = String::new();
writeln!(message, "You are in a Rust project.")?;
if let Some(workspace) = cargo_toml.workspace {
writeln!(
message,
"The project is a Cargo workspace with the following members:"
)?;
for member in workspace.members {
writeln!(message, "- {member}")?;
}
if !workspace.default_members.is_empty() {
writeln!(message, "The default members are:")?;
for member in workspace.default_members {
writeln!(message, "- {member}")?;
}
}
if !workspace.dependencies.is_empty() {
writeln!(
message,
"The following workspace dependencies are installed:"
)?;
for dependency in workspace.dependencies.keys() {
writeln!(message, "- {dependency}")?;
}
}
} else if let Some(package) = cargo_toml.package {
writeln!(
message,
"The project name is \"{name}\".",
name = package.name
)?;
let description = package
.description
.as_ref()
.and_then(|description| description.get().ok().cloned());
if let Some(description) = description.as_ref() {
writeln!(message, "It describes itself as \"{description}\".")?;
}
if !cargo_toml.dependencies.is_empty() {
writeln!(message, "The following dependencies are installed:")?;
for dependency in cargo_toml.dependencies.keys() {
writeln!(message, "- {dependency}")?;
}
}
}
Ok(message)
}
fn path_to_cargo_toml(
project: WeakModel<Project>,
cx: &mut AsyncAppContext,
) -> Result<Option<PathBuf>> {
cx.update(|cx| {
let worktree = project.update(cx, |project, _cx| {
project
.worktrees()
.next()
.ok_or_else(|| anyhow!("no worktree"))
})??;
let path_to_cargo_toml = worktree.update(cx, |worktree, _cx| {
let cargo_toml = worktree.entry_for_path("Cargo.toml")?;
Some(ProjectPath {
worktree_id: worktree.id(),
path: cargo_toml.path.clone(),
})
});
let path_to_cargo_toml = path_to_cargo_toml.and_then(|path| {
project
.update(cx, |project, cx| project.absolute_path(&path, cx))
.ok()
.flatten()
});
Ok(path_to_cargo_toml)
})?
}
}

View File

@ -1,147 +0,0 @@
use crate::{assistant_panel::Conversation, LanguageModelRequestMessage, Role};
use gpui::{ModelContext, Subscription, Task, WeakModel};
use language::{Buffer, BufferSnapshot, Rope};
use std::{fmt::Write, path::PathBuf, time::Duration};
use super::ContextUpdated;
pub struct RecentBuffersContext {
pub enabled: bool,
pub buffers: Vec<RecentBuffer>,
pub snapshot: RecentBuffersSnapshot,
pub pending_message: Option<Task<()>>,
}
pub struct RecentBuffer {
pub buffer: WeakModel<Buffer>,
pub _subscription: Subscription,
}
impl Default for RecentBuffersContext {
fn default() -> Self {
Self {
enabled: true,
buffers: Vec::new(),
snapshot: RecentBuffersSnapshot::default(),
pending_message: None,
}
}
}
impl RecentBuffersContext {
pub fn update(&mut self, cx: &mut ModelContext<Conversation>) -> ContextUpdated {
let source_buffers = self
.buffers
.iter()
.filter_map(|recent| {
let (full_path, snapshot) = recent
.buffer
.read_with(cx, |buffer, cx| {
(
buffer.file().map(|file| file.full_path(cx)),
buffer.snapshot(),
)
})
.ok()?;
Some(SourceBufferSnapshot {
full_path,
model: recent.buffer.clone(),
snapshot,
})
})
.collect::<Vec<_>>();
if !self.enabled || source_buffers.is_empty() {
self.snapshot.message = Default::default();
self.snapshot.source_buffers.clear();
self.pending_message = None;
cx.notify();
ContextUpdated::Disabled
} else {
self.pending_message = Some(cx.spawn(|this, mut cx| async move {
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
let message = if source_buffers.is_empty() {
Rope::new()
} else {
cx.background_executor()
.spawn({
let source_buffers = source_buffers.clone();
async move { message_for_recent_buffers(source_buffers) }
})
.await
};
this.update(&mut cx, |this, cx| {
this.ambient_context.recent_buffers.snapshot.source_buffers = source_buffers;
this.ambient_context.recent_buffers.snapshot.message = message;
this.count_remaining_tokens(cx);
cx.notify();
})
.ok();
}));
ContextUpdated::Updating
}
}
/// 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(),
})
.filter(|message| !message.content.is_empty())
}
}
#[derive(Clone, Default, Debug)]
pub struct RecentBuffersSnapshot {
pub message: Rope,
pub source_buffers: Vec<SourceBufferSnapshot>,
}
#[derive(Clone)]
pub struct SourceBufferSnapshot {
pub full_path: Option<PathBuf>,
pub model: WeakModel<Buffer>,
pub snapshot: BufferSnapshot,
}
impl std::fmt::Debug for SourceBufferSnapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SourceBufferSnapshot")
.field("full_path", &self.full_path)
.field("model (entity id)", &self.model.entity_id())
.field("snapshot (text)", &self.snapshot.text())
.finish()
}
}
fn message_for_recent_buffers(buffers: Vec<SourceBufferSnapshot>) -> Rope {
let mut message = String::new();
writeln!(
message,
"The following is a list of recent buffers that the user has opened."
)
.unwrap();
for buffer in buffers {
if let Some(path) = buffer.full_path {
writeln!(message, "```{}", path.display()).unwrap();
} else {
writeln!(message, "```untitled").unwrap();
}
for chunk in buffer.snapshot.chunks(0..buffer.snapshot.len(), false) {
message.push_str(chunk.text);
}
if !message.ends_with('\n') {
message.push('\n');
}
message.push_str("```\n");
}
Rope::from(message.as_str())
}

View File

@ -1,17 +1,15 @@
mod ambient_context;
pub mod assistant_panel;
pub mod assistant_settings;
mod codegen;
mod completion_provider;
mod omit_ranges;
mod prompts;
mod saved_conversation;
mod search;
mod slash_command;
mod streaming_diff;
use ambient_context::AmbientContextSnapshot;
pub use assistant_panel::AssistantPanel;
use assistant_settings::{AnthropicModel, AssistantSettings, OpenAiModel, ZedDotDevModel};
use client::{proto, Client};
use command_palette_hooks::CommandPaletteFilter;
@ -38,7 +36,8 @@ actions!(
InsertActivePrompt,
ToggleIncludeConversation,
ToggleHistory,
ApplyEdit
ApplyEdit,
ConfirmCommand
]
);
@ -188,9 +187,6 @@ pub struct LanguageModelChoiceDelta {
struct MessageMetadata {
role: Role,
status: MessageStatus,
// TODO: Delete this
#[serde(skip)]
ambient_context: AmbientContextSnapshot,
}
#[derive(Clone, Debug, Serialize, Deserialize)]

File diff suppressed because it is too large Load Diff

View File

@ -1,101 +0,0 @@
use rope::Rope;
use std::{cmp::Ordering, ops::Range};
pub(crate) fn text_in_range_omitting_ranges(
rope: &Rope,
range: Range<usize>,
omit_ranges: &[Range<usize>],
) -> String {
let mut content = String::with_capacity(range.len());
let mut omit_ranges = omit_ranges
.iter()
.skip_while(|omit_range| omit_range.end <= range.start)
.peekable();
let mut offset = range.start;
let mut chunks = rope.chunks_in_range(range.clone());
while let Some(chunk) = chunks.next() {
if let Some(omit_range) = omit_ranges.peek() {
match offset.cmp(&omit_range.start) {
Ordering::Less => {
let max_len = omit_range.start - offset;
if chunk.len() < max_len {
content.push_str(chunk);
offset += chunk.len();
} else {
content.push_str(&chunk[..max_len]);
chunks.seek(omit_range.end.min(range.end));
offset = omit_range.end;
omit_ranges.next();
}
}
Ordering::Equal | Ordering::Greater => {
chunks.seek(omit_range.end.min(range.end));
offset = omit_range.end;
omit_ranges.next();
}
}
} else {
content.push_str(chunk);
offset += chunk.len();
}
}
content
}
#[cfg(test)]
mod tests {
use super::*;
use rand::{rngs::StdRng, Rng as _};
use util::RandomCharIter;
#[gpui::test(iterations = 100)]
fn test_text_in_range_omitting_ranges(mut rng: StdRng) {
let text = RandomCharIter::new(&mut rng).take(1024).collect::<String>();
let rope = Rope::from(text.as_str());
let mut start = rng.gen_range(0..=text.len() / 2);
let mut end = rng.gen_range(text.len() / 2..=text.len());
while !text.is_char_boundary(start) {
start -= 1;
}
while !text.is_char_boundary(end) {
end += 1;
}
let range = start..end;
let mut ix = 0;
let mut omit_ranges = Vec::new();
for _ in 0..rng.gen_range(0..10) {
let mut start = rng.gen_range(ix..=text.len());
while !text.is_char_boundary(start) {
start += 1;
}
let mut end = rng.gen_range(start..=text.len());
while !text.is_char_boundary(end) {
end += 1;
}
omit_ranges.push(start..end);
ix = end;
if ix == text.len() {
break;
}
}
let mut expected_text = text[range.clone()].to_string();
for omit_range in omit_ranges.iter().rev() {
let start = omit_range
.start
.saturating_sub(range.start)
.min(range.len());
let end = omit_range.end.saturating_sub(range.start).min(range.len());
expected_text.replace_range(start..end, "");
}
assert_eq!(
text_in_range_omitting_ranges(&rope, range.clone(), &omit_ranges),
expected_text,
"text: {text:?}\nrange: {range:?}\nomit_ranges: {omit_ranges:?}"
);
}
}

View File

@ -1,7 +1,9 @@
use crate::assistant_panel::ConversationEditor;
use anyhow::Result;
pub use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandRegistry};
use editor::{CompletionProvider, Editor};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{AppContext, Model, Task, ViewContext};
use gpui::{Model, Task, ViewContext, WeakView, WindowContext};
use language::{Anchor, Buffer, CodeLabel, Documentation, LanguageServerId, ToPoint};
use parking_lot::{Mutex, RwLock};
use rope::Point;
@ -12,18 +14,18 @@ use std::{
Arc,
},
};
use workspace::Workspace;
pub use assistant_slash_command::{
SlashCommand, SlashCommandCleanup, SlashCommandInvocation, SlashCommandRegistry,
};
pub mod current_file_command;
pub mod active_command;
pub mod file_command;
pub mod project_command;
pub mod prompt_command;
pub(crate) struct SlashCommandCompletionProvider {
editor: WeakView<ConversationEditor>,
commands: Arc<SlashCommandRegistry>,
cancel_flag: Mutex<Arc<AtomicBool>>,
workspace: WeakView<Workspace>,
}
pub(crate) struct SlashCommandLine {
@ -34,18 +36,25 @@ pub(crate) struct SlashCommandLine {
}
impl SlashCommandCompletionProvider {
pub fn new(commands: Arc<SlashCommandRegistry>) -> Self {
pub fn new(
editor: WeakView<ConversationEditor>,
commands: Arc<SlashCommandRegistry>,
workspace: WeakView<Workspace>,
) -> Self {
Self {
cancel_flag: Mutex::new(Arc::new(AtomicBool::new(false))),
editor,
commands,
workspace,
}
}
fn complete_command_name(
&self,
command_name: &str,
range: Range<Anchor>,
cx: &mut AppContext,
command_range: Range<Anchor>,
name_range: Range<Anchor>,
cx: &mut WindowContext,
) -> Task<Result<Vec<project::Completion>>> {
let candidates = self
.commands
@ -60,6 +69,8 @@ impl SlashCommandCompletionProvider {
.collect::<Vec<_>>();
let commands = self.commands.clone();
let command_name = command_name.to_string();
let editor = self.editor.clone();
let workspace = self.workspace.clone();
let executor = cx.background_executor().clone();
executor.clone().spawn(async move {
let matches = match_strings(
@ -77,17 +88,37 @@ impl SlashCommandCompletionProvider {
.filter_map(|mat| {
let command = commands.command(&mat.string)?;
let mut new_text = mat.string.clone();
if command.requires_argument() {
let requires_argument = command.requires_argument();
if requires_argument {
new_text.push(' ');
}
Some(project::Completion {
old_range: range.clone(),
old_range: name_range.clone(),
documentation: Some(Documentation::SingleLine(command.description())),
new_text,
label: CodeLabel::plain(mat.string, None),
label: CodeLabel::plain(mat.string.clone(), None),
server_id: LanguageServerId(0),
lsp_completion: Default::default(),
confirm: (!requires_argument).then(|| {
let command_name = mat.string.clone();
let command_range = command_range.clone();
let editor = editor.clone();
let workspace = workspace.clone();
Arc::new(move |cx: &mut WindowContext| {
editor
.update(cx, |editor, cx| {
editor.run_command(
command_range.clone(),
&command_name,
None,
workspace.clone(),
cx,
);
})
.ok();
}) as Arc<_>
}),
})
})
.collect())
@ -98,8 +129,9 @@ impl SlashCommandCompletionProvider {
&self,
command_name: &str,
argument: String,
range: Range<Anchor>,
cx: &mut AppContext,
command_range: Range<Anchor>,
argument_range: Range<Anchor>,
cx: &mut WindowContext,
) -> Task<Result<Vec<project::Completion>>> {
let new_cancel_flag = Arc::new(AtomicBool::new(false));
let mut flag = self.cancel_flag.lock();
@ -108,17 +140,39 @@ impl SlashCommandCompletionProvider {
if let Some(command) = self.commands.command(command_name) {
let completions = command.complete_argument(argument, new_cancel_flag.clone(), cx);
let command_name: Arc<str> = command_name.into();
let editor = self.editor.clone();
let workspace = self.workspace.clone();
cx.background_executor().spawn(async move {
Ok(completions
.await?
.into_iter()
.map(|arg| project::Completion {
old_range: range.clone(),
old_range: argument_range.clone(),
label: CodeLabel::plain(arg.clone(), None),
new_text: arg.clone(),
documentation: None,
server_id: LanguageServerId(0),
lsp_completion: Default::default(),
confirm: Some(Arc::new({
let command_name = command_name.clone();
let command_range = command_range.clone();
let editor = editor.clone();
let workspace = workspace.clone();
move |cx| {
editor
.update(cx, |editor, cx| {
editor.run_command(
command_range.clone(),
&command_name,
Some(&arg),
workspace.clone(),
cx,
);
})
.ok();
}
})),
})
.collect())
})
@ -136,25 +190,44 @@ impl CompletionProvider for SlashCommandCompletionProvider {
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 Some((name, argument, command_range, argument_range)) =
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))
}
});
let command_range_start = Point::new(position.row, call.name.start as u32 - 1);
let command_range_end = Point::new(
position.row,
call.argument.as_ref().map_or(call.name.end, |arg| arg.end) as u32,
);
let command_range = buffer.anchor_after(command_range_start)
..buffer.anchor_after(command_range_end);
task.unwrap_or_else(|| Task::ready(Ok(Vec::new())))
let name = line[call.name.clone()].to_string();
Some(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();
(name, Some(argument), command_range, start..buffer_position)
} else {
let start =
buffer.anchor_after(Point::new(position.row, call.name.start as u32));
(name, None, command_range, start..buffer_position)
})
})
else {
return Task::ready(Ok(Vec::new()));
};
if let Some(argument) = argument {
self.complete_command_argument(&name, argument, command_range, argument_range, cx)
} else {
self.complete_command_name(&name, command_range, argument_range, cx)
}
}
fn resolve_completions(

View File

@ -0,0 +1,117 @@
use super::{file_command::FilePlaceholder, SlashCommand, SlashCommandOutput};
use anyhow::{anyhow, Result};
use collections::HashMap;
use editor::Editor;
use gpui::{AppContext, Entity, Task, WeakView};
use language::LspAdapterDelegate;
use std::{borrow::Cow, sync::Arc};
use ui::{IntoElement, WindowContext};
use workspace::Workspace;
pub(crate) struct ActiveSlashCommand;
impl SlashCommand for ActiveSlashCommand {
fn name(&self) -> String {
"active".into()
}
fn description(&self) -> String {
"insert active tab".into()
}
fn tooltip_text(&self) -> String {
"insert active tab".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: Arc<Self>,
_argument: Option<&str>,
workspace: WeakView<Workspace>,
_delegate: Arc<dyn LspAdapterDelegate>,
cx: &mut WindowContext,
) -> Task<Result<SlashCommandOutput>> {
let output = 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));
}
}
if let Some((buffer, _)) = most_recent_buffer {
let snapshot = buffer.read(cx).snapshot();
let path = snapshot.resolve_file_path(cx, true);
let text = cx.background_executor().spawn({
let path = path.clone();
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("```");
output
}
});
cx.foreground_executor().spawn(async move {
Ok(SlashCommandOutput {
text: text.await,
render_placeholder: Arc::new(move |id, unfold, _| {
FilePlaceholder {
id,
path: path.clone(),
unfold,
}
.into_any_element()
}),
})
})
} else {
Task::ready(Err(anyhow!("no recent buffer found")))
}
});
output.unwrap_or_else(|error| Task::ready(Err(error)))
}
}

View File

@ -1,142 +0,0 @@
use std::sync::Arc;
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 language::LspAdapterDelegate;
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: Arc<Self>,
_argument: Option<&str>,
_delegate: Arc<dyn LspAdapterDelegate>,
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)),
}
}
}

View File

@ -1,14 +1,15 @@
use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
use super::{SlashCommand, SlashCommandOutput};
use anyhow::Result;
use futures::channel::oneshot;
use fuzzy::PathMatch;
use gpui::{AppContext, Model, Task};
use gpui::{AppContext, Model, RenderOnce, SharedString, Task, WeakView};
use language::LspAdapterDelegate;
use project::{PathMatchCandidateSet, Project};
use std::{
path::Path,
path::{Path, PathBuf},
sync::{atomic::AtomicBool, Arc},
};
use ui::{prelude::*, ButtonLike, ElevationIndex};
use workspace::Workspace;
pub(crate) struct FileSlashCommand {
project: Model<Project>,
@ -30,7 +31,6 @@ impl FileSlashCommand {
.read(cx)
.visible_worktrees(cx)
.collect::<Vec<_>>();
let include_root_name = worktrees.len() > 1;
let candidate_sets = worktrees
.into_iter()
.map(|worktree| {
@ -40,7 +40,7 @@ impl FileSlashCommand {
include_ignored: worktree
.root_entry()
.map_or(false, |entry| entry.is_ignored),
include_root_name,
include_root_name: true,
directories_only: false,
}
})
@ -68,7 +68,11 @@ impl SlashCommand for FileSlashCommand {
}
fn description(&self) -> String {
"insert an entire file".into()
"insert a file".into()
}
fn tooltip_text(&self) -> String {
"insert file".into()
}
fn requires_argument(&self) -> bool {
@ -100,36 +104,30 @@ impl SlashCommand for FileSlashCommand {
fn run(
self: Arc<Self>,
argument: Option<&str>,
_workspace: WeakView<Workspace>,
_delegate: Arc<dyn LspAdapterDelegate>,
cx: &mut AppContext,
) -> SlashCommandInvocation {
cx: &mut WindowContext,
) -> Task<Result<SlashCommandOutput>> {
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(),
};
return Task::ready(Err(anyhow::anyhow!("missing path")));
};
let path = Path::new(argument);
let path = PathBuf::from(argument);
let abs_path = project.worktrees().find_map(|worktree| {
let worktree = worktree.read(cx);
worktree.entry_for_path(path)?;
worktree.absolutize(path).ok()
let worktree_root_path = Path::new(worktree.root_name());
let relative_path = path.strip_prefix(worktree_root_path).ok()?;
worktree.absolutize(&relative_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(),
};
return Task::ready(Err(anyhow::anyhow!("missing path")));
};
let fs = project.fs().clone();
let argument = argument.to_string();
let output = cx.background_executor().spawn(async move {
let text = 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("```");
@ -140,12 +138,46 @@ impl SlashCommand for FileSlashCommand {
output.push('\n');
}
output.push_str("```");
Ok(output)
anyhow::Ok(output)
});
SlashCommandInvocation {
output,
invalidated: oneshot::channel().1,
cleanup: SlashCommandCleanup::default(),
}
cx.foreground_executor().spawn(async move {
let text = text.await?;
Ok(SlashCommandOutput {
text,
render_placeholder: Arc::new(move |id, unfold, _cx| {
FilePlaceholder {
path: Some(path.clone()),
id,
unfold,
}
.into_any_element()
}),
})
})
}
}
#[derive(IntoElement)]
pub struct FilePlaceholder {
pub path: Option<PathBuf>,
pub id: ElementId,
pub unfold: Arc<dyn Fn(&mut WindowContext)>,
}
impl RenderOnce for FilePlaceholder {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
let unfold = self.unfold;
let title = if let Some(path) = self.path.as_ref() {
SharedString::from(path.to_string_lossy().to_string())
} else {
SharedString::from("untitled")
};
ButtonLike::new(self.id)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::ElevatedSurface)
.child(Icon::new(IconName::File))
.child(Label::new(title))
.on_click(move |_, cx| unfold(cx))
}
}

View File

@ -0,0 +1,151 @@
use super::{SlashCommand, SlashCommandOutput};
use anyhow::{anyhow, Context, Result};
use fs::Fs;
use gpui::{AppContext, Model, Task, WeakView};
use language::LspAdapterDelegate;
use project::{Project, ProjectPath};
use std::{
fmt::Write,
path::Path,
sync::{atomic::AtomicBool, Arc},
};
use ui::{prelude::*, ButtonLike, ElevationIndex};
use workspace::Workspace;
pub(crate) struct ProjectSlashCommand;
impl ProjectSlashCommand {
async fn build_message(fs: Arc<dyn Fs>, path_to_cargo_toml: &Path) -> Result<String> {
let buffer = fs.load(path_to_cargo_toml).await?;
let cargo_toml: cargo_toml::Manifest = toml::from_str(&buffer)?;
let mut message = String::new();
writeln!(message, "You are in a Rust project.")?;
if let Some(workspace) = cargo_toml.workspace {
writeln!(
message,
"The project is a Cargo workspace with the following members:"
)?;
for member in workspace.members {
writeln!(message, "- {member}")?;
}
if !workspace.default_members.is_empty() {
writeln!(message, "The default members are:")?;
for member in workspace.default_members {
writeln!(message, "- {member}")?;
}
}
if !workspace.dependencies.is_empty() {
writeln!(
message,
"The following workspace dependencies are installed:"
)?;
for dependency in workspace.dependencies.keys() {
writeln!(message, "- {dependency}")?;
}
}
} else if let Some(package) = cargo_toml.package {
writeln!(
message,
"The project name is \"{name}\".",
name = package.name
)?;
let description = package
.description
.as_ref()
.and_then(|description| description.get().ok().cloned());
if let Some(description) = description.as_ref() {
writeln!(message, "It describes itself as \"{description}\".")?;
}
if !cargo_toml.dependencies.is_empty() {
writeln!(message, "The following dependencies are installed:")?;
for dependency in cargo_toml.dependencies.keys() {
writeln!(message, "- {dependency}")?;
}
}
}
Ok(message)
}
fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
let worktree = project.read(cx).worktrees().next()?;
let worktree = worktree.read(cx);
let entry = worktree.entry_for_path("Cargo.toml")?;
let path = ProjectPath {
worktree_id: worktree.id(),
path: entry.path.clone(),
};
Some(Arc::from(
project.read(cx).absolute_path(&path, cx)?.as_path(),
))
}
}
impl SlashCommand for ProjectSlashCommand {
fn name(&self) -> String {
"project".into()
}
fn description(&self) -> String {
"insert current project context".into()
}
fn tooltip_text(&self) -> String {
"insert current project context".into()
}
fn complete_argument(
&self,
_query: String,
_cancel: Arc<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: Arc<Self>,
_argument: Option<&str>,
workspace: WeakView<Workspace>,
_delegate: Arc<dyn LspAdapterDelegate>,
cx: &mut WindowContext,
) -> Task<Result<SlashCommandOutput>> {
let output = workspace.update(cx, |workspace, cx| {
let project = workspace.project().clone();
let fs = workspace.project().read(cx).fs().clone();
let path = Self::path_to_cargo_toml(project, cx);
let output = cx.background_executor().spawn(async move {
let path = path.with_context(|| "Cargo.toml not found")?;
Self::build_message(fs, &path).await
});
cx.foreground_executor().spawn(async move {
let text = output.await?;
Ok(SlashCommandOutput {
text,
render_placeholder: Arc::new(move |id, unfold, _cx| {
ButtonLike::new(id)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::ElevatedSurface)
.child(Icon::new(IconName::FileTree))
.child(Label::new("Project"))
.on_click(move |_, cx| unfold(cx))
.into_any_element()
}),
})
})
});
output.unwrap_or_else(|error| Task::ready(Err(error)))
}
}

View File

@ -1,11 +1,12 @@
use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
use super::{SlashCommand, SlashCommandOutput};
use crate::prompts::PromptLibrary;
use anyhow::{anyhow, Context, Result};
use futures::channel::oneshot;
use fuzzy::StringMatchCandidate;
use gpui::{AppContext, Task};
use gpui::{AppContext, Task, WeakView};
use language::LspAdapterDelegate;
use std::sync::{atomic::AtomicBool, Arc};
use ui::{prelude::*, ButtonLike, ElevationIndex};
use workspace::Workspace;
pub(crate) struct PromptSlashCommand {
library: Arc<PromptLibrary>,
@ -26,6 +27,10 @@ impl SlashCommand for PromptSlashCommand {
"insert a prompt from the library".into()
}
fn tooltip_text(&self) -> String {
"insert prompt".into()
}
fn requires_argument(&self) -> bool {
true
}
@ -64,32 +69,43 @@ impl SlashCommand for PromptSlashCommand {
fn run(
self: Arc<Self>,
title: Option<&str>,
_workspace: WeakView<Workspace>,
_delegate: Arc<dyn LspAdapterDelegate>,
cx: &mut AppContext,
) -> SlashCommandInvocation {
cx: &mut WindowContext,
) -> Task<Result<SlashCommandOutput>> {
let Some(title) = title else {
return SlashCommandInvocation {
output: Task::ready(Err(anyhow!("missing prompt name"))),
invalidated: oneshot::channel().1,
cleanup: SlashCommandCleanup::default(),
};
return Task::ready(Err(anyhow!("missing prompt name")));
};
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.1.title().to_string() == &title)
.with_context(|| format!("no prompt found with title {:?}", title))?
.1;
Ok(prompt.body())
let title = SharedString::from(title.to_string());
let prompt = cx.background_executor().spawn({
let title = title.clone();
async move {
let prompt = library
.prompts()
.into_iter()
.map(|prompt| (prompt.1.title(), prompt))
.find(|(t, _)| t == &title)
.with_context(|| format!("no prompt found with title {:?}", title))?
.1;
anyhow::Ok(prompt.1.body())
}
});
SlashCommandInvocation {
output,
invalidated: oneshot::channel().1,
cleanup: SlashCommandCleanup::default(),
}
cx.foreground_executor().spawn(async move {
let prompt = prompt.await?;
Ok(SlashCommandOutput {
text: prompt,
render_placeholder: Arc::new(move |id, unfold, _cx| {
ButtonLike::new(id)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::ElevatedSurface)
.child(Icon::new(IconName::Library))
.child(Label::new(title.clone()))
.on_click(move |_, cx| unfold(cx))
.into_any_element()
}),
})
})
}
}

View File

@ -15,7 +15,7 @@ path = "src/assistant_slash_command.rs"
anyhow.workspace = true
collections.workspace = true
derive_more.workspace = true
futures.workspace = true
gpui.workspace = true
language.workspace = true
parking_lot.workspace = true
workspace.workspace = true

View File

@ -1,14 +1,11 @@
mod slash_command_registry;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use anyhow::Result;
use futures::channel::oneshot;
use gpui::{AppContext, Task};
use gpui::{AnyElement, AppContext, ElementId, Task, WeakView, WindowContext};
use language::LspAdapterDelegate;
pub use slash_command_registry::*;
use std::sync::{atomic::AtomicBool, Arc};
use workspace::Workspace;
pub fn init(cx: &mut AppContext) {
SlashCommandRegistry::default_global(cx);
@ -17,6 +14,7 @@ pub fn init(cx: &mut AppContext) {
pub trait SlashCommand: 'static + Send + Sync {
fn name(&self) -> String;
fn description(&self) -> String;
fn tooltip_text(&self) -> String;
fn complete_argument(
&self,
query: String,
@ -27,35 +25,24 @@ pub trait SlashCommand: 'static + Send + Sync {
fn run(
self: Arc<Self>,
argument: Option<&str>,
workspace: WeakView<Workspace>,
// TODO: We're just using the `LspAdapterDelegate` here because that is
// what the extension API is already expecting.
//
// It may be that `LspAdapterDelegate` needs a more general name, or
// perhaps another kind of delegate is needed here.
delegate: Arc<dyn LspAdapterDelegate>,
cx: &mut AppContext,
) -> SlashCommandInvocation;
cx: &mut WindowContext,
) -> Task<Result<SlashCommandOutput>>;
}
pub struct SlashCommandInvocation {
pub output: Task<Result<String>>,
pub invalidated: oneshot::Receiver<()>,
pub cleanup: SlashCommandCleanup,
}
pub type RenderFoldPlaceholder = Arc<
dyn Send
+ Sync
+ Fn(ElementId, Arc<dyn Fn(&mut WindowContext)>, &mut WindowContext) -> AnyElement,
>;
#[derive(Default)]
pub 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 struct SlashCommandOutput {
pub text: String,
pub render_placeholder: RenderFoldPlaceholder,
}

View File

@ -305,6 +305,7 @@ impl MessageEditor {
documentation: None,
server_id: LanguageServerId(0), // TODO: Make this optional or something?
lsp_completion: Default::default(), // TODO: Make this optional or something?
confirm: None,
}
})
.collect()

View File

@ -36,7 +36,13 @@ impl FlapSnapshot {
while let Some(item) = cursor.item() {
match Ord::cmp(&item.flap.range.start.to_point(snapshot).row, &row.0) {
Ordering::Less => cursor.next(snapshot),
Ordering::Equal => return Some(&item.flap),
Ordering::Equal => {
if item.flap.range.start.is_valid(snapshot) {
return Some(&item.flap);
} else {
cursor.next(snapshot);
}
}
Ordering::Greater => break,
}
}

View File

@ -20,6 +20,8 @@ pub struct FoldPlaceholder {
pub render: Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut WindowContext) -> AnyElement>,
/// If true, the element is constrained to the shaped width of an ellipsis.
pub constrain_width: bool,
/// If true, merges the fold with an adjacent one.
pub merge_adjacent: bool,
}
impl FoldPlaceholder {
@ -30,6 +32,7 @@ impl FoldPlaceholder {
Self {
render: Arc::new(|_id, _range, _cx| gpui::Empty.into_any_element()),
constrain_width: true,
merge_adjacent: true,
}
}
}
@ -374,8 +377,11 @@ impl FoldMap {
assert!(fold_range.start.0 >= sum.input.len);
while folds.peek().map_or(false, |(_, next_fold_range)| {
next_fold_range.start <= fold_range.end
while folds.peek().map_or(false, |(next_fold, next_fold_range)| {
next_fold_range.start < fold_range.end
|| (next_fold_range.start == fold_range.end
&& fold.placeholder.merge_adjacent
&& next_fold.placeholder.merge_adjacent)
}) {
let (_, next_fold_range) = folds.next().unwrap();
if next_fold_range.end > fold_range.end {

View File

@ -1628,6 +1628,7 @@ impl Editor {
})
.into_any()
}),
merge_adjacent: true,
};
let display_map = cx.new_model(|cx| {
let file_header_size = if show_excerpt_controls { 3 } else { 2 };
@ -3905,6 +3906,7 @@ impl Editor {
let snippet;
let text;
if completion.is_snippet() {
snippet = Some(Snippet::parse(&completion.new_text).log_err()?);
text = snippet.as_ref().unwrap().text.clone();
@ -3998,6 +4000,10 @@ impl Editor {
this.refresh_inline_completion(true, cx);
});
if let Some(confirm) = completion.confirm.as_ref() {
(confirm)(cx);
}
let provider = self.completion_provider.as_ref()?;
let apply_edits = provider.apply_additional_edits_for_completion(
buffer_handle,

View File

@ -3908,7 +3908,7 @@ enum LineFragment {
Text(ShapedLine),
Element {
element: Option<AnyElement>,
width: Pixels,
size: Size<Pixels>,
len: usize,
},
}
@ -3917,9 +3917,9 @@ impl fmt::Debug for LineFragment {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
LineFragment::Text(shaped_line) => f.debug_tuple("Text").field(shaped_line).finish(),
LineFragment::Element { width, len, .. } => f
LineFragment::Element { size, len, .. } => f
.debug_struct("Element")
.field("width", width)
.field("size", size)
.field("len", len)
.finish(),
}
@ -3999,7 +3999,7 @@ impl LineWithInvisibles {
len += highlighted_chunk.text.len();
fragments.push(LineFragment::Element {
element: Some(element),
width: size.width,
size,
len: highlighted_chunk.text.len(),
});
} else {
@ -4112,13 +4112,18 @@ impl LineWithInvisibles {
LineFragment::Text(line) => {
fragment_origin.x += line.width;
}
LineFragment::Element { element, width, .. } => {
LineFragment::Element { element, size, .. } => {
let mut element = element
.take()
.expect("you can't prepaint LineWithInvisibles twice");
element.prepaint_at(fragment_origin, cx);
// Center the element vertically within the line.
let mut element_origin = fragment_origin;
element_origin.y += (line_height - size.height) / 2.;
element.prepaint_at(element_origin, cx);
line_elements.push(element);
fragment_origin.x += *width;
fragment_origin.x += size.width;
}
}
}
@ -4146,8 +4151,8 @@ impl LineWithInvisibles {
line.paint(fragment_origin, line_height, cx).log_err();
fragment_origin.x += line.width;
}
LineFragment::Element { width, .. } => {
fragment_origin.x += *width;
LineFragment::Element { size, .. } => {
fragment_origin.x += size.width;
}
}
}
@ -4225,12 +4230,12 @@ impl LineWithInvisibles {
fragment_start_x += shaped_line.width;
fragment_start_index = fragment_end_index;
}
LineFragment::Element { len, width, .. } => {
LineFragment::Element { len, size, .. } => {
let fragment_end_index = fragment_start_index + len;
if index < fragment_end_index {
return fragment_start_x;
}
fragment_start_x += *width;
fragment_start_x += size.width;
fragment_start_index = fragment_end_index;
}
}
@ -4255,8 +4260,8 @@ impl LineWithInvisibles {
fragment_start_x = fragment_end_x;
fragment_start_index += shaped_line.len;
}
LineFragment::Element { len, width, .. } => {
let fragment_end_x = fragment_start_x + *width;
LineFragment::Element { len, size, .. } => {
let fragment_end_x = fragment_start_x + size.width;
if x < fragment_end_x {
return Some(fragment_start_index);
}

View File

@ -46,6 +46,7 @@ wasmtime.workspace = true
wasmtime-wasi.workspace = true
wasmparser.workspace = true
wit-component.workspace = true
workspace.workspace = true
task.workspace = true
serde_json_lenient.workspace = true
@ -58,3 +59,4 @@ fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }

View File

@ -133,6 +133,7 @@ impl LanguageServerManifestEntry {
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct SlashCommandManifestEntry {
pub description: String,
pub tooltip_text: String,
pub requires_argument: bool,
}

View File

@ -1,15 +1,12 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use assistant_slash_command::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
use futures::channel::oneshot;
use futures::FutureExt;
use gpui::{AppContext, Task};
use language::LspAdapterDelegate;
use wasmtime_wasi::WasiView;
use crate::wasm_host::{WasmExtension, WasmHost};
use anyhow::{anyhow, Result};
use assistant_slash_command::{SlashCommand, SlashCommandOutput};
use futures::FutureExt;
use gpui::{AppContext, IntoElement, Task, WeakView, WindowContext};
use language::LspAdapterDelegate;
use std::sync::{atomic::AtomicBool, Arc};
use wasmtime_wasi::WasiView;
use workspace::Workspace;
pub struct ExtensionSlashCommand {
pub(crate) extension: WasmExtension,
@ -27,6 +24,10 @@ impl SlashCommand for ExtensionSlashCommand {
self.command.description.clone()
}
fn tooltip_text(&self) -> String {
self.command.tooltip_text.clone()
}
fn requires_argument(&self) -> bool {
self.command.requires_argument
}
@ -43,11 +44,11 @@ impl SlashCommand for ExtensionSlashCommand {
fn run(
self: Arc<Self>,
argument: Option<&str>,
_workspace: WeakView<Workspace>,
delegate: Arc<dyn LspAdapterDelegate>,
cx: &mut AppContext,
) -> SlashCommandInvocation {
cx: &mut WindowContext,
) -> Task<Result<SlashCommandOutput>> {
let argument = argument.map(|arg| arg.to_string());
let output = cx.background_executor().spawn(async move {
let output = self
.extension
@ -72,14 +73,16 @@ impl SlashCommand for ExtensionSlashCommand {
}
})
.await?;
output.ok_or_else(|| anyhow!("no output from command: {}", self.command.name))
});
SlashCommandInvocation {
output,
invalidated: oneshot::channel().1,
cleanup: SlashCommandCleanup::default(),
}
cx.foreground_executor().spawn(async move {
let output = output.await?;
Ok(SlashCommandOutput {
text: output,
render_placeholder: Arc::new(|_, _, _| {
"TODO: Extension command output".into_any_element()
}),
})
})
}
}

View File

@ -1183,6 +1183,7 @@ impl ExtensionStore {
command: crate::wit::SlashCommand {
name: slash_command_name.to_string(),
description: slash_command.description.to_string(),
tooltip_text: slash_command.tooltip_text.to_string(),
requires_argument: slash_command.requires_argument,
},
extension: wasm_extension.clone(),

View File

@ -5,6 +5,8 @@ interface slash-command {
name: string,
/// The description of the slash command.
description: string,
/// The tooltip text to display for the run button.
tooltip-text: string,
/// Whether this slash command requires an argument.
requires-argument: bool,
}

View File

@ -291,6 +291,8 @@ impl Interactivity {
let action = action.downcast_ref().unwrap();
if phase == DispatchPhase::Capture {
(listener)(action, cx)
} else {
cx.propagate();
}
}),
));

View File

@ -36,7 +36,7 @@ use git::{blame::Blame, repository::GitRepository};
use globset::{Glob, GlobSet, GlobSetBuilder};
use gpui::{
AnyModel, AppContext, AsyncAppContext, BackgroundExecutor, BorrowAppContext, Context, Entity,
EventEmitter, Model, ModelContext, PromptLevel, SharedString, Task, WeakModel,
EventEmitter, Model, ModelContext, PromptLevel, SharedString, Task, WeakModel, WindowContext,
};
use itertools::Itertools;
use language::{
@ -407,7 +407,7 @@ pub struct InlayHint {
}
/// A completion provided by a language server
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct Completion {
/// The range of the buffer that will be replaced.
pub old_range: Range<Anchor>,
@ -421,6 +421,21 @@ pub struct Completion {
pub documentation: Option<Documentation>,
/// The raw completion provided by the language server.
pub lsp_completion: lsp::CompletionItem,
/// An optional callback to invoke when this completion is confirmed.
pub confirm: Option<Arc<dyn Send + Sync + Fn(&mut WindowContext)>>,
}
impl std::fmt::Debug for Completion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Completion")
.field("old_range", &self.old_range)
.field("new_text", &self.new_text)
.field("label", &self.label)
.field("server_id", &self.server_id)
.field("documentation", &self.documentation)
.field("lsp_completion", &self.lsp_completion)
.finish()
}
}
/// A completion provided by a language server
@ -2029,6 +2044,30 @@ impl Project {
})
}
pub fn open_buffer_for_full_path(
&mut self,
path: &Path,
cx: &mut ModelContext<Self>,
) -> Task<Result<Model<Buffer>>> {
if let Some(worktree_name) = path.components().next() {
let worktree = self.worktrees().find(|worktree| {
OsStr::new(worktree.read(cx).root_name()) == worktree_name.as_os_str()
});
if let Some(worktree) = worktree {
let worktree = worktree.read(cx);
let worktree_root_path = Path::new(worktree.root_name());
if let Ok(path) = path.strip_prefix(worktree_root_path) {
let project_path = ProjectPath {
worktree_id: worktree.id(),
path: path.into(),
};
return self.open_buffer(project_path, cx);
}
}
}
Task::ready(Err(anyhow!("buffer not found for {:?}", path)))
}
pub fn open_local_buffer(
&mut self,
abs_path: impl AsRef<Path>,
@ -9212,6 +9251,7 @@ impl Project {
runs: Default::default(),
filter_range: Default::default(),
},
confirm: None,
},
false,
cx,
@ -10883,6 +10923,7 @@ async fn populate_labels_for_completions(
server_id: completion.server_id,
documentation,
lsp_completion,
confirm: None,
})
}
}

View File

@ -184,6 +184,7 @@ pub enum IconName {
Tab,
Terminal,
Trash,
TriangleRight,
Update,
WholeWord,
XCircle,
@ -303,6 +304,7 @@ impl IconName {
IconName::Tab => "icons/tab.svg",
IconName::Terminal => "icons/terminal.svg",
IconName::Trash => "icons/trash.svg",
IconName::TriangleRight => "icons/triangle_right.svg",
IconName::Update => "icons/update.svg",
IconName::WholeWord => "icons/word_search.svg",
IconName::XCircle => "icons/error.svg",

View File

@ -17,3 +17,4 @@ commit = "8432ffe32ccd360534837256747beb5b1c82fca1"
[slash_commands.gleam-project]
description = "Returns information about the current Gleam project."
requires_argument = false
tooltip_text = "Insert Gleam project data"