onboarding.rs

  1use crate::welcome::{ShowWelcome, WelcomePage};
  2use command_palette_hooks::CommandPaletteFilter;
  3use db::kvp::KEY_VALUE_STORE;
  4use feature_flags::{FeatureFlag, FeatureFlagViewExt as _};
  5use fs::Fs;
  6use gpui::{
  7    Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter,
  8    FocusHandle, Focusable, IntoElement, Render, SharedString, Subscription, Task, WeakEntity,
  9    Window, actions,
 10};
 11use schemars::JsonSchema;
 12use serde::Deserialize;
 13use settings::{Settings, SettingsStore, VsCodeSettingsSource, update_settings_file};
 14use std::sync::Arc;
 15use theme::{ThemeMode, ThemeSettings};
 16use ui::{
 17    Divider, FluentBuilder, Headline, KeyBinding, ParentElement as _, StatefulInteractiveElement,
 18    ToggleButtonGroup, ToggleButtonSimple, Vector, VectorName, prelude::*, rems_from_px,
 19};
 20use workspace::{
 21    AppState, Workspace, WorkspaceId,
 22    dock::DockPosition,
 23    item::{Item, ItemEvent},
 24    open_new, with_active_or_new_workspace,
 25};
 26
 27mod editing_page;
 28mod welcome;
 29
 30pub struct OnBoardingFeatureFlag {}
 31
 32impl FeatureFlag for OnBoardingFeatureFlag {
 33    const NAME: &'static str = "onboarding";
 34}
 35
 36/// Imports settings from Visual Studio Code.
 37#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 38#[action(namespace = zed)]
 39#[serde(deny_unknown_fields)]
 40pub struct ImportVsCodeSettings {
 41    #[serde(default)]
 42    pub skip_prompt: bool,
 43}
 44
 45/// Imports settings from Cursor editor.
 46#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 47#[action(namespace = zed)]
 48#[serde(deny_unknown_fields)]
 49pub struct ImportCursorSettings {
 50    #[serde(default)]
 51    pub skip_prompt: bool,
 52}
 53
 54pub const FIRST_OPEN: &str = "first_open";
 55
 56actions!(
 57    zed,
 58    [
 59        /// Opens the onboarding view.
 60        OpenOnboarding
 61    ]
 62);
 63
 64pub fn init(cx: &mut App) {
 65    cx.on_action(|_: &OpenOnboarding, cx| {
 66        with_active_or_new_workspace(cx, |workspace, window, cx| {
 67            workspace
 68                .with_local_workspace(window, cx, |workspace, window, cx| {
 69                    let existing = workspace
 70                        .active_pane()
 71                        .read(cx)
 72                        .items()
 73                        .find_map(|item| item.downcast::<Onboarding>());
 74
 75                    if let Some(existing) = existing {
 76                        workspace.activate_item(&existing, true, true, window, cx);
 77                    } else {
 78                        let settings_page = Onboarding::new(workspace.weak_handle(), cx);
 79                        workspace.add_item_to_active_pane(
 80                            Box::new(settings_page),
 81                            None,
 82                            true,
 83                            window,
 84                            cx,
 85                        )
 86                    }
 87                })
 88                .detach();
 89        });
 90    });
 91
 92    cx.on_action(|_: &ShowWelcome, cx| {
 93        with_active_or_new_workspace(cx, |workspace, window, cx| {
 94            workspace
 95                .with_local_workspace(window, cx, |workspace, window, cx| {
 96                    let existing = workspace
 97                        .active_pane()
 98                        .read(cx)
 99                        .items()
100                        .find_map(|item| item.downcast::<WelcomePage>());
101
102                    if let Some(existing) = existing {
103                        workspace.activate_item(&existing, true, true, window, cx);
104                    } else {
105                        let settings_page = WelcomePage::new(window, cx);
106                        workspace.add_item_to_active_pane(
107                            Box::new(settings_page),
108                            None,
109                            true,
110                            window,
111                            cx,
112                        )
113                    }
114                })
115                .detach();
116        });
117    });
118
119    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
120        workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
121            let fs = <dyn Fs>::global(cx);
122            let action = *action;
123
124            window
125                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
126                    handle_import_vscode_settings(
127                        VsCodeSettingsSource::VsCode,
128                        action.skip_prompt,
129                        fs,
130                        cx,
131                    )
132                    .await
133                })
134                .detach();
135        });
136
137        workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
138            let fs = <dyn Fs>::global(cx);
139            let action = *action;
140
141            window
142                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
143                    handle_import_vscode_settings(
144                        VsCodeSettingsSource::Cursor,
145                        action.skip_prompt,
146                        fs,
147                        cx,
148                    )
149                    .await
150                })
151                .detach();
152        });
153    })
154    .detach();
155
156    cx.observe_new::<Workspace>(|_, window, cx| {
157        let Some(window) = window else {
158            return;
159        };
160
161        let onboarding_actions = [
162            std::any::TypeId::of::<OpenOnboarding>(),
163            std::any::TypeId::of::<ShowWelcome>(),
164        ];
165
166        CommandPaletteFilter::update_global(cx, |filter, _cx| {
167            filter.hide_action_types(&onboarding_actions);
168        });
169
170        cx.observe_flag::<OnBoardingFeatureFlag, _>(window, move |is_enabled, _, _, cx| {
171            if is_enabled {
172                CommandPaletteFilter::update_global(cx, |filter, _cx| {
173                    filter.show_action_types(onboarding_actions.iter());
174                });
175            } else {
176                CommandPaletteFilter::update_global(cx, |filter, _cx| {
177                    filter.hide_action_types(&onboarding_actions);
178                });
179            }
180        })
181        .detach();
182    })
183    .detach();
184}
185
186pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
187    open_new(
188        Default::default(),
189        app_state,
190        cx,
191        |workspace, window, cx| {
192            {
193                workspace.toggle_dock(DockPosition::Left, window, cx);
194                let onboarding_page = Onboarding::new(workspace.weak_handle(), cx);
195                workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
196
197                window.focus(&onboarding_page.focus_handle(cx));
198
199                cx.notify();
200            };
201            db::write_and_log(cx, || {
202                KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
203            });
204        },
205    )
206}
207
208fn read_theme_selection(cx: &App) -> ThemeMode {
209    let settings = ThemeSettings::get_global(cx);
210    settings
211        .theme_selection
212        .as_ref()
213        .and_then(|selection| selection.mode())
214        .unwrap_or_default()
215}
216
217fn write_theme_selection(theme_mode: ThemeMode, cx: &App) {
218    let fs = <dyn Fs>::global(cx);
219
220    update_settings_file::<ThemeSettings>(fs, cx, move |settings, _| {
221        settings.set_mode(theme_mode);
222    });
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226enum SelectedPage {
227    Basics,
228    Editing,
229    AiSetup,
230}
231
232struct Onboarding {
233    workspace: WeakEntity<Workspace>,
234    focus_handle: FocusHandle,
235    selected_page: SelectedPage,
236    _settings_subscription: Subscription,
237}
238
239impl Onboarding {
240    fn new(workspace: WeakEntity<Workspace>, cx: &mut App) -> Entity<Self> {
241        cx.new(|cx| Self {
242            workspace,
243            focus_handle: cx.focus_handle(),
244            selected_page: SelectedPage::Basics,
245            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
246        })
247    }
248
249    fn render_page_nav(
250        &mut self,
251        page: SelectedPage,
252        _: &mut Window,
253        cx: &mut Context<Self>,
254    ) -> impl IntoElement {
255        let text = match page {
256            SelectedPage::Basics => "Basics",
257            SelectedPage::Editing => "Editing",
258            SelectedPage::AiSetup => "AI Setup",
259        };
260        let binding = match page {
261            SelectedPage::Basics => {
262                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-1").unwrap()], cx)
263            }
264            SelectedPage::Editing => {
265                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-2").unwrap()], cx)
266            }
267            SelectedPage::AiSetup => {
268                KeyBinding::new(vec![gpui::Keystroke::parse("cmd-3").unwrap()], cx)
269            }
270        };
271        let selected = self.selected_page == page;
272        h_flex()
273            .id(text)
274            .rounded_sm()
275            .child(text)
276            .child(binding)
277            .h_8()
278            .gap_2()
279            .px_2()
280            .py_0p5()
281            .w_full()
282            .justify_between()
283            .map(|this| {
284                if selected {
285                    this.bg(Color::Selected.color(cx))
286                        .border_l_1()
287                        .border_color(Color::Accent.color(cx))
288                } else {
289                    this.text_color(Color::Muted.color(cx))
290                }
291            })
292            .hover(|style| {
293                if selected {
294                    style.bg(Color::Selected.color(cx).opacity(0.6))
295                } else {
296                    style.bg(Color::Selected.color(cx).opacity(0.3))
297                }
298            })
299            .on_click(cx.listener(move |this, _, _, cx| {
300                this.selected_page = page;
301                cx.notify();
302            }))
303    }
304
305    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
306        match self.selected_page {
307            SelectedPage::Basics => self.render_basics_page(window, cx).into_any_element(),
308            SelectedPage::Editing => {
309                crate::editing_page::render_editing_page(window, cx).into_any_element()
310            }
311            SelectedPage::AiSetup => self.render_ai_setup_page(window, cx).into_any_element(),
312        }
313    }
314
315    fn render_basics_page(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
316        let theme_mode = read_theme_selection(cx);
317
318        v_flex().child(
319            h_flex().justify_between().child(Label::new("Theme")).child(
320                ToggleButtonGroup::single_row(
321                    "theme-selector-onboarding",
322                    [
323                        ToggleButtonSimple::new("Light", |_, _, cx| {
324                            write_theme_selection(ThemeMode::Light, cx)
325                        }),
326                        ToggleButtonSimple::new("Dark", |_, _, cx| {
327                            write_theme_selection(ThemeMode::Dark, cx)
328                        }),
329                        ToggleButtonSimple::new("System", |_, _, cx| {
330                            write_theme_selection(ThemeMode::System, cx)
331                        }),
332                    ],
333                )
334                .selected_index(match theme_mode {
335                    ThemeMode::Light => 0,
336                    ThemeMode::Dark => 1,
337                    ThemeMode::System => 2,
338                })
339                .style(ui::ToggleButtonGroupStyle::Outlined)
340                .button_width(rems_from_px(64.)),
341            ),
342        )
343    }
344
345    fn render_ai_setup_page(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
346        div().child("ai setup page")
347    }
348}
349
350impl Render for Onboarding {
351    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
352        h_flex()
353            .image_cache(gpui::retain_all("onboarding-page"))
354            .key_context("onboarding-page")
355            .px_24()
356            .py_12()
357            .items_start()
358            .child(
359                v_flex()
360                    .w_1_3()
361                    .h_full()
362                    .child(
363                        h_flex()
364                            .pt_0p5()
365                            .child(Vector::square(VectorName::ZedLogo, rems(2.)))
366                            .child(
367                                v_flex()
368                                    .left_1()
369                                    .items_center()
370                                    .child(Headline::new("Welcome to Zed"))
371                                    .child(
372                                        Label::new("The editor for what's next")
373                                            .color(Color::Muted)
374                                            .italic(),
375                                    ),
376                            ),
377                    )
378                    .p_1()
379                    .child(Divider::horizontal_dashed())
380                    .child(
381                        v_flex().gap_1().children([
382                            self.render_page_nav(SelectedPage::Basics, window, cx)
383                                .into_element(),
384                            self.render_page_nav(SelectedPage::Editing, window, cx)
385                                .into_element(),
386                            self.render_page_nav(SelectedPage::AiSetup, window, cx)
387                                .into_element(),
388                        ]),
389                    ),
390            )
391            // .child(Divider::vertical_dashed())
392            .child(div().w_2_3().h_full().child(self.render_page(window, cx)))
393    }
394}
395
396impl EventEmitter<ItemEvent> for Onboarding {}
397
398impl Focusable for Onboarding {
399    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
400        self.focus_handle.clone()
401    }
402}
403
404impl Item for Onboarding {
405    type Event = ItemEvent;
406
407    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
408        "Onboarding".into()
409    }
410
411    fn telemetry_event_text(&self) -> Option<&'static str> {
412        Some("Onboarding Page Opened")
413    }
414
415    fn show_toolbar(&self) -> bool {
416        false
417    }
418
419    fn clone_on_split(
420        &self,
421        _workspace_id: Option<WorkspaceId>,
422        _: &mut Window,
423        cx: &mut Context<Self>,
424    ) -> Option<Entity<Self>> {
425        Some(Onboarding::new(self.workspace.clone(), cx))
426    }
427
428    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
429        f(*event)
430    }
431}
432
433pub async fn handle_import_vscode_settings(
434    source: VsCodeSettingsSource,
435    skip_prompt: bool,
436    fs: Arc<dyn Fs>,
437    cx: &mut AsyncWindowContext,
438) {
439    use util::truncate_and_remove_front;
440
441    let vscode_settings =
442        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
443            Ok(vscode_settings) => vscode_settings,
444            Err(err) => {
445                zlog::error!("{err}");
446                let _ = cx.prompt(
447                    gpui::PromptLevel::Info,
448                    &format!("Could not find or load a {source} settings file"),
449                    None,
450                    &["Ok"],
451                );
452                return;
453            }
454        };
455
456    if !skip_prompt {
457        let prompt = cx.prompt(
458            gpui::PromptLevel::Warning,
459            &format!(
460                "Importing {} settings may overwrite your existing settings. \
461                Will import settings from {}",
462                vscode_settings.source,
463                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
464            ),
465            None,
466            &["Ok", "Cancel"],
467        );
468        let result = cx.spawn(async move |_| prompt.await.ok()).await;
469        if result != Some(0) {
470            return;
471        }
472    };
473
474    cx.update(|_, cx| {
475        let source = vscode_settings.source;
476        let path = vscode_settings.path.clone();
477        cx.global::<SettingsStore>()
478            .import_vscode_settings(fs, vscode_settings);
479        zlog::info!("Imported {source} settings from {}", path.display());
480    })
481    .ok();
482}