Onboarding serializaion

Nate Butler created

Change summary

Cargo.lock                                |   1 
crates/onboarding_ui/Cargo.toml           |   1 
crates/onboarding_ui/src/onboarding_ui.rs | 135 ++++++++++++++++++++++++
crates/onboarding_ui/src/persistence.rs   |  53 +++++++++
4 files changed, 186 insertions(+), 4 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -10826,6 +10826,7 @@ dependencies = [
  "editor",
  "feature_flags",
  "gpui",
+ "project",
  "settings",
  "settings_ui",
  "theme",

crates/onboarding_ui/Cargo.toml 🔗

@@ -22,6 +22,7 @@ component.workspace = true
 db.workspace = true
 feature_flags.workspace = true
 gpui.workspace = true
+project.workspace = true
 settings.workspace = true
 settings_ui.workspace = true
 theme.workspace = true

crates/onboarding_ui/src/onboarding_ui.rs 🔗

@@ -1,15 +1,22 @@
 #![allow(unused, dead_code)]
+mod persistence;
+
 use client::Client;
 use command_palette_hooks::CommandPaletteFilter;
 use feature_flags::FeatureFlagAppExt as _;
-use gpui::{Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, actions, prelude::*};
+use gpui::{
+    Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, Task, WeakEntity, actions, prelude::*,
+};
+use persistence::ONBOARDING_DB;
+
+use project::Project;
 use settings_ui::SettingsUiFeatureFlag;
 use std::sync::Arc;
-use ui::{KeyBinding, ListItem, Vector, VectorName, prelude::*};
+use ui::{ListItem, Vector, VectorName, prelude::*};
 use util::ResultExt;
 use workspace::{
     Workspace, WorkspaceId,
-    item::{Item, ItemEvent},
+    item::{Item, ItemEvent, SerializableItem},
     notifications::NotifyResultExt,
 };
 
@@ -38,6 +45,8 @@ pub fn init(cx: &mut App) {
     })
     .detach();
 
+    workspace::register_serializable_item::<OnboardingUI>(cx);
+
     feature_gate_onboarding_ui_actions(cx);
 }
 
@@ -104,6 +113,7 @@ pub struct OnboardingUI {
 
     // Workspace reference for Item trait
     workspace: WeakEntity<Workspace>,
+    workspace_id: Option<WorkspaceId>,
     client: Arc<Client>,
 }
 
@@ -166,10 +176,26 @@ impl OnboardingUI {
             current_focus: OnboardingFocus::Page,
             completed_pages: [false; 4],
             workspace: workspace.weak_handle(),
+            workspace_id: workspace.database_id(),
             client,
         }
     }
 
+    fn completed_pages_to_string(&self) -> String {
+        self.completed_pages
+            .iter()
+            .map(|&completed| if completed { '1' } else { '0' })
+            .collect()
+    }
+
+    fn completed_pages_from_string(s: &str) -> [bool; 4] {
+        let mut result = [false; 4];
+        for (i, ch) in s.chars().take(4).enumerate() {
+            result[i] = ch == '1';
+        }
+        result
+    }
+
     fn jump_to_page(
         &mut self,
         page: OnboardingPage,
@@ -350,7 +376,12 @@ impl OnboardingUI {
                 },
             )
             .style(ButtonStyle::Filled)
-            .key_binding(KeyBinding::for_action(&NextPage, window, cx))
+            .key_binding(ui::KeyBinding::for_action_in(
+                &NextPage,
+                &self.focus_handle,
+                window,
+                cx,
+            ))
             .on_click(cx.listener(|this, _, window, cx| {
                 this.next_page(window, cx);
             })),
@@ -432,6 +463,15 @@ impl Item for OnboardingUI {
         f(event.clone())
     }
 
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) {
+        self.workspace_id = workspace.database_id();
+    }
+
     fn show_toolbar(&self) -> bool {
         false
     }
@@ -453,3 +493,90 @@ impl Item for OnboardingUI {
         }
     }
 }
