Files
oak-gpui/crates/agent_ui/src/mention_set.rs
T
Bennet Bo Fenner da143c5527 Fix inline assist panic (#43364)
Fixes a panic that was introduced in #42633. Repro steps:
1. Open the inline assistant and mention a file in the prompt
2. Run the inline assistant
3. Remove the mention and insert a different one
4. 💥

This would happen because the mention set still had a reference to the
old editor, because we create a new one in `PromptEditor::unlink`.

Also removes the unused
`crates/agent_ui/src/context_picker/completion_provider.rs` file, which
was not removed by mistake in the previous PR.

Release Notes:

- N/A
2025-11-23 18:26:07 +01:00

1099 lines
38 KiB
Rust

use acp_thread::{MentionUri, selection_name};
use agent::{HistoryStore, outline};
use agent_client_protocol as acp;
use agent_servers::{AgentServer, AgentServerDelegate};
use anyhow::{Context as _, Result, anyhow};
use assistant_slash_commands::codeblock_fence_for_path;
use collections::{HashMap, HashSet};
use editor::{
Anchor, Editor, EditorSnapshot, ExcerptId, FoldPlaceholder, ToOffset,
display_map::{Crease, CreaseId, CreaseMetadata, FoldId},
scroll::Autoscroll,
};
use futures::{AsyncReadExt as _, FutureExt as _, future::Shared};
use gpui::{
Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Empty, Entity, EntityId,
Image, ImageFormat, Img, SharedString, Task, WeakEntity, pulsating_between,
};
use http_client::{AsyncBody, HttpClientWithUrl};
use itertools::Either;
use language::Buffer;
use language_model::LanguageModelImage;
use multi_buffer::MultiBufferRow;
use postage::stream::Stream as _;
use project::{Project, ProjectItem, ProjectPath, Worktree};
use prompt_store::{PromptId, PromptStore};
use rope::Point;
use std::{
cell::RefCell,
ffi::OsStr,
fmt::Write,
ops::{Range, RangeInclusive},
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
time::Duration,
};
use text::OffsetRangeExt;
use ui::{ButtonLike, Disclosure, TintColor, Toggleable, prelude::*};
use util::{ResultExt, debug_panic, rel_path::RelPath};
use workspace::{Workspace, notifications::NotifyResultExt as _};
pub type MentionTask = Shared<Task<Result<Mention, String>>>;
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Mention {
Text {
content: String,
tracked_buffers: Vec<Entity<Buffer>>,
},
Image(MentionImage),
Link,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MentionImage {
pub data: SharedString,
pub format: ImageFormat,
}
pub struct MentionSet {
project: WeakEntity<Project>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
}
impl MentionSet {
pub fn new(
project: WeakEntity<Project>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
) -> Self {
Self {
project,
history_store,
prompt_store,
mentions: HashMap::default(),
}
}
pub fn contents(
&self,
full_mention_content: bool,
cx: &mut App,
) -> Task<Result<HashMap<CreaseId, (MentionUri, Mention)>>> {
let Some(project) = self.project.upgrade() else {
return Task::ready(Err(anyhow!("Project not found")));
};
let mentions = self.mentions.clone();
cx.spawn(async move |cx| {
let mut contents = HashMap::default();
for (crease_id, (mention_uri, task)) in mentions {
let content = if full_mention_content
&& let MentionUri::Directory { abs_path } = &mention_uri
{
cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))?
.await?
} else {
task.await.map_err(|e| anyhow!("{e}"))?
};
contents.insert(crease_id, (mention_uri, content));
}
Ok(contents)
})
}
pub fn remove_invalid(&mut self, snapshot: &EditorSnapshot) {
for (crease_id, crease) in snapshot.crease_snapshot.creases() {
if !crease.range().start.is_valid(snapshot.buffer_snapshot()) {
self.mentions.remove(&crease_id);
}
}
}
pub fn insert_mention(&mut self, crease_id: CreaseId, uri: MentionUri, task: MentionTask) {
self.mentions.insert(crease_id, (uri, task));
}
pub fn remove_mention(&mut self, crease_id: &CreaseId) {
self.mentions.remove(crease_id);
}
pub fn creases(&self) -> HashSet<CreaseId> {
self.mentions.keys().cloned().collect()
}
pub fn mentions(&self) -> HashSet<MentionUri> {
self.mentions.values().map(|(uri, _)| uri.clone()).collect()
}
pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) {
self.mentions = mentions;
}
pub fn clear(&mut self) -> impl Iterator<Item = (CreaseId, (MentionUri, MentionTask))> {
self.mentions.drain()
}
pub fn confirm_mention_completion(
&mut self,
crease_text: SharedString,
start: text::Anchor,
content_len: usize,
mention_uri: MentionUri,
supports_images: bool,
editor: Entity<Editor>,
workspace: &Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<()> {
let Some(project) = self.project.upgrade() else {
return Task::ready(());
};
let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
let Some(start_anchor) = snapshot.buffer_snapshot().as_singleton_anchor(start) else {
return Task::ready(());
};
let excerpt_id = start_anchor.excerpt_id;
let end_anchor = snapshot.buffer_snapshot().anchor_before(
start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1usize,
);
let crease = if let MentionUri::File { abs_path } = &mention_uri
&& let Some(extension) = abs_path.extension()
&& let Some(extension) = extension.to_str()
&& Img::extensions().contains(&extension)
&& !extension.contains("svg")
{
let Some(project_path) = project
.read(cx)
.project_path_for_absolute_path(&abs_path, cx)
else {
log::error!("project path not found");
return Task::ready(());
};
let image_task = project.update(cx, |project, cx| project.open_image(project_path, cx));
let image = cx
.spawn(async move |_, cx| {
let image = image_task.await.map_err(|e| e.to_string())?;
let image = image
.update(cx, |image, _| image.image.clone())
.map_err(|e| e.to_string())?;
Ok(image)
})
.shared();
insert_crease_for_mention(
excerpt_id,
start,
content_len,
mention_uri.name().into(),
IconName::Image.path().into(),
Some(image),
editor.clone(),
window,
cx,
)
} else {
insert_crease_for_mention(
excerpt_id,
start,
content_len,
crease_text,
mention_uri.icon_path(cx),
None,
editor.clone(),
window,
cx,
)
};
let Some((crease_id, tx)) = crease else {
return Task::ready(());
};
let task = match mention_uri.clone() {
MentionUri::Fetch { url } => {
self.confirm_mention_for_fetch(url, workspace.read(cx).client().http_client(), cx)
}
MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)),
MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx),
MentionUri::File { abs_path } => {
self.confirm_mention_for_file(abs_path, supports_images, cx)
}
MentionUri::Symbol {
abs_path,
line_range,
..
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
MentionUri::PastedImage => {
debug_panic!("pasted image URI should not be included in completions");
Task::ready(Err(anyhow!(
"pasted imaged URI should not be included in completions"
)))
}
MentionUri::Selection { .. } => {
debug_panic!("unexpected selection URI");
Task::ready(Err(anyhow!("unexpected selection URI")))
}
};
let task = cx
.spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
.shared();
self.mentions.insert(crease_id, (mention_uri, task.clone()));
// Notify the user if we failed to load the mentioned context
cx.spawn_in(window, async move |this, cx| {
let result = task.await.notify_async_err(cx);
drop(tx);
if result.is_none() {
this.update(cx, |this, cx| {
editor.update(cx, |editor, cx| {
// Remove mention
editor.edit([(start_anchor..end_anchor, "")], cx);
});
this.mentions.remove(&crease_id);
})
.ok();
}
})
}
pub fn confirm_mention_for_file(
&self,
abs_path: PathBuf,
supports_images: bool,
cx: &mut Context<Self>,
) -> Task<Result<Mention>> {
let Some(project) = self.project.upgrade() else {
return Task::ready(Err(anyhow!("project not found")));
};
let Some(project_path) = project
.read(cx)
.project_path_for_absolute_path(&abs_path, cx)
else {
return Task::ready(Err(anyhow!("project path not found")));
};
let extension = abs_path
.extension()
.and_then(OsStr::to_str)
.unwrap_or_default();
if Img::extensions().contains(&extension) && !extension.contains("svg") {
if !supports_images {
return Task::ready(Err(anyhow!("This model does not support images yet")));
}
let task = project.update(cx, |project, cx| project.open_image(project_path, cx));
return cx.spawn(async move |_, cx| {
let image = task.await?;
let image = image.update(cx, |image, _| image.image.clone())?;
let format = image.format;
let image = cx
.update(|cx| LanguageModelImage::from_image(image, cx))?
.await;
if let Some(image) = image {
Ok(Mention::Image(MentionImage {
data: image.source,
format,
}))
} else {
Err(anyhow!("Failed to convert image"))
}
});
}
let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
cx.spawn(async move |_, cx| {
let buffer = buffer.await?;
let buffer_content = outline::get_buffer_content_or_outline(
buffer.clone(),
Some(&abs_path.to_string_lossy()),
&cx,
)
.await?;
Ok(Mention::Text {
content: buffer_content.text,
tracked_buffers: vec![buffer],
})
})
}
fn confirm_mention_for_fetch(
&self,
url: url::Url,
http_client: Arc<HttpClientWithUrl>,
cx: &mut Context<Self>,
) -> Task<Result<Mention>> {
cx.background_executor().spawn(async move {
let content = fetch_url_content(http_client, url.to_string()).await?;
Ok(Mention::Text {
content,
tracked_buffers: Vec::new(),
})
})
}
fn confirm_mention_for_symbol(
&self,
abs_path: PathBuf,
line_range: RangeInclusive<u32>,
cx: &mut Context<Self>,
) -> Task<Result<Mention>> {
let Some(project) = self.project.upgrade() else {
return Task::ready(Err(anyhow!("project not found")));
};
let Some(project_path) = project
.read(cx)
.project_path_for_absolute_path(&abs_path, cx)
else {
return Task::ready(Err(anyhow!("project path not found")));
};
let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
cx.spawn(async move |_, cx| {
let buffer = buffer.await?;
let mention = buffer.update(cx, |buffer, cx| {
let start = Point::new(*line_range.start(), 0).min(buffer.max_point());
let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point());
let content = buffer.text_for_range(start..end).collect();
Mention::Text {
content,
tracked_buffers: vec![cx.entity()],
}
})?;
anyhow::Ok(mention)
})
}
fn confirm_mention_for_rule(
&mut self,
id: PromptId,
cx: &mut Context<Self>,
) -> Task<Result<Mention>> {
let Some(prompt_store) = self.prompt_store.as_ref() else {
return Task::ready(Err(anyhow!("Missing prompt store")));
};
let prompt = prompt_store.read(cx).load(id, cx);
cx.spawn(async move |_, _| {
let prompt = prompt.await?;
Ok(Mention::Text {
content: prompt,
tracked_buffers: Vec::new(),
})
})
}
pub fn confirm_mention_for_selection(
&mut self,
source_range: Range<text::Anchor>,
selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
editor: Entity<Editor>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(project) = self.project.upgrade() else {
return;
};
let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
let Some(start) = snapshot.as_singleton_anchor(source_range.start) else {
return;
};
let offset = start.to_offset(&snapshot);
for (buffer, selection_range, range_to_fold) in selections {
let range = snapshot.anchor_after(offset + range_to_fold.start)
..snapshot.anchor_after(offset + range_to_fold.end);
let abs_path = buffer
.read(cx)
.project_path(cx)
.and_then(|project_path| project.read(cx).absolute_path(&project_path, cx));
let snapshot = buffer.read(cx).snapshot();
let text = snapshot
.text_for_range(selection_range.clone())
.collect::<String>();
let point_range = selection_range.to_point(&snapshot);
let line_range = point_range.start.row..=point_range.end.row;
let uri = MentionUri::Selection {
abs_path: abs_path.clone(),
line_range: line_range.clone(),
};
let crease = crease_for_mention(
selection_name(abs_path.as_deref(), &line_range).into(),
uri.icon_path(cx),
range,
editor.downgrade(),
);
let crease_id = editor.update(cx, |editor, cx| {
let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
editor.fold_creases(vec![crease], false, window, cx);
crease_ids.first().copied().unwrap()
});
self.mentions.insert(
crease_id,
(
uri,
Task::ready(Ok(Mention::Text {
content: text,
tracked_buffers: vec![buffer],
}))
.shared(),
),
);
}
// Take this explanation with a grain of salt but, with creases being
// inserted, GPUI's recomputes the editor layout in the next frames, so
// directly calling `editor.request_autoscroll` wouldn't work as
// expected. We're leveraging `cx.on_next_frame` to wait 2 frames and
// ensure that the layout has been recalculated so that the autoscroll
// request actually shows the cursor's new position.
cx.on_next_frame(window, move |_, window, cx| {
cx.on_next_frame(window, move |_, _, cx| {
editor.update(cx, |editor, cx| {
editor.request_autoscroll(Autoscroll::fit(), cx)
});
});
});
}
fn confirm_mention_for_thread(
&mut self,
id: acp::SessionId,
cx: &mut Context<Self>,
) -> Task<Result<Mention>> {
let Some(project) = self.project.upgrade() else {
return Task::ready(Err(anyhow!("project not found")));
};
let server = Rc::new(agent::NativeAgentServer::new(
project.read(cx).fs().clone(),
self.history_store.clone(),
));
let delegate = AgentServerDelegate::new(
project.read(cx).agent_server_store().clone(),
project.clone(),
None,
None,
);
let connection = server.connect(None, delegate, cx);
cx.spawn(async move |_, cx| {
let (agent, _) = connection.await?;
let agent = agent.downcast::<agent::NativeAgentConnection>().unwrap();
let summary = agent
.0
.update(cx, |agent, cx| agent.thread_summary(id, cx))?
.await?;
anyhow::Ok(Mention::Text {
content: summary.to_string(),
tracked_buffers: Vec::new(),
})
})
}
fn confirm_mention_for_text_thread(
&mut self,
path: PathBuf,
cx: &mut Context<Self>,
) -> Task<Result<Mention>> {
let text_thread_task = self.history_store.update(cx, |store, cx| {
store.load_text_thread(path.as_path().into(), cx)
});
cx.spawn(async move |_, cx| {
let text_thread = text_thread_task.await?;
let xml = text_thread.update(cx, |text_thread, cx| text_thread.to_xml(cx))?;
Ok(Mention::Text {
content: xml,
tracked_buffers: Vec::new(),
})
})
}
}
pub(crate) fn paste_images_as_context(
editor: Entity<Editor>,
mention_set: Entity<MentionSet>,
window: &mut Window,
cx: &mut App,
) -> Option<Task<()>> {
let clipboard = cx.read_from_clipboard()?;
Some(window.spawn(cx, async move |cx| {
use itertools::Itertools;
let (mut images, paths) = clipboard
.into_entries()
.filter_map(|entry| match entry {
ClipboardEntry::Image(image) => Some(Either::Left(image)),
ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)),
_ => None,
})
.partition_map::<Vec<_>, Vec<_>, _, _, _>(std::convert::identity);
if !paths.is_empty() {
images.extend(
cx.background_spawn(async move {
let mut images = vec![];
for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) {
let Ok(content) = async_fs::read(path).await else {
continue;
};
let Ok(format) = image::guess_format(&content) else {
continue;
};
images.push(gpui::Image::from_bytes(
match format {
image::ImageFormat::Png => gpui::ImageFormat::Png,
image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg,
image::ImageFormat::WebP => gpui::ImageFormat::Webp,
image::ImageFormat::Gif => gpui::ImageFormat::Gif,
image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
image::ImageFormat::Ico => gpui::ImageFormat::Ico,
_ => continue,
},
content,
));
}
images
})
.await,
);
}
if images.is_empty() {
return;
}
let replacement_text = MentionUri::PastedImage.as_link().to_string();
cx.update(|_window, cx| {
cx.stop_propagation();
})
.ok();
for image in images {
let Ok((excerpt_id, text_anchor, multibuffer_anchor)) =
editor.update_in(cx, |message_editor, window, cx| {
let snapshot = message_editor.snapshot(window, cx);
let (excerpt_id, _, buffer_snapshot) =
snapshot.buffer_snapshot().as_singleton().unwrap();
let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
let multibuffer_anchor = snapshot
.buffer_snapshot()
.anchor_in_excerpt(*excerpt_id, text_anchor);
message_editor.edit(
[(
multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
format!("{replacement_text} "),
)],
cx,
);
(*excerpt_id, text_anchor, multibuffer_anchor)
})
else {
break;
};
let content_len = replacement_text.len();
let Some(start_anchor) = multibuffer_anchor else {
continue;
};
let Ok(end_anchor) = editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
}) else {
continue;
};
let image = Arc::new(image);
let Ok(Some((crease_id, tx))) = cx.update(|window, cx| {
insert_crease_for_mention(
excerpt_id,
text_anchor,
content_len,
MentionUri::PastedImage.name().into(),
IconName::Image.path().into(),
Some(Task::ready(Ok(image.clone())).shared()),
editor.clone(),
window,
cx,
)
}) else {
continue;
};
let task = cx
.spawn(async move |cx| {
let format = image.format;
let image = cx
.update(|_, cx| LanguageModelImage::from_image(image, cx))
.map_err(|e| e.to_string())?
.await;
drop(tx);
if let Some(image) = image {
Ok(Mention::Image(MentionImage {
data: image.source,
format,
}))
} else {
Err("Failed to convert image".into())
}
})
.shared();
mention_set
.update(cx, |mention_set, _cx| {
mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
})
.ok();
if task.await.notify_async_err(cx).is_none() {
editor
.update(cx, |editor, cx| {
editor.edit([(start_anchor..end_anchor, "")], cx);
})
.ok();
mention_set
.update(cx, |mention_set, _cx| {
mention_set.remove_mention(&crease_id)
})
.ok();
}
}
}))
}
pub(crate) fn insert_crease_for_mention(
excerpt_id: ExcerptId,
anchor: text::Anchor,
content_len: usize,
crease_label: SharedString,
crease_icon: SharedString,
// abs_path: Option<Arc<Path>>,
image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
editor: Entity<Editor>,
window: &mut Window,
cx: &mut App,
) -> Option<(CreaseId, postage::barrier::Sender)> {
let (tx, rx) = postage::barrier::channel();
let crease_id = editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
let start = start.bias_right(&snapshot);
let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
let placeholder = FoldPlaceholder {
render: render_mention_fold_button(
crease_label.clone(),
crease_icon.clone(),
start..end,
rx,
image,
cx.weak_entity(),
cx,
),
merge_adjacent: false,
..Default::default()
};
let crease = Crease::Inline {
range: start..end,
placeholder,
render_toggle: None,
render_trailer: None,
metadata: Some(CreaseMetadata {
label: crease_label,
icon_path: crease_icon,
}),
};
let ids = editor.insert_creases(vec![crease.clone()], cx);
editor.fold_creases(vec![crease], false, window, cx);
Some(ids[0])
})?;
Some((crease_id, tx))
}
pub(crate) fn crease_for_mention(
label: SharedString,
icon_path: SharedString,
range: Range<Anchor>,
editor_entity: WeakEntity<Editor>,
) -> Crease<Anchor> {
let placeholder = FoldPlaceholder {
render: render_fold_icon_button(icon_path.clone(), label.clone(), editor_entity),
merge_adjacent: false,
..Default::default()
};
let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer)
.with_metadata(CreaseMetadata { icon_path, label })
}
fn render_fold_icon_button(
icon_path: SharedString,
label: SharedString,
editor: WeakEntity<Editor>,
) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
Arc::new({
move |fold_id, fold_range, cx| {
let is_in_text_selection = editor
.update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
.unwrap_or_default();
ButtonLike::new(fold_id)
.style(ButtonStyle::Filled)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.toggle_state(is_in_text_selection)
.child(
h_flex()
.gap_1()
.child(
Icon::from_path(icon_path.clone())
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(
Label::new(label.clone())
.size(LabelSize::Small)
.buffer_font(cx)
.single_line(),
),
)
.into_any_element()
}
})
}
fn fold_toggle(
name: &'static str,
) -> impl Fn(
MultiBufferRow,
bool,
Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
&mut Window,
&mut App,
) -> AnyElement {
move |row, is_folded, fold, _window, _cx| {
Disclosure::new((name, row.0 as u64), !is_folded)
.toggle_state(is_folded)
.on_click(move |_e, window, cx| fold(!is_folded, window, cx))
.into_any_element()
}
}
fn full_mention_for_directory(
project: &Entity<Project>,
abs_path: &Path,
cx: &mut App,
) -> Task<Result<Mention>> {
fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
let mut files = Vec::new();
for entry in worktree.child_entries(path) {
if entry.is_dir() {
files.extend(collect_files_in_path(worktree, &entry.path));
} else if entry.is_file() {
files.push((
entry.path.clone(),
worktree
.full_path(&entry.path)
.to_string_lossy()
.to_string(),
));
}
}
files
}
let Some(project_path) = project
.read(cx)
.project_path_for_absolute_path(&abs_path, cx)
else {
return Task::ready(Err(anyhow!("project path not found")));
};
let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
return Task::ready(Err(anyhow!("project entry not found")));
};
let directory_path = entry.path.clone();
let worktree_id = project_path.worktree_id;
let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
return Task::ready(Err(anyhow!("worktree not found")));
};
let project = project.clone();
cx.spawn(async move |cx| {
let file_paths = worktree.read_with(cx, |worktree, _cx| {
collect_files_in_path(worktree, &directory_path)
})?;
let descendants_future = cx.update(|cx| {
futures::future::join_all(file_paths.into_iter().map(|(worktree_path, full_path)| {
let rel_path = worktree_path
.strip_prefix(&directory_path)
.log_err()
.map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
let open_task = project.update(cx, |project, cx| {
project.buffer_store().update(cx, |buffer_store, cx| {
let project_path = ProjectPath {
worktree_id,
path: worktree_path,
};
buffer_store.open_buffer(project_path, cx)
})
});
cx.spawn(async move |cx| {
let buffer = open_task.await.log_err()?;
let buffer_content = outline::get_buffer_content_or_outline(
buffer.clone(),
Some(&full_path),
&cx,
)
.await
.ok()?;
Some((rel_path, full_path, buffer_content.text, buffer))
})
}))
})?;
let contents = cx
.background_spawn(async move {
let (contents, tracked_buffers) = descendants_future
.await
.into_iter()
.flatten()
.map(|(rel_path, full_path, rope, buffer)| {
((rel_path, full_path, rope), buffer)
})
.unzip();
Mention::Text {
content: render_directory_contents(contents),
tracked_buffers,
}
})
.await;
anyhow::Ok(contents)
})
}
fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
let mut output = String::new();
for (_relative_path, full_path, content) in entries {
let fence = codeblock_fence_for_path(Some(&full_path), None);
write!(output, "\n{fence}\n{content}\n```").unwrap();
}
output
}
fn render_mention_fold_button(
label: SharedString,
icon: SharedString,
range: Range<Anchor>,
mut loading_finished: postage::barrier::Receiver,
image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
editor: WeakEntity<Editor>,
cx: &mut App,
) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
let loading = cx.new(|cx| {
let loading = cx.spawn(async move |this, cx| {
loading_finished.recv().await;
this.update(cx, |this: &mut LoadingContext, cx| {
this.loading = None;
cx.notify();
})
.ok();
});
LoadingContext {
id: cx.entity_id(),
label,
icon,
range,
editor,
loading: Some(loading),
image: image_task.clone(),
}
});
Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
}
struct LoadingContext {
id: EntityId,
label: SharedString,
icon: SharedString,
range: Range<Anchor>,
editor: WeakEntity<Editor>,
loading: Option<Task<()>>,
image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
}
impl Render for LoadingContext {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_in_text_selection = self
.editor
.update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
.unwrap_or_default();
ButtonLike::new(("loading-context", self.id))
.style(ButtonStyle::Filled)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.toggle_state(is_in_text_selection)
.when_some(self.image.clone(), |el, image_task| {
el.hoverable_tooltip(move |_, cx| {
let image = image_task.peek().cloned().transpose().ok().flatten();
let image_task = image_task.clone();
cx.new::<ImageHover>(|cx| ImageHover {
image,
_task: cx.spawn(async move |this, cx| {
if let Ok(image) = image_task.clone().await {
this.update(cx, |this, cx| {
if this.image.replace(image).is_none() {
cx.notify();
}
})
.ok();
}
}),
})
.into()
})
})
.child(
h_flex()
.gap_1()
.child(
Icon::from_path(self.icon.clone())
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(
Label::new(self.label.clone())
.size(LabelSize::Small)
.buffer_font(cx)
.single_line(),
)
.map(|el| {
if self.loading.is_some() {
el.with_animation(
"loading-context-crease",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|label, delta| label.opacity(delta),
)
.into_any()
} else {
el.into_any()
}
}),
)
}
}
struct ImageHover {
image: Option<Arc<Image>>,
_task: Task<()>,
}
impl Render for ImageHover {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
if let Some(image) = self.image.clone() {
gpui::img(image).max_w_96().max_h_96().into_any_element()
} else {
gpui::Empty.into_any_element()
}
}
}
async fn fetch_url_content(http_client: Arc<HttpClientWithUrl>, url: String) -> Result<String> {
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
enum ContentType {
Html,
Plaintext,
Json,
}
use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
let url = if !url.starts_with("https://") && !url.starts_with("http://") {
format!("https://{url}")
} else {
url
};
let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
let mut body = Vec::new();
response
.body_mut()
.read_to_end(&mut body)
.await
.context("error reading response body")?;
if response.status().is_client_error() {
let text = String::from_utf8_lossy(body.as_slice());
anyhow::bail!(
"status error {}, response: {text:?}",
response.status().as_u16()
);
}
let Some(content_type) = response.headers().get("content-type") else {
anyhow::bail!("missing Content-Type header");
};
let content_type = content_type
.to_str()
.context("invalid Content-Type header")?;
let content_type = match content_type {
"text/html" => ContentType::Html,
"text/plain" => ContentType::Plaintext,
"application/json" => ContentType::Json,
_ => ContentType::Html,
};
match content_type {
ContentType::Html => {
let mut handlers: Vec<TagHandler> = vec![
Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
Rc::new(RefCell::new(markdown::ParagraphHandler)),
Rc::new(RefCell::new(markdown::HeadingHandler)),
Rc::new(RefCell::new(markdown::ListHandler)),
Rc::new(RefCell::new(markdown::TableHandler::new())),
Rc::new(RefCell::new(markdown::StyledTextHandler)),
];
if url.contains("wikipedia.org") {
use html_to_markdown::structure::wikipedia;
handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
handlers.push(Rc::new(
RefCell::new(wikipedia::WikipediaCodeHandler::new()),
));
} else {
handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
}
convert_html_to_markdown(&body[..], &mut handlers)
}
ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
ContentType::Json => {
let json: serde_json::Value = serde_json::from_slice(&body)?;
Ok(format!(
"```json\n{}\n```",
serde_json::to_string_pretty(&json)?
))
}
}
}