agent2: Support directories in @file mentions (#36416)

Release Notes:

- N/A
This commit is contained in:
Cole Miller 2025-08-19 00:09:43 -04:00 committed by GitHub
parent 821e97a392
commit 7bcea7dc2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 325 additions and 168 deletions

View file

@ -15,7 +15,9 @@ use url::Url;
pub enum MentionUri {
File {
abs_path: PathBuf,
is_directory: bool,
},
Directory {
abs_path: PathBuf,
},
Symbol {
path: PathBuf,
@ -79,14 +81,14 @@ impl MentionUri {
})
}
} else {
let file_path =
let abs_path =
PathBuf::from(format!("{}{}", url.host_str().unwrap_or(""), path));
let is_directory = input.ends_with("/");
Ok(Self::File {
abs_path: file_path,
is_directory,
})
if input.ends_with("/") {
Ok(Self::Directory { abs_path })
} else {
Ok(Self::File { abs_path })
}
}
}
"zed" => {
@ -120,7 +122,7 @@ impl MentionUri {
pub fn name(&self) -> String {
match self {
MentionUri::File { abs_path, .. } => abs_path
MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
@ -138,18 +140,11 @@ impl MentionUri {
pub fn icon_path(&self, cx: &mut App) -> SharedString {
match self {
MentionUri::File {
abs_path,
is_directory,
} => {
if *is_directory {
FileIcons::get_folder_icon(false, cx)
.unwrap_or_else(|| IconName::Folder.path().into())
} else {
FileIcons::get_icon(abs_path, cx)
.unwrap_or_else(|| IconName::File.path().into())
}
MentionUri::File { abs_path } => {
FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
}
MentionUri::Directory { .. } => FileIcons::get_folder_icon(false, cx)
.unwrap_or_else(|| IconName::Folder.path().into()),
MentionUri::Symbol { .. } => IconName::Code.path().into(),
MentionUri::Thread { .. } => IconName::Thread.path().into(),
MentionUri::TextThread { .. } => IconName::Thread.path().into(),
@ -165,13 +160,16 @@ impl MentionUri {
pub fn to_uri(&self) -> Url {
match self {
MentionUri::File {
abs_path,
is_directory,
} => {
MentionUri::File { abs_path } => {
let mut url = Url::parse("file:///").unwrap();
let path = abs_path.to_string_lossy();
url.set_path(&path);
url
}
MentionUri::Directory { abs_path } => {
let mut url = Url::parse("file:///").unwrap();
let mut path = abs_path.to_string_lossy().to_string();
if *is_directory && !path.ends_with("/") {
if !path.ends_with("/") {
path.push_str("/");
}
url.set_path(&path);
@ -274,12 +272,8 @@ mod tests {
let file_uri = "file:///path/to/file.rs";
let parsed = MentionUri::parse(file_uri).unwrap();
match &parsed {
MentionUri::File {
abs_path,
is_directory,
} => {
MentionUri::File { abs_path } => {
assert_eq!(abs_path.to_str().unwrap(), "/path/to/file.rs");
assert!(!is_directory);
}
_ => panic!("Expected File variant"),
}
@ -291,32 +285,26 @@ mod tests {
let file_uri = "file:///path/to/dir/";
let parsed = MentionUri::parse(file_uri).unwrap();
match &parsed {
MentionUri::File {
abs_path,
is_directory,
} => {
MentionUri::Directory { abs_path } => {
assert_eq!(abs_path.to_str().unwrap(), "/path/to/dir/");
assert!(is_directory);
}
_ => panic!("Expected File variant"),
_ => panic!("Expected Directory variant"),
}
assert_eq!(parsed.to_uri().to_string(), file_uri);
}
#[test]
fn test_to_directory_uri_with_slash() {
let uri = MentionUri::File {
let uri = MentionUri::Directory {
abs_path: PathBuf::from("/path/to/dir/"),
is_directory: true,
};
assert_eq!(uri.to_uri().to_string(), "file:///path/to/dir/");
}
#[test]
fn test_to_directory_uri_without_slash() {
let uri = MentionUri::File {
let uri = MentionUri::Directory {
abs_path: PathBuf::from("/path/to/dir"),
is_directory: true,
};
assert_eq!(uri.to_uri().to_string(), "file:///path/to/dir/");
}

View file

@ -146,6 +146,7 @@ impl UserMessage {
They are up-to-date and don't need to be re-read.\n\n";
const OPEN_FILES_TAG: &str = "<files>";
const OPEN_DIRECTORIES_TAG: &str = "<directories>";
const OPEN_SYMBOLS_TAG: &str = "<symbols>";
const OPEN_THREADS_TAG: &str = "<threads>";
const OPEN_FETCH_TAG: &str = "<fetched_urls>";
@ -153,6 +154,7 @@ impl UserMessage {
"<rules>\nThe user has specified the following rules that should be applied:\n";
let mut file_context = OPEN_FILES_TAG.to_string();
let mut directory_context = OPEN_DIRECTORIES_TAG.to_string();
let mut symbol_context = OPEN_SYMBOLS_TAG.to_string();
let mut thread_context = OPEN_THREADS_TAG.to_string();
let mut fetch_context = OPEN_FETCH_TAG.to_string();
@ -168,7 +170,7 @@ impl UserMessage {
}
UserMessageContent::Mention { uri, content } => {
match uri {
MentionUri::File { abs_path, .. } => {
MentionUri::File { abs_path } => {
write!(
&mut symbol_context,
"\n{}",
@ -179,6 +181,9 @@ impl UserMessage {
)
.ok();
}
MentionUri::Directory { .. } => {
write!(&mut directory_context, "\n{}\n", content).ok();
}
MentionUri::Symbol {
path, line_range, ..
}
@ -233,6 +238,13 @@ impl UserMessage {
.push(language_model::MessageContent::Text(file_context));
}
if directory_context.len() > OPEN_DIRECTORIES_TAG.len() {
directory_context.push_str("</directories>\n");
message
.content
.push(language_model::MessageContent::Text(directory_context));
}
if symbol_context.len() > OPEN_SYMBOLS_TAG.len() {
symbol_context.push_str("</symbols>\n");
message

View file

@ -445,19 +445,20 @@ impl ContextPickerCompletionProvider {
let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
let file_uri = MentionUri::File {
abs_path,
is_directory,
let uri = if is_directory {
MentionUri::Directory { abs_path }
} else {
MentionUri::File { abs_path }
};
let crease_icon_path = file_uri.icon_path(cx);
let crease_icon_path = uri.icon_path(cx);
let completion_icon_path = if is_recent {
IconName::HistoryRerun.path().into()
} else {
crease_icon_path.clone()
};
let new_text = format!("{} ", file_uri.as_link());
let new_text = format!("{} ", uri.as_link());
let new_text_len = new_text.len();
Some(Completion {
replace_range: source_range.clone(),
@ -472,7 +473,7 @@ impl ContextPickerCompletionProvider {
source_range.start,
new_text_len - 1,
message_editor,
file_uri,
uri,
)),
})
}

View file

@ -6,6 +6,7 @@ use acp_thread::{MentionUri, selection_name};
use agent::{TextThreadStore, ThreadId, ThreadStore};
use agent_client_protocol as acp;
use anyhow::{Context as _, Result, anyhow};
use assistant_slash_commands::codeblock_fence_for_path;
use collections::{HashMap, HashSet};
use editor::{
Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
@ -15,7 +16,7 @@ use editor::{
};
use futures::{
FutureExt as _, TryFutureExt as _,
future::{Shared, try_join_all},
future::{Shared, join_all, try_join_all},
};
use gpui::{
AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, Image,
@ -23,12 +24,12 @@ use gpui::{
};
use language::{Buffer, Language};
use language_model::LanguageModelImage;
use project::{CompletionIntent, Project};
use project::{CompletionIntent, Project, ProjectPath, Worktree};
use rope::Point;
use settings::Settings;
use std::{
ffi::OsStr,
fmt::Write,
fmt::{Display, Write},
ops::Range,
path::{Path, PathBuf},
rc::Rc,
@ -245,6 +246,9 @@ impl MessageEditor {
MentionUri::Fetch { url } => {
self.confirm_mention_for_fetch(crease_id, anchor, url, window, cx);
}
MentionUri::Directory { abs_path } => {
self.confirm_mention_for_directory(crease_id, anchor, abs_path, window, cx);
}
MentionUri::Thread { id, name } => {
self.confirm_mention_for_thread(crease_id, anchor, id, name, window, cx);
}
@ -260,6 +264,124 @@ impl MessageEditor {
}
}
fn confirm_mention_for_directory(
&mut self,
crease_id: CreaseId,
anchor: Anchor,
abs_path: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
fn collect_files_in_path(worktree: &Worktree, path: &Path) -> Vec<(Arc<Path>, PathBuf)> {
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)));
}
}
files
}
let uri = MentionUri::Directory {
abs_path: abs_path.clone(),
};
let Some(project_path) = self
.project
.read(cx)
.project_path_for_absolute_path(&abs_path, cx)
else {
return;
};
let Some(entry) = self.project.read(cx).entry_for_path(&project_path, cx) else {
return;
};
let Some(worktree) = self.project.read(cx).worktree_for_entry(entry.id, cx) else {
return;
};
let project = self.project.clone();
let task = cx.spawn(async move |_, cx| {
let directory_path = entry.path.clone();
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?;
let file_paths = worktree.read_with(cx, |worktree, _cx| {
collect_files_in_path(worktree, &directory_path)
})?;
let descendants_future = cx.update(|cx| {
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)
})
});
// TODO: report load errors instead of just logging
let rope_task = cx.spawn(async move |cx| {
let buffer = open_task.await.log_err()?;
let rope = buffer
.read_with(cx, |buffer, _cx| buffer.as_rope().clone())
.log_err()?;
Some(rope)
});
cx.background_spawn(async move {
let rope = rope_task.await?;
Some((rel_path, full_path, rope.to_string()))
})
}))
})?;
let contents = cx
.background_spawn(async move {
let contents = descendants_future.await.into_iter().flatten();
contents.collect()
})
.await;
anyhow::Ok(contents)
});
let task = cx
.spawn(async move |_, _| {
task.await
.map(|contents| DirectoryContents(contents).to_string())
.map_err(|e| e.to_string())
})
.shared();
self.mention_set.directories.insert(abs_path, task.clone());
let editor = self.editor.clone();
cx.spawn_in(window, async move |this, cx| {
if task.await.notify_async_err(cx).is_some() {
this.update(cx, |this, _| {
this.mention_set.insert_uri(crease_id, uri);
})
.ok();
} else {
editor
.update(cx, |editor, cx| {
editor.display_map.update(cx, |display_map, cx| {
display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
});
editor.remove_creases([crease_id], cx);
})
.ok();
}
})
.detach();
}
fn confirm_mention_for_fetch(
&mut self,
crease_id: CreaseId,
@ -361,6 +483,104 @@ impl MessageEditor {
}
}
fn confirm_mention_for_thread(
&mut self,
crease_id: CreaseId,
anchor: Anchor,
id: ThreadId,
name: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let uri = MentionUri::Thread {
id: id.clone(),
name,
};
let open_task = self.thread_store.update(cx, |thread_store, cx| {
thread_store.open_thread(&id, window, cx)
});
let task = cx
.spawn(async move |_, cx| {
let thread = open_task.await.map_err(|e| e.to_string())?;
let content = thread
.read_with(cx, |thread, _cx| thread.latest_detailed_summary_or_text())
.map_err(|e| e.to_string())?;
Ok(content)
})
.shared();
self.mention_set.insert_thread(id, task.clone());
let editor = self.editor.clone();
cx.spawn_in(window, async move |this, cx| {
if task.await.notify_async_err(cx).is_some() {
this.update(cx, |this, _| {
this.mention_set.insert_uri(crease_id, uri);
})
.ok();
} else {
editor
.update(cx, |editor, cx| {
editor.display_map.update(cx, |display_map, cx| {
display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
});
editor.remove_creases([crease_id], cx);
})
.ok();
}
})
.detach();
}
fn confirm_mention_for_text_thread(
&mut self,
crease_id: CreaseId,
anchor: Anchor,
path: PathBuf,
name: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let uri = MentionUri::TextThread {
path: path.clone(),
name,
};
let context = self.text_thread_store.update(cx, |text_thread_store, cx| {
text_thread_store.open_local_context(path.as_path().into(), cx)
});
let task = cx
.spawn(async move |_, cx| {
let context = context.await.map_err(|e| e.to_string())?;
let xml = context
.update(cx, |context, cx| context.to_xml(cx))
.map_err(|e| e.to_string())?;
Ok(xml)
})
.shared();
self.mention_set.insert_text_thread(path, task.clone());
let editor = self.editor.clone();
cx.spawn_in(window, async move |this, cx| {
if task.await.notify_async_err(cx).is_some() {
this.update(cx, |this, _| {
this.mention_set.insert_uri(crease_id, uri);
})
.ok();
} else {
editor
.update(cx, |editor, cx| {
editor.display_map.update(cx, |display_map, cx| {
display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
});
editor.remove_creases([crease_id], cx);
})
.ok();
}
})
.detach();
}
pub fn contents(
&self,
window: &mut Window,
@ -613,13 +833,8 @@ impl MessageEditor {
if task.await.notify_async_err(cx).is_some() {
if let Some(abs_path) = abs_path.clone() {
this.update(cx, |this, _cx| {
this.mention_set.insert_uri(
crease_id,
MentionUri::File {
abs_path,
is_directory: false,
},
);
this.mention_set
.insert_uri(crease_id, MentionUri::File { abs_path });
})
.ok();
}
@ -637,104 +852,6 @@ impl MessageEditor {
.detach();
}
fn confirm_mention_for_thread(
&mut self,
crease_id: CreaseId,
anchor: Anchor,
id: ThreadId,
name: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let uri = MentionUri::Thread {
id: id.clone(),
name,
};
let open_task = self.thread_store.update(cx, |thread_store, cx| {
thread_store.open_thread(&id, window, cx)
});
let task = cx
.spawn(async move |_, cx| {
let thread = open_task.await.map_err(|e| e.to_string())?;
let content = thread
.read_with(cx, |thread, _cx| thread.latest_detailed_summary_or_text())
.map_err(|e| e.to_string())?;
Ok(content)
})
.shared();
self.mention_set.insert_thread(id, task.clone());
let editor = self.editor.clone();
cx.spawn_in(window, async move |this, cx| {
if task.await.notify_async_err(cx).is_some() {
this.update(cx, |this, _| {
this.mention_set.insert_uri(crease_id, uri);
})
.ok();
} else {
editor
.update(cx, |editor, cx| {
editor.display_map.update(cx, |display_map, cx| {
display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
});
editor.remove_creases([crease_id], cx);
})
.ok();
}
})
.detach();
}
fn confirm_mention_for_text_thread(
&mut self,
crease_id: CreaseId,
anchor: Anchor,
path: PathBuf,
name: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let uri = MentionUri::TextThread {
path: path.clone(),
name,
};
let context = self.text_thread_store.update(cx, |text_thread_store, cx| {
text_thread_store.open_local_context(path.as_path().into(), cx)
});
let task = cx
.spawn(async move |_, cx| {
let context = context.await.map_err(|e| e.to_string())?;
let xml = context
.update(cx, |context, cx| context.to_xml(cx))
.map_err(|e| e.to_string())?;
Ok(xml)
})
.shared();
self.mention_set.insert_text_thread(path, task.clone());
let editor = self.editor.clone();
cx.spawn_in(window, async move |this, cx| {
if task.await.notify_async_err(cx).is_some() {
this.update(cx, |this, _| {
this.mention_set.insert_uri(crease_id, uri);
})
.ok();
} else {
editor
.update(cx, |editor, cx| {
editor.display_map.update(cx, |display_map, cx| {
display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
});
editor.remove_creases([crease_id], cx);
})
.ok();
}
})
.detach();
}
pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.set_mode(mode);
@ -817,6 +934,10 @@ impl MessageEditor {
self.mention_set
.add_fetch_result(url, Task::ready(Ok(text)).shared());
}
MentionUri::Directory { abs_path } => {
let task = Task::ready(Ok(text)).shared();
self.mention_set.directories.insert(abs_path, task);
}
MentionUri::File { .. }
| MentionUri::Symbol { .. }
| MentionUri::Rule { .. }
@ -882,6 +1003,18 @@ impl MessageEditor {
}
}
struct DirectoryContents(Arc<[(Arc<Path>, PathBuf, String)]>);
impl Display for DirectoryContents {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (_relative_path, full_path, content) in self.0.iter() {
let fence = codeblock_fence_for_path(Some(full_path), None);
write!(f, "\n{fence}\n{content}\n```")?;
}
Ok(())
}
}
impl Focusable for MessageEditor {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.editor.focus_handle(cx)
@ -1064,6 +1197,7 @@ pub struct MentionSet {
images: HashMap<CreaseId, Shared<Task<Result<MentionImage, String>>>>,
thread_summaries: HashMap<ThreadId, Shared<Task<Result<SharedString, String>>>>,
text_thread_summaries: HashMap<PathBuf, Shared<Task<Result<String, String>>>>,
directories: HashMap<PathBuf, Shared<Task<Result<String, String>>>>,
}
impl MentionSet {
@ -1116,7 +1250,6 @@ impl MentionSet {
.map(|(&crease_id, uri)| {
match uri {
MentionUri::File { abs_path, .. } => {
// TODO directories
let uri = uri.clone();
let abs_path = abs_path.to_path_buf();
@ -1141,6 +1274,24 @@ impl MentionSet {
anyhow::Ok((crease_id, Mention::Text { uri, content }))
})
}
MentionUri::Directory { abs_path } => {
let Some(content) = self.directories.get(abs_path).cloned() else {
return Task::ready(Err(anyhow!("missing directory load task")));
};
let uri = uri.clone();
cx.spawn(async move |_| {
Ok((
crease_id,
Mention::Text {
uri,
content: content
.await
.map_err(|e| anyhow::anyhow!("{e}"))?
.to_string(),
},
))
})
}
MentionUri::Symbol {
path, line_range, ..
}

View file

@ -2790,25 +2790,30 @@ impl AcpThreadView {
if let Some(mention) = MentionUri::parse(&url).log_err() {
workspace.update(cx, |workspace, cx| match mention {
MentionUri::File { abs_path, .. } => {
MentionUri::File { abs_path } => {
let project = workspace.project();
let Some((path, entry)) = project.update(cx, |project, cx| {
let Some(path) =
project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
else {
return;
};
workspace
.open_path(path, None, true, window, cx)
.detach_and_log_err(cx);
}
MentionUri::Directory { abs_path } => {
let project = workspace.project();
let Some(entry) = project.update(cx, |project, cx| {
let path = project.find_project_path(abs_path, cx)?;
let entry = project.entry_for_path(&path, cx)?;
Some((path, entry))
project.entry_for_path(&path, cx)
}) else {
return;
};
if entry.is_dir() {
project.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(entry.id));
});
} else {
workspace
.open_path(path, None, true, window, cx)
.detach_and_log_err(cx);
}
project.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(entry.id));
});
}
MentionUri::Symbol {
path, line_range, ..