+
+impl SerializableItem for OnboardingUI {
+    fn serialized_item_kind() -> &'static str {
+        "OnboardingUI"
+    }
+
+    fn deserialize(
+        _project: Entity<Project>,
+        workspace: WeakEntity<Workspace>,
+        workspace_id: WorkspaceId,
+        item_id: u64,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task<anyhow::Result<Entity<Self>>> {
+        window.spawn(cx, async move |cx| {
+            let (current_page, completed_pages) = if let Some((page_str, completed_str)) =
+                ONBOARDING_DB.get_state(item_id, workspace_id)?
+            {
+                let page = match page_str.as_str() {
+                    "basics" => OnboardingPage::Basics,
+                    "editing" => OnboardingPage::Editing,
+                    "ai_setup" => OnboardingPage::AiSetup,
+                    "welcome" => OnboardingPage::Welcome,
+                    _ => OnboardingPage::Basics,
+                };
+                let completed = OnboardingUI::completed_pages_from_string(&completed_str);
+                (page, completed)
+            } else {
+                (OnboardingPage::Basics, [false; 4])
+            };
+
+            cx.update(|window, cx| {
+                let workspace = workspace
+                    .upgrade()
+                    .ok_or_else(|| anyhow::anyhow!("workspace dropped"))?;
+
+                workspace.update(cx, |workspace, cx| {
+                    let client = workspace.client().clone();
+                    Ok(cx.new(|cx| {
+                        let mut onboarding = OnboardingUI::new(workspace, client, cx);
+                        onboarding.current_page = current_page;
+                        onboarding.completed_pages = completed_pages;
+                        onboarding
+                    }))
+                })
+            })?
+        })
+    }
+
+    fn serialize(
+        &mut self,
+        _workspace: &mut Workspace,
+        item_id: u64,
+        _closing: bool,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<Task<anyhow::Result<()>>> {
+        let workspace_id = self.workspace_id?;
+        let current_page = match self.current_page {
+            OnboardingPage::Basics => "basics",
+            OnboardingPage::Editing => "editing",
+            OnboardingPage::AiSetup => "ai_setup",
+            OnboardingPage::Welcome => "welcome",
+        }
+        .to_string();
+        let completed_pages = self.completed_pages_to_string();
+
+        Some(cx.background_spawn(async move {
+            ONBOARDING_DB
+                .save_state(item_id, workspace_id, current_page, completed_pages)
+                .await
+        }))
+    }
+
+    fn cleanup(
+        _workspace_id: WorkspaceId,
+        _item_ids: Vec<u64>,
+        _window: &mut Window,
+        _cx: &mut App,
+    ) -> Task<anyhow::Result<()>> {
+        Task::ready(Ok(()))
+    }
+
+    fn should_serialize(&self, _event: &ItemEvent) -> bool {
+        true
+    }
+}

crates/onboarding_ui/src/persistence.rs 🔗

@@ -0,0 +1,53 @@
+use anyhow::Result;
+use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql};
+
+use workspace::{WorkspaceDb, WorkspaceId};
+
+define_connection! {
+    pub static ref ONBOARDING_DB: OnboardingDb<WorkspaceDb> =
+        &[sql!(
+            CREATE TABLE onboarding_state (
+                workspace_id INTEGER,
+                item_id INTEGER UNIQUE,
+                current_page TEXT,
+                completed_pages TEXT,
+                PRIMARY KEY(workspace_id, item_id),
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                ON DELETE CASCADE
+            ) STRICT;
+        )];
+}
+
+impl OnboardingDb {
+    pub async fn save_state(
+        &self,
+        item_id: u64,
+        workspace_id: WorkspaceId,
+        current_page: String,
+        completed_pages: String,
+    ) -> Result<()> {
+        let query =
+            "INSERT INTO onboarding_state(item_id, workspace_id, current_page, completed_pages)
+            VALUES (?1, ?2, ?3, ?4)
+            ON CONFLICT DO UPDATE SET
+                current_page = ?3,
+                completed_pages = ?4";
+        self.write(move |conn| {
+            let mut statement = Statement::prepare(conn, query)?;
+            let mut next_index = statement.bind(&item_id, 1)?;
+            next_index = statement.bind(&workspace_id, next_index)?;
+            next_index = statement.bind(&current_page, next_index)?;
+            statement.bind(&completed_pages, next_index)?;
+            statement.exec()
+        })
+        .await
+    }
+
+    query! {
+        pub fn get_state(item_id: u64, workspace_id: WorkspaceId) -> Result<Option<(String, String)>> {
+            SELECT current_page, completed_pages
+            FROM onboarding_state
+            WHERE item_id = ? AND workspace_id = ?
+        }
+    }
+}