Start on welcome experience settings

Mikayla Maki created

Change summary

Cargo.lock                           |   1 
crates/gpui/src/app.rs               |   2 
crates/settings/src/settings.rs      | 180 +++++++++++++++++++++++++++--
crates/settings/src/settings_file.rs |  50 +-------
crates/theme/src/theme.rs            |  15 ++
crates/welcome/Cargo.toml            |   1 
crates/welcome/src/welcome.rs        | 107 ++++++++++++++++-
styles/src/styleTree/app.ts          |   2 
styles/src/styleTree/welcome.ts      |  34 +++++
9 files changed, 326 insertions(+), 66 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8018,6 +8018,7 @@ name = "welcome"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "editor",
  "gpui",
  "log",
  "project",

crates/gpui/src/app.rs 🔗

@@ -5086,7 +5086,7 @@ impl<T: Entity> From<WeakModelHandle<T>> for AnyWeakModelHandle {
     }
 }
 
-#[derive(Debug)]
+#[derive(Debug, Copy)]
 pub struct WeakViewHandle<T> {
     window_id: usize,
     view_id: usize,

crates/settings/src/settings.rs 🔗

@@ -66,9 +66,18 @@ impl TelemetrySettings {
     pub fn metrics(&self) -> bool {
         self.metrics.unwrap()
     }
+
     pub fn diagnostics(&self) -> bool {
         self.diagnostics.unwrap()
     }
+
+    pub fn set_metrics(&mut self, value: bool) {
+        self.metrics = Some(value);
+    }
+
+    pub fn set_diagnostics(&mut self, value: bool) {
+        self.diagnostics = Some(value);
+    }
 }
 
 #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
@@ -679,7 +688,7 @@ pub fn settings_file_json_schema(
 
 /// Expects the key to be unquoted, and the value to be valid JSON
 /// (e.g. values should be unquoted for numbers and bools, quoted for strings)
-pub fn write_top_level_setting(
+pub fn write_settings_key(
     mut settings_content: String,
     top_level_key: &str,
     new_val: &str,
@@ -786,11 +795,160 @@ pub fn parse_json_with_comments<T: DeserializeOwned>(content: &str) -> Result<T>
     )?)
 }
 
+pub fn update_settings_file(
+    old_text: String,
+    old_file_content: SettingsFileContent,
+    update: impl FnOnce(&mut SettingsFileContent),
+) -> String {
+    let mut new_file_content = old_file_content.clone();
+    update(&mut new_file_content);
+
+    let old_json = to_json_object(old_file_content);
+    let new_json = to_json_object(new_file_content);
+
+    // Find changed fields
+    let mut diffs = vec![];
+    for (key, old_value) in old_json.iter() {
+        let new_value = new_json.get(key).unwrap();
+        if old_value != new_value {
+            if matches!(
+                new_value,
+                &Value::Null | &Value::Object(_) | &Value::Array(_)
+            ) {
+                unimplemented!("We only support updating basic values at the top level");
+            }
+
+            let new_json = serde_json::to_string_pretty(new_value)
+                .expect("Could not serialize new json field to string");
+
+            diffs.push((key, new_json));
+        }
+    }
+
+    let mut new_text = old_text;
+    for (key, new_value) in diffs {
+        new_text = write_settings_key(new_text, key, &new_value)
+    }
+    new_text
+}
+
+fn to_json_object(settings_file: SettingsFileContent) -> serde_json::Map<String, Value> {
+    let tmp = serde_json::to_value(settings_file).unwrap();
+    match tmp {
+        Value::Object(map) => map,
+        _ => unreachable!("SettingsFileContent represents a JSON map"),
+    }
+}
+
 #[cfg(test)]
 mod tests {
-    use crate::write_top_level_setting;
+    use super::*;
     use unindent::Unindent;
 
+    fn assert_new_settings<S1: Into<String>, S2: Into<String>>(
+        old_json: S1,
+        update: fn(&mut SettingsFileContent),
+        expected_new_json: S2,
+    ) {
+        let old_json = old_json.into();
+        let old_content: SettingsFileContent = serde_json::from_str(&old_json).unwrap();
+        let new_json = update_settings_file(old_json, old_content, update);
+        assert_eq!(new_json, expected_new_json.into());
+    }
+
+    #[test]
+    fn test_update_telemetry_setting_multiple_fields() {
+        assert_new_settings(
+            r#"{
+                "telemetry": {
+                    "metrics": false,
+                    "diagnostics": false
+                }
+            }"#
+            .unindent(),
+            |settings| {
+                settings.telemetry.set_diagnostics(true);
+                settings.telemetry.set_metrics(true);
+            },
+            r#"{
+                "telemetry": {
+                    "metrics": true,
+                    "diagnostics": true
+                }
+            }"#
+            .unindent(),
+        );
+    }
+
+    #[test]
+    fn test_update_telemetry_setting_weird_formatting() {
+        assert_new_settings(
+            r#"{
+                "telemetry":   { "metrics": false, "diagnostics": true }
+            }"#
+            .unindent(),
+            |settings| settings.telemetry.set_diagnostics(false),
+            r#"{
+                "telemetry":   { "metrics": false, "diagnostics": false }
+            }"#
+            .unindent(),
+        );
+    }
+
+    #[test]
+    fn test_update_telemetry_setting_other_fields() {
+        assert_new_settings(
+            r#"{
+                "telemetry": {
+                    "metrics": false,
+                    "diagnostics": true
+                }
+            }"#
+            .unindent(),
+            |settings| settings.telemetry.set_diagnostics(false),
+            r#"{
+                "telemetry": {
+                    "metrics": false,
+                    "diagnostics": false
+                }
+            }"#
+            .unindent(),
+        );
+    }
+
+    #[test]
+    fn test_update_telemetry_setting_pre_existing() {
+        assert_new_settings(
+            r#"{
+                "telemetry": {
+                    "diagnostics": true
+                }
+            }"#
+            .unindent(),
+            |settings| settings.telemetry.set_diagnostics(false),
+            r#"{
+                "telemetry": {
+                    "diagnostics": false
+                }
+            }"#
+            .unindent(),
+        );
+    }
+
+    #[test]
+    fn test_update_telemetry_setting() {
+        assert_new_settings(
+            "{}",
+            |settings| settings.telemetry.set_diagnostics(true),
+            r#"{
+                "telemetry": {
+                    "diagnostics": true
+                }
+            }"#
+            .unindent(),
+        );
+    }
+
     #[test]
     fn test_write_theme_into_settings_with_theme() {
         let settings = r#"
@@ -807,8 +965,7 @@ mod tests {
         "#
         .unindent();
 
-        let settings_after_theme =
-            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+        let settings_after_theme = write_settings_key(settings, "theme", "\"summerfruit-light\"");
 
         assert_eq!(settings_after_theme, new_settings)
     }
@@ -828,8 +985,7 @@ mod tests {
         "#
         .unindent();
 
-        let settings_after_theme =
-            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+        let settings_after_theme = write_settings_key(settings, "theme", "\"summerfruit-light\"");
 
         assert_eq!(settings_after_theme, new_settings)
     }
@@ -845,8 +1001,7 @@ mod tests {
         "#
         .unindent();
 
-        let settings_after_theme =
-            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+        let settings_after_theme = write_settings_key(settings, "theme", "\"summerfruit-light\"");
 
         assert_eq!(settings_after_theme, new_settings)
     }
@@ -856,8 +1011,7 @@ mod tests {
         let settings = r#"{ "a": "", "ok": true }"#.to_string();
         let new_settings = r#"{ "theme": "summerfruit-light", "a": "", "ok": true }"#;
 
-        let settings_after_theme =
-            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+        let settings_after_theme = write_settings_key(settings, "theme", "\"summerfruit-light\"");
 
         assert_eq!(settings_after_theme, new_settings)
     }
@@ -867,8 +1021,7 @@ mod tests {
         let settings = r#"          { "a": "", "ok": true }"#.to_string();
         let new_settings = r#"          { "theme": "summerfruit-light", "a": "", "ok": true }"#;
 
-        let settings_after_theme =
-            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+        let settings_after_theme = write_settings_key(settings, "theme", "\"summerfruit-light\"");
 
         assert_eq!(settings_after_theme, new_settings)
     }
@@ -890,8 +1043,7 @@ mod tests {
         "#
         .unindent();
 
-        let settings_after_theme =
-            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+        let settings_after_theme = write_settings_key(settings, "theme", "\"summerfruit-light\"");
 
         assert_eq!(settings_after_theme, new_settings)
     }

crates/settings/src/settings_file.rs 🔗

@@ -1,8 +1,7 @@
-use crate::{watched_json::WatchedJsonFile, write_top_level_setting, SettingsFileContent};
+use crate::{update_settings_file, watched_json::WatchedJsonFile, SettingsFileContent};
 use anyhow::Result;
 use fs::Fs;
 use gpui::MutableAppContext;
-use serde_json::Value;
 use std::{path::Path, sync::Arc};
 
 // TODO: Switch SettingsFile to open a worktree and buffer for synchronization
@@ -27,57 +26,24 @@ impl SettingsFile {
         }
     }
 
-    pub fn update(cx: &mut MutableAppContext, update: impl FnOnce(&mut SettingsFileContent)) {
+    pub fn update(
+        cx: &mut MutableAppContext,
+        update: impl 'static + Send + FnOnce(&mut SettingsFileContent),
+    ) {
         let this = cx.global::<SettingsFile>();
 
         let current_file_content = this.settings_file_content.current();
-        let mut new_file_content = current_file_content.clone();
-
-        update(&mut new_file_content);
 
         let fs = this.fs.clone();
         let path = this.path.clone();
 
         cx.background()
             .spawn(async move {
-                // Unwrap safety: These values are all guarnteed to be well formed, and we know
-                // that they will deserialize to our settings object. All of the following unwraps
-                // are therefore safe.
-                let tmp = serde_json::to_value(current_file_content).unwrap();
-                let old_json = tmp.as_object().unwrap();
-
-                let new_tmp = serde_json::to_value(new_file_content).unwrap();
-                let new_json = new_tmp.as_object().unwrap();
-
-                // Find changed fields
-                let mut diffs = vec![];
-                for (key, old_value) in old_json.iter() {
-                    let new_value = new_json.get(key).unwrap();
-                    if old_value != new_value {
-                        if matches!(
-                            new_value,
-                            &Value::Null | &Value::Object(_) | &Value::Array(_)
-                        ) {
-                            unimplemented!(
-                                "We only support updating basic values at the top level"
-                            );
-                        }
-
-                        let new_json = serde_json::to_string_pretty(new_value)
-                            .expect("Could not serialize new json field to string");
-
-                        diffs.push((key, new_json));
-                    }
-                }
+                let old_text = fs.load(path).await?;
 
-                // Have diffs, rewrite the settings file now.
-                let mut content = fs.load(path).await?;
-
-                for (key, new_value) in diffs {
-                    content = write_top_level_setting(content, key, &new_value)
-                }
+                let new_text = update_settings_file(old_text, current_file_content, update);
 
-                fs.atomic_write(path.to_path_buf(), content).await?;
+                fs.atomic_write(path.to_path_buf(), new_text).await?;
 
                 Ok(()) as Result<()>
             })

crates/theme/src/theme.rs 🔗

@@ -37,6 +37,7 @@ pub struct Theme {
     pub tooltip: TooltipStyle,
     pub terminal: TerminalStyle,
     pub feedback: FeedbackStyle,
+    pub welcome: WelcomeStyle,
     pub color_scheme: ColorScheme,
 }
 
@@ -850,6 +851,20 @@ pub struct FeedbackStyle {
     pub link_text_hover: ContainedText,
 }
 
+#[derive(Clone, Deserialize, Default)]
+pub struct WelcomeStyle {
+    pub checkbox: CheckboxStyle,
+}
+
+#[derive(Clone, Deserialize, Default)]
+pub struct CheckboxStyle {
+    pub width: f32,
+    pub height: f32,
+    pub unchecked: ContainerStyle,
+    pub checked: ContainerStyle,
+    pub hovered: ContainerStyle,
+}
+
 #[derive(Clone, Deserialize, Default)]
 pub struct ColorScheme {
     pub name: String,

crates/welcome/Cargo.toml 🔗

@@ -13,6 +13,7 @@ test-support = []
 [dependencies]
 anyhow = "1.0.38"
 log = "0.4"
+editor = { path = "../editor" }
 gpui = { path = "../gpui" }
 project = { path = "../project" }
 settings = { path = "../settings" }

crates/welcome/src/welcome.rs 🔗

@@ -1,19 +1,22 @@
 use gpui::{
     color::Color,
-    elements::{Flex, Label, ParentElement, Svg},
-    Element, Entity, MutableAppContext, View,
+    elements::{Empty, Flex, Label, MouseEventHandler, ParentElement, Svg},
+    Element, ElementBox, Entity, MutableAppContext, RenderContext, Subscription, View, ViewContext,
 };
-use settings::Settings;
+use settings::{settings_file::SettingsFile, Settings, SettingsFileContent};
+use theme::CheckboxStyle;
 use workspace::{item::Item, Welcome, Workspace};
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(|workspace: &mut Workspace, _: &Welcome, cx| {
-        let welcome_page = cx.add_view(|_cx| WelcomePage);
+        let welcome_page = cx.add_view(WelcomePage::new);
         workspace.add_item(Box::new(welcome_page), cx)
     })
 }
 
-struct WelcomePage;
+struct WelcomePage {
+    _settings_subscription: Subscription,
+}
 
 impl Entity for WelcomePage {
     type Event = ();
@@ -24,12 +27,21 @@ impl View for WelcomePage {
         "WelcomePage"
     }
 
-    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
-        let theme = &cx.global::<Settings>().theme;
+    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
+        let settings = cx.global::<Settings>();
+        let theme = settings.theme.clone();
+
+        let (diagnostics, metrics) = {
+            let telemetry = settings.telemetry();
+            (telemetry.diagnostics(), telemetry.metrics())
+        };
+
+        enum Metrics {}
+        enum Diagnostics {}
 
-        Flex::new(gpui::Axis::Vertical)
+        Flex::column()
             .with_children([
-                Flex::new(gpui::Axis::Horizontal)
+                Flex::row()
                     .with_children([
                         Svg::new("icons/terminal_16.svg")
                             .with_color(Color::red())
@@ -47,11 +59,88 @@ impl View for WelcomePage {
                     theme.editor.hover_popover.prose.clone(),
                 )
                 .boxed(),
+                Flex::row()
+                    .with_children([
+                        self.render_settings_checkbox::<Metrics>(
+                            &theme.welcome.checkbox,
+                            metrics,
+                            cx,
+                            |content, checked| {
+                                content.telemetry.set_metrics(checked);
+                            },
+                        ),
+                        Label::new(
+                            "Do you want to send telemetry?",
+                            theme.editor.hover_popover.prose.clone(),
+                        )
+                        .boxed(),
+                    ])
+                    .boxed(),
+                Flex::row()
+                    .with_children([
+                        self.render_settings_checkbox::<Diagnostics>(
+                            &theme.welcome.checkbox,
+                            diagnostics,
+                            cx,
+                            |content, checked| content.telemetry.set_diagnostics(checked),
+                        ),
+                        Label::new(
+                            "Send crash reports",
+                            theme.editor.hover_popover.prose.clone(),
+                        )
+                        .boxed(),
+                    ])
+                    .boxed(),
             ])
+            .aligned()
             .boxed()
     }
 }
 
+impl WelcomePage {
+    fn new(cx: &mut ViewContext<Self>) -> Self {
+        let handle = cx.weak_handle();
+
+        let settings_subscription = cx.observe_global::<Settings, _>(move |cx| {
+            if let Some(handle) = handle.upgrade(cx) {
+                handle.update(cx, |_, cx| cx.notify())
+            }
+        });
+
+        WelcomePage {
+            _settings_subscription: settings_subscription,
+        }
+    }
+
+    fn render_settings_checkbox<T: 'static>(
+        &self,
+        style: &CheckboxStyle,
+        checked: bool,
+        cx: &mut RenderContext<Self>,
+        set_value: fn(&mut SettingsFileContent, checked: bool) -> (),
+    ) -> ElementBox {
+        MouseEventHandler::<T>::new(0, cx, |state, _| {
+            Empty::new()
+                .constrained()
+                .with_width(style.width)
+                .with_height(style.height)
+                .contained()
+                .with_style(if checked {
+                    style.checked
+                } else if state.hovered() {
+                    style.hovered
+                } else {
+                    style.unchecked
+                })
+                .boxed()
+        })
+        .on_click(gpui::MouseButton::Left, move |_, cx| {
+            SettingsFile::update(cx, move |content| set_value(content, !checked))
+        })
+        .boxed()
+    }
+}
+
 impl Item for WelcomePage {
     fn tab_content(
         &self,

styles/src/styleTree/app.ts 🔗

@@ -20,6 +20,7 @@ import contactList from "./contactList"
 import incomingCallNotification from "./incomingCallNotification"
 import { ColorScheme } from "../themes/common/colorScheme"
 import feedback from "./feedback"
+import welcome from "./welcome"
 
 export default function app(colorScheme: ColorScheme): Object {
     return {
@@ -33,6 +34,7 @@ export default function app(colorScheme: ColorScheme): Object {
         incomingCallNotification: incomingCallNotification(colorScheme),
         picker: picker(colorScheme),
         workspace: workspace(colorScheme),
+        welcome: welcome(colorScheme),
         contextMenu: contextMenu(colorScheme),
         editor: editor(colorScheme),
         projectDiagnostics: projectDiagnostics(colorScheme),

styles/src/styleTree/welcome.ts 🔗

@@ -0,0 +1,34 @@
+
+import { ColorScheme } from "../themes/common/colorScheme";
+import { border } from "./components";
+
+export default function welcome(colorScheme: ColorScheme) {
+    let layer = colorScheme.highest;
+
+    // TODO
+    let checkbox_base = {
+        background: colorScheme.ramps.red(0.5).hex(),
+        cornerRadius: 8,
+        padding: {
+            left: 8,
+            right: 8,
+            top: 4,
+            bottom: 4,
+        },
+        shadow: colorScheme.popoverShadow,
+        border: border(layer),
+        margin: {
+            left: -8,
+        },
+    };
+
+    return {
+        checkbox: {
+            width: 9,
+            height: 9,
+            unchecked: checkbox_base,
+            checked: checkbox_base,
+            hovered: checkbox_base
+        }
+    }
+}