Merge remote-tracking branch 'origin/main' into provider-extensions

Richard Feldman created

Change summary

Cargo.lock                                 |  54 +++
Cargo.toml                                 |   4 
assets/keymaps/default-linux.json          |   4 
assets/keymaps/default-macos.json          |   8 
assets/keymaps/default-windows.json        |   4 
assets/settings/default.json               |   7 
crates/agent/src/history_store.rs          |  13 
crates/agent_ui/Cargo.toml                 |   2 
crates/agent_ui_v2/Cargo.toml              |   7 
crates/agent_ui_v2/LICENSE-GPL             |   2 
crates/editor/src/editor_tests.rs          |  10 
crates/fs/src/fs.rs                        |  33 ++
crates/git_ui/src/git_panel.rs             | 270 +++++++++++++++++++-
crates/git_ui/src/worktree_picker.rs       |  41 ++
crates/google_ai/src/google_ai.rs          |   8 
crates/gpui/src/key_dispatch.rs            |  11 
crates/gpui/src/keymap.rs                  |  35 ++
crates/gpui/src/window.rs                  |   7 
crates/language/Cargo.toml                 |   1 
crates/language/src/buffer.rs              |  25 +
crates/project/Cargo.toml                  |   1 
crates/project/src/buffer_store.rs         |  10 
crates/project/src/project.rs              |  12 
crates/search/src/buffer_search.rs         |   5 
crates/settings/src/settings_content.rs    |  16 +
crates/settings/src/vscode_import.rs       |   1 
crates/settings_ui/src/page_data.rs        |  43 +++
crates/vim/src/command.rs                  |   6 
crates/which_key/Cargo.toml                |  23 +
crates/which_key/LICENSE-GPL               |   1 
crates/which_key/src/which_key.rs          |  98 +++++++
crates/which_key/src/which_key_modal.rs    | 308 ++++++++++++++++++++++++
crates/which_key/src/which_key_settings.rs |  18 +
crates/workspace/src/dock.rs               |  14 
crates/workspace/src/modal_layer.rs        |  16 +
crates/workspace/src/security_modal.rs     |  51 +--
crates/workspace/src/workspace.rs          |  20 +
crates/worktree/Cargo.toml                 |   2 
crates/worktree/src/worktree.rs            | 104 +++++++
crates/worktree/src/worktree_tests.rs      | 191 ++++++++++++++
crates/zed/Cargo.toml                      |   5 
crates/zed/src/main.rs                     |   1 
crates/zed/src/zed.rs                      |  25 +
43 files changed, 1,393 insertions(+), 124 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -2667,9 +2667,9 @@ dependencies = [
 
 [[package]]
 name = "cap-fs-ext"
-version = "3.4.5"
+version = "3.4.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654"
+checksum = "e41cc18551193fe8fa6f15c1e3c799bc5ec9e2cfbfaa8ed46f37013e3e6c173c"
 dependencies = [
  "cap-primitives",
  "cap-std",
@@ -2679,9 +2679,9 @@ dependencies = [
 
 [[package]]
 name = "cap-net-ext"
-version = "3.4.5"
+version = "3.4.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7"
+checksum = "9f83833816c66c986e913b22ac887cec216ea09301802054316fc5301809702c"
 dependencies = [
  "cap-primitives",
  "cap-std",
@@ -2691,9 +2691,9 @@ dependencies = [
 
 [[package]]
 name = "cap-primitives"
-version = "3.4.5"
+version = "3.4.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a"
+checksum = "0a1e394ed14f39f8bc26f59d4c0c010dbe7f0a1b9bafff451b1f98b67c8af62a"
 dependencies = [
  "ambient-authority",
  "fs-set-times",
@@ -2709,9 +2709,9 @@ dependencies = [
 
 [[package]]
 name = "cap-rand"
-version = "3.4.5"
+version = "3.4.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d"
+checksum = "0acb89ccf798a28683f00089d0630dfaceec087234eae0d308c05ddeaa941b40"
 dependencies = [
  "ambient-authority",
  "rand 0.8.5",
@@ -2719,9 +2719,9 @@ dependencies = [
 
 [[package]]
 name = "cap-std"
-version = "3.4.5"
+version = "3.4.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a"
+checksum = "07c0355ca583dd58f176c3c12489d684163861ede3c9efa6fd8bba314c984189"
 dependencies = [
  "cap-primitives",
  "io-extras",
@@ -2731,9 +2731,9 @@ dependencies = [
 
 [[package]]
 name = "cap-time-ext"
-version = "3.4.5"
+version = "3.4.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80"
+checksum = "491af520b8770085daa0466978c75db90368c71896523f2464214e38359b1a5b"
 dependencies = [
  "ambient-authority",
  "cap-primitives",
@@ -2896,6 +2896,17 @@ dependencies = [
  "util",
 ]
 
+[[package]]
+name = "chardetng"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
+dependencies = [
+ "cfg-if",
+ "encoding_rs",
+ "memchr",
+]
+
 [[package]]
 name = "chrono"
 version = "0.4.42"
@@ -8803,6 +8814,7 @@ dependencies = [
  "ctor",
  "diffy",
  "ec4rs",
+ "encoding_rs",
  "fs",
  "futures 0.3.31",
  "fuzzy",
@@ -12473,6 +12485,7 @@ dependencies = [
  "dap",
  "dap_adapters",
  "db",
+ "encoding_rs",
  "extension",
  "fancy-regex",
  "fs",
@@ -19087,6 +19100,20 @@ dependencies = [
  "winsafe",
 ]
 
+[[package]]
+name = "which_key"
+version = "0.1.0"
+dependencies = [
+ "command_palette",
+ "gpui",
+ "serde",
+ "settings",
+ "theme",
+ "ui",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "whoami"
 version = "1.6.1"
@@ -20092,8 +20119,10 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "async-lock 2.8.0",
+ "chardetng",
  "clock",
  "collections",
+ "encoding_rs",
  "fs",
  "futures 0.3.31",
  "fuzzy",
@@ -20605,6 +20634,7 @@ dependencies = [
  "watch",
  "web_search",
  "web_search_providers",
+ "which_key",
  "windows 0.61.3",
  "winresource",
  "workspace",

Cargo.toml πŸ”—

@@ -192,6 +192,7 @@ members = [
     "crates/vercel",
     "crates/vim",
     "crates/vim_mode_setting",
+    "crates/which_key",
     "crates/watch",
     "crates/web_search",
     "crates/web_search_providers",
@@ -405,6 +406,7 @@ util_macros = { path = "crates/util_macros" }
 vercel = { path = "crates/vercel" }
 vim = { path = "crates/vim" }
 vim_mode_setting = { path = "crates/vim_mode_setting" }
+which_key = { path = "crates/which_key" }
 
 watch = { path = "crates/watch" }
 web_search = { path = "crates/web_search" }
@@ -466,6 +468,7 @@ bytes = "1.0"
 cargo_metadata = "0.19"
 cargo_toml = "0.21"
 cfg-if = "1.0.3"
+chardetng = "0.1"
 chrono = { version = "0.4", features = ["serde"] }
 ciborium = "0.2"
 circular-buffer = "1.0"
@@ -489,6 +492,7 @@ dotenvy = "0.15.0"
 ec4rs = "1.1"
 emojis = "0.6.1"
 env_logger = "0.11"
+encoding_rs = "0.8"
 exec = "0.3.1"
 fancy-regex = "0.16.0"
 fork = "0.4.0"

assets/keymaps/default-linux.json πŸ”—

@@ -905,8 +905,8 @@
     "bindings": {
       "left": "git_panel::CollapseSelectedEntry",
       "right": "git_panel::ExpandSelectedEntry",
-      "up": "menu::SelectPrevious",
-      "down": "menu::SelectNext",
+      "up": "git_panel::PreviousEntry",
+      "down": "git_panel::NextEntry",
       "enter": "menu::Confirm",
       "alt-y": "git::StageFile",
       "alt-shift-y": "git::UnstageFile",

assets/keymaps/default-macos.json πŸ”—

@@ -981,12 +981,12 @@
     "context": "GitPanel && ChangesList",
     "use_key_equivalents": true,
     "bindings": {
+      "up": "git_panel::PreviousEntry",
+      "down": "git_panel::NextEntry",
+      "cmd-up": "git_panel::FirstEntry",
+      "cmd-down": "git_panel::LastEntry",
       "left": "git_panel::CollapseSelectedEntry",
       "right": "git_panel::ExpandSelectedEntry",
-      "up": "menu::SelectPrevious",
-      "down": "menu::SelectNext",
-      "cmd-up": "menu::SelectFirst",
-      "cmd-down": "menu::SelectLast",
       "enter": "menu::Confirm",
       "cmd-alt-y": "git::ToggleStaged",
       "space": "git::ToggleStaged",

assets/keymaps/default-windows.json πŸ”—

@@ -908,10 +908,10 @@
     "context": "GitPanel && ChangesList",
     "use_key_equivalents": true,
     "bindings": {
+      "up": "git_panel::PreviousEntry",
+      "down": "git_panel::NextEntry",
       "left": "git_panel::CollapseSelectedEntry",
       "right": "git_panel::ExpandSelectedEntry",
-      "up": "menu::SelectPrevious",
-      "down": "menu::SelectNext",
       "enter": "menu::Confirm",
       "alt-y": "git::StageFile",
       "shift-alt-y": "git::UnstageFile",

assets/settings/default.json πŸ”—

@@ -2152,6 +2152,13 @@
     // The shape can be one of the following: "block", "bar", "underline", "hollow".
     "cursor_shape": {},
   },
+  // Which-key popup settings
+  "which_key": {
+    // Whether to show the which-key popup when holding down key combinations.
+    "enabled": false,
+    // Delay in milliseconds before showing the which-key popup.
+    "delay_ms": 1000,
+  },
   // The server to connect to. If the environment variable
   // ZED_SERVER_URL is set, it will override this setting.
   "server_url": "https://zed.dev",

crates/agent/src/history_store.rs πŸ”—

@@ -216,14 +216,10 @@ impl HistoryStore {
     }
 
     pub fn reload(&self, cx: &mut Context<Self>) {
-        let database_future = ThreadsDatabase::connect(cx);
+        let database_connection = ThreadsDatabase::connect(cx);
         cx.spawn(async move |this, cx| {
-            let threads = database_future
-                .await
-                .map_err(|err| anyhow!(err))?
-                .list_threads()
-                .await?;
-
+            let database = database_connection.await;
+            let threads = database.map_err(|err| anyhow!(err))?.list_threads().await?;
             this.update(cx, |this, cx| {
                 if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES {
                     for thread in threads
@@ -344,7 +340,8 @@ impl HistoryStore {
     fn load_recently_opened_entries(cx: &AsyncApp) -> Task<Result<VecDeque<HistoryEntryId>>> {
         cx.background_spawn(async move {
             if cfg!(any(feature = "test-support", test)) {
-                anyhow::bail!("history store does not persist in tests");
+                log::warn!("history store does not persist in tests");
+                return Ok(VecDeque::new());
             }
             let json = KEY_VALUE_STORE
                 .read_kvp(RECENTLY_OPENED_THREADS_KEY)?

crates/agent_ui/Cargo.toml πŸ”—

@@ -13,7 +13,7 @@ path = "src/agent_ui.rs"
 doctest = false
 
 [features]
-test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support"]
+test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support", "agent/test-support"]
 unit-eval = []
 
 [dependencies]

crates/agent_ui_v2/Cargo.toml πŸ”—

@@ -12,6 +12,10 @@ workspace = true
 path = "src/agent_ui_v2.rs"
 doctest = false
 
+[features]
+test-support = ["agent/test-support"]
+
+
 [dependencies]
 agent.workspace = true
 agent_servers.workspace = true
@@ -38,3 +42,6 @@ time_format.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true
+
+[dev-dependencies]
+agent = { workspace = true, features = ["test-support"] }

crates/editor/src/editor_tests.rs πŸ”—

@@ -69,7 +69,6 @@ use util::{
 use workspace::{
     CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry,
     OpenOptions, ViewId,
-    invalid_item_view::InvalidItemView,
     item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
     register_project_item,
 };
@@ -27667,11 +27666,10 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) {
         })
         .await
         .unwrap();
-
-    assert_eq!(
-        handle.to_any_view().entity_type(),
-        TypeId::of::<InvalidItemView>()
-    );
+    // The test file content `vec![0xff, 0xfe, ...]` starts with a UTF-16 LE BOM.
+    // Previously, this fell back to `InvalidItemView` because it wasn't valid UTF-8.
+    // With auto-detection enabled, this is now recognized as UTF-16 and opens in the Editor.
+    assert_eq!(handle.to_any_view().entity_type(), TypeId::of::<Editor>());
 }
 
 #[gpui::test]

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

@@ -434,7 +434,18 @@ impl RealFs {
         for component in path.components() {
             match component {
                 std::path::Component::Prefix(_) => {
-                    let canonicalized = std::fs::canonicalize(component)?;
+                    let component = component.as_os_str();
+                    let canonicalized = if component
+                        .to_str()
+                        .map(|e| e.ends_with("\\"))
+                        .unwrap_or(false)
+                    {
+                        std::fs::canonicalize(component)
+                    } else {
+                        let mut component = component.to_os_string();
+                        component.push("\\");
+                        std::fs::canonicalize(component)
+                    }?;
 
                     let mut strip = PathBuf::new();
                     for component in canonicalized.components() {
@@ -3394,6 +3405,26 @@ mod tests {
         assert_eq!(content, "Hello");
     }
 
+    #[gpui::test]
+    #[cfg(target_os = "windows")]
+    async fn test_realfs_canonicalize(executor: BackgroundExecutor) {
+        use util::paths::SanitizedPath;
+
+        let fs = RealFs {
+            bundled_git_binary_path: None,
+            executor,
+            next_job_id: Arc::new(AtomicUsize::new(0)),
+            job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
+        };
+        let temp_dir = TempDir::new().unwrap();
+        let file = temp_dir.path().join("test (1).txt");
+        let file = SanitizedPath::new(&file);
+        std::fs::write(&file, "test").unwrap();
+
+        let canonicalized = fs.canonicalize(file.as_path()).await;
+        assert!(canonicalized.is_ok());
+    }
+
     #[gpui::test]
     async fn test_rename(executor: BackgroundExecutor) {
         let fs = FakeFs::new(executor.clone());

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

@@ -46,7 +46,7 @@ use language_model::{
     ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
     Role, ZED_CLOUD_PROVIDER_ID,
 };
-use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
+use menu;
 use multi_buffer::ExcerptInfo;
 use notifications::status_toast::{StatusToast, ToastIcon};
 use panel::{
@@ -93,6 +93,14 @@ actions!(
         FocusEditor,
         /// Focuses on the changes list.
         FocusChanges,
+        /// Select next git panel menu item, and show it in the diff view
+        NextEntry,
+        /// Select previous git panel menu item, and show it in the diff view
+        PreviousEntry,
+        /// Select first git panel menu item, and show it in the diff view
+        FirstEntry,
+        /// Select last git panel menu item, and show it in the diff view
+        LastEntry,
         /// Toggles automatic co-author suggestions.
         ToggleFillCoAuthors,
         /// Toggles sorting entries by path vs status.
@@ -793,20 +801,63 @@ impl GitPanel {
     pub fn select_entry_by_path(
         &mut self,
         path: ProjectPath,
-        _: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         let Some(git_repo) = self.active_repository.as_ref() else {
             return;
         };
-        let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
-            return;
+
+        let (repo_path, section) = {
+            let repo = git_repo.read(cx);
+            let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else {
+                return;
+            };
+
+            let section = repo
+                .status_for_path(&repo_path)
+                .map(|status| status.status)
+                .map(|status| {
+                    if repo.had_conflict_on_last_merge_head_change(&repo_path) {
+                        Section::Conflict
+                    } else if status.is_created() {
+                        Section::New
+                    } else {
+                        Section::Tracked
+                    }
+                });
+
+            (repo_path, section)
         };
+
+        let mut needs_rebuild = false;
+        if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) {
+            let mut current_dir = repo_path.parent();
+            while let Some(dir) = current_dir {
+                let key = TreeKey {
+                    section,
+                    path: RepoPath::from_rel_path(dir),
+                };
+
+                if tree_state.expanded_dirs.get(&key) == Some(&false) {
+                    tree_state.expanded_dirs.insert(key, true);
+                    needs_rebuild = true;
+                }
+
+                current_dir = dir.parent();
+            }
+        }
+
+        if needs_rebuild {
+            self.update_visible_entries(window, cx);
+        }
+
         let Some(ix) = self.entry_by_path(&repo_path) else {
             return;
         };
+
         self.selected_entry = Some(ix);
-        cx.notify();
+        self.scroll_to_selected_entry(cx);
     }
 
     fn serialization_key(workspace: &Workspace) -> Option<String> {
@@ -894,9 +945,22 @@ impl GitPanel {
     }
 
     fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
-        if let Some(selected_entry) = self.selected_entry {
+        let Some(selected_entry) = self.selected_entry else {
+            cx.notify();
+            return;
+        };
+
+        let visible_index = match &self.view_mode {
+            GitPanelViewMode::Flat => Some(selected_entry),
+            GitPanelViewMode::Tree(state) => state
+                .logical_indices
+                .iter()
+                .position(|&ix| ix == selected_entry),
+        };
+
+        if let Some(visible_index) = visible_index {
             self.scroll_handle
-                .scroll_to_item(selected_entry, ScrollStrategy::Center);
+                .scroll_to_item(visible_index, ScrollStrategy::Center);
         }
 
         cx.notify();
@@ -914,12 +978,12 @@ impl GitPanel {
 
         if let GitListEntry::Directory(dir_entry) = entry {
             if dir_entry.expanded {
-                self.select_next(&SelectNext, window, cx);
+                self.select_next(&menu::SelectNext, window, cx);
             } else {
                 self.toggle_directory(&dir_entry.key, window, cx);
             }
         } else {
-            self.select_next(&SelectNext, window, cx);
+            self.select_next(&menu::SelectNext, window, cx);
         }
     }
 
@@ -937,14 +1001,19 @@ impl GitPanel {
             if dir_entry.expanded {
                 self.toggle_directory(&dir_entry.key, window, cx);
             } else {
-                self.select_previous(&SelectPrevious, window, cx);
+                self.select_previous(&menu::SelectPrevious, window, cx);
             }
         } else {
-            self.select_previous(&SelectPrevious, window, cx);
+            self.select_previous(&menu::SelectPrevious, window, cx);
         }
     }
 
-    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
+    fn select_first(
+        &mut self,
+        _: &menu::SelectFirst,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         let first_entry = match &self.view_mode {
             GitPanelViewMode::Flat => self
                 .entries
@@ -967,7 +1036,7 @@ impl GitPanel {
 
     fn select_previous(
         &mut self,
-        _: &SelectPrevious,
+        _: &menu::SelectPrevious,
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -1016,7 +1085,7 @@ impl GitPanel {
         self.scroll_to_selected_entry(cx);
     }
 
-    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+    fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
         let item_count = self.entries.len();
         if item_count == 0 {
             return;
@@ -1054,13 +1123,50 @@ impl GitPanel {
         self.scroll_to_selected_entry(cx);
     }
 
-    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
         if self.entries.last().is_some() {
             self.selected_entry = Some(self.entries.len() - 1);
             self.scroll_to_selected_entry(cx);
         }
     }
 
+    /// Show diff view at selected entry, only if the diff view is open
+    fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        maybe!({
+            let workspace = self.workspace.upgrade()?;
+
+            if let Some(project_diff) = workspace.read(cx).item_of_type::<ProjectDiff>(cx) {
+                let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
+
+                project_diff.update(cx, |project_diff, cx| {
+                    project_diff.move_to_entry(entry.clone(), window, cx);
+                });
+            }
+
+            Some(())
+        });
+    }
+
+    fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_first(&menu::SelectFirst, window, cx);
+        self.move_diff_to_entry(window, cx);
+    }
+
+    fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_last(&menu::SelectLast, window, cx);
+        self.move_diff_to_entry(window, cx);
+    }
+
+    fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_next(&menu::SelectNext, window, cx);
+        self.move_diff_to_entry(window, cx);
+    }
+
+    fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_previous(&menu::SelectPrevious, window, cx);
+        self.move_diff_to_entry(window, cx);
+    }
+
     fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
         self.commit_editor.update(cx, |editor, cx| {
             window.focus(&editor.focus_handle(cx), cx);
@@ -1074,7 +1180,7 @@ impl GitPanel {
             .as_ref()
             .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
         if have_entries && self.selected_entry.is_none() {
-            self.select_first(&SelectFirst, window, cx);
+            self.select_first(&menu::SelectFirst, window, cx);
         }
     }
 
@@ -4726,8 +4832,8 @@ impl GitPanel {
                     git::AddToGitignore.boxed_clone(),
                 )
                 .separator()
-                .action("Open Diff", Confirm.boxed_clone())
-                .action("Open File", SecondaryConfirm.boxed_clone())
+                .action("Open Diff", menu::Confirm.boxed_clone())
+                .action("Open File", menu::SecondaryConfirm.boxed_clone())
                 .separator()
                 .action_disabled_when(is_created, "View File History", Box::new(git::FileHistory))
         });
@@ -5390,6 +5496,10 @@ impl Render for GitPanel {
             .on_action(cx.listener(Self::select_next))
             .on_action(cx.listener(Self::select_previous))
             .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::first_entry))
+            .on_action(cx.listener(Self::next_entry))
+            .on_action(cx.listener(Self::previous_entry))
+            .on_action(cx.listener(Self::last_entry))
             .on_action(cx.listener(Self::close_panel))
             .on_action(cx.listener(Self::open_diff))
             .on_action(cx.listener(Self::open_file))
@@ -6855,7 +6965,7 @@ mod tests {
         // the Project Diff's active path.
         panel.update_in(cx, |panel, window, cx| {
             panel.selected_entry = Some(1);
-            panel.open_diff(&Confirm, window, cx);
+            panel.open_diff(&menu::Confirm, window, cx);
         });
         cx.run_until_parked();
 
@@ -6871,6 +6981,128 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "src": {
+                    "a": {
+                        "foo.rs": "fn foo() {}",
+                    },
+                    "b": {
+                        "bar.rs": "fn bar() {}",
+                    },
+                },
+            }),
+        )
+        .await;
+
+        fs.set_status_for_repo(
+            path!("/project/.git").as_ref(),
+            &[
+                ("src/a/foo.rs", StatusCode::Modified.worktree()),
+                ("src/b/bar.rs", StatusCode::Modified.worktree()),
+            ],
+        );
+
+        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
+        let workspace =
+            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+        cx.read(|cx| {
+            project
+                .read(cx)
+                .worktrees(cx)
+                .next()
+                .unwrap()
+                .read(cx)
+                .as_local()
+                .unwrap()
+                .scan_complete()
+        })
+        .await;
+
+        cx.executor().run_until_parked();
+
+        cx.update(|_window, cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.git_panel.get_or_insert_default().tree_view = Some(true);
+                })
+            });
+        });
+
+        let panel = workspace.update(cx, GitPanel::new).unwrap();
+
+        let handle = cx.update_window_entity(&panel, |panel, _, _| {
+            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
+        });
+        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
+        handle.await;
+
+        let src_key = panel.read_with(cx, |panel, _| {
+            panel
+                .entries
+                .iter()
+                .find_map(|entry| match entry {
+                    GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => {
+                        Some(dir.key.clone())
+                    }
+                    _ => None,
+                })
+                .expect("src directory should exist in tree view")
+        });
+
+        panel.update_in(cx, |panel, window, cx| {
+            panel.toggle_directory(&src_key, window, cx);
+        });
+
+        panel.read_with(cx, |panel, _| {
+            let state = panel
+                .view_mode
+                .tree_state()
+                .expect("tree view state should exist");
+            assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false));
+        });
+
+        let worktree_id =
+            cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
+        let project_path = ProjectPath {
+            worktree_id,
+            path: RelPath::unix("src/a/foo.rs").unwrap().into_arc(),
+        };
+
+        panel.update_in(cx, |panel, window, cx| {
+            panel.select_entry_by_path(project_path, window, cx);
+        });
+
+        panel.read_with(cx, |panel, _| {
+            let state = panel
+                .view_mode
+                .tree_state()
+                .expect("tree view state should exist");
+            assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true));
+
+            let selected_ix = panel.selected_entry.expect("selection should be set");
+            assert!(state.logical_indices.contains(&selected_ix));
+
+            let selected_entry = panel
+                .entries
+                .get(selected_ix)
+                .and_then(|entry| entry.status_entry())
+                .expect("selected entry should be a status entry");
+            assert_eq!(selected_entry.repo_path, repo_path("src/a/foo.rs"));
+        });
+    }
+
     fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) {
         assert_eq!(entries.len(), expected_paths.len());
         for (entry, expected_path) in entries.iter().zip(expected_paths) {

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

@@ -1,4 +1,5 @@
 use anyhow::Context as _;
+use collections::HashSet;
 use fuzzy::StringMatchCandidate;
 
 use git::repository::Worktree as GitWorktree;
@@ -9,7 +10,11 @@ use gpui::{
     actions, rems,
 };
 use picker::{Picker, PickerDelegate, PickerEditorPosition};
-use project::{DirectoryLister, git_store::Repository};
+use project::{
+    DirectoryLister,
+    git_store::Repository,
+    trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
+};
 use recent_projects::{RemoteConnectionModal, connect};
 use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
 use std::{path::PathBuf, sync::Arc};
@@ -219,7 +224,6 @@ impl WorktreeListDelegate {
         window: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) {
-        let workspace = self.workspace.clone();
         let Some(repo) = self.repo.clone() else {
             return;
         };
@@ -247,6 +251,7 @@ impl WorktreeListDelegate {
 
         let branch = worktree_branch.to_string();
         let window_handle = window.window_handle();
+        let workspace = self.workspace.clone();
         cx.spawn_in(window, async move |_, cx| {
             let Some(paths) = worktree_path.await? else {
                 return anyhow::Ok(());
@@ -257,8 +262,32 @@ impl WorktreeListDelegate {
                 repo.create_worktree(branch.clone(), path.clone(), commit)
             })?
             .await??;
-
-            let final_path = path.join(branch);
+            let new_worktree_path = path.join(branch);
+
+            workspace.update(cx, |workspace, cx| {
+                if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+                    let repo_path = &repo.read(cx).snapshot().work_directory_abs_path;
+                    let project = workspace.project();
+                    if let Some((parent_worktree, _)) =
+                        project.read(cx).find_worktree(repo_path, cx)
+                    {
+                        trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                            if trusted_worktrees.can_trust(parent_worktree.read(cx).id(), cx) {
+                                trusted_worktrees.trust(
+                                    HashSet::from_iter([PathTrust::AbsPath(
+                                        new_worktree_path.clone(),
+                                    )]),
+                                    project
+                                        .read(cx)
+                                        .remote_connection_options(cx)
+                                        .map(RemoteHostLocation::from),
+                                    cx,
+                                );
+                            }
+                        });
+                    }
+                }
+            })?;
 
             let (connection_options, app_state, is_local) =
                 workspace.update(cx, |workspace, cx| {
@@ -274,7 +303,7 @@ impl WorktreeListDelegate {
                     .update_in(cx, |workspace, window, cx| {
                         workspace.open_workspace_for_paths(
                             replace_current_window,
-                            vec![final_path],
+                            vec![new_worktree_path],
                             window,
                             cx,
                         )
@@ -283,7 +312,7 @@ impl WorktreeListDelegate {
             } else if let Some(connection_options) = connection_options {
                 open_remote_worktree(
                     connection_options,
-                    vec![final_path],
+                    vec![new_worktree_path],
                     app_state,
                     window_handle,
                     replace_current_window,

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

@@ -512,6 +512,8 @@ pub enum Model {
     Gemini25Pro,
     #[serde(rename = "gemini-3-pro-preview")]
     Gemini3Pro,
+    #[serde(rename = "gemini-3-flash-preview")]
+    Gemini3Flash,
     #[serde(rename = "custom")]
     Custom {
         name: String,
@@ -534,6 +536,7 @@ impl Model {
             Self::Gemini25Flash => "gemini-2.5-flash",
             Self::Gemini25Pro => "gemini-2.5-pro",
             Self::Gemini3Pro => "gemini-3-pro-preview",
+            Self::Gemini3Flash => "gemini-3-flash-preview",
             Self::Custom { name, .. } => name,
         }
     }
@@ -543,6 +546,7 @@ impl Model {
             Self::Gemini25Flash => "gemini-2.5-flash",
             Self::Gemini25Pro => "gemini-2.5-pro",
             Self::Gemini3Pro => "gemini-3-pro-preview",
+            Self::Gemini3Flash => "gemini-3-flash-preview",
             Self::Custom { name, .. } => name,
         }
     }
@@ -553,6 +557,7 @@ impl Model {
             Self::Gemini25Flash => "Gemini 2.5 Flash",
             Self::Gemini25Pro => "Gemini 2.5 Pro",
             Self::Gemini3Pro => "Gemini 3 Pro",
+            Self::Gemini3Flash => "Gemini 3 Flash",
             Self::Custom {
                 name, display_name, ..
             } => display_name.as_ref().unwrap_or(name),
@@ -565,6 +570,7 @@ impl Model {
             Self::Gemini25Flash => 1_048_576,
             Self::Gemini25Pro => 1_048_576,
             Self::Gemini3Pro => 1_048_576,
+            Self::Gemini3Flash => 1_048_576,
             Self::Custom { max_tokens, .. } => *max_tokens,
         }
     }
@@ -575,6 +581,7 @@ impl Model {
             Model::Gemini25Flash => Some(65_536),
             Model::Gemini25Pro => Some(65_536),
             Model::Gemini3Pro => Some(65_536),
+            Model::Gemini3Flash => Some(65_536),
             Model::Custom { .. } => None,
         }
     }
@@ -599,6 +606,7 @@ impl Model {
                     budget_tokens: None,
                 }
             }
+            Self::Gemini3Flash => GoogleModelMode::Default,
             Self::Custom { mode, .. } => *mode,
         }
     }

crates/gpui/src/key_dispatch.rs πŸ”—

@@ -462,6 +462,17 @@ impl DispatchTree {
         (bindings, partial, context_stack)
     }
 
+    /// Find the bindings that can follow the current input sequence.
+    pub fn possible_next_bindings_for_input(
+        &self,
+        input: &[Keystroke],
+        context_stack: &[KeyContext],
+    ) -> Vec<KeyBinding> {
+        self.keymap
+            .borrow()
+            .possible_next_bindings_for_input(input, context_stack)
+    }
+
     /// dispatch_key processes the keystroke
     /// input should be set to the value of `pending` from the previous call to dispatch_key.
     /// This returns three instructions to the input handler:

crates/gpui/src/keymap.rs πŸ”—

@@ -215,6 +215,41 @@ impl Keymap {
             Some(contexts.len())
         }
     }
+
+    /// Find the bindings that can follow the current input sequence.
+    pub fn possible_next_bindings_for_input(
+        &self,
+        input: &[Keystroke],
+        context_stack: &[KeyContext],
+    ) -> Vec<KeyBinding> {
+        let mut bindings = self
+            .bindings()
+            .enumerate()
+            .rev()
+            .filter_map(|(ix, binding)| {
+                let depth = self.binding_enabled(binding, context_stack)?;
+                let pending = binding.match_keystrokes(input);
+                match pending {
+                    None => None,
+                    Some(is_pending) => {
+                        if !is_pending || is_no_action(&*binding.action) {
+                            return None;
+                        }
+                        Some((depth, BindingIndex(ix), binding))
+                    }
+                }
+            })
+            .collect::<Vec<_>>();
+
+        bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| {
+            depth_b.cmp(depth_a).then(ix_b.cmp(ix_a))
+        });
+
+        bindings
+            .into_iter()
+            .map(|(_, _, binding)| binding.clone())
+            .collect::<Vec<_>>()
+    }
 }
 
 #[cfg(test)]

crates/gpui/src/window.rs πŸ”—

@@ -4450,6 +4450,13 @@ impl Window {
         dispatch_tree.highest_precedence_binding_for_action(action, &context_stack)
     }
 
+    /// Find the bindings that can follow the current input sequence for the current context stack.
+    pub fn possible_bindings_for_input(&self, input: &[Keystroke]) -> Vec<KeyBinding> {
+        self.rendered_frame
+            .dispatch_tree
+            .possible_next_bindings_for_input(input, &self.context_stack())
+    }
+
     fn context_stack_for_focus_handle(
         &self,
         focus_handle: &FocusHandle,

crates/language/Cargo.toml πŸ”—

@@ -32,6 +32,7 @@ async-trait.workspace = true
 clock.workspace = true
 collections.workspace = true
 ec4rs.workspace = true
+encoding_rs.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true

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

@@ -25,6 +25,7 @@ use anyhow::{Context as _, Result};
 use clock::Lamport;
 pub use clock::ReplicaId;
 use collections::{HashMap, HashSet};
+use encoding_rs::Encoding;
 use fs::MTime;
 use futures::channel::oneshot;
 use gpui::{
@@ -131,6 +132,8 @@ pub struct Buffer {
     change_bits: Vec<rc::Weak<Cell<bool>>>,
     _subscriptions: Vec<gpui::Subscription>,
     tree_sitter_data: Arc<TreeSitterData>,
+    encoding: &'static Encoding,
+    has_bom: bool,
 }
 
 #[derive(Debug)]
@@ -1100,6 +1103,8 @@ impl Buffer {
             has_conflict: false,
             change_bits: Default::default(),
             _subscriptions: Vec::new(),
+            encoding: encoding_rs::UTF_8,
+            has_bom: false,
         }
     }
 
@@ -1383,6 +1388,26 @@ impl Buffer {
         self.saved_mtime
     }
 
+    /// Returns the character encoding of the buffer's file.
+    pub fn encoding(&self) -> &'static Encoding {
+        self.encoding
+    }
+
+    /// Sets the character encoding of the buffer.
+    pub fn set_encoding(&mut self, encoding: &'static Encoding) {
+        self.encoding = encoding;
+    }
+
+    /// Returns whether the buffer has a Byte Order Mark.
+    pub fn has_bom(&self) -> bool {
+        self.has_bom
+    }
+
+    /// Sets whether the buffer has a Byte Order Mark.
+    pub fn set_has_bom(&mut self, has_bom: bool) {
+        self.has_bom = has_bom;
+    }
+
     /// Assign a language to the buffer.
     pub fn set_language_async(&mut self, language: Option<Arc<Language>>, cx: &mut Context<Self>) {
         self.set_language_(language, cfg!(any(test, feature = "test-support")), cx);

crates/project/Cargo.toml πŸ”—

@@ -40,6 +40,7 @@ clock.workspace = true
 collections.workspace = true
 context_server.workspace = true
 dap.workspace = true
+encoding_rs.workspace = true
 extension.workspace = true
 fancy-regex.workspace = true
 fs.workspace = true

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

@@ -376,6 +376,8 @@ impl LocalBufferStore {
 
         let text = buffer.as_rope().clone();
         let line_ending = buffer.line_ending();
+        let encoding = buffer.encoding();
+        let has_bom = buffer.has_bom();
         let version = buffer.version();
         let buffer_id = buffer.remote_id();
         let file = buffer.file().cloned();
@@ -387,7 +389,7 @@ impl LocalBufferStore {
         }
 
         let save = worktree.update(cx, |worktree, cx| {
-            worktree.write_file(path, text, line_ending, cx)
+            worktree.write_file(path, text, line_ending, encoding, has_bom, cx)
         });
 
         cx.spawn(async move |this, cx| {
@@ -630,7 +632,11 @@ impl LocalBufferStore {
                         })
                         .await;
                     cx.insert_entity(reservation, |_| {
-                        Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite)
+                        let mut buffer =
+                            Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite);
+                        buffer.set_encoding(loaded.encoding);
+                        buffer.set_has_bom(loaded.has_bom);
+                        buffer
                     })?
                 }
                 Err(error) if is_not_found_error(&error) => cx.new(|cx| {

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

@@ -65,6 +65,7 @@ use debugger::{
     dap_store::{DapStore, DapStoreEvent},
     session::Session,
 };
+use encoding_rs;
 pub use environment::ProjectEnvironment;
 #[cfg(test)]
 use futures::future::join_all;
@@ -5461,13 +5462,22 @@ impl Project {
                 .await
                 .context("Failed to load settings file")?;
 
+            let has_bom = file.has_bom;
+
             let new_text = cx.read_global::<SettingsStore, _>(|store, cx| {
                 store.new_text_for_update(file.text, move |settings| update(settings, cx))
             })?;
             worktree
                 .update(cx, |worktree, cx| {
                     let line_ending = text::LineEnding::detect(&new_text);
-                    worktree.write_file(rel_path.clone(), new_text.into(), line_ending, cx)
+                    worktree.write_file(
+                        rel_path.clone(),
+                        new_text.into(),
+                        line_ending,
+                        encoding_rs::UTF_8,
+                        has_bom,
+                        cx,
+                    )
                 })?
                 .await
                 .context("Failed to write settings file")?;

crates/search/src/buffer_search.rs πŸ”—

@@ -7,7 +7,6 @@ use crate::{
     search_bar::{ActionButtonState, input_base_styles, render_action_button, render_text_input},
 };
 use any_vec::AnyVec;
-use anyhow::Context as _;
 use collections::HashMap;
 use editor::{
     DisplayPoint, Editor, EditorSettings, MultiBufferOffset,
@@ -634,15 +633,19 @@ impl BufferSearchBar {
                 .read(cx)
                 .as_singleton()
                 .expect("query editor should be backed by a singleton buffer");
+
             query_buffer
                 .read(cx)
                 .set_language_registry(languages.clone());
 
             cx.spawn(async move |buffer_search_bar, cx| {
+                use anyhow::Context as _;
+
                 let regex_language = languages
                     .language_for_name("regex")
                     .await
                     .context("loading regex language")?;
+
                 buffer_search_bar
                     .update(cx, |buffer_search_bar, cx| {
                         buffer_search_bar.regex_language = Some(regex_language);

crates/settings/src/settings_content.rs πŸ”—

@@ -158,6 +158,9 @@ pub struct SettingsContent {
     /// Default: false
     pub disable_ai: Option<SaturatingBool>,
 
+    /// Settings for the which-key popup.
+    pub which_key: Option<WhichKeySettingsContent>,
+
     /// Settings related to Vim mode in Zed.
     pub vim: Option<VimSettingsContent>,
 }
@@ -976,6 +979,19 @@ pub struct ReplSettingsContent {
     pub max_columns: Option<usize>,
 }
 
+/// Settings for configuring the which-key popup behaviour.
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct WhichKeySettingsContent {
+    /// Whether to show the which-key popup when holding down key combinations
+    ///
+    /// Default: false
+    pub enabled: Option<bool>,
+    /// Delay in milliseconds before showing the which-key popup.
+    ///
+    /// Default: 700
+    pub delay_ms: Option<u64>,
+}
+
 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
 /// An ExtendingVec in the settings can only accumulate new values.
 ///

crates/settings_ui/src/page_data.rs πŸ”—

@@ -1233,6 +1233,49 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                             }
                         }).collect(),
                     }),
+                    SettingsPageItem::SectionHeader("Which-key Menu"),
+                    SettingsPageItem::SettingItem(SettingItem {
+                        title: "Show Which-key Menu",
+                        description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending.",
+                        field: Box::new(SettingField {
+                            json_path: Some("which_key.enabled"),
+                            pick: |settings_content| {
+                                settings_content
+                                    .which_key
+                                    .as_ref()
+                                    .and_then(|settings| settings.enabled.as_ref())
+                            },
+                            write: |settings_content, value| {
+                                settings_content
+                                    .which_key
+                                    .get_or_insert_default()
+                                    .enabled = value;
+                            },
+                        }),
+                        metadata: None,
+                        files: USER,
+                    }),
+                    SettingsPageItem::SettingItem(SettingItem {
+                        title: "Menu Delay",
+                        description: "Delay in milliseconds before the which-key menu appears.",
+                        field: Box::new(SettingField {
+                            json_path: Some("which_key.delay_ms"),
+                            pick: |settings_content| {
+                                settings_content
+                                    .which_key
+                                    .as_ref()
+                                    .and_then(|settings| settings.delay_ms.as_ref())
+                            },
+                            write: |settings_content, value| {
+                                settings_content
+                                    .which_key
+                                    .get_or_insert_default()
+                                    .delay_ms = value;
+                            },
+                        }),
+                        metadata: None,
+                        files: USER,
+                    }),
                     SettingsPageItem::SectionHeader("Multibuffer"),
                     SettingsPageItem::SettingItem(SettingItem {
                         title: "Double Click In Multibuffer",

crates/vim/src/command.rs πŸ”—

@@ -330,10 +330,12 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
                 let Some(range) = range.buffer_range(vim, editor, window, cx).ok() else {
                     return;
                 };
-                let Some((line_ending, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| {
+                let Some((line_ending, encoding, has_bom, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| {
                     Some(multi.as_singleton()?.update(cx, |buffer, _| {
                         (
                             buffer.line_ending(),
+                            buffer.encoding(),
+                            buffer.has_bom(),
                             buffer.as_rope().slice_rows(range.start.0..range.end.0 + 1),
                             range.start.0 == 0 && range.end.0 + 1 >= buffer.row_count(),
                         )
@@ -429,7 +431,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
                                     return;
                                 };
                                 worktree
-                                    .write_file(path.into_arc(), text.clone(), line_ending, cx)
+                                    .write_file(path.into_arc(), text.clone(), line_ending, encoding, has_bom, cx)
                                     .detach_and_prompt_err("Failed to write lines", window, cx, |_, _, _| None);
                             });
                         })

crates/which_key/Cargo.toml πŸ”—

@@ -0,0 +1,23 @@
+[package]
+name = "which_key"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/which_key.rs"
+doctest = false
+
+[dependencies]
+command_palette.workspace = true
+gpui.workspace = true
+serde.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true

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

@@ -0,0 +1,98 @@
+//! Which-key support for Zed.
+
+mod which_key_modal;
+mod which_key_settings;
+
+use gpui::{App, Keystroke};
+use settings::Settings;
+use std::{sync::LazyLock, time::Duration};
+use util::ResultExt;
+use which_key_modal::WhichKeyModal;
+use which_key_settings::WhichKeySettings;
+use workspace::Workspace;
+
+pub fn init(cx: &mut App) {
+    WhichKeySettings::register(cx);
+
+    cx.observe_new(|_: &mut Workspace, window, cx| {
+        let Some(window) = window else {
+            return;
+        };
+        let mut timer = None;
+        cx.observe_pending_input(window, move |workspace, window, cx| {
+            if window.pending_input_keystrokes().is_none() {
+                if let Some(modal) = workspace.active_modal::<WhichKeyModal>(cx) {
+                    modal.update(cx, |modal, cx| modal.dismiss(cx));
+                };
+                timer.take();
+                return;
+            }
+
+            let which_key_settings = WhichKeySettings::get_global(cx);
+            if !which_key_settings.enabled {
+                return;
+            }
+
+            let delay_ms = which_key_settings.delay_ms;
+
+            timer.replace(cx.spawn_in(window, async move |workspace_handle, cx| {
+                cx.background_executor()
+                    .timer(Duration::from_millis(delay_ms))
+                    .await;
+                workspace_handle
+                    .update_in(cx, |workspace, window, cx| {
+                        if workspace.active_modal::<WhichKeyModal>(cx).is_some() {
+                            return;
+                        };
+
+                        workspace.toggle_modal(window, cx, |window, cx| {
+                            WhichKeyModal::new(workspace_handle.clone(), window, cx)
+                        });
+                    })
+                    .log_err();
+            }));
+        })
+        .detach();
+    })
+    .detach();
+}
+
+// Hard-coded list of keystrokes to filter out from which-key display
+pub static FILTERED_KEYSTROKES: LazyLock<Vec<Vec<Keystroke>>> = LazyLock::new(|| {
+    [
+        // Modifiers on normal vim commands
+        "g h",
+        "g j",
+        "g k",
+        "g l",
+        "g $",
+        "g ^",
+        // Duplicate keys with "ctrl" held, e.g. "ctrl-w ctrl-a" is duplicate of "ctrl-w a"
+        "ctrl-w ctrl-a",
+        "ctrl-w ctrl-c",
+        "ctrl-w ctrl-h",
+        "ctrl-w ctrl-j",
+        "ctrl-w ctrl-k",
+        "ctrl-w ctrl-l",
+        "ctrl-w ctrl-n",
+        "ctrl-w ctrl-o",
+        "ctrl-w ctrl-p",
+        "ctrl-w ctrl-q",
+        "ctrl-w ctrl-s",
+        "ctrl-w ctrl-v",
+        "ctrl-w ctrl-w",
+        "ctrl-w ctrl-]",
+        "ctrl-w ctrl-shift-w",
+        "ctrl-w ctrl-g t",
+        "ctrl-w ctrl-g shift-t",
+    ]
+    .iter()
+    .filter_map(|s| {
+        let keystrokes: Result<Vec<_>, _> = s
+            .split(' ')
+            .map(|keystroke_str| Keystroke::parse(keystroke_str))
+            .collect();
+        keystrokes.ok()
+    })
+    .collect()
+});

crates/which_key/src/which_key_modal.rs πŸ”—

@@ -0,0 +1,308 @@
+//! Modal implementation for the which-key display.
+
+use gpui::prelude::FluentBuilder;
+use gpui::{
+    App, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, FontWeight, Keystroke,
+    ScrollHandle, Subscription, WeakEntity, Window,
+};
+use settings::Settings;
+use std::collections::HashMap;
+use theme::ThemeSettings;
+use ui::{
+    Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*,
+    text_for_keystrokes,
+};
+use workspace::{ModalView, Workspace};
+
+use crate::FILTERED_KEYSTROKES;
+
+pub struct WhichKeyModal {
+    _workspace: WeakEntity<Workspace>,
+    focus_handle: FocusHandle,
+    scroll_handle: ScrollHandle,
+    bindings: Vec<(SharedString, SharedString)>,
+    pending_keys: SharedString,
+    _pending_input_subscription: Subscription,
+    _focus_out_subscription: Subscription,
+}
+
+impl WhichKeyModal {
+    pub fn new(
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        // Keep focus where it currently is
+        let focus_handle = window.focused(cx).unwrap_or(cx.focus_handle());
+
+        let handle = cx.weak_entity();
+        let mut this = Self {
+            _workspace: workspace,
+            focus_handle: focus_handle.clone(),
+            scroll_handle: ScrollHandle::new(),
+            bindings: Vec::new(),
+            pending_keys: SharedString::new_static(""),
+            _pending_input_subscription: cx.observe_pending_input(
+                window,
+                |this: &mut Self, window, cx| {
+                    this.update_pending_keys(window, cx);
+                },
+            ),
+            _focus_out_subscription: window.on_focus_out(&focus_handle, cx, move |_, _, cx| {
+                handle.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
+            }),
+        };
+        this.update_pending_keys(window, cx);
+        this
+    }
+
+    pub fn dismiss(&self, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent)
+    }
+
+    fn update_pending_keys(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(pending_keys) = window.pending_input_keystrokes() else {
+            cx.emit(DismissEvent);
+            return;
+        };
+        let bindings = window.possible_bindings_for_input(pending_keys);
+
+        let mut binding_data = bindings
+            .iter()
+            .map(|binding| {
+                // Map to keystrokes
+                (
+                    binding
+                        .keystrokes()
+                        .iter()
+                        .map(|k| k.inner().to_owned())
+                        .collect::<Vec<_>>(),
+                    binding.action(),
+                )
+            })
+            .filter(|(keystrokes, _action)| {
+                // Check if this binding matches any filtered keystroke pattern
+                !FILTERED_KEYSTROKES.iter().any(|filtered| {
+                    keystrokes.len() >= filtered.len()
+                        && keystrokes[..filtered.len()] == filtered[..]
+                })
+            })
+            .map(|(keystrokes, action)| {
+                // Map to remaining keystrokes and action name
+                let remaining_keystrokes = keystrokes[pending_keys.len()..].to_vec();
+                let action_name: SharedString =
+                    command_palette::humanize_action_name(action.name()).into();
+                (remaining_keystrokes, action_name)
+            })
+            .collect();
+
+        binding_data = group_bindings(binding_data);
+
+        // Sort bindings from shortest to longest, with groups last
+        // Using stable sort to preserve relative order of equal elements
+        binding_data.sort_by(|(keystrokes_a, action_a), (keystrokes_b, action_b)| {
+            // Groups (actions starting with "+") should go last
+            let is_group_a = action_a.starts_with('+');
+            let is_group_b = action_b.starts_with('+');
+
+            // First, separate groups from non-groups
+            let group_cmp = is_group_a.cmp(&is_group_b);
+            if group_cmp != std::cmp::Ordering::Equal {
+                return group_cmp;
+            }
+
+            // Then sort by keystroke count
+            let keystroke_cmp = keystrokes_a.len().cmp(&keystrokes_b.len());
+            if keystroke_cmp != std::cmp::Ordering::Equal {
+                return keystroke_cmp;
+            }
+
+            // Finally sort by text length, then lexicographically for full stability
+            let text_a = text_for_keystrokes(keystrokes_a, cx);
+            let text_b = text_for_keystrokes(keystrokes_b, cx);
+            let text_len_cmp = text_a.len().cmp(&text_b.len());
+            if text_len_cmp != std::cmp::Ordering::Equal {
+                return text_len_cmp;
+            }
+            text_a.cmp(&text_b)
+        });
+        binding_data.dedup();
+        self.pending_keys = text_for_keystrokes(&pending_keys, cx).into();
+        self.bindings = binding_data
+            .into_iter()
+            .map(|(keystrokes, action)| (text_for_keystrokes(&keystrokes, cx).into(), action))
+            .collect();
+    }
+}
+
+impl Render for WhichKeyModal {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let has_rows = !self.bindings.is_empty();
+        let viewport_size = window.viewport_size();
+
+        let max_panel_width = px((f32::from(viewport_size.width) * 0.5).min(480.0));
+        let max_content_height = px(f32::from(viewport_size.height) * 0.4);
+
+        // Push above status bar when visible
+        let status_height = self
+            ._workspace
+            .upgrade()
+            .and_then(|workspace| {
+                workspace.read_with(cx, |workspace, cx| {
+                    if workspace.status_bar_visible(cx) {
+                        Some(
+                            DynamicSpacing::Base04.px(cx) * 2.0
+                                + ThemeSettings::get_global(cx).ui_font_size(cx),
+                        )
+                    } else {
+                        None
+                    }
+                })
+            })
+            .unwrap_or(px(0.));
+
+        let margin_bottom = px(16.);
+        let bottom_offset = margin_bottom + status_height;
+
+        // Title section
+        let title_section = {
+            let mut column = v_flex().gap(px(0.)).child(
+                div()
+                    .child(
+                        Label::new(self.pending_keys.clone())
+                            .size(LabelSize::Default)
+                            .weight(FontWeight::MEDIUM)
+                            .color(Color::Accent),
+                    )
+                    .mb(px(2.)),
+            );
+
+            if has_rows {
+                column = column.child(
+                    div()
+                        .child(Divider::horizontal().color(DividerColor::BorderFaded))
+                        .mb(px(2.)),
+                );
+            }
+
+            column
+        };
+
+        let content = h_flex()
+            .items_start()
+            .id("which-key-content")
+            .gap(px(8.))
+            .overflow_y_scroll()
+            .track_scroll(&self.scroll_handle)
+            .h_full()
+            .max_h(max_content_height)
+            .child(
+                // Keystrokes column
+                v_flex()
+                    .gap(px(4.))
+                    .flex_shrink_0()
+                    .children(self.bindings.iter().map(|(keystrokes, _)| {
+                        div()
+                            .child(
+                                Label::new(keystrokes.clone())
+                                    .size(LabelSize::Default)
+                                    .color(Color::Accent),
+                            )
+                            .text_align(gpui::TextAlign::Right)
+                    })),
+            )
+            .child(
+                // Actions column
+                v_flex()
+                    .gap(px(4.))
+                    .flex_1()
+                    .min_w_0()
+                    .children(self.bindings.iter().map(|(_, action_name)| {
+                        let is_group = action_name.starts_with('+');
+                        let label_color = if is_group {
+                            Color::Success
+                        } else {
+                            Color::Default
+                        };
+
+                        div().child(
+                            Label::new(action_name.clone())
+                                .size(LabelSize::Default)
+                                .color(label_color)
+                                .single_line()
+                                .truncate(),
+                        )
+                    })),
+            );
+
+        div()
+            .id("which-key-buffer-panel-scroll")
+            .occlude()
+            .absolute()
+            .bottom(bottom_offset)
+            .right(px(16.))
+            .min_w(px(220.))
+            .max_w(max_panel_width)
+            .elevation_3(cx)
+            .px(px(12.))
+            .child(v_flex().child(title_section).when(has_rows, |el| {
+                el.child(
+                    div()
+                        .max_h(max_content_height)
+                        .child(content)
+                        .vertical_scrollbar_for(&self.scroll_handle, window, cx),
+                )
+            }))
+    }
+}
+
+impl EventEmitter<DismissEvent> for WhichKeyModal {}
+
+impl Focusable for WhichKeyModal {
+    fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl ModalView for WhichKeyModal {
+    fn render_bare(&self) -> bool {
+        true
+    }
+}
+
+fn group_bindings(
+    binding_data: Vec<(Vec<Keystroke>, SharedString)>,
+) -> Vec<(Vec<Keystroke>, SharedString)> {
+    let mut groups: HashMap<Option<Keystroke>, Vec<(Vec<Keystroke>, SharedString)>> =
+        HashMap::new();
+
+    // Group bindings by their first keystroke
+    for (remaining_keystrokes, action_name) in binding_data {
+        let first_key = remaining_keystrokes.first().cloned();
+        groups
+            .entry(first_key)
+            .or_default()
+            .push((remaining_keystrokes, action_name));
+    }
+
+    let mut result = Vec::new();
+
+    for (first_key, mut group_bindings) in groups {
+        // Remove duplicates within each group
+        group_bindings.dedup_by_key(|(keystrokes, _)| keystrokes.clone());
+
+        if let Some(first_key) = first_key
+            && group_bindings.len() > 1
+        {
+            // This is a group - create a single entry with just the first keystroke
+            let first_keystroke = vec![first_key];
+            let count = group_bindings.len();
+            result.push((first_keystroke, format!("+{} keybinds", count).into()));
+        } else {
+            // Not a group or empty keystrokes - add all bindings as-is
+            result.append(&mut group_bindings);
+        }
+    }
+
+    result
+}

crates/which_key/src/which_key_settings.rs πŸ”—

@@ -0,0 +1,18 @@
+use settings::{RegisterSetting, Settings, SettingsContent, WhichKeySettingsContent};
+
+#[derive(Debug, Clone, Copy, RegisterSetting)]
+pub struct WhichKeySettings {
+    pub enabled: bool,
+    pub delay_ms: u64,
+}
+
+impl Settings for WhichKeySettings {
+    fn from_settings(content: &SettingsContent) -> Self {
+        let which_key: &WhichKeySettingsContent = content.which_key.as_ref().unwrap();
+
+        Self {
+            enabled: which_key.enabled.unwrap(),
+            delay_ms: which_key.delay_ms.unwrap(),
+        }
+    }
+}

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

@@ -1,5 +1,4 @@
 use crate::persistence::model::DockData;
-use crate::utility_pane::utility_slot_for_dock_position;
 use crate::{DraggedDock, Event, ModalLayer, Pane};
 use crate::{Workspace, status_bar::StatusItemView};
 use anyhow::Context as _;
@@ -705,7 +704,7 @@ impl Dock {
         panel: &Entity<T>,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) {
+    ) -> bool {
         if let Some(panel_ix) = self
             .panel_entries
             .iter()
@@ -724,15 +723,12 @@ impl Dock {
                 }
             }
 
-            let slot = utility_slot_for_dock_position(self.position);
-            if let Some(workspace) = self.workspace.upgrade() {
-                workspace.update(cx, |workspace, cx| {
-                    workspace.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx);
-                });
-            }
-
             self.panel_entries.remove(panel_ix);
             cx.notify();
+
+            true
+        } else {
+            false
         }
     }
 

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

@@ -22,12 +22,17 @@ pub trait ModalView: ManagedView {
     fn fade_out_background(&self) -> bool {
         false
     }
+
+    fn render_bare(&self) -> bool {
+        false
+    }
 }
 
 trait ModalViewHandle {
     fn on_before_dismiss(&mut self, window: &mut Window, cx: &mut App) -> DismissDecision;
     fn view(&self) -> AnyView;
     fn fade_out_background(&self, cx: &mut App) -> bool;
+    fn render_bare(&self, cx: &mut App) -> bool;
 }
 
 impl<V: ModalView> ModalViewHandle for Entity<V> {
@@ -42,6 +47,10 @@ impl<V: ModalView> ModalViewHandle for Entity<V> {
     fn fade_out_background(&self, cx: &mut App) -> bool {
         self.read(cx).fade_out_background()
     }
+
+    fn render_bare(&self, cx: &mut App) -> bool {
+        self.read(cx).render_bare()
+    }
 }
 
 pub struct ActiveModal {
@@ -167,9 +176,13 @@ impl ModalLayer {
 impl Render for ModalLayer {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let Some(active_modal) = &self.active_modal else {
-            return div();
+            return div().into_any_element();
         };
 
+        if active_modal.modal.render_bare(cx) {
+            return active_modal.modal.view().into_any_element();
+        }
+
         div()
             .absolute()
             .size_full()
@@ -195,5 +208,6 @@ impl Render for ModalLayer {
                             }),
                     ),
             )
+            .into_any_element()
     }
 }

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

@@ -102,46 +102,31 @@ impl Render for SecurityModal {
                             .child(Icon::new(IconName::Warning).color(Color::Warning))
                             .child(Label::new(header_label)),
                     )
-                    .children(self.restricted_paths.values().map(|restricted_path| {
+                    .children(self.restricted_paths.values().filter_map(|restricted_path| {
                         let abs_path = if restricted_path.is_file {
                             restricted_path.abs_path.parent()
                         } else {
                             Some(restricted_path.abs_path.as_ref())
-                        };
-
-                        let label = match abs_path {
-                            Some(abs_path) => match &restricted_path.host {
-                                Some(remote_host) => match &remote_host.user_name {
-                                    Some(user_name) => format!(
-                                        "{} ({}@{})",
-                                        self.shorten_path(abs_path).display(),
-                                        user_name,
-                                        remote_host.host_identifier
-                                    ),
-                                    None => format!(
-                                        "{} ({})",
-                                        self.shorten_path(abs_path).display(),
-                                        remote_host.host_identifier
-                                    ),
-                                },
-                                None => self.shorten_path(abs_path).display().to_string(),
-                            },
-                            None => match &restricted_path.host {
-                                Some(remote_host) => match &remote_host.user_name {
-                                    Some(user_name) => format!(
-                                        "Workspace trust ({}@{})",
-                                        user_name, remote_host.host_identifier
-                                    ),
-                                    None => {
-                                        format!("Workspace trust ({})", remote_host.host_identifier)
-                                    }
-                                },
-                                None => "Workspace trust".to_string(),
+                        }?;
+                        let label = match &restricted_path.host {
+                            Some(remote_host) => match &remote_host.user_name {
+                                Some(user_name) => format!(
+                                    "{} ({}@{})",
+                                    self.shorten_path(abs_path).display(),
+                                    user_name,
+                                    remote_host.host_identifier
+                                ),
+                                None => format!(
+                                    "{} ({})",
+                                    self.shorten_path(abs_path).display(),
+                                    remote_host.host_identifier
+                                ),
                             },
+                            None => self.shorten_path(abs_path).display().to_string(),
                         };
-                        h_flex()
+                        Some(h_flex()
                             .pl(IconSize::default().rems() + rems(0.5))
-                            .child(Label::new(label).color(Color::Muted))
+                            .child(Label::new(label).color(Color::Muted)))
                     })),
             )
             .child(

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

@@ -136,7 +136,9 @@ pub use workspace_settings::{
 use zed_actions::{Spawn, feedback::FileBugReport};
 
 use crate::{
-    item::ItemBufferKind, notifications::NotificationId, utility_pane::UTILITY_PANE_MIN_WIDTH,
+    item::ItemBufferKind,
+    notifications::NotificationId,
+    utility_pane::{UTILITY_PANE_MIN_WIDTH, utility_slot_for_dock_position},
 };
 use crate::{
     persistence::{
@@ -987,6 +989,7 @@ impl AppState {
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &mut App) -> Arc<Self> {
+        use fs::Fs;
         use node_runtime::NodeRuntime;
         use session::Session;
         use settings::SettingsStore;
@@ -997,6 +1000,7 @@ impl AppState {
         }
 
         let fs = fs::FakeFs::new(cx.background_executor().clone());
+        <dyn Fs>::set_global(fs.clone(), cx);
         let languages = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
         let clock = Arc::new(clock::FakeSystemClock::new());
         let http_client = http_client::FakeHttpClient::with_404_response();
@@ -1891,10 +1895,18 @@ impl Workspace {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let mut found_in_dock = None;
         for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] {
-            dock.update(cx, |dock, cx| {
-                dock.remove_panel(panel, window, cx);
-            })
+            let found = dock.update(cx, |dock, cx| dock.remove_panel(panel, window, cx));
+
+            if found {
+                found_in_dock = Some(dock.clone());
+            }
+        }
+        if let Some(found_in_dock) = found_in_dock {
+            let position = found_in_dock.read(cx).position();
+            let slot = utility_slot_for_dock_position(position);
+            self.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx);
         }
     }
 

crates/worktree/Cargo.toml πŸ”—

@@ -25,8 +25,10 @@ test-support = [
 [dependencies]
 anyhow.workspace = true
 async-lock.workspace = true
+chardetng.workspace = true
 clock.workspace = true
 collections.workspace = true
+encoding_rs.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true

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

@@ -5,8 +5,10 @@ mod worktree_tests;
 
 use ::ignore::gitignore::{Gitignore, GitignoreBuilder};
 use anyhow::{Context as _, Result, anyhow};
+use chardetng::EncodingDetector;
 use clock::ReplicaId;
 use collections::{HashMap, HashSet, VecDeque};
+use encoding_rs::Encoding;
 use fs::{Fs, MTime, PathEvent, RemoveOptions, Watcher, copy_recursive, read_dir_items};
 use futures::{
     FutureExt as _, Stream, StreamExt,
@@ -105,6 +107,8 @@ pub enum CreatedEntry {
 pub struct LoadedFile {
     pub file: Arc<File>,
     pub text: String,
+    pub encoding: &'static Encoding,
+    pub has_bom: bool,
 }
 
 pub struct LoadedBinaryFile {
@@ -741,10 +745,14 @@ impl Worktree {
         path: Arc<RelPath>,
         text: Rope,
         line_ending: LineEnding,
+        encoding: &'static Encoding,
+        has_bom: bool,
         cx: &Context<Worktree>,
     ) -> 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, encoding, has_bom, cx)
+            }
             Worktree::Remote(_) => {
                 Task::ready(Err(anyhow!("remote worktree can't yet write files")))
             }
@@ -1351,7 +1359,9 @@ impl LocalWorktree {
                     anyhow::bail!("File is too large to load");
                 }
             }
-            let text = fs.load(&abs_path).await?;
+
+            let content = fs.load_bytes(&abs_path).await?;
+            let (text, encoding, has_bom) = decode_byte(content);
 
             let worktree = this.upgrade().context("worktree was dropped")?;
             let file = match entry.await? {
@@ -1379,7 +1389,12 @@ impl LocalWorktree {
                 }
             };
 
-            Ok(LoadedFile { file, text })
+            Ok(LoadedFile {
+                file,
+                text,
+                encoding,
+                has_bom,
+            })
         })
     }
 
@@ -1462,6 +1477,8 @@ impl LocalWorktree {
         path: Arc<RelPath>,
         text: Rope,
         line_ending: LineEnding,
+        encoding: &'static Encoding,
+        has_bom: bool,
         cx: &Context<Worktree>,
     ) -> Task<Result<Arc<File>>> {
         let fs = self.fs.clone();
@@ -1471,7 +1488,49 @@ impl LocalWorktree {
         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 {
+                let bom_bytes = if has_bom {
+                    if encoding == encoding_rs::UTF_16LE {
+                        vec![0xFF, 0xFE]
+                    } else if encoding == encoding_rs::UTF_16BE {
+                        vec![0xFE, 0xFF]
+                    } else if encoding == encoding_rs::UTF_8 {
+                        vec![0xEF, 0xBB, 0xBF]
+                    } else {
+                        vec![]
+                    }
+                } else {
+                    vec![]
+                };
+
+                // For UTF-8, use the optimized `fs.save` which writes Rope chunks directly to disk
+                // without allocating a contiguous string.
+                if encoding == encoding_rs::UTF_8 && !has_bom {
+                    return fs.save(&abs_path, &text, line_ending).await;
+                }
+                // For legacy encodings (e.g. Shift-JIS), we fall back to converting the entire Rope
+                // to a String/Bytes in memory before writing.
+                //
+                // Note: This is inefficient for very large files compared to the streaming approach above,
+                // but supporting streaming writes for arbitrary encodings would require a significant
+                // refactor of the `fs` crate to expose a Writer interface.
+                let text_string = text.to_string();
+                let normalized_text = match line_ending {
+                    LineEnding::Unix => text_string,
+                    LineEnding::Windows => text_string.replace('\n', "\r\n"),
+                };
+
+                let (cow, _, _) = encoding.encode(&normalized_text);
+                let bytes = if !bom_bytes.is_empty() {
+                    let mut bytes = bom_bytes;
+                    bytes.extend_from_slice(&cow);
+                    bytes.into()
+                } else {
+                    cow
+                };
+
+                fs.write(&abs_path, &bytes).await
+            }
         });
 
         cx.spawn(async move |this, cx| {
@@ -5782,3 +5841,40 @@ impl fs::Watcher for NullWatcher {
         Ok(())
     }
 }
+
+fn decode_byte(bytes: Vec<u8>) -> (String, &'static Encoding, bool) {
+    // check BOM
+    if let Some((encoding, _bom_len)) = Encoding::for_bom(&bytes) {
+        let (cow, _) = encoding.decode_with_bom_removal(&bytes);
+        return (cow.into_owned(), encoding, true);
+    }
+
+    fn detect_encoding(bytes: Vec<u8>) -> (String, &'static Encoding) {
+        let mut detector = EncodingDetector::new();
+        detector.feed(&bytes, true);
+
+        let encoding = detector.guess(None, true); // Use None for TLD hint to ensure neutral detection logic.
+
+        let (cow, _, _) = encoding.decode(&bytes);
+        (cow.into_owned(), encoding)
+    }
+
+    match String::from_utf8(bytes) {
+        Ok(text) => {
+            // ISO-2022-JP (and other ISO-2022 variants) consists entirely of 7-bit ASCII bytes,
+            // so it is valid UTF-8. However, it contains escape sequences starting with '\x1b'.
+            // If we find an escape character, we double-check the encoding to prevent
+            // displaying raw escape sequences instead of the correct characters.
+            if text.contains('\x1b') {
+                let (s, enc) = detect_encoding(text.into_bytes());
+                (s, enc, false)
+            } else {
+                (text, encoding_rs::UTF_8, false)
+            }
+        }
+        Err(e) => {
+            let (s, enc) = detect_encoding(e.into_bytes());
+            (s, enc, false)
+        }
+    }
+}

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

@@ -1,5 +1,6 @@
 use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
-use anyhow::Result;
+use anyhow::{Context as _, Result};
+use encoding_rs;
 use fs::{FakeFs, Fs, RealFs, RemoveOptions};
 use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE};
 use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
@@ -19,6 +20,7 @@ use std::{
 };
 use util::{
     ResultExt, path,
+    paths::PathStyle,
     rel_path::{RelPath, rel_path},
     test::TempTree,
 };
@@ -723,6 +725,8 @@ async fn test_write_file(cx: &mut TestAppContext) {
                 rel_path("tracked-dir/file.txt").into(),
                 "hello".into(),
                 Default::default(),
+                encoding_rs::UTF_8,
+                false,
                 cx,
             )
         })
@@ -734,6 +738,8 @@ async fn test_write_file(cx: &mut TestAppContext) {
                 rel_path("ignored-dir/file.txt").into(),
                 "world".into(),
                 Default::default(),
+                encoding_rs::UTF_8,
+                false,
                 cx,
             )
         })
@@ -2035,8 +2041,14 @@ fn randomly_mutate_worktree(
                 })
             } else {
                 log::info!("overwriting file {:?} ({})", &entry.path, entry.id.0);
-                let task =
-                    worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx);
+                let task = worktree.write_file(
+                    entry.path.clone(),
+                    "".into(),
+                    Default::default(),
+                    encoding_rs::UTF_8,
+                    false,
+                    cx,
+                );
                 cx.background_spawn(async move {
                     task.await?;
                     Ok(())
@@ -2552,3 +2564,176 @@ fn init_test(cx: &mut gpui::TestAppContext) {
         cx.set_global(settings_store);
     });
 }
+
+#[gpui::test]
+async fn test_load_file_encoding(cx: &mut TestAppContext) {
+    init_test(cx);
+    let test_cases: Vec<(&str, &[u8], &str)> = vec![
+        ("utf8.txt", "こんにけは".as_bytes(), "こんにけは"), // "こんにけは" is Japanese "Hello"
+        (
+            "sjis.txt",
+            &[0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd],
+            "こんにけは",
+        ),
+        (
+            "eucjp.txt",
+            &[0xa4, 0xb3, 0xa4, 0xf3, 0xa4, 0xcb, 0xa4, 0xc1, 0xa4, 0xcf],
+            "こんにけは",
+        ),
+        (
+            "iso2022jp.txt",
+            &[
+                0x1b, 0x24, 0x42, 0x24, 0x33, 0x24, 0x73, 0x24, 0x4b, 0x24, 0x41, 0x24, 0x4f, 0x1b,
+                0x28, 0x42,
+            ],
+            "こんにけは",
+        ),
+        // Western Europe (Windows-1252)
+        // "CafΓ©" -> 0xE9 is 'Γ©' in Windows-1252 (it is typically 0xC3 0xA9 in UTF-8)
+        ("win1252.txt", &[0x43, 0x61, 0x66, 0xe9], "CafΓ©"),
+        // Chinese Simplified (GBK)
+        // Note: We use a slightly longer string here because short byte sequences can be ambiguous
+        // in multi-byte encodings. Providing more context helps the heuristic detector guess correctly.
+        // Text: "δ»Šε€©ε€©ζ°”δΈι”™" (Today's weather is not bad / nice)
+        // Bytes:
+        //   今: BD F1
+        //   倩: CC EC
+        //   倩: CC EC
+        //   ζ°”: C6 F8
+        //   不: B2 BB
+        //   ι”™: B4 ED
+        (
+            "gbk.txt",
+            &[
+                0xbd, 0xf1, 0xcc, 0xec, 0xcc, 0xec, 0xc6, 0xf8, 0xb2, 0xbb, 0xb4, 0xed,
+            ],
+            "δ»Šε€©ε€©ζ°”δΈι”™",
+        ),
+        (
+            "utf16le_bom.txt",
+            &[
+                0xFF, 0xFE, // BOM
+                0x53, 0x30, // こ
+                0x93, 0x30, // γ‚“
+                0x6B, 0x30, // に
+                0x61, 0x30, // け
+                0x6F, 0x30, // は
+            ],
+            "こんにけは",
+        ),
+        (
+            "utf8_bom.txt",
+            &[
+                0xEF, 0xBB, 0xBF, // UTF-8 BOM
+                0xE3, 0x81, 0x93, // こ
+                0xE3, 0x82, 0x93, // γ‚“
+                0xE3, 0x81, 0xAB, // に
+                0xE3, 0x81, 0xA1, // け
+                0xE3, 0x81, 0xAF, // は
+            ],
+            "こんにけは",
+        ),
+    ];
+
+    let root_path = if cfg!(windows) {
+        Path::new("C:\\root")
+    } else {
+        Path::new("/root")
+    };
+
+    let fs = FakeFs::new(cx.background_executor.clone());
+
+    let mut files_json = serde_json::Map::new();
+    for (name, _, _) in &test_cases {
+        files_json.insert(name.to_string(), serde_json::Value::String("".to_string()));
+    }
+
+    for (name, bytes, _) in &test_cases {
+        let path = root_path.join(name);
+        fs.write(&path, bytes).await.unwrap();
+    }
+
+    let tree = Worktree::local(
+        root_path,
+        true,
+        fs,
+        Default::default(),
+        true,
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+        .await;
+
+    for (name, _, expected) in test_cases {
+        let loaded = tree
+            .update(cx, |tree, cx| tree.load_file(rel_path(name), cx))
+            .await
+            .with_context(|| format!("Failed to load {}", name))
+            .unwrap();
+
+        assert_eq!(
+            loaded.text, expected,
+            "Encoding mismatch for file: {}",
+            name
+        );
+    }
+}
+
+#[gpui::test]
+async fn test_write_file_encoding(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    let root_path = if cfg!(windows) {
+        Path::new("C:\\root")
+    } else {
+        Path::new("/root")
+    };
+    fs.create_dir(root_path).await.unwrap();
+    let file_path = root_path.join("test.txt");
+
+    fs.insert_file(&file_path, "initial".into()).await;
+
+    let worktree = Worktree::local(
+        root_path,
+        true,
+        fs.clone(),
+        Default::default(),
+        true,
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    let path: Arc<Path> = Path::new("test.txt").into();
+    let rel_path = RelPath::new(&path, PathStyle::local()).unwrap().into_arc();
+
+    let text = text::Rope::from("こんにけは");
+
+    let task = worktree.update(cx, |wt, cx| {
+        wt.write_file(
+            rel_path,
+            text,
+            text::LineEnding::Unix,
+            encoding_rs::SHIFT_JIS,
+            false,
+            cx,
+        )
+    });
+
+    task.await.unwrap();
+
+    let bytes = fs.load_bytes(&file_path).await.unwrap();
+
+    let expected_bytes = vec![
+        0x82, 0xb1, // こ
+        0x82, 0xf1, // γ‚“
+        0x82, 0xc9, // に
+        0x82, 0xbf, // け
+        0x82, 0xcd, // は
+    ];
+
+    assert_eq!(bytes, expected_bytes, "Should be saved as Shift-JIS");
+}

crates/zed/Cargo.toml πŸ”—

@@ -163,6 +163,7 @@ vim_mode_setting.workspace = true
 watch.workspace = true
 web_search.workspace = true
 web_search_providers.workspace = true
+which_key.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
 zed_env_vars.workspace = true
@@ -195,6 +196,10 @@ terminal_view = { workspace = true, features = ["test-support"] }
 tree-sitter-md.workspace = true
 tree-sitter-rust.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
+agent_ui = { workspace = true, features = ["test-support"] }
+agent_ui_v2 = { workspace = true, features = ["test-support"] }
+search = { workspace = true, features = ["test-support"] }
+
 
 [package.metadata.bundle-dev]
 icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"]

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

@@ -660,6 +660,7 @@ pub fn main() {
         inspector_ui::init(app_state.clone(), cx);
         json_schema_store::init(cx);
         miniprofiler_ui::init(*STARTUP_TIME.get().unwrap(), cx);
+        which_key::init(cx);
 
         cx.observe_global::<SettingsStore>({
             let http = app_state.client.http_client();

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

@@ -707,7 +707,6 @@ fn setup_or_teardown_ai_panel<P: Panel>(
         .disable_ai
         || cfg!(test);
     let existing_panel = workspace.panel::<P>(cx);
-
     match (disable_ai, existing_panel) {
         (false, None) => cx.spawn_in(window, async move |workspace, cx| {
             let panel = load_panel(workspace.clone(), cx.clone()).await?;
@@ -2327,7 +2326,7 @@ mod tests {
     use project::{Project, ProjectPath};
     use semver::Version;
     use serde_json::json;
-    use settings::{SettingsStore, watch_config_file};
+    use settings::{SaturatingBool, SettingsStore, watch_config_file};
     use std::{
         path::{Path, PathBuf},
         time::Duration,
@@ -5171,6 +5170,28 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_disable_ai_crash(cx: &mut gpui::TestAppContext) {
+        let app_state = init_test(cx);
+        cx.update(init);
+        let project = Project::test(app_state.fs.clone(), [], cx).await;
+        let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |settings_store, cx| {
+                settings_store.update_user_settings(cx, |settings| {
+                    settings.disable_ai = Some(SaturatingBool(true));
+                });
+            });
+        });
+
+        cx.run_until_parked();
+
+        // If this panics, the test has failed
+    }
+
     #[gpui::test]
     async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) {
         let app_state = init_test(cx);