onboarding: Show indication that settings have already been imported (#35615)

Ben Kunkle created

Co-Authored-By: Danilo <danilo@zed.dev>
Co-Authored-By: Anthony <anthony@zed.dev>

Release Notes:

- N/A

Change summary

Cargo.lock                            |   1 
crates/onboarding/Cargo.toml          |   1 
crates/onboarding/src/editing_page.rs | 128 +++++++++++++++-------------
crates/onboarding/src/onboarding.rs   |  79 ++++++++++++++++-
crates/settings/src/settings_store.rs |  73 +++++++++++-----
5 files changed, 192 insertions(+), 90 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -10929,6 +10929,7 @@ dependencies = [
  "language",
  "language_model",
  "menu",
+ "notifications",
  "project",
  "schemars",
  "serde",

crates/onboarding/Cargo.toml 🔗

@@ -30,6 +30,7 @@ itertools.workspace = true
 language.workspace = true
 language_model.workspace = true
 menu.workspace = true
+notifications.workspace = true
 project.workspace = true
 schemars.workspace = true
 serde.workspace = true

crates/onboarding/src/editing_page.rs 🔗

@@ -12,7 +12,7 @@ use ui::{
     ToggleButtonGroupStyle, ToggleButtonSimple, ToggleState, Tooltip, prelude::*,
 };
 
-use crate::{ImportCursorSettings, ImportVsCodeSettings};
+use crate::{ImportCursorSettings, ImportVsCodeSettings, SettingsImportState};
 
 fn read_show_mini_map(cx: &App) -> ShowMinimap {
     editor::EditorSettings::get_global(cx).minimap.show
@@ -165,7 +165,71 @@ fn write_format_on_save(format_on_save: bool, cx: &mut App) {
     });
 }
 
-fn render_import_settings_section() -> impl IntoElement {
+fn render_setting_import_button(
+    label: SharedString,
+    icon_name: IconName,
+    action: &dyn Action,
+    imported: bool,
+) -> impl IntoElement {
+    let action = action.boxed_clone();
+    h_flex().w_full().child(
+        ButtonLike::new(label.clone())
+            .full_width()
+            .style(ButtonStyle::Outlined)
+            .size(ButtonSize::Large)
+            .child(
+                h_flex()
+                    .w_full()
+                    .justify_between()
+                    .child(
+                        h_flex()
+                            .gap_1p5()
+                            .px_1()
+                            .child(
+                                Icon::new(icon_name)
+                                    .color(Color::Muted)
+                                    .size(IconSize::XSmall),
+                            )
+                            .child(Label::new(label)),
+                    )
+                    .when(imported, |this| {
+                        this.child(
+                            h_flex()
+                                .gap_1p5()
+                                .child(
+                                    Icon::new(IconName::Check)
+                                        .color(Color::Success)
+                                        .size(IconSize::XSmall),
+                                )
+                                .child(Label::new("Imported").size(LabelSize::Small)),
+                        )
+                    }),
+            )
+            .on_click(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
+    )
+}
+
+fn render_import_settings_section(cx: &App) -> impl IntoElement {
+    let import_state = SettingsImportState::global(cx);
+    let imports: [(SharedString, IconName, &dyn Action, bool); 2] = [
+        (
+            "VS Code".into(),
+            IconName::EditorVsCode,
+            &ImportVsCodeSettings { skip_prompt: false },
+            import_state.vscode,
+        ),
+        (
+            "Cursor".into(),
+            IconName::EditorCursor,
+            &ImportCursorSettings { skip_prompt: false },
+            import_state.cursor,
+        ),
+    ];
+
+    let [vscode, cursor] = imports.map(|(label, icon_name, action, imported)| {
+        render_setting_import_button(label, icon_name, action, imported)
+    });
+
     v_flex()
         .gap_4()
         .child(
@@ -176,63 +240,7 @@ fn render_import_settings_section() -> impl IntoElement {
                         .color(Color::Muted),
                 ),
         )
-        .child(
-            h_flex()
-                .w_full()
-                .gap_4()
-                .child(
-                    h_flex().w_full().child(
-                        ButtonLike::new("import_vs_code")
-                            .full_width()
-                            .style(ButtonStyle::Outlined)
-                            .size(ButtonSize::Large)
-                            .child(
-                                h_flex()
-                                    .w_full()
-                                    .gap_1p5()
-                                    .px_1()
-                                    .child(
-                                        Icon::new(IconName::EditorVsCode)
-                                            .color(Color::Muted)
-                                            .size(IconSize::XSmall),
-                                    )
-                                    .child(Label::new("VS Code")),
-                            )
-                            .on_click(|_, window, cx| {
-                                window.dispatch_action(
-                                    ImportVsCodeSettings::default().boxed_clone(),
-                                    cx,
-                                )
-                            }),
-                    ),
-                )
-                .child(
-                    h_flex().w_full().child(
-                        ButtonLike::new("import_cursor")
-                            .full_width()
-                            .style(ButtonStyle::Outlined)
-                            .size(ButtonSize::Large)
-                            .child(
-                                h_flex()
-                                    .w_full()
-                                    .gap_1p5()
-                                    .px_1()
-                                    .child(
-                                        Icon::new(IconName::EditorCursor)
-                                            .color(Color::Muted)
-                                            .size(IconSize::XSmall),
-                                    )
-                                    .child(Label::new("Cursor")),
-                            )
-                            .on_click(|_, window, cx| {
-                                window.dispatch_action(
-                                    ImportCursorSettings::default().boxed_clone(),
-                                    cx,
-                                )
-                            }),
-                    ),
-                ),
-        )
+        .child(h_flex().w_full().gap_4().child(vscode).child(cursor))
 }
 
 fn render_font_customization_section(window: &mut Window, cx: &mut App) -> impl IntoElement {
@@ -457,6 +465,6 @@ fn render_popular_settings_section(window: &mut Window, cx: &mut App) -> impl In
 pub(crate) fn render_editing_page(window: &mut Window, cx: &mut App) -> impl IntoElement {
     v_flex()
         .gap_4()
-        .child(render_import_settings_section())
+        .child(render_import_settings_section(cx))
         .child(render_popular_settings_section(window, cx))
 }

crates/onboarding/src/onboarding.rs 🔗

@@ -6,9 +6,10 @@ use feature_flags::{FeatureFlag, FeatureFlagViewExt as _};
 use fs::Fs;
 use gpui::{
     Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter,
-    FocusHandle, Focusable, IntoElement, KeyContext, Render, SharedString, Subscription, Task,
-    WeakEntity, Window, actions,
+    FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, SharedString, Subscription,
+    Task, WeakEntity, Window, actions,
 };
+use notifications::status_toast::{StatusToast, ToastIcon};
 use schemars::JsonSchema;
 use serde::Deserialize;
 use settings::{SettingsStore, VsCodeSettingsSource};
@@ -137,9 +138,12 @@ pub fn init(cx: &mut App) {
             let fs = <dyn Fs>::global(cx);
             let action = *action;
 
+            let workspace = cx.weak_entity();
+
             window
                 .spawn(cx, async move |cx: &mut AsyncWindowContext| {
                     handle_import_vscode_settings(
+                        workspace,
                         VsCodeSettingsSource::VsCode,
                         action.skip_prompt,
                         fs,
@@ -154,9 +158,12 @@ pub fn init(cx: &mut App) {
             let fs = <dyn Fs>::global(cx);
             let action = *action;
 
+            let workspace = cx.weak_entity();
+
             window
                 .spawn(cx, async move |cx: &mut AsyncWindowContext| {
                     handle_import_vscode_settings(
+                        workspace,
                         VsCodeSettingsSource::Cursor,
                         action.skip_prompt,
                         fs,
@@ -555,6 +562,7 @@ impl Item for Onboarding {
 }
 
 pub async fn handle_import_vscode_settings(
+    workspace: WeakEntity<Workspace>,
     source: VsCodeSettingsSource,
     skip_prompt: bool,
     fs: Arc<dyn Fs>,
@@ -595,14 +603,73 @@ pub async fn handle_import_vscode_settings(
         }
     };
 
-    cx.update(|_, cx| {
+    let Ok(result_channel) = cx.update(|_, cx| {
         let source = vscode_settings.source;
         let path = vscode_settings.path.clone();
-        cx.global::<SettingsStore>()
+        let result_channel = cx
+            .global::<SettingsStore>()
             .import_vscode_settings(fs, vscode_settings);
         zlog::info!("Imported {source} settings from {}", path.display());
-    })
-    .ok();
+        result_channel
+    }) else {
+        return;
+    };
+
+    let result = result_channel.await;
+    workspace
+        .update_in(cx, |workspace, _, cx| match result {
+            Ok(_) => {
+                let confirmation_toast = StatusToast::new(
+                    format!("Your {} settings were successfully imported.", source),
+                    cx,
+                    |this, _| {
+                        this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
+                            .dismiss_button(true)
+                    },
+                );
+                SettingsImportState::update(cx, |state, _| match source {
+                    VsCodeSettingsSource::VsCode => {
+                        state.vscode = true;
+                    }
+                    VsCodeSettingsSource::Cursor => {
+                        state.cursor = true;
+                    }
+                });
+                workspace.toggle_status_toast(confirmation_toast, cx);
+            }
+            Err(_) => {
+                let error_toast = StatusToast::new(
+                    "Failed to import settings. See log for details",
+                    cx,
+                    |this, _| {
+                        this.icon(ToastIcon::new(IconName::X).color(Color::Error))
+                            .action("Open Log", |window, cx| {
+                                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
+                            })
+                            .dismiss_button(true)
+                    },
+                );
+                workspace.toggle_status_toast(error_toast, cx);
+            }
+        })
+        .ok();
+}
+
+#[derive(Default, Copy, Clone)]
+pub struct SettingsImportState {
+    pub cursor: bool,
+    pub vscode: bool,
+}
+
+impl Global for SettingsImportState {}
+
+impl SettingsImportState {
+    pub fn global(cx: &App) -> Self {
+        cx.try_global().cloned().unwrap_or_default()
+    }
+    pub fn update<R>(cx: &mut App, f: impl FnOnce(&mut Self, &mut App) -> R) -> R {
+        cx.update_default_global(f)
+    }
 }
 
 impl workspace::SerializableItem for Onboarding {

crates/settings/src/settings_store.rs 🔗

@@ -2,7 +2,11 @@ use anyhow::{Context as _, Result};
 use collections::{BTreeMap, HashMap, btree_map, hash_map};
 use ec4rs::{ConfigParser, PropertiesSource, Section};
 use fs::Fs;
-use futures::{FutureExt, StreamExt, channel::mpsc, future::LocalBoxFuture};
+use futures::{
+    FutureExt, StreamExt,
+    channel::{mpsc, oneshot},
+    future::LocalBoxFuture,
+};
 use gpui::{App, AsyncApp, BorrowAppContext, Global, Task, UpdateGlobal};
 
 use paths::{EDITORCONFIG_NAME, local_settings_file_relative_path, task_file_name};
@@ -531,39 +535,60 @@ impl SettingsStore {
             .ok();
     }
 
-    pub fn import_vscode_settings(&self, fs: Arc<dyn Fs>, vscode_settings: VsCodeSettings) {
+    pub fn import_vscode_settings(
+        &self,
+        fs: Arc<dyn Fs>,
+        vscode_settings: VsCodeSettings,
+    ) -> oneshot::Receiver<Result<()>> {
+        let (tx, rx) = oneshot::channel::<Result<()>>();
         self.setting_file_updates_tx
             .unbounded_send(Box::new(move |cx: AsyncApp| {
                 async move {
-                    let old_text = Self::load_settings(&fs).await?;
-                    let new_text = cx.read_global(|store: &SettingsStore, _cx| {
-                        store.get_vscode_edits(old_text, &vscode_settings)
-                    })?;
-                    let settings_path = paths::settings_file().as_path();
-                    if fs.is_file(settings_path).await {
-                        let resolved_path =
-                            fs.canonicalize(settings_path).await.with_context(|| {
-                                format!("Failed to canonicalize settings path {:?}", settings_path)
-                            })?;
+                    let res = async move {
+                        let old_text = Self::load_settings(&fs).await?;
+                        let new_text = cx.read_global(|store: &SettingsStore, _cx| {
+                            store.get_vscode_edits(old_text, &vscode_settings)
+                        })?;
+                        let settings_path = paths::settings_file().as_path();
+                        if fs.is_file(settings_path).await {
+                            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)
+                                })?;
+                        }
 
-                        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)
-                            })?;
+                        anyhow::Ok(())
                     }
+                    .await;
 
-                    anyhow::Ok(())
+                    let new_res = match &res {
+                        Ok(_) => anyhow::Ok(()),
+                        Err(e) => Err(anyhow::anyhow!("Failed to write settings to file {:?}", e)),
+                    };
+
+                    _ = tx.send(new_res);
+                    res
                 }
                 .boxed_local()
             }))
             .ok();
+
+        rx
     }
 }