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",
|
||||
"log",
|
||||
"lsp",
|
||||
"open",
|
||||
"paths",
|
||||
"portable-pty",
|
||||
"pretty_assertions",
|
||||
|
@ -223,6 +224,7 @@ dependencies = [
|
|||
"settings",
|
||||
"smol",
|
||||
"task",
|
||||
"tempfile",
|
||||
"terminal",
|
||||
"theme",
|
||||
"ui",
|
||||
|
|
|
@ -32,6 +32,7 @@ language.workspace = true
|
|||
language_model.workspace = true
|
||||
language_models.workspace = true
|
||||
log.workspace = true
|
||||
open.workspace = true
|
||||
paths.workspace = true
|
||||
portable-pty.workspace = true
|
||||
project.workspace = true
|
||||
|
@ -67,6 +68,7 @@ pretty_assertions.workspace = true
|
|||
project = { workspace = true, "features" = ["test-support"] }
|
||||
reqwest_client.workspace = true
|
||||
settings = { workspace = true, "features" = ["test-support"] }
|
||||
tempfile.workspace = true
|
||||
terminal = { workspace = true, "features" = ["test-support"] }
|
||||
theme = { workspace = true, "features" = ["test-support"] }
|
||||
worktree = { workspace = true, "features" = ["test-support"] }
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
use crate::{AgentResponseEvent, Thread, templates::Templates};
|
||||
use crate::{
|
||||
EditFileTool, FindPathTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization,
|
||||
CopyPathTool, CreateDirectoryTool, EditFileTool, FindPathTool, ListDirectoryTool, MovePathTool,
|
||||
OpenTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization,
|
||||
};
|
||||
use acp_thread::ModelSelector;
|
||||
use agent_client_protocol as acp;
|
||||
|
@ -416,6 +417,11 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
|||
|
||||
let thread = cx.new(|cx| {
|
||||
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(FindPathTool::new(project.clone()));
|
||||
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 find_path_tool;
|
||||
mod list_directory_tool;
|
||||
mod move_path_tool;
|
||||
mod open_tool;
|
||||
mod read_file_tool;
|
||||
mod terminal_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 find_path_tool::*;
|
||||
pub use list_directory_tool::*;
|
||||
pub use move_path_tool::*;
|
||||
pub use open_tool::*;
|
||||
pub use read_file_tool::*;
|
||||
pub use terminal_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