Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:39:26.499Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

onboarding.rs

1070 lines · 39.0 KB · rust
1use crate::multibuffer_hint::MultibufferHint;
2use client::{Client, UserStore, zed_urls};
3use cloud_api_types::Plan;
4use db::kvp::KeyValueStore;
5use fs::Fs;
6use gpui::{
7    Action, AnyElement, AnyWindowHandle, App, AppContext, AsyncWindowContext, Context, Entity,
8    EventEmitter, FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, ScrollHandle,
9    SharedString, Subscription, Task, WeakEntity, Window, actions,
10};
11use notifications::status_toast::StatusToast;
12use project::{AgentRegistryStore, agent_server_store::AllAgentServersSettings};
13use schemars::JsonSchema;
14use serde::Deserialize;
15use settings::{SettingsStore, VsCodeSettingsSource};
16#[cfg(not(debug_assertions))]
17use std::any::TypeId;
18use std::sync::Arc;
19use ui::{
20    Divider, KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName,
21    WithScrollbar as _, prelude::*, rems_from_px,
22};
23
24pub use workspace::welcome::ShowWelcome;
25use workspace::welcome::WelcomePage;
26use workspace::{
27    AppState, Workspace, WorkspaceId,
28    dock::DockPosition,
29    item::{Item, ItemEvent},
30    notifications::NotifyResultExt as _,
31    open_new, register_serializable_item, with_active_or_new_workspace,
32};
33use zed_actions::{OpenEditorOnboarding, OpenOnboarding, dev::ResetOnboarding};
34
35mod base_keymap_picker;
36mod basics_page;
37mod identity_controller;
38mod identity_profile;
39mod identity_section;
40mod identity_startup;
41pub mod multibuffer_hint;
42mod secure_input;
43mod theme_preview;
44
45/// Imports settings from Visual Studio Code.
46#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
47#[action(namespace = omega, deprecated_aliases = ["zed::ImportVsCodeSettings"])]
48#[serde(deny_unknown_fields)]
49pub struct ImportVsCodeSettings {
50    #[serde(default)]
51    pub skip_prompt: bool,
52}
53
54/// Imports settings from Cursor editor.
55#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
56#[action(namespace = omega, deprecated_aliases = ["zed::ImportCursorSettings"])]
57#[serde(deny_unknown_fields)]
58pub struct ImportCursorSettings {
59    #[serde(default)]
60    pub skip_prompt: bool,
61}
62
63pub use identity_startup::await_identity_ready;
64
65const IDENTITY_ONBOARDING_COMPLETION_KEY: &str = "omega_identity_onboarding_completion_v1";
66const IDENTITY_ONBOARDING_COMPLETION_SCHEMA: &str =
67    "openagents.omega.identity-onboarding-completion.v1";
68const EDITOR_ONBOARDING_COMPLETION_KEY: &str = "omega_editor_onboarding_completion_v1";
69const EDITOR_ONBOARDING_COMPLETION_SCHEMA: &str =
70    "openagents.omega.editor-onboarding-completion.v1";
71
72#[cfg(any(debug_assertions, test))]
73fn onboarding_completion_keys() -> &'static [&'static str] {
74    &[
75        IDENTITY_ONBOARDING_COMPLETION_KEY,
76        EDITOR_ONBOARDING_COMPLETION_KEY,
77    ]
78}
79
80#[derive(serde::Serialize)]
81struct OnboardingCompletion<'a> {
82    schema: &'static str,
83    identity_ref: &'a str,
84}
85
86actions!(
87    onboarding,
88    [
89        /// Finish the onboarding process.
90        Finish,
91        /// Sign in while in the onboarding flow.
92        SignIn,
93        /// Open the user account page while in the onboarding flow.
94        OpenAccount,
95        /// Resets the welcome screen hints to their initial state.
96        ResetHints
97    ]
98);
99
100pub fn init(cx: &mut App) {
101    cx.observe_new(|workspace: &mut Workspace, _, _cx| {
102        workspace
103            .register_action(|_workspace, _: &ResetHints, _, cx| MultibufferHint::set_count(0, cx));
104    })
105    .detach();
106
107    // Handle OpenOnboarding and OpenEditorOnboarding with the same body.
108    // Do not forward one action to the other via App::dispatch_action: that
109    // nests a window update while the first action is still dispatching and
110    // fails silently (Help → Editor Onboarding / Welcome Return to Onboarding).
111    cx.on_action(|_: &OpenOnboarding, cx| open_editor_onboarding(cx));
112    cx.on_action(|_: &OpenEditorOnboarding, cx| open_editor_onboarding(cx));
113
114    cx.on_action(|_: &ShowWelcome, cx| {
115        with_active_or_new_workspace(cx, |workspace, window, cx| {
116            workspace
117                .with_local_workspace(window, cx, |workspace, window, cx| {
118                    let existing = workspace
119                        .active_pane()
120                        .read(cx)
121                        .items()
122                        .find_map(|item| item.downcast::<WelcomePage>());
123
124                    if let Some(existing) = existing {
125                        workspace.activate_item(&existing, true, true, window, cx);
126                    } else {
127                        let settings_page = cx
128                            .new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx));
129                        workspace.add_item_to_active_pane(
130                            Box::new(settings_page),
131                            None,
132                            true,
133                            window,
134                            cx,
135                        )
136                    }
137                })
138                .detach_and_log_err(cx);
139        });
140    });
141
142    #[cfg(debug_assertions)]
143    cx.on_action(|_: &ResetOnboarding, cx| {
144        reset_onboarding_completion_records(cx);
145    });
146    #[cfg(not(debug_assertions))]
147    {
148        cx.on_action(|_: &ResetOnboarding, cx| {
149            zlog::warn!("dev::ResetOnboarding is only available in debug builds");
150            let _ = cx;
151        });
152        command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _cx| {
153            filter.hide_action_types(&[TypeId::of::<ResetOnboarding>()]);
154        });
155    }
156
157    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
158        workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
159            let fs = <dyn Fs>::global(cx);
160            let action = *action;
161
162            let workspace = cx.weak_entity();
163
164            window
165                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
166                    handle_import_vscode_settings(
167                        workspace,
168                        VsCodeSettingsSource::VsCode,
169                        action.skip_prompt,
170                        fs,
171                        cx,
172                    )
173                    .await
174                })
175                .detach();
176        });
177
178        workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
179            let fs = <dyn Fs>::global(cx);
180            let action = *action;
181
182            let workspace = cx.weak_entity();
183
184            window
185                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
186                    handle_import_vscode_settings(
187                        workspace,
188                        VsCodeSettingsSource::Cursor,
189                        action.skip_prompt,
190                        fs,
191                        cx,
192                    )
193                    .await
194                })
195                .detach();
196        });
197    })
198    .detach();
199
200    base_keymap_picker::init(cx);
201
202    register_serializable_item::<Onboarding>(cx);
203    register_serializable_item::<WelcomePage>(cx);
204}
205
206fn open_editor_onboarding(cx: &mut App) {
207    with_active_or_new_workspace(cx, |workspace, window, cx| {
208        workspace
209            .with_local_workspace(window, cx, |workspace, window, cx| {
210                let existing = workspace
211                    .active_pane()
212                    .read(cx)
213                    .items()
214                    .find_map(|item| item.downcast::<Onboarding>());
215
216                if let Some(existing) = existing {
217                    workspace.activate_item(&existing, true, true, window, cx);
218                } else {
219                    let settings_page = Onboarding::new_editor_setup(workspace, window, cx);
220                    workspace.add_item_to_active_pane(
221                        Box::new(settings_page),
222                        None,
223                        true,
224                        window,
225                        cx,
226                    )
227                }
228            })
229            .detach_and_log_err(cx);
230    });
231}
232
233#[cfg(debug_assertions)]
234fn reset_onboarding_completion_records(cx: &mut App) {
235    let kvp = KeyValueStore::global(cx);
236    let keys = onboarding_completion_keys()
237        .iter()
238        .map(|key| (*key).to_string())
239        .collect::<Vec<_>>();
240    cx.spawn(async move |cx| {
241        for key in keys {
242            if let Err(error) = kvp.delete_kvp(key.clone()).await {
243                zlog::error!("failed to clear onboarding completion `{key}`: {error:#}");
244            }
245        }
246        let _ = cx.update(|cx| {
247            with_active_or_new_workspace(cx, |workspace, _, cx| {
248                let toast = StatusToast::new(
249                    "Cleared onboarding completion records. Reopen via Help → Editor Onboarding or the command palette (cmd-shift-p / ctrl-shift-p, not the file picker).",
250                    cx,
251                    |this, _| {
252                        this.icon(
253                            Icon::new(IconName::Check)
254                                .size(IconSize::Small)
255                                .color(Color::Success),
256                        )
257                        .dismiss_button(true)
258                    },
259                );
260                workspace.toggle_status_toast(toast, cx);
261            });
262        });
263    })
264    .detach();
265}
266
267pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
268    telemetry::event!("Onboarding Page Opened");
269    open_new(
270        Default::default(),
271        app_state,
272        cx,
273        |workspace, window, cx| {
274            {
275                workspace.toggle_dock(DockPosition::Left, window, cx);
276                let onboarding_page = Onboarding::new_first_run(workspace, window, cx);
277                workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
278
279                window.focus(&onboarding_page.focus_handle(cx), cx);
280
281                cx.notify();
282            };
283        },
284    )
285}
286
287struct Onboarding {
288    mode: OnboardingMode,
289    workspace: WeakEntity<Workspace>,
290    focus_handle: FocusHandle,
291    user_store: Entity<UserStore>,
292    scroll_handle: ScrollHandle,
293    identity_section: Entity<identity_section::IdentitySection>,
294    finish_task: Option<Task<()>>,
295    finish_error: Option<SharedString>,
296    _settings_subscription: Subscription,
297    _identity_subscription: Subscription,
298    _agent_registry_subscription: Option<Subscription>,
299}
300
301#[derive(Copy, Clone)]
302enum OnboardingMode {
303    FirstRun(AnyWindowHandle),
304    EditorSetup,
305}
306
307#[derive(Copy, Clone, Debug, PartialEq, Eq)]
308enum OnboardingJourney {
309    FirstRun,
310    EditorSetup,
311}
312
313impl OnboardingJourney {
314    fn completion_key(self) -> &'static str {
315        match self {
316            Self::FirstRun => IDENTITY_ONBOARDING_COMPLETION_KEY,
317            Self::EditorSetup => EDITOR_ONBOARDING_COMPLETION_KEY,
318        }
319    }
320
321    fn completion_schema(self) -> &'static str {
322        match self {
323            Self::FirstRun => IDENTITY_ONBOARDING_COMPLETION_SCHEMA,
324            Self::EditorSetup => EDITOR_ONBOARDING_COMPLETION_SCHEMA,
325        }
326    }
327
328    fn is_serializable(self) -> bool {
329        self == Self::EditorSetup
330    }
331
332    fn completion<'a>(self, identity_ref: &'a str) -> OnboardingCompletion<'a> {
333        OnboardingCompletion {
334            schema: self.completion_schema(),
335            identity_ref,
336        }
337    }
338
339    fn basics_page_mode(self) -> basics_page::BasicsPageMode {
340        match self {
341            Self::FirstRun => basics_page::BasicsPageMode::FirstRun,
342            Self::EditorSetup => basics_page::BasicsPageMode::EditorSetup,
343        }
344    }
345}
346
347impl OnboardingMode {
348    fn journey(self) -> OnboardingJourney {
349        match self {
350            Self::FirstRun(_) => OnboardingJourney::FirstRun,
351            Self::EditorSetup => OnboardingJourney::EditorSetup,
352        }
353    }
354}
355
356impl Onboarding {
357    fn new_first_run(workspace: &Workspace, window: &mut Window, cx: &mut App) -> Entity<Self> {
358        Self::new(
359            workspace,
360            OnboardingMode::FirstRun(window.window_handle()),
361            window,
362            cx,
363        )
364    }
365
366    fn new_editor_setup(workspace: &Workspace, window: &mut Window, cx: &mut App) -> Entity<Self> {
367        Self::new(workspace, OnboardingMode::EditorSetup, window, cx)
368    }
369
370    fn new(
371        workspace: &Workspace,
372        mode: OnboardingMode,
373        window: &mut Window,
374        cx: &mut App,
375    ) -> Entity<Self> {
376        let font_family_cache = theme::FontFamilyCache::global(cx);
377
378        let installed_agents = cx
379            .global::<SettingsStore>()
380            .get::<AllAgentServersSettings>(None)
381            .clone();
382        let client = Client::global(cx);
383        let status = *client.status().borrow();
384        let plan = workspace.user_store().read(cx).plan();
385        let zed_agent_state = if status.is_signed_out()
386            || matches!(
387                status,
388                client::Status::AuthenticationError | client::Status::ConnectionError
389            ) {
390            "signed_out"
391        } else if status.is_signing_in() {
392            "signing_in"
393        } else {
394            match plan {
395                Some(Plan::ZedPro) => "pro",
396                Some(Plan::ZedProTrial) => "trial",
397                Some(Plan::ZedBusiness) => "business",
398                Some(Plan::ZedVip) => "vip",
399                Some(Plan::ZedStudent) => "student",
400                Some(Plan::ZedFree) | None => "free",
401            }
402        };
403        let agents_installed = basics_page::FEATURED_AGENTS
404            .iter()
405            .filter(|agent| installed_agents.contains_key(agent.id))
406            .map(|agent| agent.id)
407            .collect::<Vec<_>>();
408        telemetry::event!(
409            "Welcome Agent Setup Viewed",
410            zed_agent = zed_agent_state,
411            agents_installed = agents_installed,
412        );
413
414        let identity_section = identity_section::IdentitySection::new(0, window, cx);
415        let agent_registry = AgentRegistryStore::try_global(cx);
416        let onboarding = cx.new(|cx| {
417            cx.spawn(async move |this, cx| {
418                font_family_cache.prefetch(cx).await;
419                this.update(cx, |_, cx| {
420                    cx.notify();
421                })
422            })
423            .detach();
424
425            Self {
426                mode,
427                workspace: workspace.weak_handle(),
428                focus_handle: cx.focus_handle(),
429                scroll_handle: ScrollHandle::new(),
430                identity_section: identity_section.clone(),
431                finish_task: None,
432                finish_error: None,
433                user_store: workspace.user_store().clone(),
434                _settings_subscription: cx
435                    .observe_global::<SettingsStore>(move |_, cx| cx.notify()),
436                _identity_subscription: cx.observe(&identity_section, |_, _, cx| cx.notify()),
437                _agent_registry_subscription: agent_registry
438                    .as_ref()
439                    .map(|registry| cx.observe(registry, |_, _, cx| cx.notify())),
440            }
441        });
442        identity_startup::onboarding_opened(cx);
443        onboarding
444    }
445
446    fn on_finish(&mut self, _: &Finish, _: &mut Window, cx: &mut Context<Self>) {
447        if self.finish_task.is_some() {
448            return;
449        }
450        let Some(identity_ref) = self.identity_section.read(cx).ready_identity_ref() else {
451            cx.notify();
452            return;
453        };
454        let completion = self.mode.journey().completion(identity_ref.as_str());
455        let Ok(completion) = serde_json::to_string(&completion) else {
456            self.finish_error = Some("Could not record identity setup completion.".into());
457            cx.notify();
458            return;
459        };
460        let kvp = KeyValueStore::global(cx);
461        let completion_key = self.mode.journey().completion_key();
462        self.finish_error = None;
463        self.finish_task = Some(cx.spawn(async move |this, cx| {
464            let result = kvp.write_kvp(completion_key.to_string(), completion).await;
465            if let Err(error) = result {
466                zlog::error!("failed to record onboarding completion: {error:#}");
467                this.update(cx, |this, cx| {
468                    this.finish_task = None;
469                    this.finish_error = Some("Could not finish setup. Please try again.".into());
470                    cx.notify();
471                })
472                .ok();
473                return;
474            }
475
476            let Ok(mode) = this.read_with(cx, |this, _| this.mode) else {
477                return;
478            };
479            match mode {
480                OnboardingMode::FirstRun(window_handle) => {
481                    if let Err(error) = window_handle.update(cx, |_, window, cx| {
482                        window.remove_window();
483                        telemetry::event!("Finish Setup");
484                        identity_startup::release_identity_waiters(cx);
485                    }) {
486                        zlog::error!("failed to close identity onboarding window: {error:#}");
487                        this.update(cx, |this, cx| {
488                            this.finish_task = None;
489                            this.finish_error =
490                                Some("Could not finish setup. Please try again.".into());
491                            cx.notify();
492                        })
493                        .ok();
494                        return;
495                    }
496                }
497                OnboardingMode::EditorSetup => {
498                    this.update(cx, |this, cx| {
499                        this.finish_task = None;
500                        telemetry::event!("Finish Setup");
501                        identity_startup::release_identity_waiters(cx);
502                        go_to_welcome_page(cx);
503                    })
504                    .ok();
505                }
506            }
507        }));
508    }
509
510    fn handle_sign_in(&mut self, _: &SignIn, window: &mut Window, cx: &mut Context<Self>) {
511        let client = Client::global(cx);
512        let workspace = self.workspace.clone();
513
514        window
515            .spawn(cx, async move |mut cx| {
516                client
517                    .sign_in_with_optional_connect(true, &cx)
518                    .await
519                    .notify_workspace_async_err(workspace, &mut cx);
520            })
521            .detach();
522    }
523
524    fn handle_open_account(_: &OpenAccount, _: &mut Window, cx: &mut App) {
525        cx.open_url(&zed_urls::account_url(cx))
526    }
527
528    fn render_page(&mut self, compact: bool, cx: &mut Context<Self>) -> AnyElement {
529        crate::basics_page::render_basics_page(
530            &self.user_store,
531            &self.identity_section,
532            self.mode.journey().basics_page_mode(),
533            compact,
534            cx,
535        )
536        .into_any_element()
537    }
538}
539
540impl Render for Onboarding {
541    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
542        let compact = window.viewport_size().width < px(560.);
543        div()
544            .image_cache(gpui::retain_all("onboarding-page"))
545            .key_context({
546                let mut ctx = KeyContext::new_with_defaults();
547                ctx.add("Onboarding");
548                ctx.add("menu");
549                ctx
550            })
551            .track_focus(&self.focus_handle)
552            .size_full()
553            .bg(cx.theme().colors().editor_background)
554            .on_action(cx.listener(Self::on_finish))
555            .on_action(cx.listener(Self::handle_sign_in))
556            .on_action(Self::handle_open_account)
557            .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
558                window.focus_next(cx);
559                cx.notify();
560            }))
561            .on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| {
562                window.focus_prev(cx);
563                cx.notify();
564            }))
565            .vertical_scrollbar_for(&self.scroll_handle, window, cx)
566            .child(
567                div()
568                    .id("page-content")
569                    .size_full()
570                    .overflow_y_scroll()
571                    .child(
572                        v_flex()
573                            .min_w_0()
574                            .max_w(rems_from_px(780.))
575                            .w_full()
576                            .mx_auto()
577                            .when_else(compact, |this| this.p_4(), |this| this.p_12())
578                            .gap_6()
579                            .child(
580                                div()
581                                    .flex()
582                                    .w_full()
583                                    .gap_4()
584                                    .when_else(
585                                        compact,
586                                        |this| this.flex_col().items_stretch(),
587                                        |this| this.flex_row().items_center().justify_between(),
588                                    )
589                                    .child(
590                                        h_flex()
591                                            .gap_4()
592                                            .child(Vector::square(VectorName::OmegaLogo, rems(2.5)))
593                                            .child(
594                                                v_flex()
595                                                    .child(
596                                                        Headline::new("Welcome to Omega")
597                                                            .size(HeadlineSize::Small),
598                                                    )
599                                                    .child(
600                                                        Label::new("Your last IDE.")
601                                                            .color(Color::Muted)
602                                                            .size(LabelSize::Small)
603                                                            .italic(),
604                                                    ),
605                                            ),
606                                    )
607                                    .child(
608                                        v_flex()
609                                            .gap_1()
610                                            .when_else(
611                                                compact,
612                                                |this| this.items_stretch(),
613                                                |this| this.items_end(),
614                                            )
615                                            .child({
616                                                Button::new("finish_setup", "Finish Setup")
617                                                    .style(ButtonStyle::Filled)
618                                                    .size(ButtonSize::Medium)
619                                                    .tab_index(1000_isize)
620                                                    .when_else(
621                                                        compact,
622                                                        |this| this.full_width(),
623                                                        |this| this.width(rems_from_px(200.)),
624                                                    )
625                                                    .disabled(
626                                                        self.finish_task.is_some()
627                                                            || !self
628                                                                .identity_section
629                                                                .read(cx)
630                                                                .is_ready(),
631                                                    )
632                                                    .key_binding(KeyBinding::for_action_in(
633                                                        &Finish,
634                                                        &self.focus_handle,
635                                                        cx,
636                                                    ))
637                                                    .on_click(|_, window, cx| {
638                                                        window.dispatch_action(
639                                                            Finish.boxed_clone(),
640                                                            cx,
641                                                        );
642                                                    })
643                                            })
644                                            .when_some(self.finish_error.clone(), |this, error| {
645                                                this.child(
646                                                    Label::new(error)
647                                                        .size(LabelSize::Small)
648                                                        .color(Color::Error),
649                                                )
650                                            }),
651                                    ),
652                            )
653                            .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
654                            .child(self.render_page(compact, cx)),
655                    )
656                    .track_scroll(&self.scroll_handle),
657            )
658    }
659}
660
661impl EventEmitter<ItemEvent> for Onboarding {}
662
663impl Focusable for Onboarding {
664    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
665        self.focus_handle.clone()
666    }
667}
668
669impl Item for Onboarding {
670    type Event = ItemEvent;
671
672    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
673        "Onboarding".into()
674    }
675
676    fn telemetry_event_text(&self) -> Option<&'static str> {
677        Some("Onboarding Page Opened")
678    }
679
680    fn show_toolbar(&self) -> bool {
681        false
682    }
683
684    fn can_split(&self) -> bool {
685        false
686    }
687
688    fn clone_on_split(
689        &self,
690        _workspace_id: Option<WorkspaceId>,
691        _: &mut Window,
692        _: &mut Context<Self>,
693    ) -> Task<Option<Entity<Self>>> {
694        Task::ready(None)
695    }
696
697    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
698        self.identity_section.update(cx, |section, cx| {
699            section.deactivate_and_reinspect(window, cx)
700        });
701    }
702
703    fn workspace_deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
704        self.identity_section.update(cx, |section, cx| {
705            section.deactivate_and_reinspect(window, cx)
706        });
707    }
708
709    fn on_removed(&self, cx: &mut Context<Self>) {
710        identity_startup::onboarding_closed(cx);
711        self.identity_section
712            .update(cx, |section, cx| section.clear_transient_state(cx));
713    }
714
715    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
716        f(*event)
717    }
718}
719
720fn go_to_welcome_page(cx: &mut App) {
721    with_active_or_new_workspace(cx, |workspace, window, cx| {
722        let Some((onboarding_id, onboarding_idx)) = workspace
723            .active_pane()
724            .read(cx)
725            .items()
726            .enumerate()
727            .find_map(|(idx, item)| {
728                let _ = item.downcast::<Onboarding>()?;
729                Some((item.item_id(), idx))
730            })
731        else {
732            return;
733        };
734
735        workspace.active_pane().update(cx, |pane, cx| {
736            // Get the index here to get around the borrow checker
737            let idx = pane.items().enumerate().find_map(|(idx, item)| {
738                let _ = item.downcast::<WelcomePage>()?;
739                Some(idx)
740            });
741
742            if let Some(idx) = idx {
743                pane.activate_item(idx, true, true, window, cx);
744            } else {
745                let item = Box::new(
746                    cx.new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx)),
747                );
748                pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
749            }
750
751            pane.remove_item(onboarding_id, false, false, window, cx);
752        });
753    });
754}
755
756pub async fn handle_import_vscode_settings(
757    workspace: WeakEntity<Workspace>,
758    source: VsCodeSettingsSource,
759    skip_prompt: bool,
760    fs: Arc<dyn Fs>,
761    cx: &mut AsyncWindowContext,
762) {
763    use util::truncate_and_remove_front;
764
765    let vscode_settings =
766        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
767            Ok(vscode_settings) => vscode_settings,
768            Err(err) => {
769                zlog::error!("{err:?}");
770                let _ = cx.prompt(
771                    gpui::PromptLevel::Info,
772                    &format!("Could not find or load a {source} settings file"),
773                    None,
774                    &["OK"],
775                );
776                return;
777            }
778        };
779
780    if !skip_prompt {
781        let prompt = cx.prompt(
782            gpui::PromptLevel::Warning,
783            &format!(
784                "Importing {} settings may overwrite your existing settings. \
785                Will import settings from {}",
786                vscode_settings.source,
787                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
788            ),
789            None,
790            &["Import", "Cancel"],
791        );
792        let result = cx.spawn(async move |_| prompt.await.ok()).await;
793        if result != Some(0) {
794            return;
795        }
796    };
797
798    let Ok(result_channel) = cx.update(|_, cx| {
799        let source = vscode_settings.source;
800        let path = vscode_settings.path.clone();
801        let result_channel = cx
802            .global::<SettingsStore>()
803            .import_vscode_settings(fs, vscode_settings);
804        zlog::info!("Imported {source} settings from {}", path.display());
805        result_channel
806    }) else {
807        return;
808    };
809
810    let result = result_channel.await;
811    workspace
812        .update_in(cx, |workspace, _, cx| match result {
813            Ok(_) => {
814                let confirmation_toast = StatusToast::new(
815                    format!("Your {} settings were successfully imported.", source),
816                    cx,
817                    |this, _| {
818                        this.icon(
819                            Icon::new(IconName::Check)
820                                .size(IconSize::Small)
821                                .color(Color::Success),
822                        )
823                        .dismiss_button(true)
824                    },
825                );
826                SettingsImportState::update(cx, |state, _| match source {
827                    VsCodeSettingsSource::VsCode => {
828                        state.vscode = true;
829                    }
830                    VsCodeSettingsSource::Cursor => {
831                        state.cursor = true;
832                    }
833                });
834                workspace.toggle_status_toast(confirmation_toast, cx);
835            }
836            Err(_) => {
837                let error_toast = StatusToast::new(
838                    "Failed to import settings. See log for details",
839                    cx,
840                    |this, _| {
841                        this.icon(
842                            Icon::new(IconName::Close)
843                                .size(IconSize::Small)
844                                .color(Color::Error),
845                        )
846                        .action("Open Log", |window, cx| {
847                            window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
848                        })
849                        .dismiss_button(true)
850                    },
851                );
852                workspace.toggle_status_toast(error_toast, cx);
853            }
854        })
855        .ok();
856}
857
858#[derive(Default, Copy, Clone)]
859pub struct SettingsImportState {
860    pub cursor: bool,
861    pub vscode: bool,
862}
863
864impl Global for SettingsImportState {}
865
866impl SettingsImportState {
867    pub fn global(cx: &App) -> Self {
868        cx.try_global().cloned().unwrap_or_default()
869    }
870    pub fn update<R>(cx: &mut App, f: impl FnOnce(&mut Self, &mut App) -> R) -> R {
871        cx.update_default_global(f)
872    }
873}
874
875impl workspace::SerializableItem for Onboarding {
876    fn serialized_item_kind() -> &'static str {
877        "OnboardingPage"
878    }
879
880    fn cleanup(
881        workspace_id: workspace::WorkspaceId,
882        alive_items: Vec<workspace::ItemId>,
883        _window: &mut Window,
884        cx: &mut App,
885    ) -> gpui::Task<gpui::Result<()>> {
886        workspace::delete_unloaded_items(
887            alive_items,
888            workspace_id,
889            "onboarding_pages",
890            &persistence::OnboardingPagesDb::global(cx),
891            cx,
892        )
893    }
894
895    fn deserialize(
896        _project: Entity<project::Project>,
897        workspace: WeakEntity<Workspace>,
898        workspace_id: workspace::WorkspaceId,
899        item_id: workspace::ItemId,
900        window: &mut Window,
901        cx: &mut App,
902    ) -> gpui::Task<gpui::Result<Entity<Self>>> {
903        let db = persistence::OnboardingPagesDb::global(cx);
904        window.spawn(cx, async move |cx| {
905            if let Some(_) = db.get_onboarding_page(item_id, workspace_id)? {
906                workspace.update_in(cx, |workspace, window, cx| {
907                    Onboarding::new_editor_setup(workspace, window, cx)
908                })
909            } else {
910                Err(anyhow::anyhow!("No onboarding page to deserialize"))
911            }
912        })
913    }
914
915    fn serialize(
916        &mut self,
917        workspace: &mut Workspace,
918        item_id: workspace::ItemId,
919        _closing: bool,
920        _window: &mut Window,
921        cx: &mut ui::Context<Self>,
922    ) -> Option<gpui::Task<gpui::Result<()>>> {
923        if !self.mode.journey().is_serializable() {
924            return None;
925        }
926        let workspace_id = workspace.database_id()?;
927
928        let db = persistence::OnboardingPagesDb::global(cx);
929        Some(
930            cx.background_spawn(
931                async move { db.save_onboarding_page(item_id, workspace_id).await },
932            ),
933        )
934    }
935
936    fn should_serialize(&self, event: &Self::Event) -> bool {
937        self.mode.journey().is_serializable() && event == &ItemEvent::UpdateTab
938    }
939}
940
941mod persistence {
942    use db::{
943        query,
944        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
945        sqlez_macros::sql,
946    };
947    use workspace::WorkspaceDb;
948
949    pub struct OnboardingPagesDb(ThreadSafeConnection);
950
951    impl Domain for OnboardingPagesDb {
952        const NAME: &str = stringify!(OnboardingPagesDb);
953
954        const MIGRATIONS: &[&str] = &[
955            sql!(
956                        CREATE TABLE onboarding_pages (
957                            workspace_id INTEGER,
958                            item_id INTEGER UNIQUE,
959                            page_number INTEGER,
960
961                            PRIMARY KEY(workspace_id, item_id),
962                            FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
963                            ON DELETE CASCADE
964                        ) STRICT;
965            ),
966            sql!(
967                        CREATE TABLE onboarding_pages_2 (
968                            workspace_id INTEGER,
969                            item_id INTEGER UNIQUE,
970
971                            PRIMARY KEY(workspace_id, item_id),
972                            FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
973                            ON DELETE CASCADE
974                        ) STRICT;
975                        INSERT INTO onboarding_pages_2 SELECT workspace_id, item_id FROM onboarding_pages;
976                        DROP TABLE onboarding_pages;
977                        ALTER TABLE onboarding_pages_2 RENAME TO onboarding_pages;
978            ),
979        ];
980    }
981
982    db::static_connection!(OnboardingPagesDb, [WorkspaceDb]);
983
984    impl OnboardingPagesDb {
985        query! {
986            pub async fn save_onboarding_page(
987                item_id: workspace::ItemId,
988                workspace_id: workspace::WorkspaceId
989            ) -> Result<()> {
990                INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id)
991                VALUES (?, ?)
992            }
993        }
994
995        query! {
996            pub fn get_onboarding_page(
997                item_id: workspace::ItemId,
998                workspace_id: workspace::WorkspaceId
999            ) -> Result<Option<workspace::ItemId>> {
1000                SELECT item_id
1001                FROM onboarding_pages
1002                WHERE item_id = ? AND workspace_id = ?
1003            }
1004        }
1005    }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::*;
1011
1012    #[test]
1013    fn first_run_and_editor_setup_have_independent_completion_versions() {
1014        let first_run = OnboardingJourney::FirstRun;
1015        let editor_setup = OnboardingJourney::EditorSetup;
1016
1017        assert_eq!(
1018            first_run.completion_key(),
1019            "omega_identity_onboarding_completion_v1"
1020        );
1021        assert_eq!(
1022            editor_setup.completion_key(),
1023            "omega_editor_onboarding_completion_v1"
1024        );
1025        assert_ne!(first_run.completion_key(), editor_setup.completion_key());
1026        assert_ne!(
1027            first_run.completion_schema(),
1028            editor_setup.completion_schema()
1029        );
1030
1031        let identity_ref = "identity:test";
1032        let first_run_record =
1033            serde_json::to_value(first_run.completion(identity_ref)).expect("first-run completion");
1034        let editor_record =
1035            serde_json::to_value(editor_setup.completion(identity_ref)).expect("editor completion");
1036        assert_eq!(first_run_record["identity_ref"], identity_ref);
1037        assert_eq!(editor_record["identity_ref"], identity_ref);
1038        assert_ne!(first_run_record["schema"], editor_record["schema"]);
1039    }
1040
1041    #[test]
1042    fn only_editor_setup_is_serializable() {
1043        assert!(!OnboardingJourney::FirstRun.is_serializable());
1044        assert!(OnboardingJourney::EditorSetup.is_serializable());
1045        assert_eq!(
1046            OnboardingJourney::FirstRun.basics_page_mode(),
1047            basics_page::BasicsPageMode::FirstRun
1048        );
1049        assert_eq!(
1050            OnboardingJourney::EditorSetup.basics_page_mode(),
1051            basics_page::BasicsPageMode::EditorSetup
1052        );
1053    }
1054
1055    #[test]
1056    fn reset_onboarding_clears_both_completion_keys() {
1057        let keys = onboarding_completion_keys();
1058        assert_eq!(
1059            keys,
1060            &[
1061                "omega_identity_onboarding_completion_v1",
1062                "omega_editor_onboarding_completion_v1",
1063            ]
1064        );
1065        assert_eq!(OpenOnboarding.name(), "omega::OpenOnboarding");
1066        assert_eq!(OpenEditorOnboarding.name(), "omega::OpenEditorOnboarding");
1067        assert_eq!(ResetOnboarding.name(), "dev::ResetOnboarding");
1068    }
1069}
1070
Served at tenant.openagents/omega Member data and write actions are omitted.