parent
d5ed569fad
commit
ebcce8730d
10 changed files with 1324 additions and 1 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -210,6 +210,7 @@ dependencies = [
|
||||||
"language_models",
|
"language_models",
|
||||||
"log",
|
"log",
|
||||||
"lsp",
|
"lsp",
|
||||||
|
"open",
|
||||||
"paths",
|
"paths",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
|
@ -223,6 +224,7 @@ dependencies = [
|
||||||
"settings",
|
"settings",
|
||||||
"smol",
|
"smol",
|
||||||
"task",
|
"task",
|
||||||
|
"tempfile",
|
||||||
"terminal",
|
"terminal",
|
||||||
"theme",
|
"theme",
|
||||||
"ui",
|
"ui",
|
||||||
|
|
|
@ -32,6 +32,7 @@ language.workspace = true
|
||||||
language_model.workspace = true
|
language_model.workspace = true
|
||||||
language_models.workspace = true
|
language_models.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
open.workspace = true
|
||||||
paths.workspace = true
|
paths.workspace = true
|
||||||
portable-pty.workspace = true
|
portable-pty.workspace = true
|
||||||
project.workspace = true
|
project.workspace = true
|
||||||
|
@ -67,6 +68,7 @@ pretty_assertions.workspace = true
|
||||||
project = { workspace = true, "features" = ["test-support"] }
|
project = { workspace = true, "features" = ["test-support"] }
|
||||||
reqwest_client.workspace = true
|
reqwest_client.workspace = true
|
||||||
settings = { workspace = true, "features" = ["test-support"] }
|
settings = { workspace = true, "features" = ["test-support"] }
|
||||||
|
tempfile.workspace = true
|
||||||
terminal = { workspace = true, "features" = ["test-support"] }
|
terminal = { workspace = true, "features" = ["test-support"] }
|
||||||
theme = { workspace = true, "features" = ["test-support"] }
|
theme = { workspace = true, "features" = ["test-support"] }
|
||||||
worktree = { workspace = true, "features" = ["test-support"] }
|
worktree = { workspace = true, "features" = ["test-support"] }
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
use crate::{AgentResponseEvent, Thread, templates::Templates};
|
use crate::{AgentResponseEvent, Thread, templates::Templates};
|
||||||
use crate::{
|
use crate::{
|
||||||
EditFileTool, FindPathTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization,
|
CopyPathTool, CreateDirectoryTool, EditFileTool, FindPathTool, ListDirectoryTool, MovePathTool,
|
||||||
|
OpenTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization,
|
||||||
};
|
};
|
||||||
use acp_thread::ModelSelector;
|
use acp_thread::ModelSelector;
|
||||||
use agent_client_protocol as acp;
|
use agent_client_protocol as acp;
|
||||||
|
@ -416,6 +417,11 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||||
|
|
||||||
let thread = cx.new(|cx| {
|
let thread = cx.new(|cx| {
|
||||||
let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model);
|
let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model);
|
||||||
|
thread.add_tool(CreateDirectoryTool::new(project.clone()));
|
||||||
|
thread.add_tool(CopyPathTool::new(project.clone()));
|
||||||
|
thread.add_tool(MovePathTool::new(project.clone()));
|
||||||
|
thread.add_tool(ListDirectoryTool::new(project.clone()));
|
||||||
|
thread.add_tool(OpenTool::new(project.clone()));
|
||||||
thread.add_tool(ThinkingTool);
|
thread.add_tool(ThinkingTool);
|
||||||
thread.add_tool(FindPathTool::new(project.clone()));
|
thread.add_tool(FindPathTool::new(project.clone()));
|
||||||
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
|
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
|
||||||
|
|
|
@ -1,11 +1,23 @@
|
||||||
|
mod copy_path_tool;
|
||||||
|
mod create_directory_tool;
|
||||||
|
mod delete_path_tool;
|
||||||
mod edit_file_tool;
|
mod edit_file_tool;
|
||||||
mod find_path_tool;
|
mod find_path_tool;
|
||||||
|
mod list_directory_tool;
|
||||||
|
mod move_path_tool;
|
||||||
|
mod open_tool;
|
||||||
mod read_file_tool;
|
mod read_file_tool;
|
||||||
mod terminal_tool;
|
mod terminal_tool;
|
||||||
mod thinking_tool;
|
mod thinking_tool;
|
||||||
|
|
||||||
|
pub use copy_path_tool::*;
|
||||||
|
pub use create_directory_tool::*;
|
||||||
|
pub use delete_path_tool::*;
|
||||||
pub use edit_file_tool::*;
|
pub use edit_file_tool::*;
|
||||||
pub use find_path_tool::*;
|
pub use find_path_tool::*;
|
||||||
|
pub use list_directory_tool::*;
|
||||||
|
pub use move_path_tool::*;
|
||||||
|
pub use open_tool::*;
|
||||||
pub use read_file_tool::*;
|
pub use read_file_tool::*;
|
||||||
pub use terminal_tool::*;
|
pub use terminal_tool::*;
|
||||||
pub use thinking_tool::*;
|
pub use thinking_tool::*;
|
||||||
|
|
118
crates/agent2/src/tools/copy_path_tool.rs
Normal file
118
crates/agent2/src/tools/copy_path_tool.rs
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
use crate::{AgentTool, ToolCallEventStream};
|
||||||
|
use agent_client_protocol::ToolKind;
|
||||||
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
|
use gpui::{App, AppContext, Entity, SharedString, Task};
|
||||||
|
use project::Project;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use util::markdown::MarkdownInlineCode;
|
||||||
|
|
||||||
|
/// Copies a file or directory in the project, and returns confirmation that the
|
||||||
|
/// copy succeeded.
|
||||||
|
///
|
||||||
|
/// Directory contents will be copied recursively (like `cp -r`).
|
||||||
|
///
|
||||||
|
/// This tool should be used when it's desirable to create a copy of a file or
|
||||||
|
/// directory without modifying the original. It's much more efficient than
|
||||||
|
/// doing this by separately reading and then writing the file or directory's
|
||||||
|
/// contents, so this tool should be preferred over that approach whenever
|
||||||
|
/// copying is the goal.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct CopyPathToolInput {
|
||||||
|
/// The source path of the file or directory to copy.
|
||||||
|
/// If a directory is specified, its contents will be copied recursively (like `cp -r`).
|
||||||
|
///
|
||||||
|
/// <example>
|
||||||
|
/// If the project has the following files:
|
||||||
|
///
|
||||||
|
/// - directory1/a/something.txt
|
||||||
|
/// - directory2/a/things.txt
|
||||||
|
/// - directory3/a/other.txt
|
||||||
|
///
|
||||||
|
/// You can copy the first file by providing a source_path of "directory1/a/something.txt"
|
||||||
|
/// </example>
|
||||||
|
pub source_path: String,
|
||||||
|
|
||||||
|
/// The destination path where the file or directory should be copied to.
|
||||||
|
///
|
||||||
|
/// <example>
|
||||||
|
/// To copy "directory1/a/something.txt" to "directory2/b/copy.txt",
|
||||||
|
/// provide a destination_path of "directory2/b/copy.txt"
|
||||||
|
/// </example>
|
||||||
|
pub destination_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CopyPathTool {
|
||||||
|
project: Entity<Project>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CopyPathTool {
|
||||||
|
pub fn new(project: Entity<Project>) -> Self {
|
||||||
|
Self { project }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentTool for CopyPathTool {
|
||||||
|
type Input = CopyPathToolInput;
|
||||||
|
type Output = String;
|
||||||
|
|
||||||
|
fn name(&self) -> SharedString {
|
||||||
|
"copy_path".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> ToolKind {
|
||||||
|
ToolKind::Move
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> ui::SharedString {
|
||||||
|
if let Ok(input) = input {
|
||||||
|
let src = MarkdownInlineCode(&input.source_path);
|
||||||
|
let dest = MarkdownInlineCode(&input.destination_path);
|
||||||
|
format!("Copy {src} to {dest}").into()
|
||||||
|
} else {
|
||||||
|
"Copy path".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
self: Arc<Self>,
|
||||||
|
input: Self::Input,
|
||||||
|
_event_stream: ToolCallEventStream,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Task<Result<Self::Output>> {
|
||||||
|
let copy_task = self.project.update(cx, |project, cx| {
|
||||||
|
match project
|
||||||
|
.find_project_path(&input.source_path, cx)
|
||||||
|
.and_then(|project_path| project.entry_for_path(&project_path, cx))
|
||||||
|
{
|
||||||
|
Some(entity) => match project.find_project_path(&input.destination_path, cx) {
|
||||||
|
Some(project_path) => {
|
||||||
|
project.copy_entry(entity.id, None, project_path.path, cx)
|
||||||
|
}
|
||||||
|
None => Task::ready(Err(anyhow!(
|
||||||
|
"Destination path {} was outside the project.",
|
||||||
|
input.destination_path
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
None => Task::ready(Err(anyhow!(
|
||||||
|
"Source path {} was not found in the project.",
|
||||||
|
input.source_path
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let _ = copy_task.await.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Copying {} to {}",
|
||||||
|
input.source_path, input.destination_path
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(format!(
|
||||||
|
"Copied {} to {}",
|
||||||
|
input.source_path, input.destination_path
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
89
crates/agent2/src/tools/create_directory_tool.rs
Normal file
89
crates/agent2/src/tools/create_directory_tool.rs
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
use agent_client_protocol::ToolKind;
|
||||||
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
|
use gpui::{App, Entity, SharedString, Task};
|
||||||
|
use project::Project;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use util::markdown::MarkdownInlineCode;
|
||||||
|
|
||||||
|
use crate::{AgentTool, ToolCallEventStream};
|
||||||
|
|
||||||
|
/// Creates a new directory at the specified path within the project. Returns
|
||||||
|
/// confirmation that the directory was created.
|
||||||
|
///
|
||||||
|
/// This tool creates a directory and all necessary parent directories (similar
|
||||||
|
/// to `mkdir -p`). It should be used whenever you need to create new
|
||||||
|
/// directories within the project.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct CreateDirectoryToolInput {
|
||||||
|
/// The path of the new directory.
|
||||||
|
///
|
||||||
|
/// <example>
|
||||||
|
/// If the project has the following structure:
|
||||||
|
///
|
||||||
|
/// - directory1/
|
||||||
|
/// - directory2/
|
||||||
|
///
|
||||||
|
/// You can create a new directory by providing a path of "directory1/new_directory"
|
||||||
|
/// </example>
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CreateDirectoryTool {
|
||||||
|
project: Entity<Project>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateDirectoryTool {
|
||||||
|
pub fn new(project: Entity<Project>) -> Self {
|
||||||
|
Self { project }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentTool for CreateDirectoryTool {
|
||||||
|
type Input = CreateDirectoryToolInput;
|
||||||
|
type Output = String;
|
||||||
|
|
||||||
|
fn name(&self) -> SharedString {
|
||||||
|
"create_directory".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> ToolKind {
|
||||||
|
ToolKind::Read
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
|
||||||
|
if let Ok(input) = input {
|
||||||
|
format!("Create directory {}", MarkdownInlineCode(&input.path)).into()
|
||||||
|
} else {
|
||||||
|
"Create directory".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
self: Arc<Self>,
|
||||||
|
input: Self::Input,
|
||||||
|
_event_stream: ToolCallEventStream,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Task<Result<Self::Output>> {
|
||||||
|
let project_path = match self.project.read(cx).find_project_path(&input.path, cx) {
|
||||||
|
Some(project_path) => project_path,
|
||||||
|
None => {
|
||||||
|
return Task::ready(Err(anyhow!("Path to create was outside the project")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let destination_path: Arc<str> = input.path.as_str().into();
|
||||||
|
|
||||||
|
let create_entry = self.project.update(cx, |project, cx| {
|
||||||
|
project.create_entry(project_path.clone(), true, cx)
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.spawn(async move |_cx| {
|
||||||
|
create_entry
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Creating directory {destination_path}"))?;
|
||||||
|
|
||||||
|
Ok(format!("Created directory {destination_path}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
137
crates/agent2/src/tools/delete_path_tool.rs
Normal file
137
crates/agent2/src/tools/delete_path_tool.rs
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
use crate::{AgentTool, ToolCallEventStream};
|
||||||
|
use action_log::ActionLog;
|
||||||
|
use agent_client_protocol::ToolKind;
|
||||||
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
|
use futures::{SinkExt, StreamExt, channel::mpsc};
|
||||||
|
use gpui::{App, AppContext, Entity, SharedString, Task};
|
||||||
|
use project::{Project, ProjectPath};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Deletes the file or directory (and the directory's contents, recursively) at
|
||||||
|
/// the specified path in the project, and returns confirmation of the deletion.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct DeletePathToolInput {
|
||||||
|
/// The path of the file or directory to delete.
|
||||||
|
///
|
||||||
|
/// <example>
|
||||||
|
/// If the project has the following files:
|
||||||
|
///
|
||||||
|
/// - directory1/a/something.txt
|
||||||
|
/// - directory2/a/things.txt
|
||||||
|
/// - directory3/a/other.txt
|
||||||
|
///
|
||||||
|
/// You can delete the first file by providing a path of "directory1/a/something.txt"
|
||||||
|
/// </example>
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DeletePathTool {
|
||||||
|
project: Entity<Project>,
|
||||||
|
action_log: Entity<ActionLog>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeletePathTool {
|
||||||
|
pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
|
||||||
|
Self {
|
||||||
|
project,
|
||||||
|
action_log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentTool for DeletePathTool {
|
||||||
|
type Input = DeletePathToolInput;
|
||||||
|
type Output = String;
|
||||||
|
|
||||||
|
fn name(&self) -> SharedString {
|
||||||
|
"delete_path".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> ToolKind {
|
||||||
|
ToolKind::Delete
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
|
||||||
|
if let Ok(input) = input {
|
||||||
|
format!("Delete “`{}`”", input.path).into()
|
||||||
|
} else {
|
||||||
|
"Delete path".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
self: Arc<Self>,
|
||||||
|
input: Self::Input,
|
||||||
|
_event_stream: ToolCallEventStream,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Task<Result<Self::Output>> {
|
||||||
|
let path = input.path;
|
||||||
|
let Some(project_path) = self.project.read(cx).find_project_path(&path, cx) else {
|
||||||
|
return Task::ready(Err(anyhow!(
|
||||||
|
"Couldn't delete {path} because that path isn't in this project."
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(worktree) = self
|
||||||
|
.project
|
||||||
|
.read(cx)
|
||||||
|
.worktree_for_id(project_path.worktree_id, cx)
|
||||||
|
else {
|
||||||
|
return Task::ready(Err(anyhow!(
|
||||||
|
"Couldn't delete {path} because that path isn't in this project."
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let worktree_snapshot = worktree.read(cx).snapshot();
|
||||||
|
let (mut paths_tx, mut paths_rx) = mpsc::channel(256);
|
||||||
|
cx.background_spawn({
|
||||||
|
let project_path = project_path.clone();
|
||||||
|
async move {
|
||||||
|
for entry in
|
||||||
|
worktree_snapshot.traverse_from_path(true, false, false, &project_path.path)
|
||||||
|
{
|
||||||
|
if !entry.path.starts_with(&project_path.path) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
paths_tx
|
||||||
|
.send(ProjectPath {
|
||||||
|
worktree_id: project_path.worktree_id,
|
||||||
|
path: entry.path.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
anyhow::Ok(())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
let project = self.project.clone();
|
||||||
|
let action_log = self.action_log.clone();
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
while let Some(path) = paths_rx.next().await {
|
||||||
|
if let Ok(buffer) = project
|
||||||
|
.update(cx, |project, cx| project.open_buffer(path, cx))?
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
action_log.update(cx, |action_log, cx| {
|
||||||
|
action_log.will_delete_buffer(buffer.clone(), cx)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deletion_task = project
|
||||||
|
.update(cx, |project, cx| {
|
||||||
|
project.delete_file(project_path, false, cx)
|
||||||
|
})?
|
||||||
|
.with_context(|| {
|
||||||
|
format!("Couldn't delete {path} because that path isn't in this project.")
|
||||||
|
})?;
|
||||||
|
deletion_task
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Deleting {path}"))?;
|
||||||
|
Ok(format!("Deleted {path}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
664
crates/agent2/src/tools/list_directory_tool.rs
Normal file
664
crates/agent2/src/tools/list_directory_tool.rs
Normal file
|
@ -0,0 +1,664 @@
|
||||||
|
use crate::{AgentTool, ToolCallEventStream};
|
||||||
|
use agent_client_protocol::ToolKind;
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use gpui::{App, Entity, SharedString, Task};
|
||||||
|
use project::{Project, WorktreeSettings};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use settings::Settings;
|
||||||
|
use std::fmt::Write;
|
||||||
|
use std::{path::Path, sync::Arc};
|
||||||
|
use util::markdown::MarkdownInlineCode;
|
||||||
|
|
||||||
|
/// Lists files and directories in a given path. Prefer the `grep` or
|
||||||
|
/// `find_path` tools when searching the codebase.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct ListDirectoryToolInput {
|
||||||
|
/// The fully-qualified path of the directory to list in the project.
|
||||||
|
///
|
||||||
|
/// This path should never be absolute, and the first component
|
||||||
|
/// of the path should always be a root directory in a project.
|
||||||
|
///
|
||||||
|
/// <example>
|
||||||
|
/// If the project has the following root directories:
|
||||||
|
///
|
||||||
|
/// - directory1
|
||||||
|
/// - directory2
|
||||||
|
///
|
||||||
|
/// You can list the contents of `directory1` by using the path `directory1`.
|
||||||
|
/// </example>
|
||||||
|
///
|
||||||
|
/// <example>
|
||||||
|
/// If the project has the following root directories:
|
||||||
|
///
|
||||||
|
/// - foo
|
||||||
|
/// - bar
|
||||||
|
///
|
||||||
|
/// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.
|
||||||
|
/// </example>
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ListDirectoryTool {
|
||||||
|
project: Entity<Project>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListDirectoryTool {
|
||||||
|
pub fn new(project: Entity<Project>) -> Self {
|
||||||
|
Self { project }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentTool for ListDirectoryTool {
|
||||||
|
type Input = ListDirectoryToolInput;
|
||||||
|
type Output = String;
|
||||||
|
|
||||||
|
fn name(&self) -> SharedString {
|
||||||
|
"list_directory".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> ToolKind {
|
||||||
|
ToolKind::Read
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
|
||||||
|
if let Ok(input) = input {
|
||||||
|
let path = MarkdownInlineCode(&input.path);
|
||||||
|
format!("List the {path} directory's contents").into()
|
||||||
|
} else {
|
||||||
|
"List directory".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
self: Arc<Self>,
|
||||||
|
input: Self::Input,
|
||||||
|
_event_stream: ToolCallEventStream,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Task<Result<Self::Output>> {
|
||||||
|
// Sometimes models will return these even though we tell it to give a path and not a glob.
|
||||||
|
// When this happens, just list the root worktree directories.
|
||||||
|
if matches!(input.path.as_str(), "." | "" | "./" | "*") {
|
||||||
|
let output = self
|
||||||
|
.project
|
||||||
|
.read(cx)
|
||||||
|
.worktrees(cx)
|
||||||
|
.filter_map(|worktree| {
|
||||||
|
worktree.read(cx).root_entry().and_then(|entry| {
|
||||||
|
if entry.is_dir() {
|
||||||
|
entry.path.to_str()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return Task::ready(Ok(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
|
||||||
|
return Task::ready(Err(anyhow!("Path {} not found in project", input.path)));
|
||||||
|
};
|
||||||
|
let Some(worktree) = self
|
||||||
|
.project
|
||||||
|
.read(cx)
|
||||||
|
.worktree_for_id(project_path.worktree_id, cx)
|
||||||
|
else {
|
||||||
|
return Task::ready(Err(anyhow!("Worktree not found")));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if the directory whose contents we're listing is itself excluded or private
|
||||||
|
let global_settings = WorktreeSettings::get_global(cx);
|
||||||
|
if global_settings.is_path_excluded(&project_path.path) {
|
||||||
|
return Task::ready(Err(anyhow!(
|
||||||
|
"Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
|
||||||
|
&input.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if global_settings.is_path_private(&project_path.path) {
|
||||||
|
return Task::ready(Err(anyhow!(
|
||||||
|
"Cannot list directory because its path matches the user's global `private_files` setting: {}",
|
||||||
|
&input.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
|
||||||
|
if worktree_settings.is_path_excluded(&project_path.path) {
|
||||||
|
return Task::ready(Err(anyhow!(
|
||||||
|
"Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
|
||||||
|
&input.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if worktree_settings.is_path_private(&project_path.path) {
|
||||||
|
return Task::ready(Err(anyhow!(
|
||||||
|
"Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
|
||||||
|
&input.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let worktree_snapshot = worktree.read(cx).snapshot();
|
||||||
|
let worktree_root_name = worktree.read(cx).root_name().to_string();
|
||||||
|
|
||||||
|
let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
|
||||||
|
return Task::ready(Err(anyhow!("Path not found: {}", input.path)));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !entry.is_dir() {
|
||||||
|
return Task::ready(Err(anyhow!("{} is not a directory.", input.path)));
|
||||||
|
}
|
||||||
|
let worktree_snapshot = worktree.read(cx).snapshot();
|
||||||
|
|
||||||
|
let mut folders = Vec::new();
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
for entry in worktree_snapshot.child_entries(&project_path.path) {
|
||||||
|
// Skip private and excluded files and directories
|
||||||
|
if global_settings.is_path_private(&entry.path)
|
||||||
|
|| global_settings.is_path_excluded(&entry.path)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self
|
||||||
|
.project
|
||||||
|
.read(cx)
|
||||||
|
.find_project_path(&entry.path, cx)
|
||||||
|
.map(|project_path| {
|
||||||
|
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
|
||||||
|
|
||||||
|
worktree_settings.is_path_excluded(&project_path.path)
|
||||||
|
|| worktree_settings.is_path_private(&project_path.path)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_path = Path::new(&worktree_root_name)
|
||||||
|
.join(&entry.path)
|
||||||
|
.display()
|
||||||
|
.to_string();
|
||||||
|
if entry.is_dir() {
|
||||||
|
folders.push(full_path);
|
||||||
|
} else {
|
||||||
|
files.push(full_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
if !folders.is_empty() {
|
||||||
|
writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !files.is_empty() {
|
||||||
|
writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.is_empty() {
|
||||||
|
writeln!(output, "{} is empty.", input.path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task::ready(Ok(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use gpui::{TestAppContext, UpdateGlobal};
|
||||||
|
use indoc::indoc;
|
||||||
|
use project::{FakeFs, Project, WorktreeSettings};
|
||||||
|
use serde_json::json;
|
||||||
|
use settings::SettingsStore;
|
||||||
|
use util::path;
|
||||||
|
|
||||||
|
fn platform_paths(path_str: &str) -> String {
|
||||||
|
if cfg!(target_os = "windows") {
|
||||||
|
path_str.replace("/", "\\")
|
||||||
|
} else {
|
||||||
|
path_str.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_test(cx: &mut TestAppContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
let settings_store = SettingsStore::test(cx);
|
||||||
|
cx.set_global(settings_store);
|
||||||
|
language::init(cx);
|
||||||
|
Project::init_settings(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/project"),
|
||||||
|
json!({
|
||||||
|
"src": {
|
||||||
|
"main.rs": "fn main() {}",
|
||||||
|
"lib.rs": "pub fn hello() {}",
|
||||||
|
"models": {
|
||||||
|
"user.rs": "struct User {}",
|
||||||
|
"post.rs": "struct Post {}"
|
||||||
|
},
|
||||||
|
"utils": {
|
||||||
|
"helper.rs": "pub fn help() {}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"integration_test.rs": "#[test] fn test() {}"
|
||||||
|
},
|
||||||
|
"README.md": "# Project",
|
||||||
|
"Cargo.toml": "[package]"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
|
||||||
|
let tool = Arc::new(ListDirectoryTool::new(project));
|
||||||
|
|
||||||
|
// Test listing root directory
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "project".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
output,
|
||||||
|
platform_paths(indoc! {"
|
||||||
|
# Folders:
|
||||||
|
project/src
|
||||||
|
project/tests
|
||||||
|
|
||||||
|
# Files:
|
||||||
|
project/Cargo.toml
|
||||||
|
project/README.md
|
||||||
|
"})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test listing src directory
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "project/src".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
output,
|
||||||
|
platform_paths(indoc! {"
|
||||||
|
# Folders:
|
||||||
|
project/src/models
|
||||||
|
project/src/utils
|
||||||
|
|
||||||
|
# Files:
|
||||||
|
project/src/lib.rs
|
||||||
|
project/src/main.rs
|
||||||
|
"})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test listing directory with only files
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "project/tests".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!output.contains("# Folders:"));
|
||||||
|
assert!(output.contains("# Files:"));
|
||||||
|
assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/project"),
|
||||||
|
json!({
|
||||||
|
"empty_dir": {}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
|
||||||
|
let tool = Arc::new(ListDirectoryTool::new(project));
|
||||||
|
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "project/empty_dir".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(output, "project/empty_dir is empty.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/project"),
|
||||||
|
json!({
|
||||||
|
"file.txt": "content"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
|
||||||
|
let tool = Arc::new(ListDirectoryTool::new(project));
|
||||||
|
|
||||||
|
// Test non-existent path
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "project/nonexistent".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await;
|
||||||
|
assert!(output.unwrap_err().to_string().contains("Path not found"));
|
||||||
|
|
||||||
|
// Test trying to list a file instead of directory
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "project/file.txt".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
output
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("is not a directory")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_list_directory_security(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/project"),
|
||||||
|
json!({
|
||||||
|
"normal_dir": {
|
||||||
|
"file1.txt": "content",
|
||||||
|
"file2.txt": "content"
|
||||||
|
},
|
||||||
|
".mysecrets": "SECRET_KEY=abc123",
|
||||||
|
".secretdir": {
|
||||||
|
"config": "special configuration",
|
||||||
|
"secret.txt": "secret content"
|
||||||
|
},
|
||||||
|
".mymetadata": "custom metadata",
|
||||||
|
"visible_dir": {
|
||||||
|
"normal.txt": "normal content",
|
||||||
|
"special.privatekey": "private key content",
|
||||||
|
"data.mysensitive": "sensitive data",
|
||||||
|
".hidden_subdir": {
|
||||||
|
"hidden_file.txt": "hidden content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Configure settings explicitly
|
||||||
|
cx.update(|cx| {
|
||||||
|
SettingsStore::update_global(cx, |store, cx| {
|
||||||
|
store.update_user_settings::<WorktreeSettings>(cx, |settings| {
|
||||||
|
settings.file_scan_exclusions = Some(vec![
|
||||||
|
"**/.secretdir".to_string(),
|
||||||
|
"**/.mymetadata".to_string(),
|
||||||
|
"**/.hidden_subdir".to_string(),
|
||||||
|
]);
|
||||||
|
settings.private_files = Some(vec![
|
||||||
|
"**/.mysecrets".to_string(),
|
||||||
|
"**/*.privatekey".to_string(),
|
||||||
|
"**/*.mysensitive".to_string(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
|
||||||
|
let tool = Arc::new(ListDirectoryTool::new(project));
|
||||||
|
|
||||||
|
// Listing root directory should exclude private and excluded files
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "project".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Should include normal directories
|
||||||
|
assert!(output.contains("normal_dir"), "Should list normal_dir");
|
||||||
|
assert!(output.contains("visible_dir"), "Should list visible_dir");
|
||||||
|
|
||||||
|
// Should NOT include excluded or private files
|
||||||
|
assert!(
|
||||||
|
!output.contains(".secretdir"),
|
||||||
|
"Should not list .secretdir (file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.contains(".mymetadata"),
|
||||||
|
"Should not list .mymetadata (file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.contains(".mysecrets"),
|
||||||
|
"Should not list .mysecrets (private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trying to list an excluded directory should fail
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "project/.secretdir".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
output
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("file_scan_exclusions"),
|
||||||
|
"Error should mention file_scan_exclusions"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listing a directory should exclude private files within it
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "project/visible_dir".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Should include normal files
|
||||||
|
assert!(output.contains("normal.txt"), "Should list normal.txt");
|
||||||
|
|
||||||
|
// Should NOT include private files
|
||||||
|
assert!(
|
||||||
|
!output.contains("privatekey"),
|
||||||
|
"Should not list .privatekey files (private_files)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.contains("mysensitive"),
|
||||||
|
"Should not list .mysensitive files (private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should NOT include subdirectories that match exclusions
|
||||||
|
assert!(
|
||||||
|
!output.contains(".hidden_subdir"),
|
||||||
|
"Should not list .hidden_subdir (file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
|
||||||
|
// Create first worktree with its own private files
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/worktree1"),
|
||||||
|
json!({
|
||||||
|
".zed": {
|
||||||
|
"settings.json": r#"{
|
||||||
|
"file_scan_exclusions": ["**/fixture.*"],
|
||||||
|
"private_files": ["**/secret.rs", "**/config.toml"]
|
||||||
|
}"#
|
||||||
|
},
|
||||||
|
"src": {
|
||||||
|
"main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
|
||||||
|
"secret.rs": "const API_KEY: &str = \"secret_key_1\";",
|
||||||
|
"config.toml": "[database]\nurl = \"postgres://localhost/db1\""
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"test.rs": "mod tests { fn test_it() {} }",
|
||||||
|
"fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create second worktree with different private files
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/worktree2"),
|
||||||
|
json!({
|
||||||
|
".zed": {
|
||||||
|
"settings.json": r#"{
|
||||||
|
"file_scan_exclusions": ["**/internal.*"],
|
||||||
|
"private_files": ["**/private.js", "**/data.json"]
|
||||||
|
}"#
|
||||||
|
},
|
||||||
|
"lib": {
|
||||||
|
"public.js": "export function greet() { return 'Hello from worktree2'; }",
|
||||||
|
"private.js": "const SECRET_TOKEN = \"private_token_2\";",
|
||||||
|
"data.json": "{\"api_key\": \"json_secret_key\"}"
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"README.md": "# Public Documentation",
|
||||||
|
"internal.md": "# Internal Secrets and Configuration"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Set global settings
|
||||||
|
cx.update(|cx| {
|
||||||
|
SettingsStore::update_global(cx, |store, cx| {
|
||||||
|
store.update_user_settings::<WorktreeSettings>(cx, |settings| {
|
||||||
|
settings.file_scan_exclusions =
|
||||||
|
Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
|
||||||
|
settings.private_files = Some(vec!["**/.env".to_string()]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let project = Project::test(
|
||||||
|
fs.clone(),
|
||||||
|
[path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Wait for worktrees to be fully scanned
|
||||||
|
cx.executor().run_until_parked();
|
||||||
|
|
||||||
|
let tool = Arc::new(ListDirectoryTool::new(project));
|
||||||
|
|
||||||
|
// Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "worktree1/src".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(output.contains("main.rs"), "Should list main.rs");
|
||||||
|
assert!(
|
||||||
|
!output.contains("secret.rs"),
|
||||||
|
"Should not list secret.rs (local private_files)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.contains("config.toml"),
|
||||||
|
"Should not list config.toml (local private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test listing worktree1/tests - should exclude fixture.sql based on local settings
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "worktree1/tests".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(output.contains("test.rs"), "Should list test.rs");
|
||||||
|
assert!(
|
||||||
|
!output.contains("fixture.sql"),
|
||||||
|
"Should not list fixture.sql (local file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test listing worktree2/lib - should exclude private.js and data.json based on local settings
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "worktree2/lib".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(output.contains("public.js"), "Should list public.js");
|
||||||
|
assert!(
|
||||||
|
!output.contains("private.js"),
|
||||||
|
"Should not list private.js (local private_files)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output.contains("data.json"),
|
||||||
|
"Should not list data.json (local private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test listing worktree2/docs - should exclude internal.md based on local settings
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "worktree2/docs".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(output.contains("README.md"), "Should list README.md");
|
||||||
|
assert!(
|
||||||
|
!output.contains("internal.md"),
|
||||||
|
"Should not list internal.md (local file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test trying to list an excluded directory directly
|
||||||
|
let input = ListDirectoryToolInput {
|
||||||
|
path: "worktree1/src/secret.rs".into(),
|
||||||
|
};
|
||||||
|
let output = cx
|
||||||
|
.update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
output
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Cannot list directory"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
123
crates/agent2/src/tools/move_path_tool.rs
Normal file
123
crates/agent2/src/tools/move_path_tool.rs
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
use crate::{AgentTool, ToolCallEventStream};
|
||||||
|
use agent_client_protocol::ToolKind;
|
||||||
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
|
use gpui::{App, AppContext, Entity, SharedString, Task};
|
||||||
|
use project::Project;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{path::Path, sync::Arc};
|
||||||
|
use util::markdown::MarkdownInlineCode;
|
||||||
|
|
||||||
|
/// Moves or rename a file or directory in the project, and returns confirmation
|
||||||
|
/// that the move succeeded.
|
||||||
|
///
|
||||||
|
/// If the source and destination directories are the same, but the filename is
|
||||||
|
/// different, this performs a rename. Otherwise, it performs a move.
|
||||||
|
///
|
||||||
|
/// This tool should be used when it's desirable to move or rename a file or
|
||||||
|
/// directory without changing its contents at all.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct MovePathToolInput {
|
||||||
|
/// The source path of the file or directory to move/rename.
|
||||||
|
///
|
||||||
|
/// <example>
|
||||||
|
/// If the project has the following files:
|
||||||
|
///
|
||||||
|
/// - directory1/a/something.txt
|
||||||
|
/// - directory2/a/things.txt
|
||||||
|
/// - directory3/a/other.txt
|
||||||
|
///
|
||||||
|
/// You can move the first file by providing a source_path of "directory1/a/something.txt"
|
||||||
|
/// </example>
|
||||||
|
pub source_path: String,
|
||||||
|
|
||||||
|
/// The destination path where the file or directory should be moved/renamed to.
|
||||||
|
/// If the paths are the same except for the filename, then this will be a rename.
|
||||||
|
///
|
||||||
|
/// <example>
|
||||||
|
/// To move "directory1/a/something.txt" to "directory2/b/renamed.txt",
|
||||||
|
/// provide a destination_path of "directory2/b/renamed.txt"
|
||||||
|
/// </example>
|
||||||
|
pub destination_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MovePathTool {
|
||||||
|
project: Entity<Project>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovePathTool {
|
||||||
|
pub fn new(project: Entity<Project>) -> Self {
|
||||||
|
Self { project }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentTool for MovePathTool {
|
||||||
|
type Input = MovePathToolInput;
|
||||||
|
type Output = String;
|
||||||
|
|
||||||
|
fn name(&self) -> SharedString {
|
||||||
|
"move_path".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> ToolKind {
|
||||||
|
ToolKind::Move
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
|
||||||
|
if let Ok(input) = input {
|
||||||
|
let src = MarkdownInlineCode(&input.source_path);
|
||||||
|
let dest = MarkdownInlineCode(&input.destination_path);
|
||||||
|
let src_path = Path::new(&input.source_path);
|
||||||
|
let dest_path = Path::new(&input.destination_path);
|
||||||
|
|
||||||
|
match dest_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|os_str| os_str.to_os_string().into_string().ok())
|
||||||
|
{
|
||||||
|
Some(filename) if src_path.parent() == dest_path.parent() => {
|
||||||
|
let filename = MarkdownInlineCode(&filename);
|
||||||
|
format!("Rename {src} to {filename}").into()
|
||||||
|
}
|
||||||
|
_ => format!("Move {src} to {dest}").into(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"Move path".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
self: Arc<Self>,
|
||||||
|
input: Self::Input,
|
||||||
|
_event_stream: ToolCallEventStream,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Task<Result<Self::Output>> {
|
||||||
|
let rename_task = self.project.update(cx, |project, cx| {
|
||||||
|
match project
|
||||||
|
.find_project_path(&input.source_path, cx)
|
||||||
|
.and_then(|project_path| project.entry_for_path(&project_path, cx))
|
||||||
|
{
|
||||||
|
Some(entity) => match project.find_project_path(&input.destination_path, cx) {
|
||||||
|
Some(project_path) => project.rename_entry(entity.id, project_path.path, cx),
|
||||||
|
None => Task::ready(Err(anyhow!(
|
||||||
|
"Destination path {} was outside the project.",
|
||||||
|
input.destination_path
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
None => Task::ready(Err(anyhow!(
|
||||||
|
"Source path {} was not found in the project.",
|
||||||
|
input.source_path
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let _ = rename_task.await.with_context(|| {
|
||||||
|
format!("Moving {} to {}", input.source_path, input.destination_path)
|
||||||
|
})?;
|
||||||
|
Ok(format!(
|
||||||
|
"Moved {} to {}",
|
||||||
|
input.source_path, input.destination_path
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
170
crates/agent2/src/tools/open_tool.rs
Normal file
170
crates/agent2/src/tools/open_tool.rs
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
use crate::AgentTool;
|
||||||
|
use agent_client_protocol::ToolKind;
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use gpui::{App, AppContext, Entity, SharedString, Task};
|
||||||
|
use project::Project;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
use util::markdown::MarkdownEscaped;
|
||||||
|
|
||||||
|
/// This tool opens a file or URL with the default application associated with
|
||||||
|
/// it on the user's operating system:
|
||||||
|
///
|
||||||
|
/// - On macOS, it's equivalent to the `open` command
|
||||||
|
/// - On Windows, it's equivalent to `start`
|
||||||
|
/// - On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate
|
||||||
|
///
|
||||||
|
/// For example, it can open a web browser with a URL, open a PDF file with the
|
||||||
|
/// default PDF viewer, etc.
|
||||||
|
///
|
||||||
|
/// You MUST ONLY use this tool when the user has explicitly requested opening
|
||||||
|
/// something. You MUST NEVER assume that the user would like for you to use
|
||||||
|
/// this tool.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct OpenToolInput {
|
||||||
|
/// The path or URL to open with the default application.
|
||||||
|
path_or_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OpenTool {
|
||||||
|
project: Entity<Project>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenTool {
|
||||||
|
pub fn new(project: Entity<Project>) -> Self {
|
||||||
|
Self { project }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentTool for OpenTool {
|
||||||
|
type Input = OpenToolInput;
|
||||||
|
type Output = String;
|
||||||
|
|
||||||
|
fn name(&self) -> SharedString {
|
||||||
|
"open".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> ToolKind {
|
||||||
|
ToolKind::Execute
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
|
||||||
|
if let Ok(input) = input {
|
||||||
|
format!("Open `{}`", MarkdownEscaped(&input.path_or_url)).into()
|
||||||
|
} else {
|
||||||
|
"Open file or URL".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
self: Arc<Self>,
|
||||||
|
input: Self::Input,
|
||||||
|
event_stream: crate::ToolCallEventStream,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Task<Result<Self::Output>> {
|
||||||
|
// If path_or_url turns out to be a path in the project, make it absolute.
|
||||||
|
let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx);
|
||||||
|
let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())).to_string());
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
authorize.await?;
|
||||||
|
|
||||||
|
match abs_path {
|
||||||
|
Some(path) => open::that(path),
|
||||||
|
None => open::that(&input.path_or_url),
|
||||||
|
}
|
||||||
|
.context("Failed to open URL or file path")?;
|
||||||
|
|
||||||
|
Ok(format!("Successfully opened {}", input.path_or_url))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_absolute_path(
|
||||||
|
potential_path: &str,
|
||||||
|
project: Entity<Project>,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
let project = project.read(cx);
|
||||||
|
project
|
||||||
|
.find_project_path(PathBuf::from(potential_path), cx)
|
||||||
|
.and_then(|project_path| project.absolute_path(&project_path, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use gpui::TestAppContext;
|
||||||
|
use project::{FakeFs, Project};
|
||||||
|
use settings::SettingsStore;
|
||||||
|
use std::path::Path;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_to_absolute_path(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||||
|
let temp_path = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
fs.insert_tree(
|
||||||
|
&temp_path,
|
||||||
|
serde_json::json!({
|
||||||
|
"src": {
|
||||||
|
"main.rs": "fn main() {}",
|
||||||
|
"lib.rs": "pub fn lib_fn() {}"
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"readme.md": "# Project Documentation"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Use the temp_path as the root directory, not just its filename
|
||||||
|
let project = Project::test(fs.clone(), [temp_dir.path()], cx).await;
|
||||||
|
|
||||||
|
// Test cases where the function should return Some
|
||||||
|
cx.update(|cx| {
|
||||||
|
// Project-relative paths should return Some
|
||||||
|
// Create paths using the last segment of the temp path to simulate a project-relative path
|
||||||
|
let root_dir_name = Path::new(&temp_path)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_else(|| std::ffi::OsStr::new("temp"))
|
||||||
|
.to_string_lossy();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx)
|
||||||
|
.is_some(),
|
||||||
|
"Failed to resolve main.rs path"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
to_absolute_path(
|
||||||
|
&format!("{root_dir_name}/docs/readme.md",),
|
||||||
|
project.clone(),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.is_some(),
|
||||||
|
"Failed to resolve readme.md path"
|
||||||
|
);
|
||||||
|
|
||||||
|
// External URL should return None
|
||||||
|
let result = to_absolute_path("https://example.com", project.clone(), cx);
|
||||||
|
assert_eq!(result, None, "External URLs should return None");
|
||||||
|
|
||||||
|
// Path outside project
|
||||||
|
let result = to_absolute_path("../invalid/path", project.clone(), cx);
|
||||||
|
assert_eq!(result, None, "Paths outside the project should return None");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_test(cx: &mut TestAppContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
let settings_store = SettingsStore::test(cx);
|
||||||
|
cx.set_global(settings_store);
|
||||||
|
language::init(cx);
|
||||||
|
Project::init_settings(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue