use agent_client_protocol as acp;use anyhow::Result;use collections::FxHashSet;use gpui::{App, Entity, SharedString, Task};use language::Buffer;use project::Project;use schemars::JsonSchema;use serde::{Deserialize, Serialize};use std::path::PathBuf;use std::sync::Arc; use crate::{AgentTool, ToolCallEventStream}; /// Saves files that have unsaved changes.////// Use this tool when you need to edit files but they have unsaved changes that must be saved first./// Only use this tool after asking the user for permission to save their unsaved changes.#[derive(Debug, Serialize, Deserialize, JsonSchema)]pub struct SaveFileToolInput { /// The paths of the files to save. pub paths: Vec<PathBuf>,} pub struct SaveFileTool { project: Entity<Project>,} impl SaveFileTool { pub fn new(project: Entity<Project>) -> Self { Self { project } }} impl AgentTool for SaveFileTool { type Input = SaveFileToolInput; type Output = String; fn name() -> &'static str { "save_file" } fn kind() -> acp::ToolKind { acp::ToolKind::Other } fn initial_title( &self, input: Result<Self::Input, serde_json::Value>, _cx: &mut App, ) -> SharedString { match input { Ok(input) if input.paths.len() == 1 => "Save file".into(), Ok(input) => format!("Save {} files", input.paths.len()).into(), Err(_) => "Save files".into(), } } fn run( self: Arc<Self>, input: Self::Input, _event_stream: ToolCallEventStream, cx: &mut App, ) -> Task<Result<String>> { let project = self.project.clone(); let input_paths = input.paths; cx.spawn(async move |cx| { let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default(); let mut saved_paths: Vec<PathBuf> = Vec::new(); let mut clean_paths: Vec<PathBuf> = Vec::new(); let mut not_found_paths: Vec<PathBuf> = Vec::new(); let mut open_errors: Vec<(PathBuf, String)> = Vec::new(); let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new(); let mut save_errors: Vec<(String, String)> = Vec::new(); for path in input_paths { let project_path = project.read_with(cx, |project, cx| project.find_project_path(&path, cx)); let project_path = match project_path { Ok(Some(project_path)) => project_path, Ok(None) => { not_found_paths.push(path); continue; } Err(error) => { open_errors.push((path, error.to_string())); continue; } }; let open_buffer_task = project.update(cx, |project, cx| project.open_buffer(project_path, cx)); let buffer = match open_buffer_task { Ok(task) => match task.await { Ok(buffer) => buffer, Err(error) => { open_errors.push((path, error.to_string())); continue; } }, Err(error) => { open_errors.push((path, error.to_string())); continue; } }; let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) { Ok(is_dirty) => is_dirty, Err(error) => { dirty_check_errors.push((path, error.to_string())); continue; } }; if is_dirty { buffers_to_save.insert(buffer); saved_paths.push(path); } else { clean_paths.push(path); } } // Save each buffer individually since there's no batch save API. for buffer in buffers_to_save { let path_for_buffer = match buffer.read_with(cx, |buffer, _| { buffer .file() .map(|file| file.path().to_rel_path_buf()) .map(|path| path.as_rel_path().as_unix_str().to_owned()) }) { Ok(path) => path.unwrap_or_else(|| "<unknown>".to_string()), Err(error) => { save_errors.push(("<unknown>".to_string(), error.to_string())); continue; } }; let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx)); match save_task { Ok(task) => { if let Err(error) = task.await { save_errors.push((path_for_buffer, error.to_string())); } } Err(error) => { save_errors.push((path_for_buffer, error.to_string())); } } } let mut lines: Vec<String> = Vec::new(); if !saved_paths.is_empty() { lines.push(format!("Saved {} file(s).", saved_paths.len())); } if !clean_paths.is_empty() { lines.push(format!("{} clean.", clean_paths.len())); } if !not_found_paths.is_empty() { lines.push(format!("Not found ({}):", not_found_paths.len())); for path in ¬_found_paths { lines.push(format!("- {}", path.display())); } } if !open_errors.is_empty() { lines.push(format!("Open failed ({}):", open_errors.len())); for (path, error) in &open_errors { lines.push(format!("- {}: {}", path.display(), error)); } } if !dirty_check_errors.is_empty() { lines.push(format!( "Dirty check failed ({}):", dirty_check_errors.len() )); for (path, error) in &dirty_check_errors { lines.push(format!("- {}: {}", path.display(), error)); } } if !save_errors.is_empty() { lines.push(format!("Save failed ({}):", save_errors.len())); for (path, error) in &save_errors { lines.push(format!("- {}: {}", path, error)); } } if lines.is_empty() { Ok("No paths provided.".to_string()) } else { Ok(lines.join("\n")) } }) }} #[cfg(test)]mod tests { use super::*; use fs::Fs; use gpui::TestAppContext; use project::FakeFs; use serde_json::json; use settings::SettingsStore; use util::path; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); }); } #[gpui::test] async fn test_save_file_output_and_effects(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/root", json!({ "dirty.txt": "on disk: dirty\n", "clean.txt": "on disk: clean\n", }), ) .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let tool = Arc::new(SaveFileTool::new(project.clone())); // Make dirty.txt dirty in-memory. let dirty_project_path = project.read_with(cx, |project, cx| { project .find_project_path("root/dirty.txt", cx) .expect("dirty.txt should exist in project") }); let dirty_buffer = project .update(cx, |project, cx| { project.open_buffer(dirty_project_path, cx) }) .await .unwrap(); dirty_buffer.update(cx, |buffer, cx| { buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx); }); assert!( dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), "dirty.txt buffer should be dirty before save" ); // Ensure clean.txt is opened but remains clean. let clean_project_path = project.read_with(cx, |project, cx| { project .find_project_path("root/clean.txt", cx) .expect("clean.txt should exist in project") }); let clean_buffer = project .update(cx, |project, cx| { project.open_buffer(clean_project_path, cx) }) .await .unwrap(); assert!( !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), "clean.txt buffer should start clean" ); let output = cx .update(|cx| { tool.clone().run( SaveFileToolInput { paths: vec![ PathBuf::from("root/dirty.txt"), PathBuf::from("root/clean.txt"), ], }, ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); // Output should mention saved + clean. assert!( output.contains("Saved 1 file(s)."), "expected saved count line, got:\n{output}" ); assert!( output.contains("1 clean."), "expected clean count line, got:\n{output}" ); // Effect: dirty buffer should now be clean and disk should have new content. assert!( !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), "dirty.txt buffer should not be dirty after save" ); let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap(); assert_eq!( disk_dirty, "in memory: dirty\n", "dirty.txt disk content should be updated" ); // Sanity: clean buffer should remain clean and disk unchanged. let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap(); assert_eq!(disk_clean, "on disk: clean\n"); // Test empty paths case. let output = cx .update(|cx| { tool.clone().run( SaveFileToolInput { paths: vec![] }, ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); assert_eq!(output, "No paths provided."); // Test not-found path case. let output = cx .update(|cx| { tool.clone().run( SaveFileToolInput { paths: vec![PathBuf::from("nonexistent/path.txt")], }, ToolCallEventStream::test().0, cx, ) }) .await .unwrap(); assert!( output.contains("Not found (1):"), "expected not-found header line, got:\n{output}" ); assert!( output.contains("- nonexistent/path.txt"), "expected not-found path bullet, got:\n{output}" ); }}
use agent_client_protocol as acp;
use anyhow::Result;
use collections::FxHashSet;
use gpui::{App, Entity, SharedString, Task};
use language::Buffer;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{AgentTool, ToolCallEventStream};
/// Saves files that have unsaved changes.
///
/// Use this tool when you need to edit files but they have unsaved changes that must be saved first.
/// Only use this tool after asking the user for permission to save their unsaved changes.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct SaveFileToolInput {
/// The paths of the files to save.
pub paths: Vec<PathBuf>,
}
pub struct SaveFileTool {
project: Entity<Project>,
}
impl SaveFileTool {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl AgentTool for SaveFileTool {
type Input = SaveFileToolInput;
type Output = String;
fn name() -> &'static str {
"save_file"
}
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
match input {
Ok(input) if input.paths.len() == 1 => "Save file".into(),
Ok(input) => format!("Save {} files", input.paths.len()).into(),
Err(_) => "Save files".into(),
}
}
fn run(
self: Arc<Self>,
input: Self::Input,
_event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>> {
let project = self.project.clone();
let input_paths = input.paths;
cx.spawn(async move |cx| {
let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
let mut saved_paths: Vec<PathBuf> = Vec::new();
let mut clean_paths: Vec<PathBuf> = Vec::new();
let mut not_found_paths: Vec<PathBuf> = Vec::new();
let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
let mut save_errors: Vec<(String, String)> = Vec::new();
for path in input_paths {
let project_path =
project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
let project_path = match project_path {
Ok(Some(project_path)) => project_path,
Ok(None) => {
not_found_paths.push(path);
continue;
}
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
};
let open_buffer_task =
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
let buffer = match open_buffer_task {
Ok(task) => match task.await {
Ok(buffer) => buffer,
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
},
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
};
let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
Ok(is_dirty) => is_dirty,
Err(error) => {
dirty_check_errors.push((path, error.to_string()));
continue;
}
};
if is_dirty {
buffers_to_save.insert(buffer);
saved_paths.push(path);
} else {
clean_paths.push(path);
}
}
// Save each buffer individually since there's no batch save API.
for buffer in buffers_to_save {
let path_for_buffer = match buffer.read_with(cx, |buffer, _| {
buffer
.file()
.map(|file| file.path().to_rel_path_buf())
.map(|path| path.as_rel_path().as_unix_str().to_owned())
}) {
Ok(path) => path.unwrap_or_else(|| "<unknown>".to_string()),
Err(error) => {
save_errors.push(("<unknown>".to_string(), error.to_string()));
continue;
}
};
let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx));
match save_task {
Ok(task) => {
if let Err(error) = task.await {
save_errors.push((path_for_buffer, error.to_string()));
}
}
Err(error) => {
save_errors.push((path_for_buffer, error.to_string()));
}
}
}
let mut lines: Vec<String> = Vec::new();
if !saved_paths.is_empty() {
lines.push(format!("Saved {} file(s).", saved_paths.len()));
}
if !clean_paths.is_empty() {
lines.push(format!("{} clean.", clean_paths.len()));
}
if !not_found_paths.is_empty() {
lines.push(format!("Not found ({}):", not_found_paths.len()));
for path in ¬_found_paths {
lines.push(format!("- {}", path.display()));
}
}
if !open_errors.is_empty() {
lines.push(format!("Open failed ({}):", open_errors.len()));
for (path, error) in &open_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !dirty_check_errors.is_empty() {
lines.push(format!(
"Dirty check failed ({}):",
dirty_check_errors.len()
));
for (path, error) in &dirty_check_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !save_errors.is_empty() {
lines.push(format!("Save failed ({}):", save_errors.len()));
for (path, error) in &save_errors {
lines.push(format!("- {}: {}", path, error));
}
}
if lines.is_empty() {
Ok("No paths provided.".to_string())
} else {
Ok(lines.join("\n"))
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fs::Fs;
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use util::path;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
#[gpui::test]
async fn test_save_file_output_and_effects(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"dirty.txt": "on disk: dirty\n",
"clean.txt": "on disk: clean\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let tool = Arc::new(SaveFileTool::new(project.clone()));
// Make dirty.txt dirty in-memory.
let dirty_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/dirty.txt", cx)
.expect("dirty.txt should exist in project")
});
let dirty_buffer = project
.update(cx, |project, cx| {
project.open_buffer(dirty_project_path, cx)
})
.await
.unwrap();
dirty_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
});
assert!(
dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should be dirty before save"
);
// Ensure clean.txt is opened but remains clean.
let clean_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/clean.txt", cx)
.expect("clean.txt should exist in project")
});
let clean_buffer = project
.update(cx, |project, cx| {
project.open_buffer(clean_project_path, cx)
})
.await
.unwrap();
assert!(
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"clean.txt buffer should start clean"
);
let output = cx
.update(|cx| {
tool.clone().run(
SaveFileToolInput {
paths: vec![
PathBuf::from("root/dirty.txt"),
PathBuf::from("root/clean.txt"),
],
},
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
// Output should mention saved + clean.
assert!(
output.contains("Saved 1 file(s)."),
"expected saved count line, got:\n{output}"
);
assert!(
output.contains("1 clean."),
"expected clean count line, got:\n{output}"
);
// Effect: dirty buffer should now be clean and disk should have new content.
assert!(
!dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should not be dirty after save"
);
let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
assert_eq!(
disk_dirty, "in memory: dirty\n",
"dirty.txt disk content should be updated"
);
// Sanity: clean buffer should remain clean and disk unchanged.
let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap();
assert_eq!(disk_clean, "on disk: clean\n");
// Test empty paths case.
let output = cx
.update(|cx| {
tool.clone().run(
SaveFileToolInput { paths: vec![] },
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert_eq!(output, "No paths provided.");
// Test not-found path case.
let output = cx
.update(|cx| {
tool.clone().run(
SaveFileToolInput {
paths: vec![PathBuf::from("nonexistent/path.txt")],
},
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert!(
output.contains("Not found (1):"),
"expected not-found header line, got:\n{output}"
);
assert!(
output.contains("- nonexistent/path.txt"),
"expected not-found path bullet, got:\n{output}"
);
}
}