Skip to repository content1070 lines · 39.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:36:43.661Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
onboarding.rs
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