402 lines · 13.3 KB · Rust
RawDownload
use agent_client_protocol as acp;use agent_settings::AgentSettings;use anyhow::Result;use collections::FxHashSet;use futures::FutureExt as _;use gpui::{App, Entity, SharedString, Task};use language::Buffer;use project::Project;use schemars::JsonSchema;use serde::{Deserialize, Serialize};use settings::Settings;use std::path::PathBuf;use std::sync::Arc;use util::markdown::MarkdownInlineCode; use crate::{    AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,}; /// 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 settings = AgentSettings::get_global(cx);        let mut needs_confirmation = false;         for path in &input.paths {            let path_str = path.to_string_lossy();            let decision = decide_permission_from_settings(Self::name(), &path_str, settings);            match decision {                ToolPermissionDecision::Allow => {}                ToolPermissionDecision::Deny(reason) => {                    return Task::ready(Err(anyhow::anyhow!("{}", reason)));                }                ToolPermissionDecision::Confirm => {                    needs_confirmation = true;                }            }        }         let authorize = if needs_confirmation {            let title = if input.paths.len() == 1 {                format!(                    "Save {}",                    MarkdownInlineCode(&input.paths[0].to_string_lossy())                )            } else {                let paths: Vec<_> = input                    .paths                    .iter()                    .take(3)                    .map(|p| p.to_string_lossy().to_string())                    .collect();                if input.paths.len() > 3 {                    format!(                        "Save {}, and {} more",                        paths.join(", "),                        input.paths.len() - 3                    )                } else {                    format!("Save {}", paths.join(", "))                }            };            let first_path = input                .paths                .first()                .map(|p| p.to_string_lossy().to_string())                .unwrap_or_default();            let context = crate::ToolPermissionContext {                tool_name: "save_file".to_string(),                input_value: first_path,            };            Some(event_stream.authorize(title, context, cx))        } else {            None        };         let project = self.project.clone();        let input_paths = input.paths;         cx.spawn(async move |cx| {            if let Some(authorize) = authorize {                authorize.await?;            }             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 dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();            let mut save_errors: Vec<(String, String)> = Vec::new();             for path in input_paths {                let Some(project_path) =                    project.read_with(cx, |project, cx| project.find_project_path(&path, cx))                else {                    not_found_paths.push(path);                    continue;                };                 let open_buffer_task =                    project.update(cx, |project, cx| project.open_buffer(project_path, cx));                 let buffer = futures::select! {                    result = open_buffer_task.fuse() => {                        match result {                            Ok(buffer) => buffer,                            Err(error) => {                                open_errors.push((path, error.to_string()));                                continue;                            }                        }                    }                    _ = event_stream.cancelled_by_user().fuse() => {                        anyhow::bail!("Save cancelled by user");                    }                };                 let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());                 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 = 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())                    })                    .unwrap_or_else(|| "<unknown>".to_string());                 let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx));                 let save_result = futures::select! {                    result = save_task.fuse() => result,                    _ = event_stream.cancelled_by_user().fuse() => {                        anyhow::bail!("Save cancelled by user");                    }                };                if let Err(error) = save_result {                    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 &not_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);        });        cx.update(|cx| {            let mut settings = AgentSettings::get_global(cx).clone();            settings.always_allow_tool_actions = true;            AgentSettings::override_global(settings, cx);        });    }     #[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}"        );    }}