Add support for saving and opening files in different encodings. The implementation is now complete.

R Aadarsh created

Change summary

Cargo.lock                                                    |    9 
Cargo.toml                                                    |    1 
crates/agent/src/tools/edit_file_tool.rs                      |    5 
crates/agent2/Cargo.toml                                      |  104 
crates/assistant_tools/Cargo.toml                             |   94 
crates/assistant_tools/src/edit_file_tool.rs                  | 2439 +++++
crates/collab/Cargo.toml                                      |    1 
crates/collab/src/tests/integration_tests.rs                  |    5 
crates/collab/src/tests/random_project_collaboration_tests.rs |    4 
crates/copilot/Cargo.toml                                     |    2 
crates/encodings/Cargo.toml                                   |    3 
crates/encodings/src/lib.rs                                   |   50 
crates/encodings/src/selectors.rs                             |  115 
crates/extension_host/Cargo.toml                              |    1 
crates/extension_host/src/extension_host.rs                   |    4 
crates/fs/Cargo.toml                                          |    1 
crates/fs/src/encodings.rs                                    |   58 
crates/fs/src/fs.rs                                           |   33 
crates/git_ui/Cargo.toml                                      |    1 
crates/git_ui/src/file_diff_view.rs                           |    4 
crates/language/Cargo.toml                                    |    2 
crates/language/src/buffer.rs                                 |   10 
crates/project/Cargo.toml                                     |    2 
crates/project/src/buffer_store.rs                            |    4 
crates/project/src/prettier_store.rs                          |    5 
crates/project/src/project_tests.rs                           |   26 
crates/remote_server/Cargo.toml                               |    1 
crates/remote_server/src/remote_editing_tests.rs              |    7 
crates/vim/Cargo.toml                                         |    1 
crates/workspace/Cargo.toml                                   |    1 
crates/workspace/src/workspace.rs                             |   13 
crates/worktree/Cargo.toml                                    |    1 
crates/worktree/src/worktree.rs                               |   11 
crates/worktree/src/worktree_tests.rs                         |    9 
crates/zed/Cargo.toml                                         |    2 
crates/zed/src/zed.rs                                         |   10 
36 files changed, 2,962 insertions(+), 77 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -153,6 +153,7 @@ dependencies = [
  "db",
  "derive_more 0.99.20",
  "editor",
+ "encoding",
  "env_logger 0.11.8",
  "fs",
  "futures 0.3.31",
@@ -3354,6 +3355,7 @@ dependencies = [
  "dashmap 6.1.0",
  "debugger_ui",
  "editor",
+ "encoding",
  "envy",
  "extension",
  "file_finder",
@@ -5589,6 +5591,7 @@ dependencies = [
 name = "encodings"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
  "editor",
  "encoding",
  "fuzzy",
@@ -5977,6 +5980,7 @@ dependencies = [
  "criterion",
  "ctor",
  "dap",
+ "encoding",
  "extension",
  "fs",
  "futures 0.3.31",
@@ -6490,6 +6494,7 @@ dependencies = [
  "paths",
  "proto",
  "rope",
+ "schemars",
  "serde",
  "serde_json",
  "smol",
@@ -7178,6 +7183,7 @@ dependencies = [
  "ctor",
  "db",
  "editor",
+ "encoding",
  "futures 0.3.31",
  "fuzzy",
  "git",
@@ -13068,6 +13074,7 @@ dependencies = [
  "context_server",
  "dap",
  "dap_adapters",
+ "encoding",
  "extension",
  "fancy-regex 0.14.0",
  "fs",
@@ -14043,6 +14050,7 @@ dependencies = [
  "dap_adapters",
  "debug_adapter_extension",
  "editor",
+ "encoding",
  "env_logger 0.11.8",
  "extension",
  "extension_host",
@@ -20807,6 +20815,7 @@ dependencies = [
  "component",
  "dap",
  "db",
+ "encoding",
  "fs",
  "futures 0.3.31",
  "gpui",

Cargo.toml πŸ”—

@@ -498,6 +498,7 @@ documented = "0.9.1"
 dotenvy = "0.15.0"
 ec4rs = "1.1"
 emojis = "0.6.1"
+encoding = "0.2.33"
 env_logger = "0.11"
 exec = "0.3.1"
 fancy-regex = "0.14.0"

crates/agent/src/tools/edit_file_tool.rs πŸ”—

@@ -563,7 +563,8 @@ mod tests {
     use super::*;
     use crate::{ContextServerRegistry, Templates};
     use client::TelemetrySettings;
-    use fs::Fs;
+    use encoding::all::UTF_8;
+    use fs::{Fs, encodings::EncodingWrapper};
     use gpui::{TestAppContext, UpdateGlobal};
     use language_model::fake_provider::FakeLanguageModel;
     use prompt_store::ProjectContext;
@@ -744,6 +745,7 @@ mod tests {
             path!("/root/src/main.rs").as_ref(),
             &Rope::from_str_small("initial content"),
             language::LineEnding::Unix,
+            EncodingWrapper::new(UTF_8),
         )
         .await
         .unwrap();
@@ -911,6 +913,7 @@ mod tests {
             path!("/root/src/main.rs").as_ref(),
             &Rope::from_str_small("initial content"),
             language::LineEnding::Unix,
+            EncodingWrapper::new(UTF_8),
         )
         .await
         .unwrap();

crates/agent2/Cargo.toml πŸ”—

@@ -0,0 +1,104 @@
+[package]
+name = "agent2"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lib]
+path = "src/agent2.rs"
+
+[features]
+test-support = ["db/test-support"]
+e2e = []
+
+[lints]
+workspace = true
+
+[dependencies]
+acp_thread.workspace = true
+action_log.workspace = true
+agent.workspace = true
+agent-client-protocol.workspace = true
+agent_servers.workspace = true
+agent_settings.workspace = true
+anyhow.workspace = true
+assistant_context.workspace = true
+assistant_tool.workspace = true
+assistant_tools.workspace = true
+chrono.workspace = true
+client.workspace = true
+cloud_llm_client.workspace = true
+collections.workspace = true
+context_server.workspace = true
+db.workspace = true
+encoding.workspace = true
+fs.workspace = true
+futures.workspace = true
+git.workspace = true
+gpui.workspace = true
+handlebars = { workspace = true, features = ["rust-embed"] }
+html_to_markdown.workspace = true
+http_client.workspace = true
+indoc.workspace = true
+itertools.workspace = true
+language.workspace = true
+language_model.workspace = true
+language_models.workspace = true
+log.workspace = true
+open.workspace = true
+parking_lot.workspace = true
+paths.workspace = true
+project.workspace = true
+prompt_store.workspace = true
+rust-embed.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+smol.workspace = true
+sqlez.workspace = true
+task.workspace = true
+telemetry.workspace = true
+terminal.workspace = true
+thiserror.workspace = true
+text.workspace = true
+ui.workspace = true
+util.workspace = true
+uuid.workspace = true
+watch.workspace = true
+web_search.workspace = true
+workspace-hack.workspace = true
+zed_env_vars.workspace = true
+zstd.workspace = true
+
+
+[dev-dependencies]
+agent = { workspace = true, "features" = ["test-support"] }
+agent_servers = { workspace = true, "features" = ["test-support"] }
+assistant_context = { workspace = true, "features" = ["test-support"] }
+ctor.workspace = true
+client = { workspace = true, "features" = ["test-support"] }
+clock = { workspace = true, "features" = ["test-support"] }
+context_server = { workspace = true, "features" = ["test-support"] }
+db = { workspace = true, "features" = ["test-support"] }
+editor = { workspace = true, "features" = ["test-support"] }
+env_logger.workspace = true
+fs = { workspace = true, "features" = ["test-support"] }
+git = { workspace = true, "features" = ["test-support"] }
+gpui = { workspace = true, "features" = ["test-support"] }
+gpui_tokio.workspace = true
+language = { workspace = true, "features" = ["test-support"] }
+language_model = { workspace = true, "features" = ["test-support"] }
+lsp = { workspace = true, "features" = ["test-support"] }
+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"] }
+tree-sitter-rust.workspace = true
+unindent = { workspace = true }
+worktree = { workspace = true, "features" = ["test-support"] }
+zlog.workspace = true

crates/assistant_tools/Cargo.toml πŸ”—

@@ -0,0 +1,94 @@
+[package]
+name = "assistant_tools"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/assistant_tools.rs"
+
+[features]
+eval = []
+
+[dependencies]
+action_log.workspace = true
+agent_settings.workspace = true
+anyhow.workspace = true
+assistant_tool.workspace = true
+buffer_diff.workspace = true
+chrono.workspace = true
+client.workspace = true
+cloud_llm_client.workspace = true
+collections.workspace = true
+component.workspace = true
+derive_more.workspace = true
+diffy = "0.4.2"
+editor.workspace = true
+encoding.workspace = true
+feature_flags.workspace = true
+futures.workspace = true
+gpui.workspace = true
+handlebars = { workspace = true, features = ["rust-embed"] }
+html_to_markdown.workspace = true
+http_client.workspace = true
+indoc.workspace = true
+itertools.workspace = true
+language.workspace = true
+language_model.workspace = true
+log.workspace = true
+lsp.workspace = true
+markdown.workspace = true
+open.workspace = true
+paths.workspace = true
+portable-pty.workspace = true
+project.workspace = true
+prompt_store.workspace = true
+regex.workspace = true
+rust-embed.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+smallvec.workspace = true
+streaming_diff.workspace = true
+strsim.workspace = true
+task.workspace = true
+terminal.workspace = true
+terminal_view.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+watch.workspace = true
+web_search.workspace = true
+which.workspace = true
+workspace-hack.workspace = true
+workspace.workspace = true
+
+[dev-dependencies]
+lsp = { workspace = true, features = ["test-support"] }
+client = { workspace = true, features = ["test-support"] }
+clock = { workspace = true, features = ["test-support"] }
+collections = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+gpui_tokio.workspace = true
+fs = { workspace = true, features = ["test-support"] }
+language = { workspace = true, features = ["test-support"] }
+language_model = { workspace = true, features = ["test-support"] }
+language_models.workspace = true
+project = { workspace = true, features = ["test-support"] }
+rand.workspace = true
+pretty_assertions.workspace = true
+reqwest_client.workspace = true
+settings = { workspace = true, features = ["test-support"] }
+smol.workspace = true
+task = { workspace = true, features = ["test-support"]}
+tempfile.workspace = true
+theme.workspace = true
+tree-sitter-rust.workspace = true
+workspace = { workspace = true, features = ["test-support"] }
+unindent.workspace = true
+zlog.workspace = true

crates/assistant_tools/src/edit_file_tool.rs πŸ”—

@@ -0,0 +1,2439 @@
+use crate::{
+    Templates,
+    edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
+    schema::json_schema_for,
+    ui::{COLLAPSED_LINES, ToolOutputPreview},
+};
+use action_log::ActionLog;
+use agent_settings;
+use anyhow::{Context as _, Result, anyhow};
+use assistant_tool::{
+    AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
+};
+use buffer_diff::{BufferDiff, BufferDiffSnapshot};
+use editor::{
+    Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey, multibuffer_context_lines,
+};
+use futures::StreamExt;
+use gpui::{
+    Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
+    TextStyleRefinement, WeakEntity, pulsating_between, px,
+};
+use indoc::formatdoc;
+use language::{
+    Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope,
+    TextBuffer,
+    language_settings::{self, FormatOnSave, SoftWrap},
+};
+use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
+use markdown::{Markdown, MarkdownElement, MarkdownStyle};
+use paths;
+use project::{
+    Project, ProjectPath,
+    lsp_store::{FormatTrigger, LspFormatTarget},
+};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Settings;
+use std::{
+    cmp::Reverse,
+    collections::HashSet,
+    ops::Range,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+};
+use theme::ThemeSettings;
+use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*};
+use util::ResultExt;
+use workspace::Workspace;
+
+pub struct EditFileTool;
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+pub struct EditFileToolInput {
+    /// A one-line, user-friendly markdown description of the edit. This will be
+    /// shown in the UI and also passed to another model to perform the edit.
+    ///
+    /// Be terse, but also descriptive in what you want to achieve with this
+    /// edit. Avoid generic instructions.
+    ///
+    /// NEVER mention the file path in this description.
+    ///
+    /// <example>Fix API endpoint URLs</example>
+    /// <example>Update copyright year in `page_footer`</example>
+    ///
+    /// Make sure to include this field before all the others in the input object
+    /// so that we can display it immediately.
+    pub display_description: String,
+
+    /// The full path of the file to create or modify in the project.
+    ///
+    /// WARNING: When specifying which file path need changing, you MUST
+    /// start each path with one of the project's root directories.
+    ///
+    /// The following examples assume we have two root directories in the project:
+    /// - /a/b/backend
+    /// - /c/d/frontend
+    ///
+    /// <example>
+    /// `backend/src/main.rs`
+    ///
+    /// Notice how the file path starts with `backend`. Without that, the path
+    /// would be ambiguous and the call would fail!
+    /// </example>
+    ///
+    /// <example>
+    /// `frontend/db.js`
+    /// </example>
+    pub path: PathBuf,
+
+    /// The mode of operation on the file. Possible values:
+    /// - 'edit': Make granular edits to an existing file.
+    /// - 'create': Create a new file if it doesn't exist.
+    /// - 'overwrite': Replace the entire contents of an existing file.
+    ///
+    /// When a file already exists or you just created it, prefer editing
+    /// it as opposed to recreating it from scratch.
+    pub mode: EditFileMode,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum EditFileMode {
+    Edit,
+    Create,
+    Overwrite,
+}
+
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct EditFileToolOutput {
+    pub original_path: PathBuf,
+    pub new_text: String,
+    pub old_text: Arc<String>,
+    pub raw_output: Option<EditAgentOutput>,
+}
+
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+struct PartialInput {
+    #[serde(default)]
+    path: String,
+    #[serde(default)]
+    display_description: String,
+}
+
+const DEFAULT_UI_TEXT: &str = "Editing file";
+
+impl Tool for EditFileTool {
+    fn name(&self) -> String {
+        "edit_file".into()
+    }
+
+    fn needs_confirmation(
+        &self,
+        input: &serde_json::Value,
+        project: &Entity<Project>,
+        cx: &App,
+    ) -> bool {
+        if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
+            return false;
+        }
+
+        let Ok(input) = serde_json::from_value::<EditFileToolInput>(input.clone()) else {
+            // If it's not valid JSON, it's going to error and confirming won't do anything.
+            return false;
+        };
+
+        // If any path component matches the local settings folder, then this could affect
+        // the editor in ways beyond the project source, so prompt.
+        let local_settings_folder = paths::local_settings_folder_relative_path();
+        let path = Path::new(&input.path);
+        if path
+            .components()
+            .any(|component| component.as_os_str() == local_settings_folder.as_os_str())
+        {
+            return true;
+        }
+
+        // It's also possible that the global config dir is configured to be inside the project,
+        // so check for that edge case too.
+        if let Ok(canonical_path) = std::fs::canonicalize(&input.path)
+            && canonical_path.starts_with(paths::config_dir())
+        {
+            return true;
+        }
+
+        // Check if path is inside the global config directory
+        // First check if it's already inside project - if not, try to canonicalize
+        let project_path = project.read(cx).find_project_path(&input.path, cx);
+
+        // If the path is inside the project, and it's not one of the above edge cases,
+        // then no confirmation is necessary. Otherwise, confirmation is necessary.
+        project_path.is_none()
+    }
+
+    fn may_perform_edits(&self) -> bool {
+        true
+    }
+
+    fn description(&self) -> String {
+        include_str!("edit_file_tool/description.md").to_string()
+    }
+
+    fn icon(&self) -> IconName {
+        IconName::ToolPencil
+    }
+
+    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
+        json_schema_for::<EditFileToolInput>(format)
+    }
+
+    fn ui_text(&self, input: &serde_json::Value) -> String {
+        match serde_json::from_value::<EditFileToolInput>(input.clone()) {
+            Ok(input) => {
+                let path = Path::new(&input.path);
+                let mut description = input.display_description.clone();
+
+                // Add context about why confirmation may be needed
+                let local_settings_folder = paths::local_settings_folder_relative_path();
+                if path
+                    .components()
+                    .any(|c| c.as_os_str() == local_settings_folder.as_os_str())
+                {
+                    description.push_str(" (local settings)");
+                } else if let Ok(canonical_path) = std::fs::canonicalize(&input.path)
+                    && canonical_path.starts_with(paths::config_dir())
+                {
+                    description.push_str(" (global settings)");
+                }
+
+                description
+            }
+            Err(_) => "Editing file".to_string(),
+        }
+    }
+
+    fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
+        if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
+            let description = input.display_description.trim();
+            if !description.is_empty() {
+                return description.to_string();
+            }
+
+            let path = input.path.trim();
+            if !path.is_empty() {
+                return path.to_string();
+            }
+        }
+
+        DEFAULT_UI_TEXT.to_string()
+    }
+
+    fn run(
+        self: Arc<Self>,
+        input: serde_json::Value,
+        request: Arc<LanguageModelRequest>,
+        project: Entity<Project>,
+        action_log: Entity<ActionLog>,
+        model: Arc<dyn LanguageModel>,
+        window: Option<AnyWindowHandle>,
+        cx: &mut App,
+    ) -> ToolResult {
+        let input = match serde_json::from_value::<EditFileToolInput>(input) {
+            Ok(input) => input,
+            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
+        };
+
+        let project_path = match resolve_path(&input, project.clone(), cx) {
+            Ok(path) => path,
+            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
+        };
+
+        let card = window.and_then(|window| {
+            window
+                .update(cx, |_, window, cx| {
+                    cx.new(|cx| {
+                        EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
+                    })
+                })
+                .ok()
+        });
+
+        let card_clone = card.clone();
+        let action_log_clone = action_log.clone();
+        let task = cx.spawn(async move |cx: &mut AsyncApp| {
+            let edit_format = EditFormat::from_model(model.clone())?;
+            let edit_agent = EditAgent::new(
+                model,
+                project.clone(),
+                action_log_clone,
+                Templates::new(),
+                edit_format,
+            );
+
+            let buffer = project
+                .update(cx, |project, cx| {
+                    project.open_buffer(project_path.clone(), cx)
+                })?
+                .await?;
+
+            let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
+            let old_text = cx
+                .background_spawn({
+                    let old_snapshot = old_snapshot.clone();
+                    async move { Arc::new(old_snapshot.text()) }
+                })
+                .await;
+
+            if let Some(card) = card_clone.as_ref() {
+                card.update(cx, |card, cx| card.initialize(buffer.clone(), cx))?;
+            }
+
+            let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
+                edit_agent.edit(
+                    buffer.clone(),
+                    input.display_description.clone(),
+                    &request,
+                    cx,
+                )
+            } else {
+                edit_agent.overwrite(
+                    buffer.clone(),
+                    input.display_description.clone(),
+                    &request,
+                    cx,
+                )
+            };
+
+            let mut hallucinated_old_text = false;
+            let mut ambiguous_ranges = Vec::new();
+            while let Some(event) = events.next().await {
+                match event {
+                    EditAgentOutputEvent::Edited { .. } => {
+                        if let Some(card) = card_clone.as_ref() {
+                            card.update(cx, |card, cx| card.update_diff(cx))?;
+                        }
+                    }
+                    EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
+                    EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
+                    EditAgentOutputEvent::ResolvingEditRange(range) => {
+                        if let Some(card) = card_clone.as_ref() {
+                            card.update(cx, |card, cx| card.reveal_range(range, cx))?;
+                        }
+                    }
+                }
+            }
+            let agent_output = output.await?;
+
+            // If format_on_save is enabled, format the buffer
+            let format_on_save_enabled = buffer
+                .read_with(cx, |buffer, cx| {
+                    let settings = language_settings::language_settings(
+                        buffer.language().map(|l| l.name()),
+                        buffer.file(),
+                        cx,
+                    );
+                    !matches!(settings.format_on_save, FormatOnSave::Off)
+                })
+                .unwrap_or(false);
+
+            if format_on_save_enabled {
+                action_log.update(cx, |log, cx| {
+                    log.buffer_edited(buffer.clone(), cx);
+                })?;
+                let format_task = project.update(cx, |project, cx| {
+                    project.format(
+                        HashSet::from_iter([buffer.clone()]),
+                        LspFormatTarget::Buffers,
+                        false, // Don't push to history since the tool did it.
+                        FormatTrigger::Save,
+                        cx,
+                    )
+                })?;
+                format_task.await.log_err();
+            }
+
+            project
+                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
+                .await?;
+
+            // Notify the action log that we've edited the buffer (*after* formatting has completed).
+            action_log.update(cx, |log, cx| {
+                log.buffer_edited(buffer.clone(), cx);
+            })?;
+
+            let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
+            let (new_text, diff) = cx
+                .background_spawn({
+                    let new_snapshot = new_snapshot.clone();
+                    let old_text = old_text.clone();
+                    async move {
+                        let new_text = new_snapshot.text();
+                        let diff = language::unified_diff(&old_text, &new_text);
+
+                        (new_text, diff)
+                    }
+                })
+                .await;
+
+            let output = EditFileToolOutput {
+                original_path: project_path.path.to_path_buf(),
+                new_text,
+                old_text,
+                raw_output: Some(agent_output),
+            };
+
+            if let Some(card) = card_clone {
+                card.update(cx, |card, cx| {
+                    card.update_diff(cx);
+                    card.finalize(cx)
+                })
+                .log_err();
+            }
+
+            let input_path = input.path.display();
+            if diff.is_empty() {
+                anyhow::ensure!(
+                    !hallucinated_old_text,
+                    formatdoc! {"
+                        Some edits were produced but none of them could be applied.
+                        Read the relevant sections of {input_path} again so that
+                        I can perform the requested edits.
+                    "}
+                );
+                anyhow::ensure!(
+                    ambiguous_ranges.is_empty(),
+                    {
+                        let line_numbers = ambiguous_ranges
+                            .iter()
+                            .map(|range| range.start.to_string())
+                            .collect::<Vec<_>>()
+                            .join(", ");
+                        formatdoc! {"
+                            <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
+                            relevant sections of {input_path} again and extend <old_text> so
+                            that I can perform the requested edits.
+                        "}
+                    }
+                );
+                Ok(ToolResultOutput {
+                    content: ToolResultContent::Text("No edits were made.".into()),
+                    output: serde_json::to_value(output).ok(),
+                })
+            } else {
+                Ok(ToolResultOutput {
+                    content: ToolResultContent::Text(format!(
+                        "Edited {}:\n\n```diff\n{}\n```",
+                        input_path, diff
+                    )),
+                    output: serde_json::to_value(output).ok(),
+                })
+            }
+        });
+
+        ToolResult {
+            output: task,
+            card: card.map(AnyToolCard::from),
+        }
+    }
+
+    fn deserialize_card(
+        self: Arc<Self>,
+        output: serde_json::Value,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Option<AnyToolCard> {
+        let output = match serde_json::from_value::<EditFileToolOutput>(output) {
+            Ok(output) => output,
+            Err(_) => return None,
+        };
+
+        let card = cx.new(|cx| {
+            EditFileToolCard::new(output.original_path.clone(), project.clone(), window, cx)
+        });
+
+        cx.spawn({
+            let path: Arc<Path> = output.original_path.into();
+            let language_registry = project.read(cx).languages().clone();
+            let card = card.clone();
+            async move |cx| {
+                let buffer =
+                    build_buffer(output.new_text, path.clone(), &language_registry, cx).await?;
+                let buffer_diff =
+                    build_buffer_diff(output.old_text.clone(), &buffer, &language_registry, cx)
+                        .await?;
+                card.update(cx, |card, cx| {
+                    card.multibuffer.update(cx, |multibuffer, cx| {
+                        let snapshot = buffer.read(cx).snapshot();
+                        let diff = buffer_diff.read(cx);
+                        let diff_hunk_ranges = diff
+                            .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
+                            .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
+                            .collect::<Vec<_>>();
+
+                        multibuffer.set_excerpts_for_path(
+                            PathKey::for_buffer(&buffer, cx),
+                            buffer,
+                            diff_hunk_ranges,
+                            multibuffer_context_lines(cx),
+                            cx,
+                        );
+                        multibuffer.add_diff(buffer_diff, cx);
+                        let end = multibuffer.len(cx);
+                        card.total_lines =
+                            Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1);
+                    });
+
+                    cx.notify();
+                })?;
+                anyhow::Ok(())
+            }
+        })
+        .detach_and_log_err(cx);
+
+        Some(card.into())
+    }
+}
+
+/// Validate that the file path is valid, meaning:
+///
+/// - For `edit` and `overwrite`, the path must point to an existing file.
+/// - For `create`, the file must not already exist, but it's parent dir must exist.
+fn resolve_path(
+    input: &EditFileToolInput,
+    project: Entity<Project>,
+    cx: &mut App,
+) -> Result<ProjectPath> {
+    let project = project.read(cx);
+
+    match input.mode {
+        EditFileMode::Edit | EditFileMode::Overwrite => {
+            let path = project
+                .find_project_path(&input.path, cx)
+                .context("Can't edit file: path not found")?;
+
+            let entry = project
+                .entry_for_path(&path, cx)
+                .context("Can't edit file: path not found")?;
+
+            anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
+            Ok(path)
+        }
+
+        EditFileMode::Create => {
+            if let Some(path) = project.find_project_path(&input.path, cx) {
+                anyhow::ensure!(
+                    project.entry_for_path(&path, cx).is_none(),
+                    "Can't create file: file already exists"
+                );
+            }
+
+            let parent_path = input
+                .path
+                .parent()
+                .context("Can't create file: incorrect path")?;
+
+            let parent_project_path = project.find_project_path(&parent_path, cx);
+
+            let parent_entry = parent_project_path
+                .as_ref()
+                .and_then(|path| project.entry_for_path(path, cx))
+                .context("Can't create file: parent directory doesn't exist")?;
+
+            anyhow::ensure!(
+                parent_entry.is_dir(),
+                "Can't create file: parent is not a directory"
+            );
+
+            let file_name = input
+                .path
+                .file_name()
+                .context("Can't create file: invalid filename")?;
+
+            let new_file_path = parent_project_path.map(|parent| ProjectPath {
+                path: Arc::from(parent.path.join(file_name)),
+                ..parent
+            });
+
+            new_file_path.context("Can't create file")
+        }
+    }
+}
+
+pub struct EditFileToolCard {
+    path: PathBuf,
+    editor: Entity<Editor>,
+    multibuffer: Entity<MultiBuffer>,
+    project: Entity<Project>,
+    buffer: Option<Entity<Buffer>>,
+    base_text: Option<Arc<String>>,
+    buffer_diff: Option<Entity<BufferDiff>>,
+    revealed_ranges: Vec<Range<Anchor>>,
+    diff_task: Option<Task<Result<()>>>,
+    preview_expanded: bool,
+    error_expanded: Option<Entity<Markdown>>,
+    full_height_expanded: bool,
+    total_lines: Option<u32>,
+}
+
+impl EditFileToolCard {
+    pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
+        let expand_edit_card = agent_settings::AgentSettings::get_global(cx).expand_edit_card;
+        let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
+
+        let editor = cx.new(|cx| {
+            let mut editor = Editor::new(
+                EditorMode::Full {
+                    scale_ui_elements_with_buffer_font_size: false,
+                    show_active_line_background: false,
+                    sized_by_content: true,
+                },
+                multibuffer.clone(),
+                Some(project.clone()),
+                window,
+                cx,
+            );
+            editor.set_show_gutter(false, cx);
+            editor.disable_inline_diagnostics();
+            editor.disable_expand_excerpt_buttons(cx);
+            // Keep horizontal scrollbar so user can scroll horizontally if needed
+            editor.set_show_vertical_scrollbar(false, cx);
+            editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
+            editor.set_soft_wrap_mode(SoftWrap::None, cx);
+            editor.scroll_manager.set_forbid_vertical_scroll(true);
+            editor.set_show_indent_guides(false, cx);
+            editor.set_read_only(true);
+            editor.set_show_breakpoints(false, cx);
+            editor.set_show_code_actions(false, cx);
+            editor.set_show_git_diff_gutter(false, cx);
+            editor.set_expand_all_diff_hunks(cx);
+            editor
+        });
+        Self {
+            path,
+            project,
+            editor,
+            multibuffer,
+            buffer: None,
+            base_text: None,
+            buffer_diff: None,
+            revealed_ranges: Vec::new(),
+            diff_task: None,
+            preview_expanded: true,
+            error_expanded: None,
+            full_height_expanded: expand_edit_card,
+            total_lines: None,
+        }
+    }
+
+    pub fn initialize(&mut self, buffer: Entity<Buffer>, cx: &mut App) {
+        let buffer_snapshot = buffer.read(cx).snapshot();
+        let base_text = buffer_snapshot.text();
+        let language_registry = buffer.read(cx).language_registry();
+        let text_snapshot = buffer.read(cx).text_snapshot();
+
+        // Create a buffer diff with the current text as the base
+        let buffer_diff = cx.new(|cx| {
+            let mut diff = BufferDiff::new(&text_snapshot, cx);
+            let _ = diff.set_base_text(
+                buffer_snapshot.clone(),
+                language_registry,
+                text_snapshot,
+                cx,
+            );
+            diff
+        });
+
+        self.buffer = Some(buffer);
+        self.base_text = Some(base_text.into());
+        self.buffer_diff = Some(buffer_diff.clone());
+
+        // Add the diff to the multibuffer
+        self.multibuffer
+            .update(cx, |multibuffer, cx| multibuffer.add_diff(buffer_diff, cx));
+    }
+
+    pub fn is_loading(&self) -> bool {
+        self.total_lines.is_none()
+    }
+
+    pub fn update_diff(&mut self, cx: &mut Context<Self>) {
+        let Some(buffer) = self.buffer.as_ref() else {
+            return;
+        };
+        let Some(buffer_diff) = self.buffer_diff.as_ref() else {
+            return;
+        };
+
+        let buffer = buffer.clone();
+        let buffer_diff = buffer_diff.clone();
+        let base_text = self.base_text.clone();
+        self.diff_task = Some(cx.spawn(async move |this, cx| {
+            let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?;
+            let diff_snapshot = BufferDiff::update_diff(
+                buffer_diff.clone(),
+                text_snapshot.clone(),
+                base_text,
+                false,
+                false,
+                None,
+                None,
+                cx,
+            )
+            .await?;
+            buffer_diff.update(cx, |diff, cx| {
+                diff.set_snapshot(diff_snapshot, &text_snapshot, cx)
+            })?;
+            this.update(cx, |this, cx| this.update_visible_ranges(cx))
+        }));
+    }
+
+    pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Self>) {
+        self.revealed_ranges.push(range);
+        self.update_visible_ranges(cx);
+    }
+
+    fn update_visible_ranges(&mut self, cx: &mut Context<Self>) {
+        let Some(buffer) = self.buffer.as_ref() else {
+            return;
+        };
+
+        let ranges = self.excerpt_ranges(cx);
+        self.total_lines = self.multibuffer.update(cx, |multibuffer, cx| {
+            multibuffer.set_excerpts_for_path(
+                PathKey::for_buffer(buffer, cx),
+                buffer.clone(),
+                ranges,
+                multibuffer_context_lines(cx),
+                cx,
+            );
+            let end = multibuffer.len(cx);
+            Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
+        });
+        cx.notify();
+    }
+
+    fn excerpt_ranges(&self, cx: &App) -> Vec<Range<Point>> {
+        let Some(buffer) = self.buffer.as_ref() else {
+            return Vec::new();
+        };
+        let Some(diff) = self.buffer_diff.as_ref() else {
+            return Vec::new();
+        };
+
+        let buffer = buffer.read(cx);
+        let diff = diff.read(cx);
+        let mut ranges = diff
+            .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx)
+            .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
+            .collect::<Vec<_>>();
+        ranges.extend(
+            self.revealed_ranges
+                .iter()
+                .map(|range| range.to_point(buffer)),
+        );
+        ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end)));
+
+        // Merge adjacent ranges
+        let mut ranges = ranges.into_iter().peekable();
+        let mut merged_ranges = Vec::new();
+        while let Some(mut range) = ranges.next() {
+            while let Some(next_range) = ranges.peek() {
+                if range.end >= next_range.start {
+                    range.end = range.end.max(next_range.end);
+                    ranges.next();
+                } else {
+                    break;
+                }
+            }
+
+            merged_ranges.push(range);
+        }
+        merged_ranges
+    }
+
+    pub fn finalize(&mut self, cx: &mut Context<Self>) -> Result<()> {
+        let ranges = self.excerpt_ranges(cx);
+        let buffer = self.buffer.take().context("card was already finalized")?;
+        let base_text = self
+            .base_text
+            .take()
+            .context("card was already finalized")?;
+        let language_registry = self.project.read(cx).languages().clone();
+
+        // Replace the buffer in the multibuffer with the snapshot
+        let buffer = cx.new(|cx| {
+            let language = buffer.read(cx).language().cloned();
+            let buffer = TextBuffer::new_normalized(
+                0,
+                cx.entity_id().as_non_zero_u64().into(),
+                buffer.read(cx).line_ending(),
+                buffer.read(cx).as_rope().clone(),
+            );
+            let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
+            buffer.set_language(language, cx);
+            buffer
+        });
+
+        let buffer_diff = cx.spawn({
+            let buffer = buffer.clone();
+            async move |_this, cx| {
+                build_buffer_diff(base_text, &buffer, &language_registry, cx).await
+            }
+        });
+
+        cx.spawn(async move |this, cx| {
+            let buffer_diff = buffer_diff.await?;
+            this.update(cx, |this, cx| {
+                this.multibuffer.update(cx, |multibuffer, cx| {
+                    let path_key = PathKey::for_buffer(&buffer, cx);
+                    multibuffer.clear(cx);
+                    multibuffer.set_excerpts_for_path(
+                        path_key,
+                        buffer,
+                        ranges,
+                        multibuffer_context_lines(cx),
+                        cx,
+                    );
+                    multibuffer.add_diff(buffer_diff.clone(), cx);
+                });
+
+                cx.notify();
+            })
+        })
+        .detach_and_log_err(cx);
+        Ok(())
+    }
+}
+
+impl ToolCard for EditFileToolCard {
+    fn render(
+        &mut self,
+        status: &ToolUseStatus,
+        window: &mut Window,
+        workspace: WeakEntity<Workspace>,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let error_message = match status {
+            ToolUseStatus::Error(err) => Some(err),
+            _ => None,
+        };
+
+        let running_or_pending = match status {
+            ToolUseStatus::Running | ToolUseStatus::Pending => Some(()),
+            _ => None,
+        };
+
+        let should_show_loading = running_or_pending.is_some() && !self.full_height_expanded;
+
+        let path_label_button = h_flex()
+            .id(("edit-tool-path-label-button", self.editor.entity_id()))
+            .w_full()
+            .max_w_full()
+            .px_1()
+            .gap_0p5()
+            .cursor_pointer()
+            .rounded_sm()
+            .opacity(0.8)
+            .hover(|label| {
+                label
+                    .opacity(1.)
+                    .bg(cx.theme().colors().element_hover.opacity(0.5))
+            })
+            .tooltip(Tooltip::text("Jump to File"))
+            .child(
+                h_flex()
+                    .child(
+                        Icon::new(IconName::ToolPencil)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
+                    .child(
+                        div()
+                            .text_size(rems(0.8125))
+                            .child(self.path.display().to_string())
+                            .ml_1p5()
+                            .mr_0p5(),
+                    )
+                    .child(
+                        Icon::new(IconName::ArrowUpRight)
+                            .size(IconSize::Small)
+                            .color(Color::Ignored),
+                    ),
+            )
+            .on_click({
+                let path = self.path.clone();
+                move |_, window, cx| {
+                    workspace
+                        .update(cx, {
+                            |workspace, cx| {
+                                let Some(project_path) =
+                                    workspace.project().read(cx).find_project_path(&path, cx)
+                                else {
+                                    return;
+                                };
+                                let open_task =
+                                    workspace.open_path(project_path, None, true, window, cx);
+                                window
+                                    .spawn(cx, async move |cx| {
+                                        let item = open_task.await?;
+                                        if let Some(active_editor) = item.downcast::<Editor>() {
+                                            active_editor
+                                                .update_in(cx, |editor, window, cx| {
+                                                    let snapshot =
+                                                        editor.buffer().read(cx).snapshot(cx);
+                                                    let first_hunk = editor
+                                                        .diff_hunks_in_ranges(
+                                                            &[editor::Anchor::min()
+                                                                ..editor::Anchor::max()],
+                                                            &snapshot,
+                                                        )
+                                                        .next();
+                                                    if let Some(first_hunk) = first_hunk {
+                                                        let first_hunk_start =
+                                                            first_hunk.multi_buffer_range().start;
+                                                        editor.change_selections(
+                                                            Default::default(),
+                                                            window,
+                                                            cx,
+                                                            |selections| {
+                                                                selections.select_anchor_ranges([
+                                                                    first_hunk_start
+                                                                        ..first_hunk_start,
+                                                                ]);
+                                                            },
+                                                        )
+                                                    }
+                                                })
+                                                .log_err();
+                                        }
+                                        anyhow::Ok(())
+                                    })
+                                    .detach_and_log_err(cx);
+                            }
+                        })
+                        .ok();
+                }
+            })
+            .into_any_element();
+
+        let codeblock_header_bg = cx
+            .theme()
+            .colors()
+            .element_background
+            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
+
+        let codeblock_header = h_flex()
+            .flex_none()
+            .p_1()
+            .gap_1()
+            .justify_between()
+            .rounded_t_md()
+            .when(error_message.is_none(), |header| {
+                header.bg(codeblock_header_bg)
+            })
+            .child(path_label_button)
+            .when(should_show_loading, |header| {
+                header.pr_1p5().child(
+                    Icon::new(IconName::ArrowCircle)
+                        .size(IconSize::XSmall)
+                        .color(Color::Info)
+                        .with_rotate_animation(2),
+                )
+            })
+            .when_some(error_message, |header, error_message| {
+                header.child(
+                    h_flex()
+                        .gap_1()
+                        .child(
+                            Icon::new(IconName::Close)
+                                .size(IconSize::Small)
+                                .color(Color::Error),
+                        )
+                        .child(
+                            Disclosure::new(
+                                ("edit-file-error-disclosure", self.editor.entity_id()),
+                                self.error_expanded.is_some(),
+                            )
+                            .opened_icon(IconName::ChevronUp)
+                            .closed_icon(IconName::ChevronDown)
+                            .on_click(cx.listener({
+                                let error_message = error_message.clone();
+
+                                move |this, _event, _window, cx| {
+                                    if this.error_expanded.is_some() {
+                                        this.error_expanded.take();
+                                    } else {
+                                        this.error_expanded = Some(cx.new(|cx| {
+                                            Markdown::new(error_message.clone(), None, None, cx)
+                                        }))
+                                    }
+                                    cx.notify();
+                                }
+                            })),
+                        ),
+                )
+            })
+            .when(error_message.is_none() && !self.is_loading(), |header| {
+                header.child(
+                    Disclosure::new(
+                        ("edit-file-disclosure", self.editor.entity_id()),
+                        self.preview_expanded,
+                    )
+                    .opened_icon(IconName::ChevronUp)
+                    .closed_icon(IconName::ChevronDown)
+                    .on_click(cx.listener(
+                        move |this, _event, _window, _cx| {
+                            this.preview_expanded = !this.preview_expanded;
+                        },
+                    )),
+                )
+            });
+
+        let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
+            let line_height = editor
+                .style()
+                .map(|style| style.text.line_height_in_pixels(window.rem_size()))
+                .unwrap_or_default();
+
+            editor.set_text_style_refinement(TextStyleRefinement {
+                font_size: Some(
+                    TextSize::Small
+                        .rems(cx)
+                        .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
+                        .into(),
+                ),
+                ..TextStyleRefinement::default()
+            });
+            let element = editor.render(window, cx);
+            (element.into_any_element(), line_height)
+        });
+
+        let border_color = cx.theme().colors().border.opacity(0.6);
+
+        let waiting_for_diff = {
+            let styles = [
+                ("w_4_5", (0.1, 0.85), 2000),
+                ("w_1_4", (0.2, 0.75), 2200),
+                ("w_2_4", (0.15, 0.64), 1900),
+                ("w_3_5", (0.25, 0.72), 2300),
+                ("w_2_5", (0.3, 0.56), 1800),
+            ];
+
+            let mut container = v_flex()
+                .p_3()
+                .gap_1()
+                .border_t_1()
+                .rounded_b_md()
+                .border_color(border_color)
+                .bg(cx.theme().colors().editor_background);
+
+            for (width_method, pulse_range, duration_ms) in styles.iter() {
+                let (min_opacity, max_opacity) = *pulse_range;
+                let placeholder = match *width_method {
+                    "w_4_5" => div().w_3_4(),
+                    "w_1_4" => div().w_1_4(),
+                    "w_2_4" => div().w_2_4(),
+                    "w_3_5" => div().w_3_5(),
+                    "w_2_5" => div().w_2_5(),
+                    _ => div().w_1_2(),
+                }
+                .id("loading_div")
+                .h_1()
+                .rounded_full()
+                .bg(cx.theme().colors().element_active)
+                .with_animation(
+                    "loading_pulsate",
+                    Animation::new(Duration::from_millis(*duration_ms))
+                        .repeat()
+                        .with_easing(pulsating_between(min_opacity, max_opacity)),
+                    |label, delta| label.opacity(delta),
+                );
+
+                container = container.child(placeholder);
+            }
+
+            container
+        };
+
+        v_flex()
+            .mb_2()
+            .border_1()
+            .when(error_message.is_some(), |card| card.border_dashed())
+            .border_color(border_color)
+            .rounded_md()
+            .overflow_hidden()
+            .child(codeblock_header)
+            .when_some(self.error_expanded.as_ref(), |card, error_markdown| {
+                card.child(
+                    v_flex()
+                        .p_2()
+                        .gap_1()
+                        .border_t_1()
+                        .border_dashed()
+                        .border_color(border_color)
+                        .bg(cx.theme().colors().editor_background)
+                        .rounded_b_md()
+                        .child(
+                            Label::new("Error")
+                                .size(LabelSize::XSmall)
+                                .color(Color::Error),
+                        )
+                        .child(
+                            div()
+                                .rounded_md()
+                                .text_ui_sm(cx)
+                                .bg(cx.theme().colors().editor_background)
+                                .child(MarkdownElement::new(
+                                    error_markdown.clone(),
+                                    markdown_style(window, cx),
+                                )),
+                        ),
+                )
+            })
+            .when(self.is_loading() && error_message.is_none(), |card| {
+                card.child(waiting_for_diff)
+            })
+            .when(self.preview_expanded && !self.is_loading(), |card| {
+                let editor_view = v_flex()
+                    .relative()
+                    .h_full()
+                    .when(!self.full_height_expanded, |editor_container| {
+                        editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0))
+                    })
+                    .overflow_hidden()
+                    .border_t_1()
+                    .border_color(border_color)
+                    .bg(cx.theme().colors().editor_background)
+                    .child(editor);
+
+                card.child(
+                    ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id())
+                        .with_total_lines(self.total_lines.unwrap_or(0) as usize)
+                        .toggle_state(self.full_height_expanded)
+                        .with_collapsed_fade()
+                        .on_toggle({
+                            let this = cx.entity().downgrade();
+                            move |is_expanded, _window, cx| {
+                                if let Some(this) = this.upgrade() {
+                                    this.update(cx, |this, _cx| {
+                                        this.full_height_expanded = is_expanded;
+                                    });
+                                }
+                            }
+                        }),
+                )
+            })
+    }
+}
+
+fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
+    let theme_settings = ThemeSettings::get_global(cx);
+    let ui_font_size = TextSize::Default.rems(cx);
+    let mut text_style = window.text_style();
+
+    text_style.refine(&TextStyleRefinement {
+        font_family: Some(theme_settings.ui_font.family.clone()),
+        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
+        font_features: Some(theme_settings.ui_font.features.clone()),
+        font_size: Some(ui_font_size.into()),
+        color: Some(cx.theme().colors().text),
+        ..Default::default()
+    });
+
+    MarkdownStyle {
+        base_text_style: text_style.clone(),
+        selection_background_color: cx.theme().colors().element_selection_background,
+        ..Default::default()
+    }
+}
+
+async fn build_buffer(
+    mut text: String,
+    path: Arc<Path>,
+    language_registry: &Arc<language::LanguageRegistry>,
+    cx: &mut AsyncApp,
+) -> Result<Entity<Buffer>> {
+    let line_ending = LineEnding::detect(&text);
+    LineEnding::normalize(&mut text);
+    let text = Rope::from(text);
+    let language = cx
+        .update(|_cx| language_registry.language_for_file_path(&path))?
+        .await
+        .ok();
+    let buffer = cx.new(|cx| {
+        let buffer = TextBuffer::new_normalized(
+            0,
+            cx.entity_id().as_non_zero_u64().into(),
+            line_ending,
+            text,
+        );
+        let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
+        buffer.set_language(language, cx);
+        buffer
+    })?;
+    Ok(buffer)
+}
+
+async fn build_buffer_diff(
+    old_text: Arc<String>,
+    buffer: &Entity<Buffer>,
+    language_registry: &Arc<LanguageRegistry>,
+    cx: &mut AsyncApp,
+) -> Result<Entity<BufferDiff>> {
+    let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
+
+    let old_text_rope = cx
+        .background_spawn({
+            let old_text = old_text.clone();
+            async move { Rope::from(old_text.as_str()) }
+        })
+        .await;
+    let base_buffer = cx
+        .update(|cx| {
+            Buffer::build_snapshot(
+                old_text_rope,
+                buffer.language().cloned(),
+                Some(language_registry.clone()),
+                cx,
+            )
+        })?
+        .await;
+
+    let diff_snapshot = cx
+        .update(|cx| {
+            BufferDiffSnapshot::new_with_base_buffer(
+                buffer.text.clone(),
+                Some(old_text),
+                base_buffer,
+                cx,
+            )
+        })?
+        .await;
+
+    let secondary_diff = cx.new(|cx| {
+        let mut diff = BufferDiff::new(&buffer, cx);
+        diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
+        diff
+    })?;
+
+    cx.new(|cx| {
+        let mut diff = BufferDiff::new(&buffer.text, cx);
+        diff.set_snapshot(diff_snapshot, &buffer, cx);
+        diff.set_secondary_diff(secondary_diff);
+        diff
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use ::fs::{Fs, encodings::EncodingWrapper};
+    use client::TelemetrySettings;
+    use encoding::all::UTF_8;
+    use gpui::{TestAppContext, UpdateGlobal};
+    use language_model::fake_provider::FakeLanguageModel;
+    use serde_json::json;
+    use settings::SettingsStore;
+    use std::fs;
+    use util::path;
+
+    #[gpui::test]
+    async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = project::FakeFs::new(cx.executor());
+        fs.insert_tree("/root", json!({})).await;
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+        let result = cx
+            .update(|cx| {
+                let input = serde_json::to_value(EditFileToolInput {
+                    display_description: "Some edit".into(),
+                    path: "root/nonexistent_file.txt".into(),
+                    mode: EditFileMode::Edit,
+                })
+                .unwrap();
+                Arc::new(EditFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log,
+                        model,
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        assert_eq!(
+            result.unwrap_err().to_string(),
+            "Can't edit file: path not found"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
+        let mode = &EditFileMode::Create;
+
+        let result = test_resolve_path(mode, "root/new.txt", cx);
+        assert_resolved_path_eq(result.await, "new.txt");
+
+        let result = test_resolve_path(mode, "new.txt", cx);
+        assert_resolved_path_eq(result.await, "new.txt");
+
+        let result = test_resolve_path(mode, "dir/new.txt", cx);
+        assert_resolved_path_eq(result.await, "dir/new.txt");
+
+        let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
+        assert_eq!(
+            result.await.unwrap_err().to_string(),
+            "Can't create file: file already exists"
+        );
+
+        let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
+        assert_eq!(
+            result.await.unwrap_err().to_string(),
+            "Can't create file: parent directory doesn't exist"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
+        let mode = &EditFileMode::Edit;
+
+        let path_with_root = "root/dir/subdir/existing.txt";
+        let path_without_root = "dir/subdir/existing.txt";
+        let result = test_resolve_path(mode, path_with_root, cx);
+        assert_resolved_path_eq(result.await, path_without_root);
+
+        let result = test_resolve_path(mode, path_without_root, cx);
+        assert_resolved_path_eq(result.await, path_without_root);
+
+        let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
+        assert_eq!(
+            result.await.unwrap_err().to_string(),
+            "Can't edit file: path not found"
+        );
+
+        let result = test_resolve_path(mode, "root/dir", cx);
+        assert_eq!(
+            result.await.unwrap_err().to_string(),
+            "Can't edit file: path is a directory"
+        );
+    }
+
+    async fn test_resolve_path(
+        mode: &EditFileMode,
+        path: &str,
+        cx: &mut TestAppContext,
+    ) -> anyhow::Result<ProjectPath> {
+        init_test(cx);
+
+        let fs = project::FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/root",
+            json!({
+                "dir": {
+                    "subdir": {
+                        "existing.txt": "hello"
+                    }
+                }
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+        let input = EditFileToolInput {
+            display_description: "Some edit".into(),
+            path: path.into(),
+            mode: mode.clone(),
+        };
+
+        cx.update(|cx| resolve_path(&input, project, cx))
+    }
+
+    fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
+        let actual = path
+            .expect("Should return valid path")
+            .path
+            .to_str()
+            .unwrap()
+            .replace("\\", "/"); // Naive Windows paths normalization
+        assert_eq!(actual, expected);
+    }
+
+    #[test]
+    fn still_streaming_ui_text_with_path() {
+        let input = json!({
+            "path": "src/main.rs",
+            "display_description": "",
+            "old_string": "old code",
+            "new_string": "new code"
+        });
+
+        assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
+    }
+
+    #[test]
+    fn still_streaming_ui_text_with_description() {
+        let input = json!({
+            "path": "",
+            "display_description": "Fix error handling",
+            "old_string": "old code",
+            "new_string": "new code"
+        });
+
+        assert_eq!(
+            EditFileTool.still_streaming_ui_text(&input),
+            "Fix error handling",
+        );
+    }
+
+    #[test]
+    fn still_streaming_ui_text_with_path_and_description() {
+        let input = json!({
+            "path": "src/main.rs",
+            "display_description": "Fix error handling",
+            "old_string": "old code",
+            "new_string": "new code"
+        });
+
+        assert_eq!(
+            EditFileTool.still_streaming_ui_text(&input),
+            "Fix error handling",
+        );
+    }
+
+    #[test]
+    fn still_streaming_ui_text_no_path_or_description() {
+        let input = json!({
+            "path": "",
+            "display_description": "",
+            "old_string": "old code",
+            "new_string": "new code"
+        });
+
+        assert_eq!(
+            EditFileTool.still_streaming_ui_text(&input),
+            DEFAULT_UI_TEXT,
+        );
+    }
+
+    #[test]
+    fn still_streaming_ui_text_with_null() {
+        let input = serde_json::Value::Null;
+
+        assert_eq!(
+            EditFileTool.still_streaming_ui_text(&input),
+            DEFAULT_UI_TEXT,
+        );
+    }
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            language::init(cx);
+            TelemetrySettings::register(cx);
+            agent_settings::AgentSettings::register(cx);
+            Project::init_settings(cx);
+        });
+    }
+
+    fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) {
+        cx.update(|cx| {
+            // Set custom data directory (config will be under data_dir/config)
+            paths::set_custom_data_dir(data_dir.to_str().unwrap());
+
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            language::init(cx);
+            TelemetrySettings::register(cx);
+            agent_settings::AgentSettings::register(cx);
+            Project::init_settings(cx);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_format_on_save(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = project::FakeFs::new(cx.executor());
+        fs.insert_tree("/root", json!({"src": {}})).await;
+
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+        // Set up a Rust language with LSP formatting support
+        let rust_language = Arc::new(language::Language::new(
+            language::LanguageConfig {
+                name: "Rust".into(),
+                matcher: language::LanguageMatcher {
+                    path_suffixes: vec!["rs".to_string()],
+                    ..Default::default()
+                },
+                ..Default::default()
+            },
+            None,
+        ));
+
+        // Register the language and fake LSP
+        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+        language_registry.add(rust_language);
+
+        let mut fake_language_servers = language_registry.register_fake_lsp(
+            "Rust",
+            language::FakeLspAdapter {
+                capabilities: lsp::ServerCapabilities {
+                    document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                    ..Default::default()
+                },
+                ..Default::default()
+            },
+        );
+
+        // Create the file
+        fs.save(
+            path!("/root/src/main.rs").as_ref(),
+            &"initial content".into(),
+            language::LineEnding::Unix,
+            EncodingWrapper::new(UTF_8),
+        )
+        .await
+        .unwrap();
+
+        // Open the buffer to trigger LSP initialization
+        let buffer = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/root/src/main.rs"), cx)
+            })
+            .await
+            .unwrap();
+
+        // Register the buffer with language servers
+        let _handle = project.update(cx, |project, cx| {
+            project.register_buffer_with_language_servers(&buffer, cx)
+        });
+
+        const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
+        const FORMATTED_CONTENT: &str =
+            "This file was formatted by the fake formatter in the test.\n";
+
+        // Get the fake language server and set up formatting handler
+        let fake_language_server = fake_language_servers.next().await.unwrap();
+        fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
+            |_, _| async move {
+                Ok(Some(vec![lsp::TextEdit {
+                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
+                    new_text: FORMATTED_CONTENT.to_string(),
+                }]))
+            }
+        });
+
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+
+        // First, test with format_on_save enabled
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
+                    cx,
+                    |settings| {
+                        settings.defaults.format_on_save = Some(FormatOnSave::On);
+                        settings.defaults.formatter =
+                            Some(language::language_settings::SelectedFormatter::Auto);
+                    },
+                );
+            });
+        });
+
+        // Have the model stream unformatted content
+        let edit_result = {
+            let edit_task = cx.update(|cx| {
+                let input = serde_json::to_value(EditFileToolInput {
+                    display_description: "Create main function".into(),
+                    path: "root/src/main.rs".into(),
+                    mode: EditFileMode::Overwrite,
+                })
+                .unwrap();
+                Arc::new(EditFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            });
+
+            // Stream the unformatted content
+            cx.executor().run_until_parked();
+            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
+            model.end_last_completion_stream();
+
+            edit_task.await
+        };
+        assert!(edit_result.is_ok());
+
+        // Wait for any async operations (e.g. formatting) to complete
+        cx.executor().run_until_parked();
+
+        // Read the file to verify it was formatted automatically
+        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
+        assert_eq!(
+            // Ignore carriage returns on Windows
+            new_content.replace("\r\n", "\n"),
+            FORMATTED_CONTENT,
+            "Code should be formatted when format_on_save is enabled"
+        );
+
+        let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
+
+        assert_eq!(
+            stale_buffer_count, 0,
+            "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
+             This causes the agent to think the file was modified externally when it was just formatted.",
+            stale_buffer_count
+        );
+
+        // Next, test with format_on_save disabled
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
+                    cx,
+                    |settings| {
+                        settings.defaults.format_on_save = Some(FormatOnSave::Off);
+                    },
+                );
+            });
+        });
+
+        // Stream unformatted edits again
+        let edit_result = {
+            let edit_task = cx.update(|cx| {
+                let input = serde_json::to_value(EditFileToolInput {
+                    display_description: "Update main function".into(),
+                    path: "root/src/main.rs".into(),
+                    mode: EditFileMode::Overwrite,
+                })
+                .unwrap();
+                Arc::new(EditFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            });
+
+            // Stream the unformatted content
+            cx.executor().run_until_parked();
+            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
+            model.end_last_completion_stream();
+
+            edit_task.await
+        };
+        assert!(edit_result.is_ok());
+
+        // Wait for any async operations (e.g. formatting) to complete
+        cx.executor().run_until_parked();
+
+        // Verify the file was not formatted
+        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
+        assert_eq!(
+            // Ignore carriage returns on Windows
+            new_content.replace("\r\n", "\n"),
+            UNFORMATTED_CONTENT,
+            "Code should not be formatted when format_on_save is disabled"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = project::FakeFs::new(cx.executor());
+        fs.insert_tree("/root", json!({"src": {}})).await;
+
+        // Create a simple file with trailing whitespace
+        fs.save(
+            path!("/root/src/main.rs").as_ref(),
+            &"initial content".into(),
+            language::LineEnding::Unix,
+            EncodingWrapper::new(UTF_8),
+        )
+        .await
+        .unwrap();
+
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+
+        // First, test with remove_trailing_whitespace_on_save enabled
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
+                    cx,
+                    |settings| {
+                        settings.defaults.remove_trailing_whitespace_on_save = Some(true);
+                    },
+                );
+            });
+        });
+
+        const CONTENT_WITH_TRAILING_WHITESPACE: &str =
+            "fn main() {  \n    println!(\"Hello!\");  \n}\n";
+
+        // Have the model stream content that contains trailing whitespace
+        let edit_result = {
+            let edit_task = cx.update(|cx| {
+                let input = serde_json::to_value(EditFileToolInput {
+                    display_description: "Create main function".into(),
+                    path: "root/src/main.rs".into(),
+                    mode: EditFileMode::Overwrite,
+                })
+                .unwrap();
+                Arc::new(EditFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            });
+
+            // Stream the content with trailing whitespace
+            cx.executor().run_until_parked();
+            model.send_last_completion_stream_text_chunk(
+                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
+            );
+            model.end_last_completion_stream();
+
+            edit_task.await
+        };
+        assert!(edit_result.is_ok());
+
+        // Wait for any async operations (e.g. formatting) to complete
+        cx.executor().run_until_parked();
+
+        // Read the file to verify trailing whitespace was removed automatically
+        assert_eq!(
+            // Ignore carriage returns on Windows
+            fs.load(path!("/root/src/main.rs").as_ref())
+                .await
+                .unwrap()
+                .replace("\r\n", "\n"),
+            "fn main() {\n    println!(\"Hello!\");\n}\n",
+            "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
+        );
+
+        // Next, test with remove_trailing_whitespace_on_save disabled
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
+                    cx,
+                    |settings| {
+                        settings.defaults.remove_trailing_whitespace_on_save = Some(false);
+                    },
+                );
+            });
+        });
+
+        // Stream edits again with trailing whitespace
+        let edit_result = {
+            let edit_task = cx.update(|cx| {
+                let input = serde_json::to_value(EditFileToolInput {
+                    display_description: "Update main function".into(),
+                    path: "root/src/main.rs".into(),
+                    mode: EditFileMode::Overwrite,
+                })
+                .unwrap();
+                Arc::new(EditFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            });
+
+            // Stream the content with trailing whitespace
+            cx.executor().run_until_parked();
+            model.send_last_completion_stream_text_chunk(
+                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
+            );
+            model.end_last_completion_stream();
+
+            edit_task.await
+        };
+        assert!(edit_result.is_ok());
+
+        // Wait for any async operations (e.g. formatting) to complete
+        cx.executor().run_until_parked();
+
+        // Verify the file still has trailing whitespace
+        // Read the file again - it should still have trailing whitespace
+        let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
+        assert_eq!(
+            // Ignore carriage returns on Windows
+            final_content.replace("\r\n", "\n"),
+            CONTENT_WITH_TRAILING_WHITESPACE,
+            "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_needs_confirmation(cx: &mut TestAppContext) {
+        init_test(cx);
+        let tool = Arc::new(EditFileTool);
+        let fs = project::FakeFs::new(cx.executor());
+        fs.insert_tree("/root", json!({})).await;
+
+        // Test 1: Path with .zed component should require confirmation
+        let input_with_zed = json!({
+            "display_description": "Edit settings",
+            "path": ".zed/settings.json",
+            "mode": "edit"
+        });
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        cx.update(|cx| {
+            assert!(
+                tool.needs_confirmation(&input_with_zed, &project, cx),
+                "Path with .zed component should require confirmation"
+            );
+        });
+
+        // Test 2: Absolute path should require confirmation
+        let input_absolute = json!({
+            "display_description": "Edit file",
+            "path": "/etc/hosts",
+            "mode": "edit"
+        });
+        cx.update(|cx| {
+            assert!(
+                tool.needs_confirmation(&input_absolute, &project, cx),
+                "Absolute path should require confirmation"
+            );
+        });
+
+        // Test 3: Relative path without .zed should not require confirmation
+        let input_relative = json!({
+            "display_description": "Edit file",
+            "path": "root/src/main.rs",
+            "mode": "edit"
+        });
+        cx.update(|cx| {
+            assert!(
+                !tool.needs_confirmation(&input_relative, &project, cx),
+                "Relative path without .zed should not require confirmation"
+            );
+        });
+
+        // Test 4: Path with .zed in the middle should require confirmation
+        let input_zed_middle = json!({
+            "display_description": "Edit settings",
+            "path": "root/.zed/tasks.json",
+            "mode": "edit"
+        });
+        cx.update(|cx| {
+            assert!(
+                tool.needs_confirmation(&input_zed_middle, &project, cx),
+                "Path with .zed in any component should require confirmation"
+            );
+        });
+
+        // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
+        cx.update(|cx| {
+            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+            settings.always_allow_tool_actions = true;
+            agent_settings::AgentSettings::override_global(settings, cx);
+
+            assert!(
+                !tool.needs_confirmation(&input_with_zed, &project, cx),
+                "When always_allow_tool_actions is true, no confirmation should be needed"
+            );
+            assert!(
+                !tool.needs_confirmation(&input_absolute, &project, cx),
+                "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) {
+        // Set up a custom config directory for testing
+        let temp_dir = tempfile::tempdir().unwrap();
+        init_test_with_config(cx, temp_dir.path());
+
+        let tool = Arc::new(EditFileTool);
+
+        // Test ui_text shows context for various paths
+        let test_cases = vec![
+            (
+                json!({
+                    "display_description": "Update config",
+                    "path": ".zed/settings.json",
+                    "mode": "edit"
+                }),
+                "Update config (local settings)",
+                ".zed path should show local settings context",
+            ),
+            (
+                json!({
+                    "display_description": "Fix bug",
+                    "path": "src/.zed/local.json",
+                    "mode": "edit"
+                }),
+                "Fix bug (local settings)",
+                "Nested .zed path should show local settings context",
+            ),
+            (
+                json!({
+                    "display_description": "Update readme",
+                    "path": "README.md",
+                    "mode": "edit"
+                }),
+                "Update readme",
+                "Normal path should not show additional context",
+            ),
+            (
+                json!({
+                    "display_description": "Edit config",
+                    "path": "config.zed",
+                    "mode": "edit"
+                }),
+                "Edit config",
+                ".zed as extension should not show context",
+            ),
+        ];
+
+        for (input, expected_text, description) in test_cases {
+            cx.update(|_cx| {
+                let ui_text = tool.ui_text(&input);
+                assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
+            });
+        }
+    }
+
+    #[gpui::test]
+    async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) {
+        init_test(cx);
+        let tool = Arc::new(EditFileTool);
+        let fs = project::FakeFs::new(cx.executor());
+
+        // Create a project in /project directory
+        fs.insert_tree("/project", json!({})).await;
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+
+        // Test file outside project requires confirmation
+        let input_outside = json!({
+            "display_description": "Edit file",
+            "path": "/outside/file.txt",
+            "mode": "edit"
+        });
+        cx.update(|cx| {
+            assert!(
+                tool.needs_confirmation(&input_outside, &project, cx),
+                "File outside project should require confirmation"
+            );
+        });
+
+        // Test file inside project doesn't require confirmation
+        let input_inside = json!({
+            "display_description": "Edit file",
+            "path": "project/file.txt",
+            "mode": "edit"
+        });
+        cx.update(|cx| {
+            assert!(
+                !tool.needs_confirmation(&input_inside, &project, cx),
+                "File inside project should not require confirmation"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) {
+        // Set up a custom data directory for testing
+        let temp_dir = tempfile::tempdir().unwrap();
+        init_test_with_config(cx, temp_dir.path());
+
+        let tool = Arc::new(EditFileTool);
+        let fs = project::FakeFs::new(cx.executor());
+        fs.insert_tree("/home/user/myproject", json!({})).await;
+        let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await;
+
+        // Get the actual local settings folder name
+        let local_settings_folder = paths::local_settings_folder_relative_path();
+
+        // Test various config path patterns
+        let test_cases = vec![
+            (
+                format!("{}/settings.json", local_settings_folder.display()),
+                true,
+                "Top-level local settings file".to_string(),
+            ),
+            (
+                format!(
+                    "myproject/{}/settings.json",
+                    local_settings_folder.display()
+                ),
+                true,
+                "Local settings in project path".to_string(),
+            ),
+            (
+                format!("src/{}/config.toml", local_settings_folder.display()),
+                true,
+                "Local settings in subdirectory".to_string(),
+            ),
+            (
+                ".zed.backup/file.txt".to_string(),
+                true,
+                ".zed.backup is outside project".to_string(),
+            ),
+            (
+                "my.zed/file.txt".to_string(),
+                true,
+                "my.zed is outside project".to_string(),
+            ),
+            (
+                "myproject/src/file.zed".to_string(),
+                false,
+                ".zed as file extension".to_string(),
+            ),
+            (
+                "myproject/normal/path/file.rs".to_string(),
+                false,
+                "Normal file without config paths".to_string(),
+            ),
+        ];
+
+        for (path, should_confirm, description) in test_cases {
+            let input = json!({
+                "display_description": "Edit file",
+                "path": path,
+                "mode": "edit"
+            });
+            cx.update(|cx| {
+                assert_eq!(
+                    tool.needs_confirmation(&input, &project, cx),
+                    should_confirm,
+                    "Failed for case: {} - path: {}",
+                    description,
+                    path
+                );
+            });
+        }
+    }
+
+    #[gpui::test]
+    async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) {
+        // Set up a custom data directory for testing
+        let temp_dir = tempfile::tempdir().unwrap();
+        init_test_with_config(cx, temp_dir.path());
+
+        let tool = Arc::new(EditFileTool);
+        let fs = project::FakeFs::new(cx.executor());
+
+        // Create test files in the global config directory
+        let global_config_dir = paths::config_dir();
+        fs::create_dir_all(&global_config_dir).unwrap();
+        let global_settings_path = global_config_dir.join("settings.json");
+        fs::write(&global_settings_path, "{}").unwrap();
+
+        fs.insert_tree("/project", json!({})).await;
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+
+        // Test global config paths
+        let test_cases = vec![
+            (
+                global_settings_path.to_str().unwrap().to_string(),
+                true,
+                "Global settings file should require confirmation",
+            ),
+            (
+                global_config_dir
+                    .join("keymap.json")
+                    .to_str()
+                    .unwrap()
+                    .to_string(),
+                true,
+                "Global keymap file should require confirmation",
+            ),
+            (
+                "project/normal_file.rs".to_string(),
+                false,
+                "Normal project file should not require confirmation",
+            ),
+        ];
+
+        for (path, should_confirm, description) in test_cases {
+            let input = json!({
+                "display_description": "Edit file",
+                "path": path,
+                "mode": "edit"
+            });
+            cx.update(|cx| {
+                assert_eq!(
+                    tool.needs_confirmation(&input, &project, cx),
+                    should_confirm,
+                    "Failed for case: {}",
+                    description
+                );
+            });
+        }
+    }
+
+    #[gpui::test]
+    async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
+        init_test(cx);
+        let tool = Arc::new(EditFileTool);
+        let fs = project::FakeFs::new(cx.executor());
+
+        // Create multiple worktree directories
+        fs.insert_tree(
+            "/workspace/frontend",
+            json!({
+                "src": {
+                    "main.js": "console.log('frontend');"
+                }
+            }),
+        )
+        .await;
+        fs.insert_tree(
+            "/workspace/backend",
+            json!({
+                "src": {
+                    "main.rs": "fn main() {}"
+                }
+            }),
+        )
+        .await;
+        fs.insert_tree(
+            "/workspace/shared",
+            json!({
+                ".zed": {
+                    "settings.json": "{}"
+                }
+            }),
+        )
+        .await;
+
+        // Create project with multiple worktrees
+        let project = Project::test(
+            fs.clone(),
+            [
+                path!("/workspace/frontend").as_ref(),
+                path!("/workspace/backend").as_ref(),
+                path!("/workspace/shared").as_ref(),
+            ],
+            cx,
+        )
+        .await;
+
+        // Test files in different worktrees
+        let test_cases = vec![
+            ("frontend/src/main.js", false, "File in first worktree"),
+            ("backend/src/main.rs", false, "File in second worktree"),
+            (
+                "shared/.zed/settings.json",
+                true,
+                ".zed file in third worktree",
+            ),
+            ("/etc/hosts", true, "Absolute path outside all worktrees"),
+            (
+                "../outside/file.txt",
+                true,
+                "Relative path outside worktrees",
+            ),
+        ];
+
+        for (path, should_confirm, description) in test_cases {
+            let input = json!({
+                "display_description": "Edit file",
+                "path": path,
+                "mode": "edit"
+            });
+            cx.update(|cx| {
+                assert_eq!(
+                    tool.needs_confirmation(&input, &project, cx),
+                    should_confirm,
+                    "Failed for case: {} - path: {}",
+                    description,
+                    path
+                );
+            });
+        }
+    }
+
+    #[gpui::test]
+    async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
+        init_test(cx);
+        let tool = Arc::new(EditFileTool);
+        let fs = project::FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/project",
+            json!({
+                ".zed": {
+                    "settings.json": "{}"
+                },
+                "src": {
+                    ".zed": {
+                        "local.json": "{}"
+                    }
+                }
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+
+        // Test edge cases
+        let test_cases = vec![
+            // Empty path - find_project_path returns Some for empty paths
+            ("", false, "Empty path is treated as project root"),
+            // Root directory
+            ("/", true, "Root directory should be outside project"),
+            // Parent directory references - find_project_path resolves these
+            (
+                "project/../other",
+                false,
+                "Path with .. is resolved by find_project_path",
+            ),
+            (
+                "project/./src/file.rs",
+                false,
+                "Path with . should work normally",
+            ),
+            // Windows-style paths (if on Windows)
+            #[cfg(target_os = "windows")]
+            ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
+            #[cfg(target_os = "windows")]
+            ("project\\src\\main.rs", false, "Windows-style project path"),
+        ];
+
+        for (path, should_confirm, description) in test_cases {
+            let input = json!({
+                "display_description": "Edit file",
+                "path": path,
+                "mode": "edit"
+            });
+            cx.update(|cx| {
+                assert_eq!(
+                    tool.needs_confirmation(&input, &project, cx),
+                    should_confirm,
+                    "Failed for case: {} - path: {}",
+                    description,
+                    path
+                );
+            });
+        }
+    }
+
+    #[gpui::test]
+    async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) {
+        init_test(cx);
+        let tool = Arc::new(EditFileTool);
+
+        // Test UI text for various scenarios
+        let test_cases = vec![
+            (
+                json!({
+                    "display_description": "Update config",
+                    "path": ".zed/settings.json",
+                    "mode": "edit"
+                }),
+                "Update config (local settings)",
+                ".zed path should show local settings context",
+            ),
+            (
+                json!({
+                    "display_description": "Fix bug",
+                    "path": "src/.zed/local.json",
+                    "mode": "edit"
+                }),
+                "Fix bug (local settings)",
+                "Nested .zed path should show local settings context",
+            ),
+            (
+                json!({
+                    "display_description": "Update readme",
+                    "path": "README.md",
+                    "mode": "edit"
+                }),
+                "Update readme",
+                "Normal path should not show additional context",
+            ),
+            (
+                json!({
+                    "display_description": "Edit config",
+                    "path": "config.zed",
+                    "mode": "edit"
+                }),
+                "Edit config",
+                ".zed as extension should not show context",
+            ),
+        ];
+
+        for (input, expected_text, description) in test_cases {
+            cx.update(|_cx| {
+                let ui_text = tool.ui_text(&input);
+                assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
+            });
+        }
+    }
+
+    #[gpui::test]
+    async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
+        init_test(cx);
+        let tool = Arc::new(EditFileTool);
+        let fs = project::FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/project",
+            json!({
+                "existing.txt": "content",
+                ".zed": {
+                    "settings.json": "{}"
+                }
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+
+        // Test different EditFileMode values
+        let modes = vec![
+            EditFileMode::Edit,
+            EditFileMode::Create,
+            EditFileMode::Overwrite,
+        ];
+
+        for mode in modes {
+            // Test .zed path with different modes
+            let input_zed = json!({
+                "display_description": "Edit settings",
+                "path": "project/.zed/settings.json",
+                "mode": mode
+            });
+            cx.update(|cx| {
+                assert!(
+                    tool.needs_confirmation(&input_zed, &project, cx),
+                    ".zed path should require confirmation regardless of mode: {:?}",
+                    mode
+                );
+            });
+
+            // Test outside path with different modes
+            let input_outside = json!({
+                "display_description": "Edit file",
+                "path": "/outside/file.txt",
+                "mode": mode
+            });
+            cx.update(|cx| {
+                assert!(
+                    tool.needs_confirmation(&input_outside, &project, cx),
+                    "Outside path should require confirmation regardless of mode: {:?}",
+                    mode
+                );
+            });
+
+            // Test normal path with different modes
+            let input_normal = json!({
+                "display_description": "Edit file",
+                "path": "project/normal.txt",
+                "mode": mode
+            });
+            cx.update(|cx| {
+                assert!(
+                    !tool.needs_confirmation(&input_normal, &project, cx),
+                    "Normal path should not require confirmation regardless of mode: {:?}",
+                    mode
+                );
+            });
+        }
+    }
+
+    #[gpui::test]
+    async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) {
+        // Set up with custom directories for deterministic testing
+        let temp_dir = tempfile::tempdir().unwrap();
+        init_test_with_config(cx, temp_dir.path());
+
+        let tool = Arc::new(EditFileTool);
+        let fs = project::FakeFs::new(cx.executor());
+        fs.insert_tree("/project", json!({})).await;
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+
+        // Enable always_allow_tool_actions
+        cx.update(|cx| {
+            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+            settings.always_allow_tool_actions = true;
+            agent_settings::AgentSettings::override_global(settings, cx);
+        });
+
+        // Test that all paths that normally require confirmation are bypassed
+        let global_settings_path = paths::config_dir().join("settings.json");
+        fs::create_dir_all(paths::config_dir()).unwrap();
+        fs::write(&global_settings_path, "{}").unwrap();
+
+        let test_cases = vec![
+            ".zed/settings.json",
+            "project/.zed/config.toml",
+            global_settings_path.to_str().unwrap(),
+            "/etc/hosts",
+            "/absolute/path/file.txt",
+            "../outside/project.txt",
+        ];
+
+        for path in test_cases {
+            let input = json!({
+                "display_description": "Edit file",
+                "path": path,
+                "mode": "edit"
+            });
+            cx.update(|cx| {
+                assert!(
+                    !tool.needs_confirmation(&input, &project, cx),
+                    "Path {} should not require confirmation when always_allow_tool_actions is true",
+                    path
+                );
+            });
+        }
+
+        // Disable always_allow_tool_actions and verify confirmation is required again
+        cx.update(|cx| {
+            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+            settings.always_allow_tool_actions = false;
+            agent_settings::AgentSettings::override_global(settings, cx);
+        });
+
+        // Verify .zed path requires confirmation again
+        let input = json!({
+            "display_description": "Edit file",
+            "path": ".zed/settings.json",
+            "mode": "edit"
+        });
+        cx.update(|cx| {
+            assert!(
+                tool.needs_confirmation(&input, &project, cx),
+                ".zed path should require confirmation when always_allow_tool_actions is false"
+            );
+        });
+    }
+}

crates/collab/Cargo.toml πŸ”—

@@ -31,6 +31,7 @@ chrono.workspace = true
 clock.workspace = true
 collections.workspace = true
 dashmap.workspace = true
+encoding.workspace = true
 envy = "0.4.2"
 futures.workspace = true
 gpui.workspace = true

crates/collab/src/tests/integration_tests.rs πŸ”—

@@ -12,7 +12,8 @@ use buffer_diff::{DiffHunkSecondaryStatus, DiffHunkStatus, assert_hunks};
 use call::{ActiveCall, ParticipantLocation, Room, room};
 use client::{RECEIVE_TIMEOUT, User};
 use collections::{HashMap, HashSet};
-use fs::{FakeFs, Fs as _, RemoveOptions};
+use encoding::all::UTF_8;
+use fs::{FakeFs, Fs as _, RemoveOptions, encodings::EncodingWrapper};
 use futures::{StreamExt as _, channel::mpsc};
 use git::{
     repository::repo_path,
@@ -3701,6 +3702,7 @@ async fn test_buffer_reloading(
             path!("/dir/a.txt").as_ref(),
             &new_contents,
             LineEnding::Windows,
+            EncodingWrapper::new(UTF_8),
         )
         .await
         .unwrap();
@@ -4481,6 +4483,7 @@ async fn test_reloading_buffer_manually(
             path!("/a/a.rs").as_ref(),
             &Rope::from_str_small("let seven = 7;"),
             LineEnding::Unix,
+            EncodingWrapper::new(UTF_8),
         )
         .await
         .unwrap();

crates/collab/src/tests/random_project_collaboration_tests.rs πŸ”—

@@ -5,7 +5,8 @@ use async_trait::async_trait;
 use call::ActiveCall;
 use collections::{BTreeMap, HashMap};
 use editor::Bias;
-use fs::{FakeFs, Fs as _};
+use encoding::all::UTF_8;
+use fs::{FakeFs, Fs as _, encodings::EncodingWrapper};
 use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode};
 use gpui::{BackgroundExecutor, Entity, TestAppContext};
 use language::{
@@ -943,6 +944,7 @@ impl RandomizedTest for ProjectCollaborationTest {
                             &path,
                             &Rope::from_str_small(content.as_str()),
                             text::LineEnding::Unix,
+                            EncodingWrapper::new(UTF_8),
                         )
                         .await
                         .unwrap();

crates/copilot/Cargo.toml πŸ”—

@@ -30,6 +30,7 @@ client.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true
 dirs.workspace = true
+encoding.workspace = true
 fs.workspace = true
 futures.workspace = true
 gpui.workspace = true
@@ -53,7 +54,6 @@ ui.workspace = true
 util.workspace = true
 workspace.workspace = true
 itertools.workspace = true
-encoding = "0.2.33"
 
 
 [target.'cfg(windows)'.dependencies]

crates/encodings/Cargo.toml πŸ”—

@@ -5,6 +5,7 @@ publish.workspace = true
 edition.workspace = true
 
 [dependencies]
+anyhow.workspace = true
 ui.workspace = true
 workspace.workspace = true
 gpui.workspace = true
@@ -12,7 +13,7 @@ picker.workspace = true
 util.workspace = true
 fuzzy.workspace = true
 editor.workspace = true
-encoding = "0.2.33"
+encoding.workspace = true
 language.workspace = true
 
 [lints]

crates/encodings/src/lib.rs πŸ”—

@@ -1,3 +1,4 @@
+///! A crate for handling file encodings in the text editor.
 use editor::Editor;
 use encoding::Encoding;
 use encoding::all::{
@@ -12,7 +13,7 @@ use ui::{Button, ButtonCommon, Context, LabelSize, Render, Tooltip, Window, div}
 use ui::{Clickable, ParentElement};
 use workspace::{ItemHandle, StatusItemView, Workspace};
 
-use crate::selectors::save_or_reopen::{EncodingSaveOrReopenSelector, get_current_encoding};
+use crate::selectors::save_or_reopen::EncodingSaveOrReopenSelector;
 
 /// A status bar item that shows the current file encoding and allows changing it.
 pub struct EncodingIndicator {
@@ -44,8 +45,6 @@ impl Render for EncodingIndicator {
 }
 
 impl EncodingIndicator {
-    pub fn get_current_encoding(&self, cx: &mut Context<Self>, editor: WeakEntity<Editor>) {}
-
     pub fn new(
         encoding: Option<&'static dyn encoding::Encoding>,
         workspace: WeakEntity<Workspace>,
@@ -187,3 +186,48 @@ pub fn encoding_from_index(index: usize) -> &'static dyn Encoding {
         _ => UTF_8,
     }
 }
+
+/// Get an encoding from its name.
+pub fn encoding_from_name(name: &str) -> &'static dyn Encoding {
+    match name {
+        "UTF-8" => UTF_8,
+        "UTF-16 LE" => UTF_16LE,
+        "UTF-16 BE" => UTF_16BE,
+        "IBM866" => IBM866,
+        "ISO 8859-1" => ISO_8859_1,
+        "ISO 8859-2" => ISO_8859_2,
+        "ISO 8859-3" => ISO_8859_3,
+        "ISO 8859-4" => ISO_8859_4,
+        "ISO 8859-5" => ISO_8859_5,
+        "ISO 8859-6" => ISO_8859_6,
+        "ISO 8859-7" => ISO_8859_7,
+        "ISO 8859-8" => ISO_8859_8,
+        "ISO 8859-10" => ISO_8859_10,
+        "ISO 8859-13" => ISO_8859_13,
+        "ISO 8859-14" => ISO_8859_14,
+        "ISO 8859-15" => ISO_8859_15,
+        "ISO 8859-16" => ISO_8859_16,
+        "KOI8-R" => KOI8_R,
+        "KOI8-U" => KOI8_U,
+        "MacRoman" => MAC_ROMAN,
+        "Mac Cyrillic" => MAC_CYRILLIC,
+        "Windows-874" => WINDOWS_874,
+        "Windows-1250" => WINDOWS_1250,
+        "Windows-1251" => WINDOWS_1251,
+        "Windows-1252" => WINDOWS_1252,
+        "Windows-1253" => WINDOWS_1253,
+        "Windows-1254" => WINDOWS_1254,
+        "Windows-1255" => WINDOWS_1255,
+        "Windows-1256" => WINDOWS_1256,
+        "Windows-1257" => WINDOWS_1257,
+        "Windows-1258" => WINDOWS_1258,
+        "Windows-949" => WINDOWS_949,
+        "EUC-JP" => EUC_JP,
+        "ISO 2022-JP" => ISO_2022_JP,
+        "GBK" => GBK,
+        "GB18030" => GB18030,
+        "Big5" => BIG5_2003,
+        "HZ-GB-2312" => HZ,
+        _ => UTF_8, // Default to UTF-8 for unknown names
+    }
+}

crates/encodings/src/selectors.rs πŸ”—

@@ -1,30 +1,28 @@
+/// This module contains the encoding selectors for saving or reopening files with a different encoding.
+/// It provides a modal view that allows the user to choose between saving with a different encoding
+/// or reopening with a different encoding, and then selecting the desired encoding from a list.
 pub mod save_or_reopen {
     use editor::Editor;
     use gpui::Styled;
     use gpui::{AppContext, ParentElement};
     use picker::Picker;
     use picker::PickerDelegate;
-    use std::cell::RefCell;
-    use std::ops::{Deref, DerefMut};
-    use std::rc::Rc;
-    use std::sync::Arc;
     use std::sync::atomic::AtomicBool;
     use util::ResultExt;
 
     use fuzzy::{StringMatch, StringMatchCandidate};
     use gpui::{DismissEvent, Entity, EventEmitter, Focusable, WeakEntity};
 
-    use ui::{Context, HighlightedLabel, Label, ListItem, Render, Window, rems, v_flex};
+    use ui::{Context, HighlightedLabel, ListItem, Render, Window, rems, v_flex};
     use workspace::{ModalView, Workspace};
 
-    use crate::selectors::encoding::{Action, EncodingSelector, EncodingSelectorDelegate};
+    use crate::selectors::encoding::{Action, EncodingSelector};
 
     /// A modal view that allows the user to select between saving with a different encoding or
     /// reopening with a different encoding.
     pub struct EncodingSaveOrReopenSelector {
         picker: Entity<Picker<EncodingSaveOrReopenDelegate>>,
         pub current_selection: usize,
-        workspace: WeakEntity<Workspace>,
     }
 
     impl EncodingSaveOrReopenSelector {
@@ -41,7 +39,6 @@ pub mod save_or_reopen {
             Self {
                 picker,
                 current_selection: 0,
-                workspace,
             }
         }
 
@@ -119,9 +116,17 @@ pub mod save_or_reopen {
                         .read(cx)
                         .active_excerpt(cx)?;
 
+                    let weak_workspace = workspace.read(cx).weak_handle();
+
                     workspace.update(cx, |workspace, cx| {
                         workspace.toggle_modal(window, cx, |window, cx| {
-                            EncodingSelector::new(window, cx, Action::Save, buffer.downgrade())
+                            EncodingSelector::new(
+                                window,
+                                cx,
+                                Action::Save,
+                                buffer.downgrade(),
+                                weak_workspace,
+                            )
                         })
                     });
                 }
@@ -134,9 +139,17 @@ pub mod save_or_reopen {
                         .read(cx)
                         .active_excerpt(cx)?;
 
+                    let weak_workspace = workspace.read(cx).weak_handle();
+
                     workspace.update(cx, |workspace, cx| {
                         workspace.toggle_modal(window, cx, |window, cx| {
-                            EncodingSelector::new(window, cx, Action::Reopen, buffer.downgrade())
+                            EncodingSelector::new(
+                                window,
+                                cx,
+                                Action::Reopen,
+                                buffer.downgrade(),
+                                weak_workspace,
+                            )
                         })
                     });
                 }
@@ -165,7 +178,7 @@ pub mod save_or_reopen {
         ) {
             self.current_selection = ix;
             self.selector
-                .update(cx, |selector, cx| {
+                .update(cx, |selector, _cx| {
                     selector.current_selection = ix;
                 })
                 .log_err();
@@ -217,7 +230,7 @@ pub mod save_or_reopen {
                         .min(delegate.matches.len().saturating_sub(1));
                     delegate
                         .selector
-                        .update(cx, |selector, cx| {
+                        .update(cx, |selector, _cx| {
                             selector.current_selection = delegate.current_selection
                         })
                         .log_err();
@@ -263,33 +276,27 @@ pub mod save_or_reopen {
     }
 }
 
+/// This module contains the encoding selector for choosing an encoding to save or reopen a file with.
 pub mod encoding {
-    use std::{
-        ops::DerefMut,
-        rc::{Rc, Weak},
-        sync::{Arc, atomic::AtomicBool},
-    };
+    use std::sync::atomic::AtomicBool;
 
     use fuzzy::{StringMatch, StringMatchCandidate};
-    use gpui::{
-        AppContext, BackgroundExecutor, DismissEvent, Entity, EventEmitter, Focusable, Length,
-        WeakEntity, actions,
-    };
+    use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, WeakEntity};
     use language::Buffer;
     use picker::{Picker, PickerDelegate};
     use ui::{
-        Context, DefiniteLength, HighlightedLabel, Label, ListItem, ListItemSpacing, ParentElement,
-        Render, Styled, Window, rems, v_flex,
+        Context, HighlightedLabel, ListItem, ListItemSpacing, ParentElement, Render, Styled,
+        Window, rems, v_flex,
     };
     use util::{ResultExt, TryFutureExt};
     use workspace::{ModalView, Workspace};
 
-    use crate::encoding_from_index;
+    use crate::encoding_from_name;
 
     /// A modal view that allows the user to select an encoding from a list of encodings.
     pub struct EncodingSelector {
         picker: Entity<Picker<EncodingSelectorDelegate>>,
-        action: Action,
+        workspace: WeakEntity<Workspace>,
     }
 
     pub struct EncodingSelectorDelegate {
@@ -298,12 +305,14 @@ pub mod encoding {
         matches: Vec<StringMatch>,
         selector: WeakEntity<EncodingSelector>,
         buffer: WeakEntity<Buffer>,
+        action: Action,
     }
 
     impl EncodingSelectorDelegate {
         pub fn new(
             selector: WeakEntity<EncodingSelector>,
             buffer: WeakEntity<Buffer>,
+            action: Action,
         ) -> EncodingSelectorDelegate {
             EncodingSelectorDelegate {
                 current_selection: 0,
@@ -350,6 +359,7 @@ pub mod encoding {
                 matches: Vec::new(),
                 selector,
                 buffer,
+                action,
             }
         }
     }
@@ -365,12 +375,7 @@ pub mod encoding {
             self.current_selection
         }
 
-        fn set_selected_index(
-            &mut self,
-            ix: usize,
-            window: &mut Window,
-            cx: &mut Context<Picker<Self>>,
-        ) {
+        fn set_selected_index(&mut self, ix: usize, _: &mut Window, _: &mut Context<Picker<Self>>) {
             self.current_selection = ix;
         }
 
@@ -427,21 +432,40 @@ pub mod encoding {
             })
         }
 
-        fn confirm(
-            &mut self,
-            secondary: bool,
-            window: &mut Window,
-            cx: &mut Context<Picker<Self>>,
-        ) {
+        fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
             if let Some(buffer) = self.buffer.upgrade() {
                 buffer.update(cx, |buffer, cx| {
-                    buffer.encoding = encoding_from_index(self.current_selection)
+                    buffer.encoding =
+                        encoding_from_name(self.matches[self.current_selection].string.as_str());
+                    if self.action == Action::Reopen {
+                        let executor = cx.background_executor().clone();
+                        executor.spawn(buffer.reload(cx)).detach();
+                    } else if self.action == Action::Save {
+                        let executor = cx.background_executor().clone();
+
+                        let workspace = self
+                            .selector
+                            .upgrade()
+                            .unwrap()
+                            .read(cx)
+                            .workspace
+                            .upgrade()
+                            .unwrap();
+
+                        executor
+                            .spawn(workspace.update(cx, |workspace, cx| {
+                                workspace
+                                    .save_active_item(workspace::SaveIntent::Save, window, cx)
+                                    .log_err()
+                            }))
+                            .detach();
+                    }
                 });
             }
             self.dismissed(window, cx);
         }
 
-        fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
             self.selector
                 .update(cx, |_, cx| cx.emit(DismissEvent))
                 .log_err();
@@ -450,9 +474,9 @@ pub mod encoding {
         fn render_match(
             &self,
             ix: usize,
-            selected: bool,
-            window: &mut Window,
-            cx: &mut Context<Picker<Self>>,
+            _: bool,
+            _: &mut Window,
+            _: &mut Context<Picker<Self>>,
         ) -> Option<Self::ListItem> {
             Some(
                 ListItem::new(ix)
@@ -466,6 +490,7 @@ pub mod encoding {
     }
 
     /// The action to perform after selecting an encoding.
+    #[derive(PartialEq, Clone)]
     pub enum Action {
         Save,
         Reopen,
@@ -477,11 +502,13 @@ pub mod encoding {
             cx: &mut Context<EncodingSelector>,
             action: Action,
             buffer: WeakEntity<Buffer>,
+            workspace: WeakEntity<Workspace>,
         ) -> EncodingSelector {
-            let delegate = EncodingSelectorDelegate::new(cx.entity().downgrade(), buffer);
+            let delegate =
+                EncodingSelectorDelegate::new(cx.entity().downgrade(), buffer, action.clone());
             let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
 
-            EncodingSelector { picker, action }
+            EncodingSelector { picker, workspace }
         }
     }
 

crates/extension_host/Cargo.toml πŸ”—

@@ -23,6 +23,7 @@ async-trait.workspace = true
 client.workspace = true
 collections.workspace = true
 dap.workspace = true
+encoding.workspace = true
 extension.workspace = true
 fs.workspace = true
 futures.workspace = true

crates/extension_host/src/extension_host.rs πŸ”—

@@ -12,6 +12,7 @@ use async_tar::Archive;
 use client::ExtensionProvides;
 use client::{Client, ExtensionMetadata, GetExtensionsResponse, proto, telemetry::Telemetry};
 use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map};
+use encoding::all::UTF_8;
 pub use extension::ExtensionManifest;
 use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
 use extension::{
@@ -20,6 +21,7 @@ use extension::{
     ExtensionLanguageServerProxy, ExtensionSlashCommandProxy, ExtensionSnippetProxy,
     ExtensionThemeProxy,
 };
+use fs::encodings::EncodingWrapper;
 use fs::{Fs, RemoveOptions};
 use futures::future::join_all;
 use futures::{
@@ -1506,6 +1508,7 @@ impl ExtensionStore {
                     &index_path,
                     &Rope::from_str(&index_json, &executor),
                     Default::default(),
+                    EncodingWrapper::new(UTF_8),
                 )
                 .await
                 .context("failed to save extension index")
@@ -1678,6 +1681,7 @@ impl ExtensionStore {
                     &tmp_dir.join(EXTENSION_TOML),
                     &Rope::from_str_small(&manifest_toml),
                     language::LineEnding::Unix,
+                    EncodingWrapper::new(UTF_8),
                 )
                 .await?;
             } else {

crates/fs/Cargo.toml πŸ”—

@@ -16,6 +16,7 @@ anyhow.workspace = true
 async-tar.workspace = true
 async-trait.workspace = true
 collections.workspace = true
+encoding.workspace = true
 futures.workspace = true
 git.workspace = true
 gpui.workspace = true

crates/fs/src/encodings.rs πŸ”—

@@ -1,14 +1,70 @@
-use anyhow::{Error, Result};
+//! Encoding and decoding utilities using the `encoding` crate.
+use std::fmt::Debug;
 
+use anyhow::{Error, Result};
 use encoding::Encoding;
+use serde::{Deserialize, de::Visitor};
 
 /// A wrapper around `encoding::Encoding` to implement `Send` and `Sync`.
 /// Since the reference is static, it is safe to send it across threads.
 pub struct EncodingWrapper(&'static dyn Encoding);
 
+impl Debug for EncodingWrapper {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_tuple("EncodingWrapper")
+            .field(&self.0.name())
+            .finish()
+    }
+}
+
+pub struct EncodingWrapperVisitor;
+
+impl<'vi> Visitor<'vi> for EncodingWrapperVisitor {
+    type Value = EncodingWrapper;
+
+    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+        formatter.write_str("a valid encoding name")
+    }
+
+    fn visit_str<E: serde::de::Error>(self, encoding: &str) -> Result<EncodingWrapper, E> {
+        Ok(EncodingWrapper(
+            encoding::label::encoding_from_whatwg_label(encoding)
+                .ok_or_else(|| serde::de::Error::custom("Invalid Encoding"))?,
+        ))
+    }
+
+    fn visit_string<E: serde::de::Error>(self, encoding: String) -> Result<EncodingWrapper, E> {
+        Ok(EncodingWrapper(
+            encoding::label::encoding_from_whatwg_label(&encoding)
+                .ok_or_else(|| serde::de::Error::custom("Invalid Encoding"))?,
+        ))
+    }
+}
+
+impl<'de> Deserialize<'de> for EncodingWrapper {
+    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        deserializer.deserialize_str(EncodingWrapperVisitor)
+    }
+}
+
+impl PartialEq for EncodingWrapper {
+    fn eq(&self, other: &Self) -> bool {
+        self.0.name() == other.0.name()
+    }
+}
+
 unsafe impl Send for EncodingWrapper {}
 unsafe impl Sync for EncodingWrapper {}
 
+impl Clone for EncodingWrapper {
+    fn clone(&self) -> Self {
+        EncodingWrapper(self.0)
+    }
+}
+
 impl EncodingWrapper {
     pub fn new(encoding: &'static dyn Encoding) -> EncodingWrapper {
         EncodingWrapper(encoding)

crates/fs/src/fs.rs πŸ”—

@@ -62,6 +62,7 @@ use std::ffi::OsStr;
 #[cfg(any(test, feature = "test-support"))]
 pub use fake_git_repo::{LOAD_HEAD_TEXT_TASK, LOAD_INDEX_TEXT_TASK};
 use crate::encodings::EncodingWrapper;
+use crate::encodings::from_utf8;
 
 pub trait Watcher: Send + Sync {
     fn add(&self, path: &Path) -> Result<()>;
@@ -129,7 +130,13 @@ pub trait Fs: Send + Sync {
 
     async fn load_bytes(&self, path: &Path) -> Result<Vec<u8>>;
     async fn atomic_write(&self, path: PathBuf, text: String) -> Result<()>;
-    async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()>;
+    async fn save(
+        &self,
+        path: &Path,
+        text: &Rope,
+        line_ending: LineEnding,
+        encoding: EncodingWrapper,
+    ) -> Result<()>;
     async fn write(&self, path: &Path, content: &[u8]) -> Result<()>;
     async fn canonicalize(&self, path: &Path) -> Result<PathBuf>;
     async fn is_file(&self, path: &Path) -> bool;
@@ -674,7 +681,13 @@ impl Fs for RealFs {
         Ok(())
     }
 
-    async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> {
+    async fn save(
+        &self,
+        path: &Path,
+        text: &Rope,
+        line_ending: LineEnding,
+        encoding: EncodingWrapper,
+    ) -> Result<()> {
         let buffer_size = text.summary().len.min(10 * 1024);
         if let Some(path) = path.parent() {
             self.create_dir(path).await?;
@@ -682,7 +695,9 @@ impl Fs for RealFs {
         let file = smol::fs::File::create(path).await?;
         let mut writer = smol::io::BufWriter::with_capacity(buffer_size, file);
         for chunk in chunks(text, line_ending) {
-            writer.write_all(chunk.as_bytes()).await?;
+            writer
+                .write_all(&from_utf8(chunk.to_string(), encoding.clone()).await?)
+                .await?;
         }
         writer.flush().await?;
         Ok(())
@@ -2395,14 +2410,22 @@ impl Fs for FakeFs {
         Ok(())
     }
 
-    async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> {
+    async fn save(
+        &self,
+        path: &Path,
+        text: &Rope,
+        line_ending: LineEnding,
+        encoding: EncodingWrapper,
+    ) -> Result<()> {
+        use crate::encodings::from_utf8;
+
         self.simulate_random_delay().await;
         let path = normalize_path(path);
         let content = chunks(text, line_ending).collect::<String>();
         if let Some(path) = path.parent() {
             self.create_dir(path).await?;
         }
-        self.write_file_internal(path, content.into_bytes(), false)?;
+        self.write_file_internal(path, from_utf8(content, encoding).await?, false)?;
         Ok(())
     }
 

crates/git_ui/Cargo.toml πŸ”—

@@ -29,6 +29,7 @@ command_palette_hooks.workspace = true
 component.workspace = true
 db.workspace = true
 editor.workspace = true
+encoding.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 git.workspace = true

crates/git_ui/src/file_diff_view.rs πŸ”—

@@ -358,10 +358,12 @@ impl Render for FileDiffView {
 mod tests {
     use super::*;
     use editor::test::editor_test_context::assert_state_with_diff;
+    use encoding::all::UTF_8;
     use gpui::TestAppContext;
     use language::Rope;
     use project::{FakeFs, Fs, Project};
     use settings::SettingsStore;
+    use project::{FakeFs, Fs, Project, encodings::EncodingWrapper};
     use std::path::PathBuf;
     use unindent::unindent;
     use util::path;
@@ -440,6 +442,7 @@ mod tests {
                 ",
             )),
             Default::default(),
+            EncodingWrapper::new(UTF_8),
         )
         .await
         .unwrap();
@@ -474,6 +477,7 @@ mod tests {
                 ",
             )),
             Default::default(),
+            EncodingWrapper::new(UTF_8),
         )
         .await
         .unwrap();

crates/language/Cargo.toml πŸ”—

@@ -30,7 +30,9 @@ anyhow.workspace = true
 async-trait.workspace = true
 clock.workspace = true
 collections.workspace = true
+diffy = "0.4.2"
 ec4rs.workspace = true
+encoding.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true

crates/language/src/buffer.rs πŸ”—

@@ -22,7 +22,7 @@ use clock::Lamport;
 pub use clock::ReplicaId;
 use collections::HashMap;
 use encoding::Encoding;
-use fs::{Fs, MTime, RealFs};
+use fs::MTime;
 use futures::channel::oneshot;
 use gpui::{
     App, AppContext as _, BackgroundExecutor, Context, Entity, EventEmitter, HighlightStyle,
@@ -1348,7 +1348,7 @@ impl Buffer {
     /// Reloads the contents of the buffer from disk.
     pub fn reload(&mut self, cx: &Context<Self>) -> oneshot::Receiver<Option<Transaction>> {
         let (tx, rx) = futures::channel::oneshot::channel();
-        let encoding = self.encoding.clone();
+        let encoding = self.encoding;
         let prev_version = self.text.version();
         self.reload_task = Some(cx.spawn(async move |this, cx| {
             let Some((new_mtime, new_text)) = this.update(cx, |this, cx| {
@@ -5238,11 +5238,7 @@ impl LocalFile for TestFile {
         unimplemented!()
     }
 
-    fn load_with_encoding(
-        &self,
-        cx: &App,
-        encoding: &'static dyn Encoding,
-    ) -> Task<Result<String>> {
+    fn load_with_encoding(&self, _: &App, _: &'static dyn Encoding) -> Task<Result<String>> {
         unimplemented!()
     }
 }

crates/project/Cargo.toml πŸ”—

@@ -39,6 +39,7 @@ clock.workspace = true
 collections.workspace = true
 context_server.workspace = true
 dap.workspace = true
+encoding.workspace = true
 extension.workspace = true
 fancy-regex.workspace = true
 fs.workspace = true
@@ -90,6 +91,7 @@ worktree.workspace = true
 zeroize.workspace = true
 zlog.workspace = true
 
+
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }

crates/project/src/buffer_store.rs πŸ”—

@@ -387,6 +387,8 @@ impl LocalBufferStore {
         let version = buffer.version();
         let buffer_id = buffer.remote_id();
         let file = buffer.file().cloned();
+        let encoding = buffer.encoding;
+
         if file
             .as_ref()
             .is_some_and(|file| file.disk_state() == DiskState::New)
@@ -395,7 +397,7 @@ impl LocalBufferStore {
         }
 
         let save = worktree.update(cx, |worktree, cx| {
-            worktree.write_file(path, text, line_ending, cx)
+            worktree.write_file(path.as_ref(), text, line_ending, cx, encoding)
         });
 
         cx.spawn(async move |this, cx| {

crates/project/src/prettier_store.rs πŸ”—

@@ -7,7 +7,8 @@ use std::{
 
 use anyhow::{Context as _, Result, anyhow};
 use collections::{HashMap, HashSet};
-use fs::Fs;
+use encoding::all::UTF_8;
+use fs::{Fs, encodings::EncodingWrapper};
 use futures::{
     FutureExt,
     future::{self, Shared},
@@ -981,10 +982,12 @@ async fn save_prettier_server_file(
     executor: &BackgroundExecutor,
 ) -> anyhow::Result<()> {
     let prettier_wrapper_path = default_prettier_dir().join(prettier::PRETTIER_SERVER_FILE);
+    let encoding_wrapper = EncodingWrapper::new(UTF_8);
     fs.save(
         &prettier_wrapper_path,
         &text::Rope::from_str(prettier::PRETTIER_SERVER_JS, executor),
         text::LineEnding::Unix,
+        encoding_wrapper,
     )
     .await
     .with_context(|| {

crates/project/src/project_tests.rs πŸ”—

@@ -12,7 +12,8 @@ use buffer_diff::{
     BufferDiffEvent, CALCULATE_DIFF_TASK, DiffHunkSecondaryStatus, DiffHunkStatus,
     DiffHunkStatusKind, assert_hunks,
 };
-use fs::FakeFs;
+use encoding::all::UTF_8;
+use fs::{FakeFs, encodings::EncodingWrapper};
 use futures::{StreamExt, future};
 use git::{
     GitHostingProviderRegistry,
@@ -1459,10 +1460,14 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon
     )
     .await
     .unwrap();
+
+    let encoding_wrapper = EncodingWrapper::new(UTF_8);
+
     fs.save(
         path!("/the-root/Cargo.lock").as_ref(),
         &Rope::default(),
         Default::default(),
+        encoding_wrapper.clone(),
     )
     .await
     .unwrap();
@@ -1470,6 +1475,7 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon
         path!("/the-stdlib/LICENSE").as_ref(),
         &Rope::default(),
         Default::default(),
+        encoding_wrapper.clone(),
     )
     .await
     .unwrap();
@@ -1477,6 +1483,7 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon
         path!("/the/stdlib/src/string.rs").as_ref(),
         &Rope::default(),
         Default::default(),
+        encoding_wrapper,
     )
     .await
     .unwrap();
@@ -4068,12 +4075,15 @@ async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext)
     // the next file change occurs.
     cx.executor().deprioritize(*language::BUFFER_DIFF_TASK);
 
+    let encoding_wrapper = EncodingWrapper::new(UTF_8);
+
     // Change the buffer's file on disk, and then wait for the file change
     // to be detected by the worktree, so that the buffer starts reloading.
     fs.save(
         path!("/dir/file1").as_ref(),
         &Rope::from_str("the first contents", cx.background_executor()),
         Default::default(),
+        encoding_wrapper.clone(),
     )
     .await
     .unwrap();
@@ -4085,6 +4095,7 @@ async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext)
         path!("/dir/file1").as_ref(),
         &Rope::from_str("the second contents", cx.background_executor()),
         Default::default(),
+        encoding_wrapper,
     )
     .await
     .unwrap();
@@ -4123,12 +4134,15 @@ async fn test_edit_buffer_while_it_reloads(cx: &mut gpui::TestAppContext) {
     // the next file change occurs.
     cx.executor().deprioritize(*language::BUFFER_DIFF_TASK);
 
+    let encoding_wrapper = EncodingWrapper::new(UTF_8);
+
     // Change the buffer's file on disk, and then wait for the file change
     // to be detected by the worktree, so that the buffer starts reloading.
     fs.save(
         path!("/dir/file1").as_ref(),
         &Rope::from_str("the first contents", cx.background_executor()),
         Default::default(),
+        encoding_wrapper,
     )
     .await
     .unwrap();
@@ -4803,10 +4817,14 @@ async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) {
 
     let (new_contents, new_offsets) =
         marked_text_offsets("oneˇ\nthree ˇFOURˇ five\nsixtyˇ seven\n");
+
+    let encoding_wrapper = EncodingWrapper::new(UTF_8);
+
     fs.save(
         path!("/dir/the-file").as_ref(),
         &Rope::from_str(new_contents.as_str(), cx.background_executor()),
         LineEnding::Unix,
+        encoding_wrapper,
     )
     .await
     .unwrap();
@@ -4834,11 +4852,14 @@ async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) {
         assert!(!buffer.has_conflict());
     });
 
+    let encoding_wrapper = EncodingWrapper::new(UTF_8);
+
     // Change the file on disk again, adding blank lines to the beginning.
     fs.save(
         path!("/dir/the-file").as_ref(),
         &Rope::from_str("\n\n\nAAAA\naaa\nBB\nbbbbb\n", cx.background_executor()),
         LineEnding::Unix,
+        encoding_wrapper,
     )
     .await
     .unwrap();
@@ -4885,12 +4906,15 @@ async fn test_buffer_line_endings(cx: &mut gpui::TestAppContext) {
         assert_eq!(buffer.line_ending(), LineEnding::Windows);
     });
 
+    let encoding_wrapper = EncodingWrapper::new(UTF_8);
+
     // Change a file's line endings on disk from unix to windows. The buffer's
     // state updates correctly.
     fs.save(
         path!("/dir/file1").as_ref(),
         &Rope::from_str("aaa\nb\nc\n", cx.background_executor()),
         LineEnding::Windows,
+        encoding_wrapper,
     )
     .await
     .unwrap();

crates/remote_server/Cargo.toml πŸ”—

@@ -28,6 +28,7 @@ clap.workspace = true
 client.workspace = true
 dap_adapters.workspace = true
 debug_adapter_extension.workspace = true
+encoding.workspace = true
 env_logger.workspace = true
 extension.workspace = true
 extension_host.workspace = true

crates/remote_server/src/remote_editing_tests.rs πŸ”—

@@ -6,10 +6,10 @@ use agent::{AgentTool, ReadFileTool, ReadFileToolInput, ToolCallEventStream};
 use client::{Client, UserStore};
 use clock::FakeSystemClock;
 use collections::{HashMap, HashSet};
-use language_model::LanguageModelToolResultContent;
+use encoding::all::UTF_8;
 
 use extension::ExtensionHostProxy;
-use fs::{FakeFs, Fs};
+use fs::{FakeFs, Fs, encodings::EncodingWrapper};
 use gpui::{AppContext as _, Entity, SemanticVersion, SharedString, TestAppContext};
 use http_client::{BlockedHttpClient, FakeHttpClient};
 use language::{
@@ -122,6 +122,7 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test
         path!("/code/project1/src/main.rs").as_ref(),
         &Rope::from_str_small("fn main() {}"),
         Default::default(),
+        EncodingWrapper::new(UTF_8),
     )
     .await
     .unwrap();
@@ -768,6 +769,7 @@ async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppCont
         &PathBuf::from(path!("/code/project1/src/lib.rs")),
         &Rope::from_str_small("bangles"),
         LineEnding::Unix,
+        EncodingWrapper::new(UTF_8),
     )
     .await
     .unwrap();
@@ -783,6 +785,7 @@ async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppCont
         &PathBuf::from(path!("/code/project1/src/lib.rs")),
         &Rope::from_str_small("bloop"),
         LineEnding::Unix,
+        EncodingWrapper::new(UTF_8),
     )
     .await
     .unwrap();

crates/vim/Cargo.toml πŸ”—

@@ -54,6 +54,7 @@ vim_mode_setting.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
 
+
 [dev-dependencies]
 assets.workspace = true
 command_palette.workspace = true

crates/workspace/Cargo.toml πŸ”—

@@ -35,6 +35,7 @@ clock.workspace = true
 collections.workspace = true
 component.workspace = true
 db.workspace = true
+encoding.workspace = true
 fs.workspace = true
 futures.workspace = true
 gpui.workspace = true

crates/workspace/src/workspace.rs πŸ”—

@@ -19,6 +19,8 @@ mod workspace_settings;
 
 pub use crate::notifications::NotificationFrame;
 pub use dock::Panel;
+use encoding::all::UTF_8;
+use fs::encodings::EncodingWrapper;
 pub use path_list::PathList;
 pub use toast_layer::{ToastAction, ToastLayer, ToastView};
 
@@ -132,7 +134,6 @@ use crate::persistence::{
 };
 use crate::{item::ItemBufferKind, notifications::NotificationId};
 
-
 pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200);
 
 static ZED_WINDOW_SIZE: LazyLock<Option<Size<Pixels>>> = LazyLock::new(|| {
@@ -7587,8 +7588,14 @@ pub fn create_and_open_local_file(
         let fs = workspace.read_with(cx, |workspace, _| workspace.app_state().fs.clone())?;
         if !fs.is_file(path).await {
             fs.create_file(path, Default::default()).await?;
-            fs.save(path, &default_content(cx), Default::default())
-                .await?;
+            let encoding_wrapper = EncodingWrapper::new(UTF_8);
+            fs.save(
+                path,
+                &default_content(),
+                Default::default(),
+                encoding_wrapper,
+            )
+            .await?;
         }
 
         let mut items = workspace

crates/worktree/Cargo.toml πŸ”—

@@ -27,6 +27,7 @@ anyhow.workspace = true
 async-lock.workspace = true
 clock.workspace = true
 collections.workspace = true
+encoding.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true

crates/worktree/src/worktree.rs πŸ”—

@@ -735,9 +735,10 @@ impl Worktree {
         text: Rope,
         line_ending: LineEnding,
         cx: &Context<Worktree>,
+        encoding: &'static dyn Encoding,
     ) -> Task<Result<Arc<File>>> {
         match self {
-            Worktree::Local(this) => this.write_file(path, text, line_ending, cx),
+            Worktree::Local(this) => this.write_file(path, text, line_ending, cx, encoding),
             Worktree::Remote(_) => {
                 Task::ready(Err(anyhow!("remote worktree can't yet write files")))
             }
@@ -1450,15 +1451,21 @@ impl LocalWorktree {
         text: Rope,
         line_ending: LineEnding,
         cx: &Context<Worktree>,
+        encoding: &'static dyn Encoding,
     ) -> Task<Result<Arc<File>>> {
         let fs = self.fs.clone();
         let is_private = self.is_path_private(&path);
         let abs_path = self.absolutize(&path);
 
+        let encoding_wrapper = EncodingWrapper::new(encoding);
+
         let write = cx.background_spawn({
             let fs = fs.clone();
             let abs_path = abs_path.clone();
-            async move { fs.save(&abs_path, &text, line_ending).await }
+            async move {
+                fs.save(&abs_path, &text, line_ending, encoding_wrapper)
+                    .await
+            }
         });
 
         cx.spawn(async move |this, cx| {

crates/worktree/src/worktree_tests.rs πŸ”—

@@ -3,7 +3,8 @@ use crate::{
     worktree_settings::WorktreeSettings,
 };
 use anyhow::Result;
-use fs::{FakeFs, Fs, RealFs, RemoveOptions};
+use encoding::all::UTF_8;
+use fs::{FakeFs, Fs, RealFs, RemoveOptions, encodings::EncodingWrapper};
 use git::GITIGNORE;
 use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
 use parking_lot::Mutex;
@@ -651,6 +652,7 @@ async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) {
         "/root/.gitignore".as_ref(),
         &Rope::from_str("e", cx.background_executor()),
         Default::default(),
+        encoding_wrapper,
     )
     .await
     .unwrap();
@@ -724,6 +726,7 @@ async fn test_write_file(cx: &mut TestAppContext) {
                 Rope::from_str("hello", cx.background_executor()),
                 Default::default(),
                 cx,
+                UTF_8,
             )
         })
         .await
@@ -735,6 +738,7 @@ async fn test_write_file(cx: &mut TestAppContext) {
                 Rope::from_str("world", cx.background_executor()),
                 Default::default(),
                 cx,
+                UTF_8,
             )
         })
         .await
@@ -1769,6 +1773,7 @@ fn randomly_mutate_worktree(
                     Rope::default(),
                     Default::default(),
                     cx,
+                    UTF_8,
                 );
                 cx.background_spawn(async move {
                     task.await?;
@@ -1857,10 +1862,12 @@ async fn randomly_mutate_fs(
             ignore_path.strip_prefix(root_path).unwrap(),
             ignore_contents
         );
+        let encoding_wrapper = EncodingWrapper::new(UTF_8);
         fs.save(
             &ignore_path,
             &Rope::from_str(ignore_contents.as_str(), executor),
             Default::default(),
+            encoding_wrapper,
         )
         .await
         .unwrap();

crates/zed/Cargo.toml πŸ”—

@@ -52,6 +52,7 @@ debugger_ui.workspace = true
 diagnostics.workspace = true
 editor.workspace = true
 zeta2_tools.workspace = true
+encoding.workspace = true
 encodings.workspace = true
 env_logger.workspace = true
 extension.workspace = true
@@ -166,7 +167,6 @@ zeta.workspace = true
 zeta2.workspace = true
 zlog.workspace = true
 zlog_settings.workspace = true
-encoding = "0.2.33"
 
 [target.'cfg(target_os = "windows")'.dependencies]
 windows.workspace = true

crates/zed/src/zed.rs πŸ”—

@@ -2176,6 +2176,8 @@ mod tests {
     use assets::Assets;
     use collections::HashSet;
     use editor::{DisplayPoint, Editor, SelectionEffects, display_map::DisplayRow};
+    use encoding::all::UTF_8;
+    use fs::encodings::EncodingWrapper;
     use gpui::{
         Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, SemanticVersion,
         TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle, actions,
@@ -4381,6 +4383,7 @@ mod tests {
                 "/settings.json".as_ref(),
                 &Rope::from_str_small(r#"{"base_keymap": "Atom"}"#),
                 Default::default(),
+                EncodingWrapper::new(UTF_8),
             )
             .await
             .unwrap();
@@ -4391,6 +4394,7 @@ mod tests {
                 "/keymap.json".as_ref(),
                 &Rope::from_str_small(r#"[{"bindings": {"backspace": "test_only::ActionA"}}]"#),
                 Default::default(),
+                EncodingWrapper::new(UTF_8),
             )
             .await
             .unwrap();
@@ -4439,6 +4443,7 @@ mod tests {
                 "/keymap.json".as_ref(),
                 &Rope::from_str_small(r#"[{"bindings": {"backspace": "test_only::ActionB"}}]"#),
                 Default::default(),
+                EncodingWrapper::new(UTF_8),
             )
             .await
             .unwrap();
@@ -4459,6 +4464,7 @@ mod tests {
                 "/settings.json".as_ref(),
                 &Rope::from_str_small(r#"{"base_keymap": "JetBrains"}"#),
                 Default::default(),
+                EncodingWrapper::new(UTF_8),
             )
             .await
             .unwrap();
@@ -4499,6 +4505,7 @@ mod tests {
                 "/settings.json".as_ref(),
                 &Rope::from_str_small(r#"{"base_keymap": "Atom"}"#),
                 Default::default(),
+                EncodingWrapper::new(UTF_8),
             )
             .await
             .unwrap();
@@ -4508,6 +4515,7 @@ mod tests {
                 "/keymap.json".as_ref(),
                 &Rope::from_str_small(r#"[{"bindings": {"backspace": "test_only::ActionA"}}]"#),
                 Default::default(),
+                EncodingWrapper::new(UTF_8),
             )
             .await
             .unwrap();
@@ -4551,6 +4559,7 @@ mod tests {
                 "/keymap.json".as_ref(),
                 &Rope::from_str_small(r#"[{"bindings": {"backspace": null}}]"#),
                 Default::default(),
+                EncodingWrapper::new(UTF_8),
             )
             .await
             .unwrap();
@@ -4571,6 +4580,7 @@ mod tests {
                 "/settings.json".as_ref(),
                 &Rope::from_str_small(r#"{"base_keymap": "JetBrains"}"#),
                 Default::default(),
+                EncodingWrapper::new(UTF_8),
             )
             .await
             .unwrap();