Merge branch 'main' into v0.173.x

Joseph T. Lyons created

Change summary

Cargo.lock                                                      |  46 
Cargo.toml                                                      |   2 
assets/icons/file_icons/file_types.json                         |   5 
assets/icons/zed_predict_bg.svg                                 |   4 
assets/settings/default.json                                    |  10 
crates/client/Cargo.toml                                        |   2 
crates/client/src/client.rs                                     |  17 
crates/editor/src/editor.rs                                     |  46 
crates/editor/src/inline_completion_tests.rs                    |  51 
crates/http_client/Cargo.toml                                   |   2 
crates/http_client/src/http_client.rs                           |  21 
crates/inline_completion_button/src/inline_completion_button.rs |  48 
crates/multi_buffer/src/multi_buffer.rs                         |  15 
crates/reqwest_client/src/reqwest_client.rs                     |   5 
crates/settings/src/keymap_file.rs                              |  23 
crates/settings/src/settings_file.rs                            |  36 
crates/theme/src/icon_theme.rs                                  |   5 
crates/ui/src/components/context_menu.rs                        |  11 
crates/zed/src/main.rs                                          |   7 
crates/zed/src/zed.rs                                           | 182 
crates/zed/src/zed/migrate.rs                                   | 315 ++
crates/zeta/src/onboarding_modal.rs                             |  23 
docs/src/configuring-zed.md                                     |   2 
23 files changed, 550 insertions(+), 328 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2625,8 +2625,6 @@ dependencies = [
  "rand 0.8.5",
  "release_channel",
  "rpc",
- "rustls 0.23.22",
- "rustls-native-certs 0.8.1",
  "schemars",
  "serde",
  "serde_json",
@@ -6014,6 +6012,8 @@ dependencies = [
  "futures 0.3.31",
  "http 1.2.0",
  "log",
+ "rustls 0.23.22",
+ "rustls-platform-verifier",
  "serde",
  "serde_json",
  "url",
@@ -10502,16 +10502,16 @@ dependencies = [
 
 [[package]]
 name = "quinn-udp"
-version = "0.5.8"
+version = "0.5.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52cd4b1eff68bf27940dd39811292c49e007f4d0b4c357358dc9b0197be6b527"
+checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904"
 dependencies = [
  "cfg_aliases 0.2.1",
  "libc",
  "once_cell",
  "socket2",
  "tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
 ]
 
 [[package]]
@@ -11492,6 +11492,33 @@ dependencies = [
  "web-time",
 ]
 
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e012c45844a1790332c9386ed4ca3a06def221092eda277e6f079728f8ea99da"
+dependencies = [
+ "core-foundation 0.10.0",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls 0.23.22",
+ "rustls-native-certs 0.8.1",
+ "rustls-platform-verifier-android",
+ "rustls-webpki 0.102.8",
+ "security-framework 3.0.1",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
+
 [[package]]
 name = "rustls-webpki"
 version = "0.101.7"
@@ -15398,6 +15425,15 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "webpki-root-certs"
+version = "0.26.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09aed61f5e8d2c18344b3faa33a4c837855fe56642757754775548fee21386c4"
+dependencies = [
+ "rustls-pki-types",
+]
+
 [[package]]
 name = "webpki-roots"
 version = "0.26.7"

Cargo.toml 🔗

@@ -482,7 +482,7 @@ rustc-demangle = "0.1.23"
 rust-embed = { version = "8.4", features = ["include-exclude"] }
 rustc-hash = "2.1.0"
 rustls = { version = "0.23.22" }
-rustls-native-certs = "0.8.0"
+rustls-platform-verifier = "0.5.0"
 schemars = { version = "0.8", features = ["impl_json_schema", "indexmap2"] }
 semver = "1.0"
 serde = { version = "1.0", features = ["derive", "rc"] }

assets/icons/file_icons/file_types.json 🔗

@@ -27,11 +27,13 @@
     "coffee": "coffeescript",
     "conf": "settings",
     "cpp": "cpp",
+    "cs": "csharp",
     "css": "css",
     "csv": "storage",
     "cxx": "cpp",
     "cts": "typescript",
     "ctsx": "react",
+    "cue": "cue",
     "dart": "dart",
     "dat": "storage",
     "db": "storage",
@@ -66,6 +68,7 @@
     "gitattributes": "vcs",
     "gitignore": "vcs",
     "gitkeep": "vcs",
+    "gitlab-ci.yml": "gitlab",
     "gitmodules": "vcs",
     "TAG_EDITMSG": "vcs",
     "MERGE_MSG": "vcs",
@@ -113,6 +116,7 @@
     "lockb": "bun",
     "log": "log",
     "lua": "lua",
+    "luau": "luau",
     "m4a": "audio",
     "m4v": "video",
     "markdown": "markdown",
@@ -188,6 +192,7 @@
     "scss": "sass",
     "sdf": "storage",
     "sh": "terminal",
+    "sol": "solidity",
     "sql": "storage",
     "sqlite": "storage",
     "stylelint.config.cjs": "stylelint",

assets/icons/zed_predict_bg.svg 🔗

@@ -1,6 +1,6 @@
-<svg width="440" height="128" xmlns="http://www.w3.org/2000/svg">
+<svg width="480" height="128" xmlns="http://www.w3.org/2000/svg">
   <defs>
-    <pattern id="tilePattern" width="22" height="22" patternUnits="userSpaceOnUse">
+    <pattern id="tilePattern" width="23" height="23" patternUnits="userSpaceOnUse">
       <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
         <path d="M12 5L14 8L12 11" stroke="black" stroke-width="1.5"/>
         <path d="M10 6.5L11 8L10 9.5" stroke="black" stroke-width="1.5"/>

assets/settings/default.json 🔗

@@ -652,15 +652,15 @@
     // There are 5 possible width values:
     //
     // 1. Small: This value is essentially a fixed width.
-    //    "modal_width": "small"
+    //    "modal_max_width": "small"
     // 2. Medium:
-    //    "modal_width": "medium"
+    //    "modal_max_width": "medium"
     // 3. Large:
-    //    "modal_width": "large"
+    //    "modal_max_width": "large"
     // 4. Extra Large:
-    //    "modal_width": "xlarge"
+    //    "modal_max_width": "xlarge"
     // 5. Fullscreen: This value removes any horizontal padding, as it consumes the whole viewport width.
-    //    "modal_width": "full"
+    //    "modal_max_width": "full"
     //
     // Default: small
     "modal_max_width": "small"

crates/client/Cargo.toml 🔗

@@ -33,8 +33,6 @@ postage.workspace = true
 rand.workspace = true
 release_channel.workspace = true
 rpc = { workspace = true, features = ["gpui"] }
-rustls-native-certs.workspace = true
-rustls.workspace = true
 schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true

crates/client/src/client.rs 🔗

@@ -146,8 +146,6 @@ pub fn init_settings(cx: &mut App) {
 }
 
 pub fn init(client: &Arc<Client>, cx: &mut App) {
-    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
-
     let client = Arc::downgrade(client);
     cx.on_action({
         let client = client.clone();
@@ -1126,24 +1124,11 @@ impl Client {
 
             match url_scheme {
                 Https => {
-                    let client_config = {
-                        let mut root_store = rustls::RootCertStore::empty();
-
-                        let root_certs = rustls_native_certs::load_native_certs();
-                        for error in root_certs.errors {
-                            log::warn!("error loading native certs: {:?}", error);
-                        }
-                        root_store.add_parsable_certificates(root_certs.certs);
-                        rustls::ClientConfig::builder()
-                            .with_root_certificates(root_store)
-                            .with_no_client_auth()
-                    };
-
                     let (stream, _) =
                         async_tungstenite::async_tls::client_async_tls_with_connector(
                             request,
                             stream,
-                            Some(client_config.into()),
+                            Some(http_client::tls_config().into()),
                         )
                         .await?;
                     Ok(Connection::new(

crates/editor/src/editor.rs 🔗

@@ -709,6 +709,7 @@ pub struct Editor {
     /// Used to prevent flickering as the user types while the menu is open
     stale_inline_completion_in_menu: Option<InlineCompletionState>,
     edit_prediction_settings: EditPredictionSettings,
+    edit_prediction_cursor_on_leading_whitespace: bool,
     inline_completions_hidden_for_vim_mode: bool,
     show_inline_completions_override: Option<bool>,
     menu_inline_completions_policy: MenuInlineCompletionsPolicy,
@@ -1423,6 +1424,7 @@ impl Editor {
             show_inline_completions_override: None,
             menu_inline_completions_policy: MenuInlineCompletionsPolicy::ByProvider,
             edit_prediction_settings: EditPredictionSettings::Disabled,
+            edit_prediction_cursor_on_leading_whitespace: false,
             custom_context_menu: None,
             show_git_blame_gutter: false,
             show_git_blame_inline: false,
@@ -1567,8 +1569,12 @@ impl Editor {
         if has_active_edit_prediction {
             key_context.add("copilot_suggestion");
             key_context.add(EDIT_PREDICTION_KEY_CONTEXT);
-
-            if showing_completions || self.edit_prediction_requires_modifier() {
+            if showing_completions
+                || self.edit_prediction_requires_modifier()
+                // Require modifier key when the cursor is on leading whitespace, to allow `tab`
+                // bindings to insert tab characters.
+                || self.edit_prediction_cursor_on_leading_whitespace
+            {
                 key_context.add(EDIT_PREDICTION_REQUIRES_MODIFIER_KEY_CONTEXT);
             }
         }
@@ -4931,23 +4937,6 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let buffer = self.buffer.read(cx);
-        let snapshot = buffer.snapshot(cx);
-        let selection = self.selections.newest_adjusted(cx);
-        let cursor = selection.head();
-        let current_indent = snapshot.indent_size_for_line(MultiBufferRow(cursor.row));
-        let suggested_indents = snapshot.suggested_indents([cursor.row], cx);
-        if let Some(suggested_indent) = suggested_indents.get(&MultiBufferRow(cursor.row)).copied()
-        {
-            if cursor.column < suggested_indent.len
-                && cursor.column <= current_indent.len
-                && current_indent.len <= suggested_indent.len
-            {
-                self.tab(&Default::default(), window, cx);
-                return;
-            }
-        }
-
         if self.show_edit_predictions_in_menu() {
             self.hide_context_menu(window, cx);
         }
@@ -5216,7 +5205,7 @@ impl Editor {
             return;
         };
 
-        if &accept_keystroke.modifiers == modifiers {
+        if &accept_keystroke.modifiers == modifiers && accept_keystroke.modifiers.modified() {
             if matches!(
                 self.edit_prediction_preview,
                 EditPredictionPreview::Inactive
@@ -5298,6 +5287,9 @@ impl Editor {
             return None;
         }
 
+        self.edit_prediction_cursor_on_leading_whitespace =
+            multibuffer.is_line_whitespace_upto(cursor);
+
         let inline_completion = provider.suggest(&buffer, cursor_buffer_position, cx)?;
         let edits = inline_completion
             .edits
@@ -12835,11 +12827,17 @@ impl Editor {
             .and_then(|f| f.as_local())
     }
 
-    fn target_file_abs_path(&self, cx: &mut Context<Self>) -> Option<PathBuf> {
+    pub fn target_file_abs_path(&self, cx: &mut Context<Self>) -> Option<PathBuf> {
         self.active_excerpt(cx).and_then(|(_, buffer, _)| {
-            let project_path = buffer.read(cx).project_path(cx)?;
-            let project = self.project.as_ref()?.read(cx);
-            project.absolute_path(&project_path, cx)
+            let buffer = buffer.read(cx);
+            if let Some(project_path) = buffer.project_path(cx) {
+                let project = self.project.as_ref()?.read(cx);
+                project.absolute_path(&project_path, cx)
+            } else {
+                buffer
+                    .file()
+                    .and_then(|file| file.as_local().map(|file| file.abs_path(cx)))
+            }
         })
     }
 

crates/editor/src/inline_completion_tests.rs 🔗

@@ -1,10 +1,9 @@
 use gpui::{prelude::*, Entity};
 use indoc::indoc;
 use inline_completion::EditPredictionProvider;
-use language::{Language, LanguageConfig};
 use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint};
 use project::Project;
-use std::{num::NonZeroU32, ops::Range, sync::Arc};
+use std::ops::Range;
 use text::{Point, ToOffset};
 
 use crate::{
@@ -124,54 +123,6 @@ async fn test_inline_completion_jump_button(cx: &mut gpui::TestAppContext) {
     "});
 }
 
-#[gpui::test]
-async fn test_indentation(cx: &mut gpui::TestAppContext) {
-    init_test(cx, |settings| {
-        settings.defaults.tab_size = NonZeroU32::new(4)
-    });
-
-    let language = Arc::new(
-        Language::new(
-            LanguageConfig::default(),
-            Some(tree_sitter_rust::LANGUAGE.into()),
-        )
-        .with_indents_query(r#"(_ "(" ")" @end) @indent"#)
-        .unwrap(),
-    );
-
-    let mut cx = EditorTestContext::new(cx).await;
-    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
-    let provider = cx.new(|_| FakeInlineCompletionProvider::default());
-    assign_editor_completion_provider(provider.clone(), &mut cx);
-
-    cx.set_state(indoc! {"
-        const a: A = (
-        ˇ
-        );
-    "});
-
-    propose_edits(
-        &provider,
-        vec![(Point::new(1, 0)..Point::new(1, 0), "    const function()")],
-        &mut cx,
-    );
-    cx.update_editor(|editor, window, cx| editor.update_visible_inline_completion(window, cx));
-
-    assert_editor_active_edit_completion(&mut cx, |_, edits| {
-        assert_eq!(edits.len(), 1);
-        assert_eq!(edits[0].1.as_str(), "    const function()");
-    });
-
-    // When the cursor is before the suggested indentation level, accepting a
-    // completion should just indent.
-    accept_completion(&mut cx);
-    cx.assert_editor_state(indoc! {"
-        const a: A = (
-            ˇ
-        );
-    "});
-}
-
 #[gpui::test]
 async fn test_inline_completion_invalidation_range(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});

crates/http_client/Cargo.toml 🔗

@@ -25,3 +25,5 @@ log.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 url.workspace = true
+rustls.workspace = true
+rustls-platform-verifier.workspace = true

crates/http_client/src/http_client.rs 🔗

@@ -8,14 +8,33 @@ pub use http::{self, Method, Request, Response, StatusCode, Uri};
 
 use futures::future::BoxFuture;
 use http::request::Builder;
+use rustls::ClientConfig;
+use rustls_platform_verifier::ConfigVerifierExt;
 #[cfg(feature = "test-support")]
 use std::fmt;
 use std::{
     any::type_name,
-    sync::{Arc, Mutex},
+    sync::{Arc, Mutex, OnceLock},
 };
 pub use url::Url;
 
+static TLS_CONFIG: OnceLock<rustls::ClientConfig> = OnceLock::new();
+
+pub fn tls_config() -> ClientConfig {
+    TLS_CONFIG
+        .get_or_init(|| {
+            // rustls uses the `aws_lc_rs` provider by default
+            // This only errors if the default provider has already
+            // been installed. We can ignore this `Result`.
+            rustls::crypto::aws_lc_rs::default_provider()
+                .install_default()
+                .ok();
+
+            ClientConfig::with_platform_verifier()
+        })
+        .clone()
+}
+
 #[derive(Default, Debug, Clone, PartialEq, Eq, Hash)]
 pub enum RedirectPolicy {
     #[default]

crates/inline_completion_button/src/inline_completion_button.rs 🔗

@@ -406,7 +406,7 @@ impl InlineCompletionButton {
 
         if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
             menu = menu.toggleable_entry(
-                "This File",
+                "This Buffer",
                 self.editor_show_predictions,
                 IconPosition::Start,
                 Some(Box::new(ToggleEditPrediction)),
@@ -451,33 +451,41 @@ impl InlineCompletionButton {
                 let enabled = data_collection.is_enabled();
                 let is_open_source = data_collection.is_project_open_source();
                 let is_collecting = data_collection.is_enabled();
+                let (icon_name, icon_color) = if is_open_source && is_collecting {
+                    (IconName::Check, Color::Success)
+                } else {
+                    (IconName::Check, Color::Accent)
+                };
 
                 menu = menu.item(
-                    ContextMenuEntry::new("Share Training Data")
+                    ContextMenuEntry::new("Training Data Collection")
                         .toggleable(IconPosition::Start, data_collection.is_enabled())
-                        .icon_color(if is_open_source && is_collecting {
-                            Color::Success
-                        } else {
-                            Color::Accent
-                        })
+                        .icon(icon_name)
+                        .icon_color(icon_color)
                         .documentation_aside(move |cx| {
                             let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
                                 (true, true) => (
-                                    "Project identified as open-source, and you're sharing data.",
+                                    "Project identified as open source, and you're sharing data.",
                                     Color::Default,
                                     IconName::Check,
                                     Color::Success,
                                 ),
                                 (true, false) => (
-                                    "Project identified as open-source, but you're not sharing data.",
+                                    "Project identified as open source, but you're not sharing data.",
+                                    Color::Muted,
+                                    IconName::Close,
                                     Color::Muted,
-                                    IconName::XCircle,
+                                ),
+                                (false, true) => (
+                                    "Project not identified as open source. No data captured.",
+                                    Color::Muted,
+                                    IconName::Close,
                                     Color::Muted,
                                 ),
-                                (false, _) => (
-                                    "Project not identified as open-source. No data captured.",
+                                (false, false) => (
+                                    "Project not identified as open source, and setting turned off.",
                                     Color::Muted,
-                                    IconName::XCircle,
+                                    IconName::Close,
                                     Color::Muted,
                                 ),
                             };
@@ -485,7 +493,7 @@ impl InlineCompletionButton {
                                 .gap_2()
                                 .child(
                                     Label::new(indoc!{
-                                        "Help us improve our open model by sharing data from open source repositories. \
+                                        "Help us improve our open dataset model by sharing data from open source repositories. \
                                         Zed must detect a license file in your repo for this setting to take effect."
                                     })
                                 )
@@ -516,6 +524,16 @@ impl InlineCompletionButton {
                             }
                         })
                 );
+
+                if is_collecting && !is_open_source {
+                    menu = menu.item(
+                        ContextMenuEntry::new("No data captured.")
+                            .disabled(true)
+                            .icon(IconName::Close)
+                            .icon_color(Color::Error)
+                            .icon_size(IconSize::Small),
+                    );
+                }
             }
         }
 
@@ -556,7 +574,7 @@ impl InlineCompletionButton {
             language::EditPredictionsMode::EagerPreview => true,
         };
         menu = menu.separator().toggleable_entry(
-            "Eager Preview",
+            "Eager Preview Mode",
             is_eager_preview_enabled,
             IconPosition::Start,
             None,

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -4236,6 +4236,21 @@ impl MultiBufferSnapshot {
         indent
     }
 
+    pub fn is_line_whitespace_upto<T>(&self, position: T) -> bool
+    where
+        T: ToOffset,
+    {
+        for char in self.reversed_chars_at(position) {
+            if !char.is_whitespace() {
+                return false;
+            }
+            if char == '\n' {
+                return true;
+            }
+        }
+        return true;
+    }
+
     pub fn prev_non_blank_row(&self, mut row: MultiBufferRow) -> Option<MultiBufferRow> {
         while row.0 > 0 {
             row.0 -= 1;

crates/reqwest_client/src/reqwest_client.rs 🔗

@@ -51,7 +51,10 @@ impl ReqwestClient {
         }) {
             client = client.proxy(proxy);
         }
-        let client = client.build()?;
+
+        let client = client
+            .use_preconfigured_tls(http_client::tls_config())
+            .build()?;
         let mut client: ReqwestClient = client.into();
         client.proxy = proxy;
         Ok(client)

crates/settings/src/keymap_file.rs 🔗

@@ -10,7 +10,7 @@ use schemars::{
     schema::{ArrayValidation, InstanceType, Schema, SchemaObject, SubschemaValidation},
     JsonSchema,
 };
-use serde::{Deserialize, Serialize};
+use serde::Deserialize;
 use serde_json::Value;
 use std::{any::TypeId, fmt::Write, rc::Rc, sync::Arc, sync::LazyLock};
 use util::{asset_str, markdown::MarkdownString};
@@ -47,12 +47,12 @@ pub(crate) static KEY_BINDING_VALIDATORS: LazyLock<BTreeMap<TypeId, Box<dyn KeyB
 
 /// Keymap configuration consisting of sections. Each section may have a context predicate which
 /// determines whether its bindings are used.
-#[derive(Debug, Deserialize, Default, Clone, JsonSchema, Serialize)]
+#[derive(Debug, Deserialize, Default, Clone, JsonSchema)]
 #[serde(transparent)]
 pub struct KeymapFile(Vec<KeymapSection>);
 
 /// Keymap section which binds keystrokes to actions.
-#[derive(Debug, Deserialize, Default, Clone, JsonSchema, Serialize)]
+#[derive(Debug, Deserialize, Default, Clone, JsonSchema)]
 pub struct KeymapSection {
     /// Determines when these bindings are active. When just a name is provided, like `Editor` or
     /// `Workspace`, the bindings will be active in that context. Boolean expressions like `X && Y`,
@@ -97,9 +97,9 @@ impl KeymapSection {
 /// Unlike the other json types involved in keymaps (including actions), this doc-comment will not
 /// be included in the generated JSON schema, as it manually defines its `JsonSchema` impl. The
 /// actual schema used for it is automatically generated in `KeymapFile::generate_json_schema`.
-#[derive(Debug, Deserialize, Default, Clone, Serialize)]
+#[derive(Debug, Deserialize, Default, Clone)]
 #[serde(transparent)]
-pub struct KeymapAction(pub(crate) Value);
+pub struct KeymapAction(Value);
 
 impl std::fmt::Display for KeymapAction {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -133,11 +133,9 @@ impl JsonSchema for KeymapAction {
 pub enum KeymapFileLoadResult {
     Success {
         key_bindings: Vec<KeyBinding>,
-        keymap_file: KeymapFile,
     },
     SomeFailedToLoad {
         key_bindings: Vec<KeyBinding>,
-        keymap_file: KeymapFile,
         error_message: MarkdownString,
     },
     JsonParseFailure {
@@ -152,7 +150,7 @@ impl KeymapFile {
 
     pub fn load_asset(asset_path: &str, cx: &App) -> anyhow::Result<Vec<KeyBinding>> {
         match Self::load(asset_str::<SettingsAssets>(asset_path).as_ref(), cx) {
-            KeymapFileLoadResult::Success { key_bindings, .. } => Ok(key_bindings),
+            KeymapFileLoadResult::Success { key_bindings } => Ok(key_bindings),
             KeymapFileLoadResult::SomeFailedToLoad { error_message, .. } => Err(anyhow!(
                 "Error loading built-in keymap \"{asset_path}\": {error_message}"
             )),
@@ -202,7 +200,6 @@ impl KeymapFile {
         if content.is_empty() {
             return KeymapFileLoadResult::Success {
                 key_bindings: Vec::new(),
-                keymap_file: KeymapFile(Vec::new()),
             };
         }
         let keymap_file = match parse_json_with_comments::<Self>(content) {
@@ -296,10 +293,7 @@ impl KeymapFile {
         }
 
         if errors.is_empty() {
-            KeymapFileLoadResult::Success {
-                key_bindings,
-                keymap_file,
-            }
+            KeymapFileLoadResult::Success { key_bindings }
         } else {
             let mut error_message = "Errors in user keymap file.\n".to_owned();
             for (context, section_errors) in errors {
@@ -317,7 +311,6 @@ impl KeymapFile {
             }
             KeymapFileLoadResult::SomeFailedToLoad {
                 key_bindings,
-                keymap_file,
                 error_message: MarkdownString(error_message),
             }
         }
@@ -619,7 +612,7 @@ fn inline_code_string(text: &str) -> MarkdownString {
 
 #[cfg(test)]
 mod tests {
-    use super::KeymapFile;
+    use crate::KeymapFile;
 
     #[test]
     fn can_deserialize_keymap_with_trailing_comma() {

crates/settings/src/settings_file.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{settings_store::SettingsStore, Settings};
 use fs::Fs;
 use futures::{channel::mpsc, StreamExt};
-use gpui::{App, BackgroundExecutor, ReadGlobal, UpdateGlobal};
+use gpui::{App, BackgroundExecutor, ReadGlobal};
 use std::{path::PathBuf, sync::Arc, time::Duration};
 
 pub const EMPTY_THEME_NAME: &str = "empty-theme";
@@ -78,40 +78,6 @@ pub fn watch_config_file(
     rx
 }
 
-pub fn handle_settings_file_changes(
-    mut user_settings_file_rx: mpsc::UnboundedReceiver<String>,
-    cx: &mut App,
-    settings_changed: impl Fn(Result<serde_json::Value, anyhow::Error>, &mut App) + 'static,
-) {
-    let user_settings_content = cx
-        .background_executor()
-        .block(user_settings_file_rx.next())
-        .unwrap();
-    SettingsStore::update_global(cx, |store, cx| {
-        let result = store.set_user_settings(&user_settings_content, cx);
-        if let Err(err) = &result {
-            log::error!("Failed to load user settings: {err}");
-        }
-        settings_changed(result, cx);
-    });
-    cx.spawn(move |cx| async move {
-        while let Some(user_settings_content) = user_settings_file_rx.next().await {
-            let result = cx.update_global(|store: &mut SettingsStore, cx| {
-                let result = store.set_user_settings(&user_settings_content, cx);
-                if let Err(err) = &result {
-                    log::error!("Failed to load user settings: {err}");
-                }
-                settings_changed(result, cx);
-                cx.refresh_windows();
-            });
-            if result.is_err() {
-                break; // App dropped
-            }
-        }
-    })
-    .detach();
-}
-
 pub fn update_settings_file<T: Settings>(
     fs: Arc<dyn Fs>,
     cx: &App,

crates/theme/src/icon_theme.rs 🔗

@@ -66,7 +66,9 @@ const FILE_ICONS: &[(&str, &str)] = &[
     ("code", "icons/file_icons/code.svg"),
     ("coffeescript", "icons/file_icons/coffeescript.svg"),
     ("cpp", "icons/file_icons/cpp.svg"),
+    ("csharp", "icons/file_icons/file.svg"),
     ("css", "icons/file_icons/css.svg"),
+    ("cue", "icons/file_icons/file.svg"),
     ("dart", "icons/file_icons/dart.svg"),
     ("default", "icons/file_icons/file.svg"),
     ("diff", "icons/file_icons/diff.svg"),
@@ -78,6 +80,7 @@ const FILE_ICONS: &[(&str, &str)] = &[
     ("eslint", "icons/file_icons/eslint.svg"),
     ("font", "icons/file_icons/font.svg"),
     ("fsharp", "icons/file_icons/fsharp.svg"),
+    ("gitlab", "icons/file_icons/settings.svg"),
     ("gleam", "icons/file_icons/gleam.svg"),
     ("go", "icons/file_icons/go.svg"),
     ("graphql", "icons/file_icons/graphql.svg"),
@@ -94,6 +97,7 @@ const FILE_ICONS: &[(&str, &str)] = &[
     ("lock", "icons/file_icons/lock.svg"),
     ("log", "icons/file_icons/info.svg"),
     ("lua", "icons/file_icons/lua.svg"),
+    ("luau", "icons/file_icons/file.svg"),
     ("markdown", "icons/file_icons/book.svg"),
     ("metal", "icons/file_icons/metal.svg"),
     ("nim", "icons/file_icons/nim.svg"),
@@ -112,6 +116,7 @@ const FILE_ICONS: &[(&str, &str)] = &[
     ("sass", "icons/file_icons/sass.svg"),
     ("scala", "icons/file_icons/scala.svg"),
     ("settings", "icons/file_icons/settings.svg"),
+    ("solidity", "icons/file_icons/file.svg"),
     ("storage", "icons/file_icons/database.svg"),
     ("stylelint", "icons/file_icons/javascript.svg"),
     ("svelte", "icons/file_icons/html.svg"),

crates/ui/src/components/context_menu.rs 🔗

@@ -598,6 +598,7 @@ impl Render for ContextMenu {
                                         }) => {
                                             let handler = handler.clone();
                                             let menu = cx.entity().downgrade();
+
                                             let icon_color = if *disabled {
                                                 Color::Muted
                                             } else if toggle.is_some() {
@@ -605,16 +606,18 @@ impl Render for ContextMenu {
                                             } else {
                                                 icon_color.unwrap_or(Color::Default)
                                             };
+
                                             let label_color = if *disabled {
                                                 Color::Muted
                                             } else {
                                                 Color::Default
                                             };
+
                                             let label_element = if let Some(icon_name) = icon {
                                                 h_flex()
                                                     .gap_1p5()
                                                     .when(
-                                                        *icon_position == IconPosition::Start,
+                                                        *icon_position == IconPosition::Start && toggle.is_none(),
                                                         |flex| {
                                                             flex.child(
                                                                 Icon::new(*icon_name)
@@ -643,8 +646,10 @@ impl Render for ContextMenu {
                                                     .color(label_color)
                                                     .into_any_element()
                                             };
+
                                             let documentation_aside_callback =
                                                 documentation_aside.clone();
+
                                             div()
                                                 .id(("context-menu-child", ix))
                                                 .when_some(
@@ -675,7 +680,7 @@ impl Render for ContextMenu {
                                                             |list_item, (position, toggled)| {
                                                                 let contents =
                                                                     div().flex_none().child(
-                                                                        Icon::new(IconName::Check)
+                                                                        Icon::new(icon.unwrap_or(IconName::Check))
                                                                             .color(icon_color)
                                                                             .size(*icon_size)
                                                                     )
@@ -778,7 +783,7 @@ impl Render for ContextMenu {
                                         }
                                     }
                                 },
-                            ))),
+                            )))
                     ),
             )
     }

crates/zed/src/main.rs 🔗

@@ -34,7 +34,7 @@ use project::project_settings::ProjectSettings;
 use recent_projects::{open_ssh_project, SshSettings};
 use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
 use session::{AppSession, Session};
-use settings::{handle_settings_file_changes, watch_config_file, Settings, SettingsStore};
+use settings::{watch_config_file, Settings, SettingsStore};
 use simplelog::ConfigBuilder;
 use std::{
     env,
@@ -52,8 +52,9 @@ use welcome::{show_welcome_view, BaseKeymap, FIRST_OPEN};
 use workspace::{AppState, SerializedWorkspaceLocation, WorkspaceSettings, WorkspaceStore};
 use zed::{
     app_menus, build_window_options, derive_paths_with_position, handle_cli_connection,
-    handle_keymap_file_changes, handle_settings_changed, initialize_workspace,
-    inline_completion_registry, open_paths_with_positions, OpenListener, OpenRequest,
+    handle_keymap_file_changes, handle_settings_changed, handle_settings_file_changes,
+    initialize_workspace, inline_completion_registry, open_paths_with_positions, OpenListener,
+    OpenRequest,
 };
 
 #[cfg(unix)]

crates/zed/src/zed.rs 🔗

@@ -21,14 +21,16 @@ use command_palette_hooks::CommandPaletteFilter;
 use editor::ProposedChangesEditorToolbar;
 use editor::{scroll::Autoscroll, Editor, MultiBuffer};
 use feature_flags::{FeatureFlagAppExt, FeatureFlagViewExt, GitUiFeatureFlag};
-use fs::Fs;
 use futures::{channel::mpsc, select_biased, StreamExt};
 use gpui::{
     actions, point, px, Action, App, AppContext as _, AsyncApp, Context, DismissEvent, Element,
     Entity, Focusable, KeyBinding, MenuItem, ParentElement, PathPromptOptions, PromptLevel,
-    ReadGlobal, SharedString, Styled, Task, TitlebarOptions, Window, WindowKind, WindowOptions,
+    ReadGlobal, SharedString, Styled, Task, TitlebarOptions, UpdateGlobal, Window, WindowKind,
+    WindowOptions,
 };
 use image_viewer::ImageInfo;
+use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType};
+use migrator::{migrate_keymap, migrate_settings};
 pub use open_listener::*;
 use outline_panel::OutlinePanel;
 use paths::{local_settings_file_relative_path, local_tasks_file_relative_path};
@@ -150,6 +152,7 @@ pub fn initialize_workspace(
         let workspace_handle = cx.entity().clone();
         let center_pane = workspace.active_pane().clone();
         initialize_pane(workspace, &center_pane, window, cx);
+
         cx.subscribe_in(&workspace_handle, window, {
             move |workspace, _, event, window, cx| match event {
                 workspace::Event::PaneAdded(pane) => {
@@ -855,7 +858,6 @@ fn initialize_pane(
             toolbar.add_item(breadcrumbs, window, cx);
             let buffer_search_bar = cx.new(|cx| search::BufferSearchBar::new(window, cx));
             toolbar.add_item(buffer_search_bar.clone(), window, cx);
-
             let proposed_change_bar = cx.new(|_| ProposedChangesEditorToolbar::new());
             toolbar.add_item(proposed_change_bar, window, cx);
             let quick_action_bar =
@@ -869,6 +871,8 @@ fn initialize_pane(
             toolbar.add_item(lsp_log_item, window, cx);
             let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new());
             toolbar.add_item(syntax_tree_item, window, cx);
+            let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx));
+            toolbar.add_item(migration_banner, window, cx);
         })
     });
 }
@@ -1097,6 +1101,68 @@ fn open_log_file(workspace: &mut Workspace, window: &mut Window, cx: &mut Contex
         .detach();
 }
 
+pub fn handle_settings_file_changes(
+    mut user_settings_file_rx: mpsc::UnboundedReceiver<String>,
+    cx: &mut App,
+    settings_changed: impl Fn(Option<anyhow::Error>, &mut App) + 'static,
+) {
+    MigrationNotification::set_global(cx.new(|_| MigrationNotification), cx);
+    let content = cx
+        .background_executor()
+        .block(user_settings_file_rx.next())
+        .unwrap();
+    let user_settings_content = if let Ok(Some(migrated_content)) = migrate_settings(&content) {
+        migrated_content
+    } else {
+        content
+    };
+    SettingsStore::update_global(cx, |store, cx| {
+        let result = store.set_user_settings(&user_settings_content, cx);
+        if let Err(err) = &result {
+            log::error!("Failed to load user settings: {err}");
+        }
+        settings_changed(result.err(), cx);
+    });
+    cx.spawn(move |cx| async move {
+        while let Some(content) = user_settings_file_rx.next().await {
+            let user_settings_content;
+            let content_migrated;
+
+            if let Ok(Some(migrated_content)) = migrate_settings(&content) {
+                user_settings_content = migrated_content;
+                content_migrated = true;
+            } else {
+                user_settings_content = content;
+                content_migrated = false;
+            }
+
+            cx.update(|cx| {
+                if let Some(notifier) = MigrationNotification::try_global(cx) {
+                    notifier.update(cx, |_, cx| {
+                        cx.emit(MigrationEvent::ContentChanged {
+                            migration_type: MigrationType::Settings,
+                            migrated: content_migrated,
+                        });
+                    });
+                }
+            })
+            .ok();
+            let result = cx.update_global(|store: &mut SettingsStore, cx| {
+                let result = store.set_user_settings(&user_settings_content, cx);
+                if let Err(err) = &result {
+                    log::error!("Failed to load user settings: {err}");
+                }
+                settings_changed(result.err(), cx);
+                cx.refresh_windows();
+            });
+            if result.is_err() {
+                break; // App dropped
+            }
+        }
+    })
+    .detach();
+}
+
 pub fn handle_keymap_file_changes(
     mut user_keymap_file_rx: mpsc::UnboundedReceiver<String>,
     cx: &mut App,
@@ -1137,47 +1203,46 @@ pub fn handle_keymap_file_changes(
 
     cx.spawn(move |cx| async move {
         let mut user_keymap_content = String::new();
+        let mut content_migrated = false;
         loop {
             select_biased! {
                 _ = base_keymap_rx.next() => {},
                 _ = keyboard_layout_rx.next() => {},
                 content = user_keymap_file_rx.next() => {
                     if let Some(content) = content {
-                        user_keymap_content = content;
+                        if let Ok(Some(migrated_content)) = migrate_keymap(&content) {
+                            user_keymap_content = migrated_content;
+                            content_migrated = true;
+                        } else {
+                            user_keymap_content = content;
+                            content_migrated = false;
+                        }
                     }
                 }
             };
             cx.update(|cx| {
+                if let Some(notifier) = MigrationNotification::try_global(cx) {
+                    notifier.update(cx, |_, cx| {
+                        cx.emit(MigrationEvent::ContentChanged {
+                            migration_type: MigrationType::Keymap,
+                            migrated: content_migrated,
+                        });
+                    });
+                }
                 let load_result = KeymapFile::load(&user_keymap_content, cx);
                 match load_result {
-                    KeymapFileLoadResult::Success {
-                        key_bindings,
-                        keymap_file,
-                    } => {
+                    KeymapFileLoadResult::Success { key_bindings } => {
                         reload_keymaps(cx, key_bindings);
-                        dismiss_app_notification(&notification_id, cx);
-                        show_keymap_migration_notification_if_needed(
-                            keymap_file,
-                            notification_id.clone(),
-                            cx,
-                        );
+                        dismiss_app_notification(&notification_id.clone(), cx);
                     }
                     KeymapFileLoadResult::SomeFailedToLoad {
                         key_bindings,
-                        keymap_file,
                         error_message,
                     } => {
                         if !key_bindings.is_empty() {
                             reload_keymaps(cx, key_bindings);
                         }
-                        dismiss_app_notification(&notification_id, cx);
-                        if !show_keymap_migration_notification_if_needed(
-                            keymap_file,
-                            notification_id.clone(),
-                            cx,
-                        ) {
-                            show_keymap_file_load_error(notification_id.clone(), error_message, cx);
-                        }
+                        show_keymap_file_load_error(notification_id.clone(), error_message, cx);
                     }
                     KeymapFileLoadResult::JsonParseFailure { error } => {
                         show_keymap_file_json_error(notification_id.clone(), &error, cx)
@@ -1209,66 +1274,6 @@ fn show_keymap_file_json_error(
     });
 }
 
-fn show_keymap_migration_notification_if_needed(
-    keymap_file: KeymapFile,
-    notification_id: NotificationId,
-    cx: &mut App,
-) -> bool {
-    if !migrate::should_migrate_keymap(keymap_file) {
-        return false;
-    }
-    let message = MarkdownString(format!(
-        "Keymap migration needed, as the format for some actions has changed. \
-        You can migrate your keymap by clicking below. A backup will be created at {}.",
-        MarkdownString::inline_code(&paths::keymap_backup_file().to_string_lossy())
-    ));
-    show_markdown_app_notification(
-        notification_id,
-        message,
-        "Backup and Migrate Keymap".into(),
-        move |_, cx| {
-            let fs = <dyn Fs>::global(cx);
-            cx.spawn(move |weak_notification, mut cx| async move {
-                migrate::migrate_keymap(fs).await.ok();
-                weak_notification
-                    .update(&mut cx, |_, cx| {
-                        cx.emit(DismissEvent);
-                    })
-                    .ok();
-            })
-            .detach();
-        },
-        cx,
-    );
-    return true;
-}
-
-fn show_settings_migration_notification_if_needed(
-    notification_id: NotificationId,
-    settings: serde_json::Value,
-    cx: &mut App,
-) {
-    if !migrate::should_migrate_settings(&settings) {
-        return;
-    }
-    let message = MarkdownString(format!(
-        "Settings migration needed, as the format for some settings has changed. \
-            You can migrate your settings by clicking below. A backup will be created at {}.",
-        MarkdownString::inline_code(&paths::settings_backup_file().to_string_lossy())
-    ));
-    show_markdown_app_notification(
-        notification_id,
-        message,
-        "Backup and Migrate Settings".into(),
-        move |_, cx| {
-            let fs = <dyn Fs>::global(cx);
-            migrate::migrate_settings(fs, cx);
-            cx.emit(DismissEvent);
-        },
-        cx,
-    );
-}
-
 fn show_keymap_file_load_error(
     notification_id: NotificationId,
     error_message: MarkdownString,
@@ -1363,12 +1368,12 @@ pub fn load_default_keymap(cx: &mut App) {
     }
 }
 
-pub fn handle_settings_changed(result: Result<serde_json::Value, anyhow::Error>, cx: &mut App) {
+pub fn handle_settings_changed(error: Option<anyhow::Error>, cx: &mut App) {
     struct SettingsParseErrorNotification;
     let id = NotificationId::unique::<SettingsParseErrorNotification>();
 
-    match result {
-        Err(error) => {
+    match error {
+        Some(error) => {
             if let Some(InvalidSettingsError::LocalSettings { .. }) =
                 error.downcast_ref::<InvalidSettingsError>()
             {
@@ -1387,9 +1392,8 @@ pub fn handle_settings_changed(result: Result<serde_json::Value, anyhow::Error>,
                 })
             });
         }
-        Ok(settings) => {
+        None => {
             dismiss_app_notification(&id, cx);
-            show_settings_migration_notification_if_needed(id, settings, cx);
         }
     }
 }
@@ -1672,7 +1676,7 @@ mod tests {
     use language::{LanguageMatcher, LanguageRegistry};
     use project::{project_settings::ProjectSettings, Project, ProjectPath, WorktreeSettings};
     use serde_json::json;
-    use settings::{handle_settings_file_changes, watch_config_file, SettingsStore};
+    use settings::{watch_config_file, SettingsStore};
     use std::{
         path::{Path, PathBuf},
         time::Duration,

crates/zed/src/zed/migrate.rs 🔗

@@ -1,63 +1,256 @@
-use std::sync::Arc;
-
-use anyhow::Context;
+use anyhow::{Context as _, Result};
+use editor::Editor;
 use fs::Fs;
+use migrator::{migrate_keymap, migrate_settings};
 use settings::{KeymapFile, SettingsStore};
+use util::ResultExt;
 
-pub fn should_migrate_settings(settings: &serde_json::Value) -> bool {
-    let Ok(old_text) = serde_json::to_string(settings) else {
-        return false;
-    };
-    migrator::migrate_settings(&old_text)
-        .ok()
-        .flatten()
-        .is_some()
+use std::sync::Arc;
+
+use gpui::{Entity, EventEmitter, Global};
+use ui::prelude::*;
+use workspace::item::ItemHandle;
+use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace};
+
+#[derive(Debug, Copy, Clone, PartialEq)]
+pub enum MigrationType {
+    Keymap,
+    Settings,
+}
+
+pub struct MigrationBanner {
+    migration_type: Option<MigrationType>,
+}
+
+pub enum MigrationEvent {
+    ContentChanged {
+        migration_type: MigrationType,
+        migrated: bool,
+    },
+}
+
+pub struct MigrationNotification;
+
+impl EventEmitter<MigrationEvent> for MigrationNotification {}
+
+impl MigrationNotification {
+    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
+        cx.try_global::<GlobalMigrationNotification>()
+            .map(|notifier| notifier.0.clone())
+    }
+
+    pub fn set_global(notifier: Entity<Self>, cx: &mut App) {
+        cx.set_global(GlobalMigrationNotification(notifier));
+    }
 }
 
-pub fn migrate_settings(fs: Arc<dyn Fs>, cx: &mut gpui::App) {
-    cx.background_executor()
-        .spawn(async move {
-            let old_text = SettingsStore::load_settings(&fs).await?;
-            let Some(new_text) = migrator::migrate_settings(&old_text)? else {
-                return anyhow::Ok(());
-            };
-            let settings_path = paths::settings_file().as_path();
-            if fs.is_file(settings_path).await {
-                fs.atomic_write(paths::settings_backup_file().to_path_buf(), old_text)
-                    .await
-                    .with_context(|| {
-                        "Failed to create settings backup in home directory".to_string()
-                    })?;
-                let resolved_path = fs.canonicalize(settings_path).await.with_context(|| {
-                    format!("Failed to canonicalize settings path {:?}", settings_path)
-                })?;
-                fs.atomic_write(resolved_path.clone(), new_text)
-                    .await
-                    .with_context(|| {
-                        format!("Failed to write settings to file {:?}", resolved_path)
-                    })?;
-            } else {
-                fs.atomic_write(settings_path.to_path_buf(), new_text)
-                    .await
-                    .with_context(|| {
-                        format!("Failed to write settings to file {:?}", settings_path)
-                    })?;
+struct GlobalMigrationNotification(Entity<MigrationNotification>);
+
+impl Global for GlobalMigrationNotification {}
+
+impl MigrationBanner {
+    pub fn new(_: &Workspace, cx: &mut Context<'_, Self>) -> Self {
+        if let Some(notifier) = MigrationNotification::try_global(cx) {
+            cx.subscribe(
+                &notifier,
+                move |migrator_banner, _, event: &MigrationEvent, cx| {
+                    migrator_banner.handle_notification(event, cx);
+                },
+            )
+            .detach();
+        }
+        Self {
+            migration_type: None,
+        }
+    }
+
+    fn backup_file_name(&self) -> String {
+        match self.migration_type {
+            Some(MigrationType::Keymap) => paths::keymap_backup_file()
+                .file_name()
+                .unwrap_or_default()
+                .to_string_lossy()
+                .into_owned(),
+            Some(MigrationType::Settings) => paths::settings_backup_file()
+                .file_name()
+                .unwrap_or_default()
+                .to_string_lossy()
+                .into_owned(),
+            None => String::new(),
+        }
+    }
+
+    fn handle_notification(&mut self, event: &MigrationEvent, cx: &mut Context<'_, Self>) {
+        match event {
+            MigrationEvent::ContentChanged {
+                migration_type,
+                migrated,
+            } => {
+                if self.migration_type == Some(*migration_type) {
+                    let location = if *migrated {
+                        ToolbarItemLocation::Secondary
+                    } else {
+                        ToolbarItemLocation::Hidden
+                    };
+                    cx.emit(ToolbarItemEvent::ChangeLocation(location));
+                    cx.notify();
+                }
             }
-            Ok(())
-        })
-        .detach_and_log_err(cx);
+        }
+    }
 }
 
-pub fn should_migrate_keymap(keymap_file: KeymapFile) -> bool {
-    let Ok(old_text) = serde_json::to_string(&keymap_file) else {
-        return false;
-    };
-    migrator::migrate_keymap(&old_text).ok().flatten().is_some()
+impl EventEmitter<ToolbarItemEvent> for MigrationBanner {}
+
+impl ToolbarItemView for MigrationBanner {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> ToolbarItemLocation {
+        cx.notify();
+        let Some(target) = active_pane_item
+            .and_then(|item| item.act_as::<Editor>(cx))
+            .and_then(|editor| editor.update(cx, |editor, cx| editor.target_file_abs_path(cx)))
+        else {
+            return ToolbarItemLocation::Hidden;
+        };
+
+        if &target == paths::keymap_file() {
+            self.migration_type = Some(MigrationType::Keymap);
+            let fs = <dyn Fs>::global(cx);
+            let should_migrate = should_migrate_keymap(fs);
+            cx.spawn_in(window, |this, mut cx| async move {
+                if let Ok(true) = should_migrate.await {
+                    this.update(&mut cx, |_, cx| {
+                        cx.emit(ToolbarItemEvent::ChangeLocation(
+                            ToolbarItemLocation::Secondary,
+                        ));
+                        cx.notify();
+                    })
+                    .log_err();
+                }
+            })
+            .detach();
+        } else if &target == paths::settings_file() {
+            self.migration_type = Some(MigrationType::Settings);
+            let fs = <dyn Fs>::global(cx);
+            let should_migrate = should_migrate_settings(fs);
+            cx.spawn_in(window, |this, mut cx| async move {
+                if let Ok(true) = should_migrate.await {
+                    this.update(&mut cx, |_, cx| {
+                        cx.emit(ToolbarItemEvent::ChangeLocation(
+                            ToolbarItemLocation::Secondary,
+                        ));
+                        cx.notify();
+                    })
+                    .log_err();
+                }
+            })
+            .detach();
+        }
+
+        return ToolbarItemLocation::Hidden;
+    }
+}
+
+impl Render for MigrationBanner {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let migration_type = self.migration_type;
+        let file_type = match migration_type {
+            Some(MigrationType::Keymap) => "keymap",
+            Some(MigrationType::Settings) => "settings",
+            None => "",
+        };
+        let backup_file_name = self.backup_file_name();
+
+        h_flex()
+            .py_1()
+            .pl_2()
+            .pr_1()
+            .flex_wrap()
+            .justify_between()
+            .bg(cx.theme().status().info_background.opacity(0.6))
+            .border_1()
+            .border_color(cx.theme().colors().border_variant)
+            .rounded_md()
+            .overflow_hidden()
+            .child(
+                h_flex()
+                    .gap_2()
+                    .child(
+                        Icon::new(IconName::Warning)
+                            .size(IconSize::XSmall)
+                            .color(Color::Warning),
+                    )
+                    .child(
+                        h_flex()
+                            .gap_0p5()
+                            .child(
+                                Label::new(format!(
+                                    "Your {} file uses deprecated settings which can be \
+                                    automatically updated. A backup will be saved to",
+                                    file_type
+                                ))
+                                .color(Color::Default),
+                            )
+                            .child(
+                                div()
+                                    .px_1()
+                                    .bg(cx.theme().colors().background)
+                                    .rounded_sm()
+                                    .child(
+                                        Label::new(backup_file_name)
+                                            .buffer_font(cx)
+                                            .size(LabelSize::Small),
+                                    ),
+                            ),
+                    ),
+            )
+            .child(
+                Button::new("backup-and-migrate", "Backup and Update").on_click(move |_, _, cx| {
+                    let fs = <dyn Fs>::global(cx);
+                    match migration_type {
+                        Some(MigrationType::Keymap) => {
+                            cx.spawn(
+                                move |_| async move { write_keymap_migration(&fs).await.ok() },
+                            )
+                            .detach();
+                        }
+                        Some(MigrationType::Settings) => {
+                            cx.spawn(
+                                move |_| async move { write_settings_migration(&fs).await.ok() },
+                            )
+                            .detach();
+                        }
+                        None => unreachable!(),
+                    }
+                }),
+            )
+            .into_any_element()
+    }
 }
 
-pub async fn migrate_keymap(fs: Arc<dyn Fs>) -> anyhow::Result<()> {
+async fn should_migrate_keymap(fs: Arc<dyn Fs>) -> Result<bool> {
     let old_text = KeymapFile::load_keymap_file(&fs).await?;
-    let Some(new_text) = migrator::migrate_keymap(&old_text)? else {
+    if let Ok(Some(_)) = migrate_keymap(&old_text) {
+        return Ok(true);
+    };
+    Ok(false)
+}
+
+async fn should_migrate_settings(fs: Arc<dyn Fs>) -> Result<bool> {
+    let old_text = SettingsStore::load_settings(&fs).await?;
+    if let Ok(Some(_)) = migrate_settings(&old_text) {
+        return Ok(true);
+    };
+    Ok(false)
+}
+
+async fn write_keymap_migration(fs: &Arc<dyn Fs>) -> Result<()> {
+    let old_text = KeymapFile::load_keymap_file(fs).await?;
+    let Ok(Some(new_text)) = migrate_keymap(&old_text) else {
         return Ok(());
     };
     let keymap_path = paths::keymap_file().as_path();
@@ -77,6 +270,30 @@ pub async fn migrate_keymap(fs: Arc<dyn Fs>) -> anyhow::Result<()> {
             .await
             .with_context(|| format!("Failed to write keymap to file {:?}", keymap_path))?;
     }
+    Ok(())
+}
 
+async fn write_settings_migration(fs: &Arc<dyn Fs>) -> Result<()> {
+    let old_text = SettingsStore::load_settings(fs).await?;
+    let Ok(Some(new_text)) = migrate_settings(&old_text) else {
+        return Ok(());
+    };
+    let settings_path = paths::settings_file().as_path();
+    if fs.is_file(settings_path).await {
+        fs.atomic_write(paths::settings_backup_file().to_path_buf(), old_text)
+            .await
+            .with_context(|| "Failed to create settings backup in home directory".to_string())?;
+        let resolved_path = fs
+            .canonicalize(settings_path)
+            .await
+            .with_context(|| format!("Failed to canonicalize settings path {:?}", settings_path))?;
+        fs.atomic_write(resolved_path.clone(), new_text)
+            .await
+            .with_context(|| format!("Failed to write settings to file {:?}", resolved_path))?;
+    } else {
+        fs.atomic_write(settings_path.to_path_buf(), new_text)
+            .await
+            .with_context(|| format!("Failed to write settings to file {:?}", settings_path))?;
+    }
     Ok(())
 }

crates/zeta/src/onboarding_modal.rs 🔗

@@ -168,7 +168,7 @@ impl Render for ZedPredictModal {
             .id("edit-prediction-onboarding")
             .key_context("ZedPredictModal")
             .relative()
-            .w(px(440.))
+            .w(px(480.))
             .h_full()
             .max_h(max_height)
             .p_4()
@@ -201,7 +201,7 @@ impl Render for ZedPredictModal {
                         svg()
                             .path("icons/zed_predict_bg.svg")
                             .text_color(cx.theme().colors().icon_disabled)
-                            .w(px(418.))
+                            .w(px(460.))
                             .h(px(128.))
                             .overflow_hidden(),
                     ),
@@ -354,7 +354,7 @@ impl Render for ZedPredictModal {
                                         "training-data-checkbox",
                                         self.data_collection_opted_in.into(),
                                     )
-                                    .label("Optionally share training data (OSS-only).")
+                                    .label("Open source repos: optionally share training data.")
                                     .fill()
                                     .on_click(cx.listener(
                                         move |this, state, _window, cx| {
@@ -391,26 +391,27 @@ impl Render for ZedPredictModal {
                                     .border_color(cx.theme().colors().border_variant)
                                     .child(
                                         div().child(
-                                            Label::new("To improve edit predictions, help fine-tune Zed's model by sharing data from the open-source projects you work on.")
+                                            Label::new("To improve edit predictions, please consider contributing to our open dataset based on your interactions within open source repositories.")
                                                 .mb_1()
                                         )
                                     )
                                     .child(info_item(
-                                        "We ask this exclusively for open-source projects.",
+                                        "We ask this exclusively for open source projects.",
                                     ))
                                     .child(info_item(
-                                        "Zed automatically detects if your project is open-source.",
-                                    ))
-                                    .child(info_item(
-                                        "This setting is valid for all OSS projects you open in Zed.",
+                                        "Zed automatically detects if your project is open source.",
                                     ))
                                     .child(info_item("Toggle it anytime via the status bar menu."))
                                     .child(multiline_info_item(
-                                        "Files with sensitive data, like `.env`, are excluded",
+                                        "If turned on, this setting is valid for all open source projects",
+                                        label_item("you open in Zed.")
+                                    ))
+                                    .child(multiline_info_item(
+                                        "Files with sensitive data, like `.env`, are excluded by default",
                                         h_flex()
                                             .w_full()
                                             .flex_wrap()
-                                            .child(label_item("by default via the"))
+                                            .child(label_item("via the"))
                                             .child(
                                                 Button::new("doc-link", "disabled_globs").on_click(
                                                     cx.listener(Self::inline_completions_doc),

docs/src/configuring-zed.md 🔗

@@ -1574,7 +1574,7 @@ Or to set a `socks5` proxy:
 ### Modal Max Width
 
 - Description: Max-width of the file finder modal. It can take one of these values: `small`, `medium`, `large`, `xlarge`, and `full`.
-- Setting: `max_modal_width`
+- Setting: `modal_max_width`
 - Default: `small`
 
 ## Preferred Line Length