Merge remote-tracking branch 'origin/main' into zed2-workspace

Conrad Irwin created

Change summary

.github/workflows/release_actions.yml            |  45 +-
Cargo.lock                                       |   2 
assets/icons/at-sign.svg                         |   1 
assets/icons/bell-off.svg                        |   1 
assets/icons/bell-ring.svg                       |   1 
assets/icons/bell.svg                            |   4 
assets/icons/mail-open.svg                       |   1 
crates/ai/src/test.rs                            |  11 
crates/assistant/src/assistant_panel.rs          |  35 
crates/assistant/src/codegen.rs                  |   4 
crates/collab/Cargo.toml                         |   2 
crates/db2/src/db2.rs                            | 274 +++++++------
crates/db2/src/kvp.rs                            |  58 +-
crates/gpui2/src/element.rs                      |  15 
crates/gpui2/src/window.rs                       |  83 +++
crates/storybook2/src/storybook2.rs              |   2 
crates/theme2/src/colors.rs                      |   3 
crates/theme2/src/default_colors.rs              | 351 +++++++++--------
crates/theme2/src/theme2.rs                      |  12 
crates/ui2/src/components/list.rs                | 164 +++++---
crates/ui2/src/components/notifications_panel.rs | 354 +++++++++++++++++
crates/ui2/src/components/panel.rs               |  16 
crates/ui2/src/components/tab.rs                 |  14 
crates/ui2/src/components/tab_bar.rs             |   8 
crates/ui2/src/elements.rs                       |   2 
crates/ui2/src/elements/icon.rs                  |  35 +
crates/ui2/src/elements/indicator.rs             |  23 +
crates/ui2/src/elements/input.rs                 |  15 
crates/ui2/src/elements/label.rs                 |  11 
crates/ui2/src/lib.rs                            |   1 
crates/ui2/src/prelude.rs                        |  18 
crates/ui2/src/static_data.rs                    | 241 +++++++++++
crates/ui2/src/utils.rs                          |   3 
crates/ui2/src/utils/format_distance.rs          | 173 ++++++++
34 files changed, 1,452 insertions(+), 531 deletions(-)

Detailed changes

.github/workflows/release_actions.yml 🔗

@@ -6,26 +6,27 @@ jobs:
   discord_release:
     runs-on: ubuntu-latest
     steps:
-    - name: Get release URL
-      id: get-release-url
-      run: |
-        if [ "${{ github.event.release.prerelease }}" == "true" ]; then
-          URL="https://zed.dev/releases/preview/latest"
-        else
-          URL="https://zed.dev/releases/stable/latest"
-        fi
-        echo "::set-output name=URL::$URL"
-    - name: Get content
-      uses: 2428392/gh-truncate-string-action@v1.2.0
-      id: get-content
-      with:
-        stringToTruncate: |
-          📣 Zed [${{ github.event.release.tag_name }}](${{ steps.get-release-url.outputs.URL }}) was just released!
+      - name: Get release URL
+        id: get-release-url
+        run: |
+          if [ "${{ github.event.release.prerelease }}" == "true" ]; then
+            URL="https://zed.dev/releases/preview/latest"
+          else
+            URL="https://zed.dev/releases/stable/latest"
+          fi
+          echo "::set-output name=URL::$URL"
+      - name: Get content
+        uses: 2428392/gh-truncate-string-action@v1.3.0
+        id: get-content
+        with:
+          stringToTruncate: |
+            📣 Zed [${{ github.event.release.tag_name }}](${{ steps.get-release-url.outputs.URL }}) was just released!
 
-          ${{ github.event.release.body }}
-        maxLength: 2000
-    - name: Discord Webhook Action
-      uses: tsickert/discord-webhook@v5.3.0
-      with:
-        webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
-        content: ${{ steps.get-content.outputs.string }}
+            ${{ github.event.release.body }}
+          maxLength: 2000
+          truncationSymbol: "..."
+      - name: Discord Webhook Action
+        uses: tsickert/discord-webhook@v5.3.0
+        with:
+          webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
+          content: ${{ steps.get-content.outputs.string }}

Cargo.lock 🔗

@@ -1603,7 +1603,7 @@ dependencies = [
 
 [[package]]
 name = "collab"
-version = "0.27.0"
+version = "0.28.0"
 dependencies = [
  "anyhow",
  "async-trait",

assets/icons/at-sign.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-at-sign"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/></svg>

assets/icons/bell-off.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bell-off"><path d="M8.7 3A6 6 0 0 1 18 8a21.3 21.3 0 0 0 .6 5"/><path d="M17 17H3s3-2 3-9a4.67 4.67 0 0 1 .3-1.7"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="m2 2 20 20"/></svg>

assets/icons/bell-ring.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bell-ring"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg>

assets/icons/bell.svg 🔗

@@ -1,8 +1 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
-  <path
-    fill-rule="evenodd"
-    clip-rule="evenodd"

assets/icons/mail-open.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail-open"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></svg>

crates/ai/src/test.rs 🔗

@@ -153,10 +153,17 @@ impl FakeCompletionProvider {
 
     pub fn send_completion(&self, completion: impl Into<String>) {
         let mut tx = self.last_completion_tx.lock();
-        tx.as_mut().unwrap().try_send(completion.into()).unwrap();
+
+        println!("COMPLETION TX: {:?}", &tx);
+
+        let a = tx.as_mut().unwrap();
+        a.try_send(completion.into()).unwrap();
+
+        // tx.as_mut().unwrap().try_send(completion.into()).unwrap();
     }
 
     pub fn finish_completion(&self) {
+        println!("FINISHING COMPLETION");
         self.last_completion_tx.lock().take().unwrap();
     }
 }
@@ -181,8 +188,10 @@ impl CompletionProvider for FakeCompletionProvider {
         &self,
         _prompt: Box<dyn CompletionRequest>,
     ) -> BoxFuture<'static, anyhow::Result<BoxStream<'static, anyhow::Result<String>>>> {
+        println!("COMPLETING");
         let (tx, rx) = mpsc::channel(1);
         *self.last_completion_tx.lock() = Some(tx);
+        println!("TX: {:?}", *self.last_completion_tx.lock());
         async move { Ok(rx.map(|rx| Ok(rx)).boxed()) }.boxed()
     }
     fn box_clone(&self) -> Box<dyn CompletionProvider> {

crates/assistant/src/assistant_panel.rs 🔗

@@ -142,7 +142,7 @@ pub struct AssistantPanel {
     zoomed: bool,
     has_focus: bool,
     toolbar: ViewHandle<Toolbar>,
-    completion_provider: Box<dyn CompletionProvider>,
+    completion_provider: Arc<dyn CompletionProvider>,
     api_key_editor: Option<ViewHandle<Editor>>,
     languages: Arc<LanguageRegistry>,
     fs: Arc<dyn Fs>,
@@ -204,7 +204,7 @@ impl AssistantPanel {
 
                     let semantic_index = SemanticIndex::global(cx);
                     // Defaulting currently to GPT4, allow for this to be set via config.
-                    let completion_provider = Box::new(OpenAICompletionProvider::new(
+                    let completion_provider = Arc::new(OpenAICompletionProvider::new(
                         "gpt-4",
                         cx.background().clone(),
                     ));
@@ -259,7 +259,13 @@ impl AssistantPanel {
         cx: &mut ViewContext<Workspace>,
     ) {
         let this = if let Some(this) = workspace.panel::<AssistantPanel>(cx) {
-            if this.update(cx, |assistant, _| assistant.has_credentials()) {
+            if this.update(cx, |assistant, cx| {
+                if !assistant.has_credentials() {
+                    assistant.load_credentials(cx);
+                };
+
+                assistant.has_credentials()
+            }) {
                 this
             } else {
                 workspace.focus_panel::<AssistantPanel>(cx);
@@ -320,13 +326,10 @@ impl AssistantPanel {
         };
 
         let inline_assist_id = post_inc(&mut self.next_inline_assist_id);
-        let provider = Arc::new(OpenAICompletionProvider::new(
-            "gpt-4",
-            cx.background().clone(),
-        ));
+        let provider = self.completion_provider.clone();
 
         // Retrieve Credentials Authenticates the Provider
-        // provider.retrieve_credentials(cx);
+        provider.retrieve_credentials(cx);
 
         let codegen = cx.add_model(|cx| {
             Codegen::new(editor.read(cx).buffer().clone(), codegen_kind, provider, cx)
@@ -1439,7 +1442,7 @@ struct Conversation {
     pending_save: Task<Result<()>>,
     path: Option<PathBuf>,
     _subscriptions: Vec<Subscription>,
-    completion_provider: Box<dyn CompletionProvider>,
+    completion_provider: Arc<dyn CompletionProvider>,
 }
 
 impl Entity for Conversation {
@@ -1450,7 +1453,7 @@ impl Conversation {
     fn new(
         language_registry: Arc<LanguageRegistry>,
         cx: &mut ModelContext<Self>,
-        completion_provider: Box<dyn CompletionProvider>,
+        completion_provider: Arc<dyn CompletionProvider>,
     ) -> Self {
         let markdown = language_registry.language_for_name("Markdown");
         let buffer = cx.add_model(|cx| {
@@ -1544,7 +1547,7 @@ impl Conversation {
             None => Some(Uuid::new_v4().to_string()),
         };
         let model = saved_conversation.model;
-        let completion_provider: Box<dyn CompletionProvider> = Box::new(
+        let completion_provider: Arc<dyn CompletionProvider> = Arc::new(
             OpenAICompletionProvider::new(model.full_name(), cx.background().clone()),
         );
         completion_provider.retrieve_credentials(cx);
@@ -2201,7 +2204,7 @@ struct ConversationEditor {
 
 impl ConversationEditor {
     fn new(
-        completion_provider: Box<dyn CompletionProvider>,
+        completion_provider: Arc<dyn CompletionProvider>,
         language_registry: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
         workspace: WeakViewHandle<Workspace>,
@@ -3406,7 +3409,7 @@ mod tests {
         init(cx);
         let registry = Arc::new(LanguageRegistry::test());
 
-        let completion_provider = Box::new(FakeCompletionProvider::new());
+        let completion_provider = Arc::new(FakeCompletionProvider::new());
         let conversation = cx.add_model(|cx| Conversation::new(registry, cx, completion_provider));
         let buffer = conversation.read(cx).buffer.clone();
 
@@ -3535,7 +3538,7 @@ mod tests {
         cx.set_global(SettingsStore::test(cx));
         init(cx);
         let registry = Arc::new(LanguageRegistry::test());
-        let completion_provider = Box::new(FakeCompletionProvider::new());
+        let completion_provider = Arc::new(FakeCompletionProvider::new());
 
         let conversation = cx.add_model(|cx| Conversation::new(registry, cx, completion_provider));
         let buffer = conversation.read(cx).buffer.clone();
@@ -3633,7 +3636,7 @@ mod tests {
         cx.set_global(SettingsStore::test(cx));
         init(cx);
         let registry = Arc::new(LanguageRegistry::test());
-        let completion_provider = Box::new(FakeCompletionProvider::new());
+        let completion_provider = Arc::new(FakeCompletionProvider::new());
         let conversation = cx.add_model(|cx| Conversation::new(registry, cx, completion_provider));
         let buffer = conversation.read(cx).buffer.clone();
 
@@ -3716,7 +3719,7 @@ mod tests {
         cx.set_global(SettingsStore::test(cx));
         init(cx);
         let registry = Arc::new(LanguageRegistry::test());
-        let completion_provider = Box::new(FakeCompletionProvider::new());
+        let completion_provider = Arc::new(FakeCompletionProvider::new());
         let conversation =
             cx.add_model(|cx| Conversation::new(registry.clone(), cx, completion_provider));
         let buffer = conversation.read(cx).buffer.clone();

crates/assistant/src/codegen.rs 🔗

@@ -367,6 +367,8 @@ fn strip_invalid_spans_from_codeblock(
 
 #[cfg(test)]
 mod tests {
+    use std::sync::Arc;
+
     use super::*;
     use ai::test::FakeCompletionProvider;
     use futures::stream::{self};
@@ -437,6 +439,7 @@ mod tests {
             let max_len = cmp::min(new_text.len(), 10);
             let len = rng.gen_range(1..=max_len);
             let (chunk, suffix) = new_text.split_at(len);
+            println!("CHUNK: {:?}", &chunk);
             provider.send_completion(chunk);
             new_text = suffix;
             deterministic.run_until_parked();
@@ -569,6 +572,7 @@ mod tests {
             let max_len = cmp::min(new_text.len(), 10);
             let len = rng.gen_range(1..=max_len);
             let (chunk, suffix) = new_text.split_at(len);
+            println!("{:?}", &chunk);
             provider.send_completion(chunk);
             new_text = suffix;
             deterministic.run_until_parked();

crates/collab/Cargo.toml 🔗

@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
 default-run = "collab"
 edition = "2021"
 name = "collab"
-version = "0.27.0"
+version = "0.28.0"
 publish = false
 
 [[bin]]

crates/db2/src/db2.rs 🔗

@@ -190,138 +190,142 @@ where
         .detach()
 }
 
-// #[cfg(test)]
-// mod tests {
-//     use std::thread;
-
-//     use sqlez::domain::Domain;
-//     use sqlez_macros::sql;
-//     use tempdir::TempDir;
-
-//     use crate::open_db;
-
-//     // Test bad migration panics
-//     #[gpui::test]
-//     #[should_panic]
-//     async fn test_bad_migration_panics() {
-//         enum BadDB {}
-
-//         impl Domain for BadDB {
-//             fn name() -> &'static str {
-//                 "db_tests"
-//             }
-
-//             fn migrations() -> &'static [&'static str] {
-//                 &[
-//                     sql!(CREATE TABLE test(value);),
-//                     // failure because test already exists
-//                     sql!(CREATE TABLE test(value);),
-//                 ]
-//             }
-//         }
-
-//         let tempdir = TempDir::new("DbTests").unwrap();
-//         let _bad_db = open_db::<BadDB>(tempdir.path(), &util::channel::ReleaseChannel::Dev).await;
-//     }
-
-//     /// Test that DB exists but corrupted (causing recreate)
-//     #[gpui::test]
-//     async fn test_db_corruption() {
-//         enum CorruptedDB {}
-
-//         impl Domain for CorruptedDB {
-//             fn name() -> &'static str {
-//                 "db_tests"
-//             }
-
-//             fn migrations() -> &'static [&'static str] {
-//                 &[sql!(CREATE TABLE test(value);)]
-//             }
-//         }
-
-//         enum GoodDB {}
-
-//         impl Domain for GoodDB {
-//             fn name() -> &'static str {
-//                 "db_tests" //Notice same name
-//             }
-
-//             fn migrations() -> &'static [&'static str] {
-//                 &[sql!(CREATE TABLE test2(value);)] //But different migration
-//             }
-//         }
-
-//         let tempdir = TempDir::new("DbTests").unwrap();
-//         {
-//             let corrupt_db =
-//                 open_db::<CorruptedDB>(tempdir.path(), &util::channel::ReleaseChannel::Dev).await;
-//             assert!(corrupt_db.persistent());
-//         }
-
-//         let good_db = open_db::<GoodDB>(tempdir.path(), &util::channel::ReleaseChannel::Dev).await;
-//         assert!(
-//             good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()
-//                 .unwrap()
-//                 .is_none()
-//         );
-//     }
-
-//     /// Test that DB exists but corrupted (causing recreate)
-//     #[gpui::test(iterations = 30)]
-//     async fn test_simultaneous_db_corruption() {
-//         enum CorruptedDB {}
-
-//         impl Domain for CorruptedDB {
-//             fn name() -> &'static str {
-//                 "db_tests"
-//             }
-
-//             fn migrations() -> &'static [&'static str] {
-//                 &[sql!(CREATE TABLE test(value);)]
-//             }
-//         }
-
-//         enum GoodDB {}
-
-//         impl Domain for GoodDB {
-//             fn name() -> &'static str {
-//                 "db_tests" //Notice same name
-//             }
-
-//             fn migrations() -> &'static [&'static str] {
-//                 &[sql!(CREATE TABLE test2(value);)] //But different migration
-//             }
-//         }
-
-//         let tempdir = TempDir::new("DbTests").unwrap();
-//         {
-//             // Setup the bad database
-//             let corrupt_db =
-//                 open_db::<CorruptedDB>(tempdir.path(), &util::channel::ReleaseChannel::Dev).await;
-//             assert!(corrupt_db.persistent());
-//         }
-
-//         // Try to connect to it a bunch of times at once
-//         let mut guards = vec![];
-//         for _ in 0..10 {
-//             let tmp_path = tempdir.path().to_path_buf();
-//             let guard = thread::spawn(move || {
-//                 let good_db = smol::block_on(open_db::<GoodDB>(
-//                     tmp_path.as_path(),
-//                     &util::channel::ReleaseChannel::Dev,
-//                 ));
-//                 assert!(
-//                     good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()
-//                         .unwrap()
-//                         .is_none()
-//                 );
-//             });
-
-//             guards.push(guard);
-//         }
-
-//         for guard in guards.into_iter() {
-//             assert!(guard.join().is_ok());
-//         }
-//     }
-// }
+#[cfg(test)]
+mod tests {
+    use std::thread;
+
+    use sqlez::domain::Domain;
+    use sqlez_macros::sql;
+    use tempdir::TempDir;
+
+    use crate::open_db;
+
+    // Test bad migration panics
+    #[gpui2::test]
+    #[should_panic]
+    async fn test_bad_migration_panics() {
+        enum BadDB {}
+
+        impl Domain for BadDB {
+            fn name() -> &'static str {
+                "db_tests"
+            }
+
+            fn migrations() -> &'static [&'static str] {
+                &[
+                    sql!(CREATE TABLE test(value);),
+                    // failure because test already exists
+                    sql!(CREATE TABLE test(value);),
+                ]
+            }
+        }
+
+        let tempdir = TempDir::new("DbTests").unwrap();
+        let _bad_db = open_db::<BadDB>(tempdir.path(), &util::channel::ReleaseChannel::Dev).await;
+    }
+
+    /// Test that DB exists but corrupted (causing recreate)
+    #[gpui2::test]
+    async fn test_db_corruption(cx: &mut gpui2::TestAppContext) {
+        cx.executor().allow_parking();
+
+        enum CorruptedDB {}
+
+        impl Domain for CorruptedDB {
+            fn name() -> &'static str {
+                "db_tests"
+            }
+
+            fn migrations() -> &'static [&'static str] {
+                &[sql!(CREATE TABLE test(value);)]
+            }
+        }
+
+        enum GoodDB {}
+
+        impl Domain for GoodDB {
+            fn name() -> &'static str {
+                "db_tests" //Notice same name
+            }
+
+            fn migrations() -> &'static [&'static str] {
+                &[sql!(CREATE TABLE test2(value);)] //But different migration
+            }
+        }
+
+        let tempdir = TempDir::new("DbTests").unwrap();
+        {
+            let corrupt_db =
+                open_db::<CorruptedDB>(tempdir.path(), &util::channel::ReleaseChannel::Dev).await;
+            assert!(corrupt_db.persistent());
+        }
+
+        let good_db = open_db::<GoodDB>(tempdir.path(), &util::channel::ReleaseChannel::Dev).await;
+        assert!(
+            good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()
+                .unwrap()
+                .is_none()
+        );
+    }
+
+    /// Test that DB exists but corrupted (causing recreate)
+    #[gpui2::test(iterations = 30)]
+    async fn test_simultaneous_db_corruption(cx: &mut gpui2::TestAppContext) {
+        cx.executor().allow_parking();
+
+        enum CorruptedDB {}
+
+        impl Domain for CorruptedDB {
+            fn name() -> &'static str {
+                "db_tests"
+            }
+
+            fn migrations() -> &'static [&'static str] {
+                &[sql!(CREATE TABLE test(value);)]
+            }
+        }
+
+        enum GoodDB {}
+
+        impl Domain for GoodDB {
+            fn name() -> &'static str {
+                "db_tests" //Notice same name
+            }
+
+            fn migrations() -> &'static [&'static str] {
+                &[sql!(CREATE TABLE test2(value);)] //But different migration
+            }
+        }
+
+        let tempdir = TempDir::new("DbTests").unwrap();
+        {
+            // Setup the bad database
+            let corrupt_db =
+                open_db::<CorruptedDB>(tempdir.path(), &util::channel::ReleaseChannel::Dev).await;
+            assert!(corrupt_db.persistent());
+        }
+
+        // Try to connect to it a bunch of times at once
+        let mut guards = vec![];
+        for _ in 0..10 {
+            let tmp_path = tempdir.path().to_path_buf();
+            let guard = thread::spawn(move || {
+                let good_db = smol::block_on(open_db::<GoodDB>(
+                    tmp_path.as_path(),
+                    &util::channel::ReleaseChannel::Dev,
+                ));
+                assert!(
+                    good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()
+                        .unwrap()
+                        .is_none()
+                );
+            });
+
+            guards.push(guard);
+        }
+
+        for guard in guards.into_iter() {
+            assert!(guard.join().is_ok());
+        }
+    }
+}

crates/db2/src/kvp.rs 🔗

@@ -31,32 +31,32 @@ impl KeyValueStore {
     }
 }
 
-// #[cfg(test)]
-// mod tests {
-//     use crate::kvp::KeyValueStore;
-
-//     #[gpui::test]
-//     async fn test_kvp() {
-//         let db = KeyValueStore(crate::open_test_db("test_kvp").await);
-
-//         assert_eq!(db.read_kvp("key-1").unwrap(), None);
-
-//         db.write_kvp("key-1".to_string(), "one".to_string())
-//             .await
-//             .unwrap();
-//         assert_eq!(db.read_kvp("key-1").unwrap(), Some("one".to_string()));
-
-//         db.write_kvp("key-1".to_string(), "one-2".to_string())
-//             .await
-//             .unwrap();
-//         assert_eq!(db.read_kvp("key-1").unwrap(), Some("one-2".to_string()));
-
-//         db.write_kvp("key-2".to_string(), "two".to_string())
-//             .await
-//             .unwrap();
-//         assert_eq!(db.read_kvp("key-2").unwrap(), Some("two".to_string()));
-
-//         db.delete_kvp("key-1".to_string()).await.unwrap();
-//         assert_eq!(db.read_kvp("key-1").unwrap(), None);
-//     }
-// }
+#[cfg(test)]
+mod tests {
+    use crate::kvp::KeyValueStore;
+
+    #[gpui2::test]
+    async fn test_kvp() {
+        let db = KeyValueStore(crate::open_test_db("test_kvp").await);
+
+        assert_eq!(db.read_kvp("key-1").unwrap(), None);
+
+        db.write_kvp("key-1".to_string(), "one".to_string())
+            .await
+            .unwrap();
+        assert_eq!(db.read_kvp("key-1").unwrap(), Some("one".to_string()));
+
+        db.write_kvp("key-1".to_string(), "one-2".to_string())
+            .await
+            .unwrap();
+        assert_eq!(db.read_kvp("key-1").unwrap(), Some("one-2".to_string()));
+
+        db.write_kvp("key-2".to_string(), "two".to_string())
+            .await
+            .unwrap();
+        assert_eq!(db.read_kvp("key-2").unwrap(), Some("two".to_string()));
+
+        db.delete_kvp("key-1".to_string()).await.unwrap();
+        assert_eq!(db.read_kvp("key-1").unwrap(), None);
+    }
+}

crates/gpui2/src/element.rs 🔗

@@ -198,14 +198,19 @@ impl<V> AnyElement<V> {
 pub trait Component<V> {
     fn render(self) -> AnyElement<V>;
 
-    fn when(mut self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
+    fn map<U>(self, f: impl FnOnce(Self) -> U) -> U
     where
         Self: Sized,
+        U: Component<V>,
     {
-        if condition {
-            self = then(self);
-        }
-        self
+        f(self)
+    }
+
+    fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
+    where
+        Self: Sized,
+    {
+        self.map(|this| if condition { then(this) } else { this })
     }
 }
 

crates/gpui2/src/window.rs 🔗

@@ -6,8 +6,9 @@ use crate::{
     Model, ModelContext, Modifiers, MonochromeSprite, MouseButton, MouseDownEvent, MouseMoveEvent,
     MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformWindow, Point, PolychromeSprite,
     PromptLevel, Quad, Render, RenderGlyphParams, RenderImageParams, RenderSvgParams, ScaledPixels,
-    SceneBuilder, Shadow, SharedString, Size, Style, Subscription, TaffyLayoutEngine, Task,
-    Underline, UnderlineStyle, View, VisualContext, WeakView, WindowOptions, SUBPIXEL_VARIANTS,
+    SceneBuilder, Shadow, SharedString, Size, Style, SubscriberSet, Subscription,
+    TaffyLayoutEngine, Task, Underline, UnderlineStyle, View, VisualContext, WeakView,
+    WindowBounds, WindowOptions, SUBPIXEL_VARIANTS,
 };
 use anyhow::{anyhow, Result};
 use collections::HashMap;
@@ -53,6 +54,7 @@ pub enum DispatchPhase {
     Capture,
 }
 
+type AnyObserver = Box<dyn FnMut(&mut WindowContext) -> bool + 'static>;
 type AnyListener = Box<dyn Fn(&dyn Any, DispatchPhase, &mut WindowContext) + 'static>;
 type AnyKeyListener = Box<
     dyn Fn(
@@ -185,6 +187,10 @@ pub struct Window {
     default_prevented: bool,
     mouse_position: Point<Pixels>,
     scale_factor: f32,
+    bounds: WindowBounds,
+    bounds_observers: SubscriberSet<(), AnyObserver>,
+    active: bool,
+    activation_observers: SubscriberSet<(), AnyObserver>,
     pub(crate) scene_builder: SceneBuilder,
     pub(crate) dirty: bool,
     pub(crate) last_blur: Option<Option<FocusId>>,
@@ -203,16 +209,34 @@ impl Window {
         let mouse_position = platform_window.mouse_position();
         let content_size = platform_window.content_size();
         let scale_factor = platform_window.scale_factor();
+        let bounds = platform_window.bounds();
+
         platform_window.on_resize(Box::new({
             let mut cx = cx.to_async();
-            move |content_size, scale_factor| {
+            move |_, _| {
+                handle
+                    .update(&mut cx, |_, cx| cx.window_bounds_changed())
+                    .log_err();
+            }
+        }));
+        platform_window.on_moved(Box::new({
+            let mut cx = cx.to_async();
+            move || {
+                handle
+                    .update(&mut cx, |_, cx| cx.window_bounds_changed())
+                    .log_err();
+            }
+        }));
+        platform_window.on_active_status_change(Box::new({
+            let mut cx = cx.to_async();
+            move |active| {
                 handle
                     .update(&mut cx, |_, cx| {
-                        cx.window.scale_factor = scale_factor;
-                        cx.window.scene_builder = SceneBuilder::new();
-                        cx.window.content_size = content_size;
-                        cx.window.display_id = cx.window.platform_window.display().id();
-                        cx.window.dirty = true;
+                        cx.window.active = active;
+                        cx.window
+                            .activation_observers
+                            .clone()
+                            .retain(&(), |callback| callback(cx));
                     })
                     .log_err();
             }
@@ -256,6 +280,10 @@ impl Window {
             default_prevented: true,
             mouse_position,
             scale_factor,
+            bounds,
+            bounds_observers: SubscriberSet::new(),
+            active: false,
+            activation_observers: SubscriberSet::new(),
             scene_builder: SceneBuilder::new(),
             dirty: true,
             last_blur: None,
@@ -525,6 +553,23 @@ impl<'a> WindowContext<'a> {
         bounds
     }
 
+    fn window_bounds_changed(&mut self) {
+        self.window.scale_factor = self.window.platform_window.scale_factor();
+        self.window.content_size = self.window.platform_window.content_size();
+        self.window.bounds = self.window.platform_window.bounds();
+        self.window.display_id = self.window.platform_window.display().id();
+        self.window.dirty = true;
+
+        self.window
+            .bounds_observers
+            .clone()
+            .retain(&(), |callback| callback(self));
+    }
+
+    pub fn window_bounds(&self) -> WindowBounds {
+        self.window.bounds
+    }
+
     /// The scale factor of the display associated with the window. For example, it could
     /// return 2.0 for a "retina" display, indicating that each logical pixel should actually
     /// be rendered as two pixels on screen.
@@ -1717,6 +1762,28 @@ impl<'a, V: 'static> ViewContext<'a, V> {
         });
     }
 
+    pub fn observe_window_bounds(
+        &mut self,
+        mut callback: impl FnMut(&mut V, &mut ViewContext<V>) + 'static,
+    ) -> Subscription {
+        let view = self.view.downgrade();
+        self.window.bounds_observers.insert(
+            (),
+            Box::new(move |cx| view.update(cx, |view, cx| callback(view, cx)).is_ok()),
+        )
+    }
+
+    pub fn observe_window_activation(
+        &mut self,
+        mut callback: impl FnMut(&mut V, &mut ViewContext<V>) + 'static,
+    ) -> Subscription {
+        let view = self.view.downgrade();
+        self.window.activation_observers.insert(
+            (),
+            Box::new(move |cx| view.update(cx, |view, cx| callback(view, cx)).is_ok()),
+        )
+    }
+
     pub fn on_focus_changed(
         &mut self,
         listener: impl Fn(&mut V, &FocusEvent, &mut ViewContext<V>) + 'static,

crates/storybook2/src/storybook2.rs 🔗

@@ -77,7 +77,7 @@ fn main() {
             WindowOptions {
                 bounds: WindowBounds::Fixed(Bounds {
                     origin: Default::default(),
-                    size: size(px(1700.), px(980.)).into(),
+                    size: size(px(1500.), px(780.)).into(),
                 }),
                 ..Default::default()
             },

crates/theme2/src/colors.rs 🔗

@@ -64,6 +64,7 @@ pub struct ThemeColors {
     pub element_selected: Hsla,
     pub element_disabled: Hsla,
     pub element_placeholder: Hsla,
+    pub element_drop_target: Hsla,
     pub ghost_element: Hsla,
     pub ghost_element_hover: Hsla,
     pub ghost_element_active: Hsla,
@@ -83,6 +84,8 @@ pub struct ThemeColors {
     pub title_bar: Hsla,
     pub toolbar: Hsla,
     pub tab_bar: Hsla,
+    pub tab_inactive: Hsla,
+    pub tab_active: Hsla,
     pub editor: Hsla,
     pub editor_subheader: Hsla,
     pub editor_active_line: Hsla,

crates/theme2/src/default_colors.rs 🔗

@@ -9,6 +9,10 @@ use crate::{
     ColorScale,
 };
 
+fn neutral() -> ColorScaleSet {
+    slate()
+}
+
 impl Default for SystemColors {
     fn default() -> Self {
         Self {
@@ -24,16 +28,16 @@ impl Default for StatusColors {
     fn default() -> Self {
         Self {
             conflict: red().dark().step_11(),
-            created: gpui2::black(),
-            deleted: gpui2::black(),
-            error: gpui2::black(),
-            hidden: gpui2::black(),
-            ignored: gpui2::black(),
-            info: gpui2::black(),
-            modified: gpui2::black(),
-            renamed: gpui2::black(),
-            success: gpui2::black(),
-            warning: gpui2::black(),
+            created: grass().dark().step_11(),
+            deleted: red().dark().step_11(),
+            error: red().dark().step_11(),
+            hidden: neutral().dark().step_11(),
+            ignored: neutral().dark().step_11(),
+            info: blue().dark().step_11(),
+            modified: yellow().dark().step_11(),
+            renamed: blue().dark().step_11(),
+            success: grass().dark().step_11(),
+            warning: yellow().dark().step_11(),
         }
     }
 }
@@ -41,12 +45,12 @@ impl Default for StatusColors {
 impl Default for GitStatusColors {
     fn default() -> Self {
         Self {
-            conflict: gpui2::rgba(0xdec184ff).into(),
-            created: gpui2::rgba(0xa1c181ff).into(),
-            deleted: gpui2::rgba(0xd07277ff).into(),
-            ignored: gpui2::rgba(0x555a63ff).into(),
-            modified: gpui2::rgba(0x74ade8ff).into(),
-            renamed: gpui2::rgba(0xdec184ff).into(),
+            conflict: orange().dark().step_11(),
+            created: grass().dark().step_11(),
+            deleted: red().dark().step_11(),
+            ignored: neutral().dark().step_11(),
+            modified: yellow().dark().step_11(),
+            renamed: blue().dark().step_11(),
         }
     }
 }
@@ -82,54 +86,57 @@ impl SyntaxTheme {
     pub fn default_light() -> Self {
         Self {
             highlights: vec![
+                ("attribute".into(), cyan().light().step_11().into()),
+                ("boolean".into(), tomato().light().step_11().into()),
+                ("comment".into(), neutral().light().step_11().into()),
+                ("comment.doc".into(), iris().light().step_12().into()),
+                ("constant".into(), red().light().step_7().into()),
+                ("constructor".into(), red().light().step_7().into()),
+                ("embedded".into(), red().light().step_7().into()),
+                ("emphasis".into(), red().light().step_7().into()),
+                ("emphasis.strong".into(), red().light().step_7().into()),
+                ("enum".into(), red().light().step_7().into()),
+                ("function".into(), red().light().step_7().into()),
+                ("hint".into(), red().light().step_7().into()),
+                ("keyword".into(), orange().light().step_11().into()),
+                ("label".into(), red().light().step_7().into()),
+                ("link_text".into(), red().light().step_7().into()),
+                ("link_uri".into(), red().light().step_7().into()),
+                ("number".into(), red().light().step_7().into()),
+                ("operator".into(), red().light().step_7().into()),
+                ("predictive".into(), red().light().step_7().into()),
+                ("preproc".into(), red().light().step_7().into()),
+                ("primary".into(), red().light().step_7().into()),
+                ("property".into(), red().light().step_7().into()),
+                ("punctuation".into(), neutral().light().step_11().into()),
                 (
-                    "string.special.symbol".into(),
-                    gpui2::rgba(0xad6e26ff).into(),
+                    "punctuation.bracket".into(),
+                    neutral().light().step_11().into(),
+                ),
+                (
+                    "punctuation.delimiter".into(),
+                    neutral().light().step_11().into(),
                 ),
-                ("hint".into(), gpui2::rgba(0x9294beff).into()),
-                ("link_uri".into(), gpui2::rgba(0x3882b7ff).into()),
-                ("type".into(), gpui2::rgba(0x3882b7ff).into()),
-                ("string.regex".into(), gpui2::rgba(0xad6e26ff).into()),
-                ("constant".into(), gpui2::rgba(0x669f59ff).into()),
-                ("function".into(), gpui2::rgba(0x5b79e3ff).into()),
-                ("string.special".into(), gpui2::rgba(0xad6e26ff).into()),
-                ("punctuation.bracket".into(), gpui2::rgba(0x4d4f52ff).into()),
-                ("variable".into(), gpui2::rgba(0x383a41ff).into()),
-                ("punctuation".into(), gpui2::rgba(0x383a41ff).into()),
-                ("property".into(), gpui2::rgba(0xd3604fff).into()),
-                ("string".into(), gpui2::rgba(0x649f57ff).into()),
-                ("predictive".into(), gpui2::rgba(0x9b9ec6ff).into()),
-                ("attribute".into(), gpui2::rgba(0x5c78e2ff).into()),
-                ("number".into(), gpui2::rgba(0xad6e25ff).into()),
-                ("constructor".into(), gpui2::rgba(0x5c78e2ff).into()),
-                ("embedded".into(), gpui2::rgba(0x383a41ff).into()),
-                ("title".into(), gpui2::rgba(0xd3604fff).into()),
-                ("tag".into(), gpui2::rgba(0x5c78e2ff).into()),
-                ("boolean".into(), gpui2::rgba(0xad6e25ff).into()),
                 (
                     "punctuation.list_marker".into(),
-                    gpui2::rgba(0xd3604fff).into(),
+                    blue().light().step_11().into(),
                 ),
-                ("variant".into(), gpui2::rgba(0x5b79e3ff).into()),
-                ("emphasis".into(), gpui2::rgba(0x5c78e2ff).into()),
-                ("link_text".into(), gpui2::rgba(0x5b79e3ff).into()),
-                ("comment".into(), gpui2::rgba(0xa2a3a7ff).into()),
-                ("punctuation.special".into(), gpui2::rgba(0xb92b46ff).into()),
-                ("emphasis.strong".into(), gpui2::rgba(0xad6e25ff).into()),
-                ("primary".into(), gpui2::rgba(0x383a41ff).into()),
+                ("punctuation.special".into(), red().light().step_7().into()),
+                ("string".into(), jade().light().step_11().into()),
+                ("string.escape".into(), red().light().step_7().into()),
+                ("string.regex".into(), tomato().light().step_11().into()),
+                ("string.special".into(), red().light().step_7().into()),
                 (
-                    "punctuation.delimiter".into(),
-                    gpui2::rgba(0x4d4f52ff).into(),
+                    "string.special.symbol".into(),
+                    red().light().step_7().into(),
                 ),
-                ("label".into(), gpui2::rgba(0x5c78e2ff).into()),
-                ("keyword".into(), gpui2::rgba(0xa449abff).into()),
-                ("string.escape".into(), gpui2::rgba(0x7c7e86ff).into()),
-                ("text.literal".into(), gpui2::rgba(0x649f57ff).into()),
-                ("variable.special".into(), gpui2::rgba(0xad6e25ff).into()),
-                ("comment.doc".into(), gpui2::rgba(0x7c7e86ff).into()),
-                ("enum".into(), gpui2::rgba(0xd3604fff).into()),
-                ("operator".into(), gpui2::rgba(0x3882b7ff).into()),
-                ("preproc".into(), gpui2::rgba(0x383a41ff).into()),
+                ("tag".into(), red().light().step_7().into()),
+                ("text.literal".into(), red().light().step_7().into()),
+                ("title".into(), red().light().step_7().into()),
+                ("type".into(), red().light().step_7().into()),
+                ("variable".into(), red().light().step_7().into()),
+                ("variable.special".into(), red().light().step_7().into()),
+                ("variant".into(), red().light().step_7().into()),
             ],
         }
     }
@@ -137,54 +144,54 @@ impl SyntaxTheme {
     pub fn default_dark() -> Self {
         Self {
             highlights: vec![
-                ("keyword".into(), gpui2::rgba(0xb477cfff).into()),
-                ("comment.doc".into(), gpui2::rgba(0x878e98ff).into()),
-                ("variant".into(), gpui2::rgba(0x73ade9ff).into()),
-                ("property".into(), gpui2::rgba(0xd07277ff).into()),
-                ("function".into(), gpui2::rgba(0x73ade9ff).into()),
-                ("type".into(), gpui2::rgba(0x6eb4bfff).into()),
-                ("tag".into(), gpui2::rgba(0x74ade8ff).into()),
-                ("string.escape".into(), gpui2::rgba(0x878e98ff).into()),
-                ("punctuation.bracket".into(), gpui2::rgba(0xb2b9c6ff).into()),
-                ("hint".into(), gpui2::rgba(0x5a6f89ff).into()),
-                ("punctuation".into(), gpui2::rgba(0xacb2beff).into()),
-                ("comment".into(), gpui2::rgba(0x5d636fff).into()),
-                ("emphasis".into(), gpui2::rgba(0x74ade8ff).into()),
-                ("punctuation.special".into(), gpui2::rgba(0xb1574bff).into()),
-                ("link_uri".into(), gpui2::rgba(0x6eb4bfff).into()),
-                ("string.regex".into(), gpui2::rgba(0xbf956aff).into()),
-                ("constructor".into(), gpui2::rgba(0x73ade9ff).into()),
-                ("operator".into(), gpui2::rgba(0x6eb4bfff).into()),
-                ("constant".into(), gpui2::rgba(0xdfc184ff).into()),
-                ("string.special".into(), gpui2::rgba(0xbf956aff).into()),
-                ("emphasis.strong".into(), gpui2::rgba(0xbf956aff).into()),
+                ("attribute".into(), cyan().dark().step_11().into()),
+                ("boolean".into(), tomato().dark().step_11().into()),
+                ("comment".into(), neutral().dark().step_11().into()),
+                ("comment.doc".into(), iris().dark().step_12().into()),
+                ("constant".into(), red().dark().step_7().into()),
+                ("constructor".into(), red().dark().step_7().into()),
+                ("embedded".into(), red().dark().step_7().into()),
+                ("emphasis".into(), red().dark().step_7().into()),
+                ("emphasis.strong".into(), red().dark().step_7().into()),
+                ("enum".into(), red().dark().step_7().into()),
+                ("function".into(), red().dark().step_7().into()),
+                ("hint".into(), red().dark().step_7().into()),
+                ("keyword".into(), orange().dark().step_11().into()),
+                ("label".into(), red().dark().step_7().into()),
+                ("link_text".into(), red().dark().step_7().into()),
+                ("link_uri".into(), red().dark().step_7().into()),
+                ("number".into(), red().dark().step_7().into()),
+                ("operator".into(), red().dark().step_7().into()),
+                ("predictive".into(), red().dark().step_7().into()),
+                ("preproc".into(), red().dark().step_7().into()),
+                ("primary".into(), red().dark().step_7().into()),
+                ("property".into(), red().dark().step_7().into()),
+                ("punctuation".into(), neutral().dark().step_11().into()),
                 (
-                    "string.special.symbol".into(),
-                    gpui2::rgba(0xbf956aff).into(),
+                    "punctuation.bracket".into(),
+                    neutral().dark().step_11().into(),
                 ),
-                ("primary".into(), gpui2::rgba(0xacb2beff).into()),
-                ("preproc".into(), gpui2::rgba(0xc8ccd4ff).into()),
-                ("string".into(), gpui2::rgba(0xa1c181ff).into()),
                 (
                     "punctuation.delimiter".into(),
-                    gpui2::rgba(0xb2b9c6ff).into(),
+                    neutral().dark().step_11().into(),
                 ),
-                ("embedded".into(), gpui2::rgba(0xc8ccd4ff).into()),
-                ("enum".into(), gpui2::rgba(0xd07277ff).into()),
-                ("variable.special".into(), gpui2::rgba(0xbf956aff).into()),
-                ("text.literal".into(), gpui2::rgba(0xa1c181ff).into()),
-                ("attribute".into(), gpui2::rgba(0x74ade8ff).into()),
-                ("link_text".into(), gpui2::rgba(0x73ade9ff).into()),
-                ("title".into(), gpui2::rgba(0xd07277ff).into()),
-                ("predictive".into(), gpui2::rgba(0x5a6a87ff).into()),
-                ("number".into(), gpui2::rgba(0xbf956aff).into()),
-                ("label".into(), gpui2::rgba(0x74ade8ff).into()),
-                ("variable".into(), gpui2::rgba(0xc8ccd4ff).into()),
-                ("boolean".into(), gpui2::rgba(0xbf956aff).into()),
                 (
                     "punctuation.list_marker".into(),
-                    gpui2::rgba(0xd07277ff).into(),
+                    blue().dark().step_11().into(),
                 ),
+                ("punctuation.special".into(), red().dark().step_7().into()),
+                ("string".into(), jade().dark().step_11().into()),
+                ("string.escape".into(), red().dark().step_7().into()),
+                ("string.regex".into(), tomato().dark().step_11().into()),
+                ("string.special".into(), red().dark().step_7().into()),
+                ("string.special.symbol".into(), red().dark().step_7().into()),
+                ("tag".into(), red().dark().step_7().into()),
+                ("text.literal".into(), red().dark().step_7().into()),
+                ("title".into(), red().dark().step_7().into()),
+                ("type".into(), red().dark().step_7().into()),
+                ("variable".into(), red().dark().step_7().into()),
+                ("variable.special".into(), red().dark().step_7().into()),
+                ("variant".into(), red().dark().step_7().into()),
             ],
         }
     }
@@ -192,82 +199,92 @@ impl SyntaxTheme {
 
 impl ThemeColors {
     pub fn default_light() -> Self {
+        let system = SystemColors::default();
+
         Self {
-            border: gpui2::white(),
-            border_variant: gpui2::white(),
-            border_focused: gpui2::white(),
-            border_transparent: gpui2::white(),
-            elevated_surface: gpui2::white(),
-            surface: gpui2::white(),
-            background: gpui2::white(),
-            element: gpui2::white(),
-            element_hover: gpui2::white(),
-            element_active: gpui2::white(),
-            element_selected: gpui2::white(),
-            element_disabled: gpui2::white(),
-            element_placeholder: gpui2::white(),
-            ghost_element: gpui2::white(),
-            ghost_element_hover: gpui2::white(),
-            ghost_element_active: gpui2::white(),
-            ghost_element_selected: gpui2::white(),
-            ghost_element_disabled: gpui2::white(),
-            text: gpui2::white(),
-            text_muted: gpui2::white(),
-            text_placeholder: gpui2::white(),
-            text_disabled: gpui2::white(),
-            text_accent: gpui2::white(),
-            icon: gpui2::white(),
-            icon_muted: gpui2::white(),
-            icon_disabled: gpui2::white(),
-            icon_placeholder: gpui2::white(),
-            icon_accent: gpui2::white(),
-            status_bar: gpui2::white(),
-            title_bar: gpui2::white(),
-            toolbar: gpui2::white(),
-            tab_bar: gpui2::white(),
-            editor: gpui2::white(),
-            editor_subheader: gpui2::white(),
-            editor_active_line: gpui2::white(),
+            border: neutral().light().step_6(),
+            border_variant: neutral().light().step_5(),
+            border_focused: blue().light().step_5(),
+            border_transparent: system.transparent,
+            elevated_surface: neutral().light().step_2(),
+            surface: neutral().light().step_2(),
+            background: neutral().light().step_1(),
+            element: neutral().light().step_3(),
+            element_hover: neutral().light().step_4(),
+            element_active: neutral().light().step_5(),
+            element_selected: neutral().light().step_5(),
+            element_disabled: neutral().light_alpha().step_3(),
+            element_placeholder: neutral().light().step_11(),
+            element_drop_target: blue().light_alpha().step_2(),
+            ghost_element: system.transparent,
+            ghost_element_hover: neutral().light().step_4(),
+            ghost_element_active: neutral().light().step_5(),
+            ghost_element_selected: neutral().light().step_5(),
+            ghost_element_disabled: neutral().light_alpha().step_3(),
+            text: neutral().light().step_12(),
+            text_muted: neutral().light().step_11(),
+            text_placeholder: neutral().light().step_10(),
+            text_disabled: neutral().light().step_9(),
+            text_accent: blue().light().step_11(),
+            icon: neutral().light().step_11(),
+            icon_muted: neutral().light().step_10(),
+            icon_disabled: neutral().light().step_9(),
+            icon_placeholder: neutral().light().step_10(),
+            icon_accent: blue().light().step_11(),
+            status_bar: neutral().light().step_2(),
+            title_bar: neutral().light().step_2(),
+            toolbar: neutral().light().step_1(),
+            tab_bar: neutral().light().step_2(),
+            tab_active: neutral().light().step_1(),
+            tab_inactive: neutral().light().step_2(),
+            editor: neutral().light().step_1(),
+            editor_subheader: neutral().light().step_2(),
+            editor_active_line: neutral().light_alpha().step_3(),
         }
     }
 
     pub fn default_dark() -> Self {
+        let system = SystemColors::default();
+
         Self {
-            border: gpui2::rgba(0x464b57ff).into(),
-            border_variant: gpui2::rgba(0x464b57ff).into(),
-            border_focused: gpui2::rgba(0x293b5bff).into(),
-            border_transparent: gpui2::rgba(0x00000000).into(),
-            elevated_surface: gpui2::rgba(0x3b414dff).into(),
-            surface: gpui2::rgba(0x2f343eff).into(),
-            background: gpui2::rgba(0x3b414dff).into(),
-            element: gpui2::rgba(0x3b414dff).into(),
-            element_hover: gpui2::rgba(0xffffff1e).into(),
-            element_active: gpui2::rgba(0xffffff28).into(),
-            element_selected: gpui2::rgba(0x18243dff).into(),
-            element_disabled: gpui2::rgba(0x00000000).into(),
-            element_placeholder: gpui2::black(),
-            ghost_element: gpui2::rgba(0x00000000).into(),
-            ghost_element_hover: gpui2::rgba(0xffffff14).into(),
-            ghost_element_active: gpui2::rgba(0xffffff1e).into(),
-            ghost_element_selected: gpui2::rgba(0x18243dff).into(),
-            ghost_element_disabled: gpui2::rgba(0x00000000).into(),
-            text: gpui2::rgba(0xc8ccd4ff).into(),
-            text_muted: gpui2::rgba(0x838994ff).into(),
-            text_placeholder: gpui2::rgba(0xd07277ff).into(),
-            text_disabled: gpui2::rgba(0x555a63ff).into(),
-            text_accent: gpui2::rgba(0x74ade8ff).into(),
-            icon: gpui2::black(),
-            icon_muted: gpui2::rgba(0x838994ff).into(),
-            icon_disabled: gpui2::black(),
-            icon_placeholder: gpui2::black(),
-            icon_accent: gpui2::black(),
-            status_bar: gpui2::rgba(0x3b414dff).into(),
-            title_bar: gpui2::rgba(0x3b414dff).into(),
-            toolbar: gpui2::rgba(0x282c33ff).into(),
-            tab_bar: gpui2::rgba(0x2f343eff).into(),
-            editor: gpui2::rgba(0x282c33ff).into(),
-            editor_subheader: gpui2::rgba(0x2f343eff).into(),
-            editor_active_line: gpui2::rgba(0x2f343eff).into(),
+            border: neutral().dark().step_6(),
+            border_variant: neutral().dark().step_5(),
+            border_focused: blue().dark().step_5(),
+            border_transparent: system.transparent,
+            elevated_surface: neutral().dark().step_2(),
+            surface: neutral().dark().step_2(),
+            background: neutral().dark().step_1(),
+            element: neutral().dark().step_3(),
+            element_hover: neutral().dark().step_4(),
+            element_active: neutral().dark().step_5(),
+            element_selected: neutral().dark().step_5(),
+            element_disabled: neutral().dark_alpha().step_3(),
+            element_placeholder: neutral().dark().step_11(),
+            element_drop_target: blue().dark_alpha().step_2(),
+            ghost_element: system.transparent,
+            ghost_element_hover: neutral().dark().step_4(),
+            ghost_element_active: neutral().dark().step_5(),
+            ghost_element_selected: neutral().dark().step_5(),
+            ghost_element_disabled: neutral().dark_alpha().step_3(),
+            text: neutral().dark().step_12(),
+            text_muted: neutral().dark().step_11(),
+            text_placeholder: neutral().dark().step_10(),
+            text_disabled: neutral().dark().step_9(),
+            text_accent: blue().dark().step_11(),
+            icon: neutral().dark().step_11(),
+            icon_muted: neutral().dark().step_10(),
+            icon_disabled: neutral().dark().step_9(),
+            icon_placeholder: neutral().dark().step_10(),
+            icon_accent: blue().dark().step_11(),
+            status_bar: neutral().dark().step_2(),
+            title_bar: neutral().dark().step_2(),
+            toolbar: neutral().dark().step_1(),
+            tab_bar: neutral().dark().step_2(),
+            tab_active: neutral().dark().step_1(),
+            tab_inactive: neutral().dark().step_2(),
+            editor: neutral().dark().step_1(),
+            editor_subheader: neutral().dark().step_2(),
+            editor_active_line: neutral().dark_alpha().step_3(),
         }
     }
 }

crates/theme2/src/theme2.rs 🔗

@@ -70,6 +70,18 @@ impl ThemeVariant {
         &self.styles.syntax
     }
 
+    /// Returns the [`StatusColors`] for the theme.
+    #[inline(always)]
+    pub fn status(&self) -> &StatusColors {
+        &self.styles.status
+    }
+
+    /// Returns the [`GitStatusColors`] for the theme.
+    #[inline(always)]
+    pub fn git(&self) -> &GitStatusColors {
+        &self.styles.git
+    }
+
     /// Returns the color for the syntax node with the given name.
     #[inline(always)]
     pub fn syntax_color(&self, name: &str) -> Hsla {

crates/ui2/src/components/list.rs 🔗

@@ -1,4 +1,4 @@
-use gpui2::{div, relative, Div};
+use gpui2::{div, px, relative, Div};
 
 use crate::settings::user_settings;
 use crate::{
@@ -15,12 +15,20 @@ pub enum ListItemVariant {
     Inset,
 }
 
+pub enum ListHeaderMeta {
+    // TODO: These should be IconButtons
+    Tools(Vec<Icon>),
+    // TODO: This should be a button
+    Button(Label),
+    Text(Label),
+}
+
 #[derive(Component)]
 pub struct ListHeader {
     label: SharedString,
     left_icon: Option<Icon>,
+    meta: Option<ListHeaderMeta>,
     variant: ListItemVariant,
-    state: InteractionState,
     toggleable: Toggleable,
 }
 
@@ -29,9 +37,9 @@ impl ListHeader {
         Self {
             label: label.into(),
             left_icon: None,
+            meta: None,
             variant: ListItemVariant::default(),
-            state: InteractionState::default(),
-            toggleable: Toggleable::Toggleable(ToggleState::Toggled),
+            toggleable: Toggleable::NotToggleable,
         }
     }
 
@@ -50,8 +58,8 @@ impl ListHeader {
         self
     }
 
-    pub fn state(mut self, state: InteractionState) -> Self {
-        self.state = state;
+    pub fn meta(mut self, meta: Option<ListHeaderMeta>) -> Self {
+        self.meta = meta;
         self
     }
 
@@ -74,34 +82,36 @@ impl ListHeader {
         }
     }
 
-    fn label_color(&self) -> LabelColor {
-        match self.state {
-            InteractionState::Disabled => LabelColor::Disabled,
-            _ => Default::default(),
-        }
-    }
-
-    fn icon_color(&self) -> IconColor {
-        match self.state {
-            InteractionState::Disabled => IconColor::Disabled,
-            _ => Default::default(),
-        }
-    }
-
     fn render<V: 'static>(self, _view: &mut V, cx: &mut ViewContext<V>) -> impl Component<V> {
         let is_toggleable = self.toggleable != Toggleable::NotToggleable;
         let is_toggled = self.toggleable.is_toggled();
 
         let disclosure_control = self.disclosure_control();
 
+        let meta = match self.meta {
+            Some(ListHeaderMeta::Tools(icons)) => div().child(
+                h_stack()
+                    .gap_2()
+                    .items_center()
+                    .children(icons.into_iter().map(|i| {
+                        IconElement::new(i)
+                            .color(IconColor::Muted)
+                            .size(IconSize::Small)
+                    })),
+            ),
+            Some(ListHeaderMeta::Button(label)) => div().child(label),
+            Some(ListHeaderMeta::Text(label)) => div().child(label),
+            None => div(),
+        };
+
         h_stack()
-            .flex_1()
             .w_full()
             .bg(cx.theme().colors().surface)
-            .when(self.state == InteractionState::Focused, |this| {
-                this.border()
-                    .border_color(cx.theme().colors().border_focused)
-            })
+            // TODO: Add focus state
+            // .when(self.state == InteractionState::Focused, |this| {
+            //     this.border()
+            //         .border_color(cx.theme().colors().border_focused)
+            // })
             .relative()
             .child(
                 div()
@@ -109,22 +119,28 @@ impl ListHeader {
                     .when(self.variant == ListItemVariant::Inset, |this| this.px_2())
                     .flex()
                     .flex_1()
+                    .items_center()
+                    .justify_between()
                     .w_full()
                     .gap_1()
-                    .items_center()
                     .child(
-                        div()
-                            .flex()
+                        h_stack()
                             .gap_1()
-                            .items_center()
-                            .children(self.left_icon.map(|i| {
-                                IconElement::new(i)
-                                    .color(IconColor::Muted)
-                                    .size(IconSize::Small)
-                            }))
-                            .child(Label::new(self.label.clone()).color(LabelColor::Muted)),
+                            .child(
+                                div()
+                                    .flex()
+                                    .gap_1()
+                                    .items_center()
+                                    .children(self.left_icon.map(|i| {
+                                        IconElement::new(i)
+                                            .color(IconColor::Muted)
+                                            .size(IconSize::Small)
+                                    }))
+                                    .child(Label::new(self.label.clone()).color(LabelColor::Muted)),
+                            )
+                            .child(disclosure_control),
                     )
-                    .child(disclosure_control),
+                    .child(meta),
             )
     }
 }
@@ -473,42 +489,63 @@ impl<V: 'static> ListDetailsEntry<V> {
     fn render(self, _view: &mut V, cx: &mut ViewContext<V>) -> impl Component<V> {
         let settings = user_settings(cx);
 
-        let (item_bg, item_bg_hover, item_bg_active) = match self.seen {
-            true => (
-                cx.theme().colors().ghost_element,
-                cx.theme().colors().ghost_element_hover,
-                cx.theme().colors().ghost_element_active,
-            ),
-            false => (
-                cx.theme().colors().element,
-                cx.theme().colors().element_hover,
-                cx.theme().colors().element_active,
-            ),
-        };
+        let (item_bg, item_bg_hover, item_bg_active) = (
+            cx.theme().colors().ghost_element,
+            cx.theme().colors().ghost_element_hover,
+            cx.theme().colors().ghost_element_active,
+        );
 
         let label_color = match self.seen {
             true => LabelColor::Muted,
             false => LabelColor::Default,
         };
 
-        v_stack()
+        div()
             .relative()
             .group("")
             .bg(item_bg)
-            .px_1()
-            .py_1_5()
+            .px_2()
+            .py_1p5()
             .w_full()
-            .line_height(relative(1.2))
-            .child(Label::new(self.label.clone()).color(label_color))
-            .children(
-                self.meta
-                    .map(|meta| Label::new(meta).color(LabelColor::Muted)),
-            )
+            .z_index(1)
+            .when(!self.seen, |this| {
+                this.child(
+                    div()
+                        .absolute()
+                        .left(px(3.0))
+                        .top_3()
+                        .rounded_full()
+                        .border_2()
+                        .border_color(cx.theme().colors().surface)
+                        .w(px(9.0))
+                        .h(px(9.0))
+                        .z_index(2)
+                        .bg(cx.theme().status().info),
+                )
+            })
             .child(
-                h_stack()
+                v_stack()
+                    .w_full()
+                    .line_height(relative(1.2))
                     .gap_1()
-                    .justify_end()
-                    .children(self.actions.unwrap_or_default()),
+                    .child(
+                        div()
+                            .w_5()
+                            .h_5()
+                            .rounded_full()
+                            .bg(cx.theme().colors().icon_accent),
+                    )
+                    .child(Label::new(self.label.clone()).color(label_color))
+                    .children(
+                        self.meta
+                            .map(|meta| Label::new(meta).color(LabelColor::Muted)),
+                    )
+                    .child(
+                        h_stack()
+                            .gap_1()
+                            .justify_end()
+                            .children(self.actions.unwrap_or_default()),
+                    ),
             )
     }
 }
@@ -522,7 +559,7 @@ impl ListSeparator {
     }
 
     fn render<V: 'static>(self, _view: &mut V, cx: &mut ViewContext<V>) -> impl Component<V> {
-        div().h_px().w_full().bg(cx.theme().colors().border)
+        div().h_px().w_full().bg(cx.theme().colors().border_variant)
     }
 }
 
@@ -564,14 +601,15 @@ impl<V: 'static> List<V> {
         let is_toggled = Toggleable::is_toggled(&self.toggleable);
 
         let list_content = match (self.items.is_empty(), is_toggled) {
-            (_, false) => div(),
             (false, _) => div().children(self.items),
-            (true, _) => {
+            (true, false) => div(),
+            (true, true) => {
                 div().child(Label::new(self.empty_message.clone()).color(LabelColor::Muted))
             }
         };
 
         v_stack()
+            .w_full()
             .py_1()
             .children(self.header.map(|header| header.toggleable(self.toggleable)))
             .child(list_content)

crates/ui2/src/components/notifications_panel.rs 🔗

@@ -1,5 +1,10 @@
-use crate::{prelude::*, static_new_notification_items, static_read_notification_items};
-use crate::{List, ListHeader};
+use crate::utils::naive_format_distance_from_now;
+use crate::{
+    h_stack, prelude::*, static_new_notification_items_2, v_stack, Avatar, Button, Icon,
+    IconButton, IconElement, Label, LabelColor, LineHeightStyle, ListHeaderMeta, ListSeparator,
+    UnreadIndicator,
+};
+use crate::{ClickHandler, ListHeader};
 
 #[derive(Component)]
 pub struct NotificationsPanel {
@@ -16,31 +21,348 @@ impl NotificationsPanel {
             .id(self.id.clone())
             .flex()
             .flex_col()
-            .w_full()
-            .h_full()
+            .size_full()
             .bg(cx.theme().colors().surface)
             .child(
-                div()
-                    .id("header")
-                    .w_full()
-                    .flex()
-                    .flex_col()
+                ListHeader::new("Notifications").meta(Some(ListHeaderMeta::Tools(vec![
+                    Icon::AtSign,
+                    Icon::BellOff,
+                    Icon::MailOpen,
+                ]))),
+            )
+            .child(ListSeparator::new())
+            .child(
+                v_stack()
+                    .id("notifications-panel-scroll-view")
+                    .py_1()
                     .overflow_y_scroll()
+                    .flex_1()
+                    .child(
+                        div()
+                            .mx_2()
+                            .p_1()
+                            // TODO: Add cursor style
+                            // .cursor(Cursor::IBeam)
+                            .bg(cx.theme().colors().element)
+                            .border()
+                            .border_color(cx.theme().colors().border_variant)
+                            .child(
+                                Label::new("Search...")
+                                    .color(LabelColor::Placeholder)
+                                    .line_height_style(LineHeightStyle::UILabel),
+                            ),
+                    )
+                    .child(v_stack().px_1().children(static_new_notification_items_2())),
+            )
+    }
+}
+
+pub enum ButtonOrIconButton<V: 'static> {
+    Button(Button<V>),
+    IconButton(IconButton<V>),
+}
+
+impl<V: 'static> From<Button<V>> for ButtonOrIconButton<V> {
+    fn from(value: Button<V>) -> Self {
+        Self::Button(value)
+    }
+}
+
+impl<V: 'static> From<IconButton<V>> for ButtonOrIconButton<V> {
+    fn from(value: IconButton<V>) -> Self {
+        Self::IconButton(value)
+    }
+}
+
+pub struct NotificationAction<V: 'static> {
+    button: ButtonOrIconButton<V>,
+    tooltip: SharedString,
+    /// Shows after action is chosen
+    ///
+    /// For example, if the action is "Accept" the taken message could be:
+    ///
+    /// - `(None,"Accepted")` - "Accepted"
+    ///
+    /// - `(Some(Icon::Check),"Accepted")` - ✓ "Accepted"
+    taken_message: (Option<Icon>, SharedString),
+}
+
+impl<V: 'static> NotificationAction<V> {
+    pub fn new(
+        button: impl Into<ButtonOrIconButton<V>>,
+        tooltip: impl Into<SharedString>,
+        (icon, taken_message): (Option<Icon>, impl Into<SharedString>),
+    ) -> Self {
+        Self {
+            button: button.into(),
+            tooltip: tooltip.into(),
+            taken_message: (icon, taken_message.into()),
+        }
+    }
+}
+
+pub enum ActorOrIcon {
+    Actor(PublicActor),
+    Icon(Icon),
+}
+
+pub struct NotificationMeta<V: 'static> {
+    items: Vec<(Option<Icon>, SharedString, Option<ClickHandler<V>>)>,
+}
+
+struct NotificationHandlers<V: 'static> {
+    click: Option<ClickHandler<V>>,
+}
+
+impl<V: 'static> Default for NotificationHandlers<V> {
+    fn default() -> Self {
+        Self { click: None }
+    }
+}
+
+#[derive(Component)]
+pub struct Notification<V: 'static> {
+    id: ElementId,
+    slot: ActorOrIcon,
+    message: SharedString,
+    date_received: NaiveDateTime,
+    meta: Option<NotificationMeta<V>>,
+    actions: Option<[NotificationAction<V>; 2]>,
+    unread: bool,
+    new: bool,
+    action_taken: Option<NotificationAction<V>>,
+    handlers: NotificationHandlers<V>,
+}
+
+impl<V> Notification<V> {
+    fn new(
+        id: ElementId,
+        message: SharedString,
+        date_received: NaiveDateTime,
+        slot: ActorOrIcon,
+        click_action: Option<ClickHandler<V>>,
+    ) -> Self {
+        let handlers = if click_action.is_some() {
+            NotificationHandlers {
+                click: click_action,
+            }
+        } else {
+            NotificationHandlers::default()
+        };
+
+        Self {
+            id,
+            date_received,
+            message,
+            meta: None,
+            slot,
+            actions: None,
+            unread: true,
+            new: false,
+            action_taken: None,
+            handlers,
+        }
+    }
+
+    /// Creates a new notification with an actor slot.
+    ///
+    /// Requires a click action.
+    pub fn new_actor_message(
+        id: impl Into<ElementId>,
+        message: impl Into<SharedString>,
+        date_received: NaiveDateTime,
+        actor: PublicActor,
+        click_action: ClickHandler<V>,
+    ) -> Self {
+        Self::new(
+            id.into(),
+            message.into(),
+            date_received,
+            ActorOrIcon::Actor(actor),
+            Some(click_action),
+        )
+    }
+
+    /// Creates a new notification with an icon slot.
+    ///
+    /// Requires a click action.
+    pub fn new_icon_message(
+        id: impl Into<ElementId>,
+        message: impl Into<SharedString>,
+        date_received: NaiveDateTime,
+        icon: Icon,
+        click_action: ClickHandler<V>,
+    ) -> Self {
+        Self::new(
+            id.into(),
+            message.into(),
+            date_received,
+            ActorOrIcon::Icon(icon),
+            Some(click_action),
+        )
+    }
+
+    /// Creates a new notification with an actor slot
+    /// and a Call To Action row.
+    ///
+    /// Cannot take a click action due to required actions.
+    pub fn new_actor_with_actions(
+        id: impl Into<ElementId>,
+        message: impl Into<SharedString>,
+        date_received: NaiveDateTime,
+        actor: PublicActor,
+        actions: [NotificationAction<V>; 2],
+    ) -> Self {
+        Self::new(
+            id.into(),
+            message.into(),
+            date_received,
+            ActorOrIcon::Actor(actor),
+            None,
+        )
+        .actions(actions)
+    }
+
+    /// Creates a new notification with an icon slot
+    /// and a Call To Action row.
+    ///
+    /// Cannot take a click action due to required actions.
+    pub fn new_icon_with_actions(
+        id: impl Into<ElementId>,
+        message: impl Into<SharedString>,
+        date_received: NaiveDateTime,
+        icon: Icon,
+        actions: [NotificationAction<V>; 2],
+    ) -> Self {
+        Self::new(
+            id.into(),
+            message.into(),
+            date_received,
+            ActorOrIcon::Icon(icon),
+            None,
+        )
+        .actions(actions)
+    }
+
+    fn on_click(mut self, handler: ClickHandler<V>) -> Self {
+        self.handlers.click = Some(handler);
+        self
+    }
+
+    pub fn actions(mut self, actions: [NotificationAction<V>; 2]) -> Self {
+        self.actions = Some(actions);
+        self
+    }
+
+    pub fn meta(mut self, meta: NotificationMeta<V>) -> Self {
+        self.meta = Some(meta);
+        self
+    }
+
+    fn render_meta_items(&self, cx: &mut ViewContext<V>) -> impl Component<V> {
+        if let Some(meta) = &self.meta {
+            h_stack().children(
+                meta.items
+                    .iter()
+                    .map(|(icon, text, _)| {
+                        let mut meta_el = div();
+                        if let Some(icon) = icon {
+                            meta_el = meta_el.child(IconElement::new(icon.clone()));
+                        }
+                        meta_el.child(Label::new(text.clone()).color(LabelColor::Muted))
+                    })
+                    .collect::<Vec<_>>(),
+            )
+        } else {
+            div()
+        }
+    }
+
+    fn render_slot(&self, cx: &mut ViewContext<V>) -> impl Component<V> {
+        match &self.slot {
+            ActorOrIcon::Actor(actor) => Avatar::new(actor.avatar.clone()).render(),
+            ActorOrIcon::Icon(icon) => IconElement::new(icon.clone()).render(),
+        }
+    }
+
+    fn render(self, _view: &mut V, cx: &mut ViewContext<V>) -> impl Component<V> {
+        div()
+            .relative()
+            .id(self.id.clone())
+            .p_1()
+            .flex()
+            .flex_col()
+            .w_full()
+            .children(
+                Some(
+                    div()
+                        .absolute()
+                        .left(px(3.0))
+                        .top_3()
+                        .z_index(2)
+                        .child(UnreadIndicator::new()),
+                )
+                .filter(|_| self.unread),
+            )
+            .child(
+                v_stack()
+                    .z_index(1)
+                    .gap_1()
+                    .w_full()
                     .child(
-                        List::new(static_new_notification_items())
-                            .header(ListHeader::new("NEW").toggle(ToggleState::Toggled))
-                            .toggle(ToggleState::Toggled),
+                        h_stack()
+                            .w_full()
+                            .gap_2()
+                            .child(self.render_slot(cx))
+                            .child(div().flex_1().child(Label::new(self.message.clone()))),
                     )
                     .child(
-                        List::new(static_read_notification_items())
-                            .header(ListHeader::new("EARLIER").toggle(ToggleState::Toggled))
-                            .empty_message("No new notifications")
-                            .toggle(ToggleState::Toggled),
+                        h_stack()
+                            .justify_between()
+                            .child(
+                                h_stack()
+                                    .gap_1()
+                                    .child(
+                                        Label::new(naive_format_distance_from_now(
+                                            self.date_received,
+                                            true,
+                                            true,
+                                        ))
+                                        .color(LabelColor::Muted),
+                                    )
+                                    .child(self.render_meta_items(cx)),
+                            )
+                            .child(match (self.actions, self.action_taken) {
+                                // Show nothing
+                                (None, _) => div(),
+                                // Show the taken_message
+                                (Some(_), Some(action_taken)) => h_stack()
+                                    .children(action_taken.taken_message.0.map(|icon| {
+                                        IconElement::new(icon).color(crate::IconColor::Muted)
+                                    }))
+                                    .child(
+                                        Label::new(action_taken.taken_message.1.clone())
+                                            .color(LabelColor::Muted),
+                                    ),
+                                // Show the actions
+                                (Some(actions), None) => {
+                                    h_stack().children(actions.map(|action| match action.button {
+                                        ButtonOrIconButton::Button(button) => {
+                                            Component::render(button)
+                                        }
+                                        ButtonOrIconButton::IconButton(icon_button) => {
+                                            Component::render(icon_button)
+                                        }
+                                    }))
+                                }
+                            }),
                     ),
             )
     }
 }
 
+use chrono::NaiveDateTime;
+use gpui2::{px, Styled};
 #[cfg(feature = "stories")]
 pub use stories::*;
 

crates/ui2/src/components/panel.rs 🔗

@@ -98,16 +98,14 @@ impl<V: 'static> Panel<V> {
         v_stack()
             .id(self.id.clone())
             .flex_initial()
-            .when(
-                self.current_side == PanelSide::Left || self.current_side == PanelSide::Right,
-                |this| this.h_full().w(current_size),
-            )
-            .when(self.current_side == PanelSide::Left, |this| this.border_r())
-            .when(self.current_side == PanelSide::Right, |this| {
-                this.border_l()
+            .map(|this| match self.current_side {
+                PanelSide::Left | PanelSide::Right => this.h_full().w(current_size),
+                PanelSide::Bottom => this,
             })
-            .when(self.current_side == PanelSide::Bottom, |this| {
-                this.border_b().w_full().h(current_size)
+            .map(|this| match self.current_side {
+                PanelSide::Left => this.border_r(),
+                PanelSide::Right => this.border_l(),
+                PanelSide::Bottom => this.border_b().w_full().h(current_size),
             })
             .bg(cx.theme().colors().surface)
             .border_color(cx.theme().colors().border)

crates/ui2/src/components/tab.rs 🔗

@@ -1,6 +1,6 @@
 use crate::prelude::*;
 use crate::{Icon, IconColor, IconElement, Label, LabelColor};
-use gpui2::{black, red, Div, ElementId, Render, View, VisualContext};
+use gpui2::{red, Div, ElementId, Render, View, VisualContext};
 
 #[derive(Component, Clone)]
 pub struct Tab {
@@ -108,13 +108,13 @@ impl Tab {
         let close_icon = || IconElement::new(Icon::Close).color(IconColor::Muted);
 
         let (tab_bg, tab_hover_bg, tab_active_bg) = match self.current {
-            true => (
-                cx.theme().colors().ghost_element,
+            false => (
+                cx.theme().colors().tab_inactive,
                 cx.theme().colors().ghost_element_hover,
                 cx.theme().colors().ghost_element_active,
             ),
-            false => (
-                cx.theme().colors().element,
+            true => (
+                cx.theme().colors().tab_active,
                 cx.theme().colors().element_hover,
                 cx.theme().colors().element_active,
             ),
@@ -127,7 +127,7 @@ impl Tab {
         div()
             .id(self.id.clone())
             .on_drag(move |_view, cx| cx.build_view(|cx| drag_state.clone()))
-            .drag_over::<TabDragState>(|d| d.bg(black()))
+            .drag_over::<TabDragState>(|d| d.bg(cx.theme().colors().element_drop_target))
             .on_drop(|_view, state: View<TabDragState>, cx| {
                 eprintln!("{:?}", state.read(cx));
             })
@@ -144,7 +144,7 @@ impl Tab {
                     .px_1()
                     .flex()
                     .items_center()
-                    .gap_1()
+                    .gap_1p5()
                     .children(has_fs_conflict.then(|| {
                         IconElement::new(Icon::ExclamationTriangle)
                             .size(crate::IconSize::Small)

crates/ui2/src/components/tab_bar.rs 🔗

@@ -27,6 +27,7 @@ impl TabBar {
         let (can_navigate_back, can_navigate_forward) = self.can_navigate;
 
         div()
+            .group("tab_bar")
             .id(self.id.clone())
             .w_full()
             .flex()
@@ -34,6 +35,7 @@ impl TabBar {
             // Left Side
             .child(
                 div()
+                    .relative()
                     .px_1()
                     .flex()
                     .flex_none()
@@ -41,6 +43,7 @@ impl TabBar {
                     // Nav Buttons
                     .child(
                         div()
+                            .right_0()
                             .flex()
                             .items_center()
                             .gap_px()
@@ -67,10 +70,15 @@ impl TabBar {
             // Right Side
             .child(
                 div()
+                    // We only use absolute here since we don't
+                    // have opacity or `hidden()` yet
+                    .absolute()
+                    .neg_top_7()
                     .px_1()
                     .flex()
                     .flex_none()
                     .gap_2()
+                    .group_hover("tab_bar", |this| this.top_0())
                     // Nav Buttons
                     .child(
                         div()

crates/ui2/src/elements.rs 🔗

@@ -2,6 +2,7 @@ mod avatar;
 mod button;
 mod details;
 mod icon;
+mod indicator;
 mod input;
 mod label;
 mod player;
@@ -12,6 +13,7 @@ pub use avatar::*;
 pub use button::*;
 pub use details::*;
 pub use icon::*;
+pub use indicator::*;
 pub use input::*;
 pub use label::*;
 pub use player::*;

crates/ui2/src/elements/icon.rs 🔗

@@ -26,23 +26,21 @@ pub enum IconColor {
 
 impl IconColor {
     pub fn color(self, cx: &WindowContext) -> Hsla {
-        let theme_colors = cx.theme().colors();
-
         match self {
-            IconColor::Default => theme_colors.icon,
-            IconColor::Muted => theme_colors.icon_muted,
-            IconColor::Disabled => theme_colors.icon_disabled,
-            IconColor::Placeholder => theme_colors.icon_placeholder,
-            IconColor::Accent => theme_colors.icon_accent,
-            IconColor::Error => gpui2::red(),
-            IconColor::Warning => gpui2::red(),
-            IconColor::Success => gpui2::red(),
-            IconColor::Info => gpui2::red(),
+            IconColor::Default => cx.theme().colors().icon,
+            IconColor::Muted => cx.theme().colors().icon_muted,
+            IconColor::Disabled => cx.theme().colors().icon_disabled,
+            IconColor::Placeholder => cx.theme().colors().icon_placeholder,
+            IconColor::Accent => cx.theme().colors().icon_accent,
+            IconColor::Error => cx.theme().status().error,
+            IconColor::Warning => cx.theme().status().warning,
+            IconColor::Success => cx.theme().status().success,
+            IconColor::Info => cx.theme().status().info,
         }
     }
 }
 
-#[derive(Debug, Default, PartialEq, Copy, Clone, EnumIter)]
+#[derive(Debug, PartialEq, Copy, Clone, EnumIter)]
 pub enum Icon {
     Ai,
     ArrowLeft,
@@ -51,6 +49,7 @@ pub enum Icon {
     AudioOff,
     AudioOn,
     Bolt,
+    Check,
     ChevronDown,
     ChevronLeft,
     ChevronRight,
@@ -69,7 +68,6 @@ pub enum Icon {
     Folder,
     FolderOpen,
     FolderX,
-    #[default]
     Hash,
     InlayHint,
     MagicWand,
@@ -91,6 +89,11 @@ pub enum Icon {
     XCircle,
     Copilot,
     Envelope,
+    Bell,
+    BellOff,
+    BellRing,
+    MailOpen,
+    AtSign,
 }
 
 impl Icon {
@@ -103,6 +106,7 @@ impl Icon {
             Icon::AudioOff => "icons/speaker-off.svg",
             Icon::AudioOn => "icons/speaker-loud.svg",
             Icon::Bolt => "icons/bolt.svg",
+            Icon::Check => "icons/check.svg",
             Icon::ChevronDown => "icons/chevron_down.svg",
             Icon::ChevronLeft => "icons/chevron_left.svg",
             Icon::ChevronRight => "icons/chevron_right.svg",
@@ -142,6 +146,11 @@ impl Icon {
             Icon::XCircle => "icons/error.svg",
             Icon::Copilot => "icons/copilot.svg",
             Icon::Envelope => "icons/feedback.svg",
+            Icon::Bell => "icons/bell.svg",
+            Icon::BellOff => "icons/bell-off.svg",
+            Icon::BellRing => "icons/bell-ring.svg",
+            Icon::MailOpen => "icons/mail-open.svg",
+            Icon::AtSign => "icons/at-sign.svg",
         }
     }
 }

crates/ui2/src/elements/indicator.rs 🔗

@@ -0,0 +1,23 @@
+use gpui2::px;
+
+use crate::prelude::*;
+
+#[derive(Component)]
+pub struct UnreadIndicator;
+
+impl UnreadIndicator {
+    pub fn new() -> Self {
+        Self
+    }
+
+    fn render<V: 'static>(self, _view: &mut V, cx: &mut ViewContext<V>) -> impl Component<V> {
+        div()
+            .rounded_full()
+            .border_2()
+            .border_color(cx.theme().colors().surface)
+            .w(px(9.0))
+            .h(px(9.0))
+            .z_index(2)
+            .bg(cx.theme().status().info)
+    }
+}

crates/ui2/src/elements/input.rs 🔗

@@ -94,14 +94,13 @@ impl Input {
             .active(|style| style.bg(input_active_bg))
             .flex()
             .items_center()
-            .child(
-                div()
-                    .flex()
-                    .items_center()
-                    .text_sm()
-                    .when(self.value.is_empty(), |this| this.child(placeholder_label))
-                    .when(!self.value.is_empty(), |this| this.child(label)),
-            )
+            .child(div().flex().items_center().text_sm().map(|this| {
+                if self.value.is_empty() {
+                    this.child(placeholder_label)
+                } else {
+                    this.child(label)
+                }
+            }))
     }
 }
 

crates/ui2/src/elements/label.rs 🔗

@@ -21,11 +21,11 @@ impl LabelColor {
         match self {
             Self::Default => cx.theme().colors().text,
             Self::Muted => cx.theme().colors().text_muted,
-            Self::Created => gpui2::red(),
-            Self::Modified => gpui2::red(),
-            Self::Deleted => gpui2::red(),
+            Self::Created => cx.theme().status().created,
+            Self::Modified => cx.theme().status().modified,
+            Self::Deleted => cx.theme().status().deleted,
             Self::Disabled => cx.theme().colors().text_disabled,
-            Self::Hidden => gpui2::red(),
+            Self::Hidden => cx.theme().status().hidden,
             Self::Placeholder => cx.theme().colors().text_placeholder,
             Self::Accent => cx.theme().colors().text_accent,
         }
@@ -79,8 +79,7 @@ impl Label {
                 this.relative().child(
                     div()
                         .absolute()
-                        .top_px()
-                        .my_auto()
+                        .top_1_2()
                         .w_full()
                         .h_px()
                         .bg(LabelColor::Hidden.hsla(cx)),

crates/ui2/src/lib.rs 🔗

@@ -23,6 +23,7 @@ mod elevation;
 pub mod prelude;
 pub mod settings;
 mod static_data;
+pub mod utils;
 
 pub use components::*;
 pub use elements::*;

crates/ui2/src/prelude.rs 🔗

@@ -10,6 +10,24 @@ pub use theme2::ActiveTheme;
 use gpui2::Hsla;
 use strum::EnumIter;
 
+/// Represents a person with a Zed account's public profile.
+/// All data in this struct should be considered public.
+pub struct PublicActor {
+    pub username: SharedString,
+    pub avatar: SharedString,
+    pub is_contact: bool,
+}
+
+impl PublicActor {
+    pub fn new(username: impl Into<SharedString>, avatar: impl Into<SharedString>) -> Self {
+        Self {
+            username: username.into(),
+            avatar: avatar.into(),
+            is_contact: false,
+        }
+    }
+}
+
 #[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, EnumIter)]
 pub enum FileSystemStatus {
     #[default]

crates/ui2/src/static_data.rs 🔗

@@ -1,17 +1,20 @@
 use std::path::PathBuf;
 use std::str::FromStr;
+use std::sync::Arc;
 
+use chrono::DateTime;
 use gpui2::{AppContext, ViewContext};
 use rand::Rng;
 use theme2::ActiveTheme;
 
 use crate::{
     Buffer, BufferRow, BufferRows, Button, EditorPane, FileSystemStatus, GitStatus,
-    HighlightedLine, Icon, Keybinding, Label, LabelColor, ListEntry, ListEntrySize, ListItem,
-    Livestream, MicStatus, ModifierKeys, PaletteItem, Player, PlayerCallStatus,
-    PlayerWithCallStatus, ScreenShareStatus, Symbol, Tab, ToggleState, VideoStatus,
+    HighlightedLine, Icon, Keybinding, Label, LabelColor, ListEntry, ListEntrySize, ListSubHeader,
+    Livestream, MicStatus, ModifierKeys, Notification, PaletteItem, Player, PlayerCallStatus,
+    PlayerWithCallStatus, PublicActor, ScreenShareStatus, Symbol, Tab, ToggleState, VideoStatus,
 };
 use crate::{HighlightedText, ListDetailsEntry};
+use crate::{ListItem, NotificationAction};
 
 pub fn static_tabs_example() -> Vec<Tab> {
     vec![
@@ -325,27 +328,227 @@ pub fn static_players_with_call_status() -> Vec<PlayerWithCallStatus> {
     ]
 }
 
-pub fn static_new_notification_items<V: 'static>() -> Vec<ListItem<V>> {
+pub fn static_new_notification_items_2<V: 'static>() -> Vec<Notification<V>> {
     vec![
-        ListDetailsEntry::new("maxdeviant invited you to join a stream in #design.")
-            .meta("4 people in stream."),
-        ListDetailsEntry::new("nathansobo accepted your contact request."),
+        Notification::new_icon_message(
+            "notif-1",
+            "You were mentioned in a note.",
+            DateTime::parse_from_rfc3339("2023-11-02T11:59:57Z")
+                .unwrap()
+                .naive_local(),
+            Icon::AtSign,
+            Arc::new(|_, _| {}),
+        ),
+        Notification::new_actor_with_actions(
+            "notif-2",
+            "as-cii sent you a contact request.",
+            DateTime::parse_from_rfc3339("2023-11-02T12:09:07Z")
+                .unwrap()
+                .naive_local(),
+            PublicActor::new("as-cii", "http://github.com/as-cii.png?s=50"),
+            [
+                NotificationAction::new(
+                    Button::new("Decline"),
+                    "Decline Request",
+                    (Some(Icon::XCircle), "Declined"),
+                ),
+                NotificationAction::new(
+                    Button::new("Accept").variant(crate::ButtonVariant::Filled),
+                    "Accept Request",
+                    (Some(Icon::Check), "Accepted"),
+                ),
+            ],
+        ),
+        Notification::new_icon_message(
+            "notif-3",
+            "You were mentioned #design.",
+            DateTime::parse_from_rfc3339("2023-11-02T12:09:07Z")
+                .unwrap()
+                .naive_local(),
+            Icon::MessageBubbles,
+            Arc::new(|_, _| {}),
+        ),
+        Notification::new_actor_with_actions(
+            "notif-4",
+            "as-cii sent you a contact request.",
+            DateTime::parse_from_rfc3339("2023-11-01T12:09:07Z")
+                .unwrap()
+                .naive_local(),
+            PublicActor::new("as-cii", "http://github.com/as-cii.png?s=50"),
+            [
+                NotificationAction::new(
+                    Button::new("Decline"),
+                    "Decline Request",
+                    (Some(Icon::XCircle), "Declined"),
+                ),
+                NotificationAction::new(
+                    Button::new("Accept").variant(crate::ButtonVariant::Filled),
+                    "Accept Request",
+                    (Some(Icon::Check), "Accepted"),
+                ),
+            ],
+        ),
+        Notification::new_icon_message(
+            "notif-5",
+            "You were mentioned in a note.",
+            DateTime::parse_from_rfc3339("2023-10-28T12:09:07Z")
+                .unwrap()
+                .naive_local(),
+            Icon::AtSign,
+            Arc::new(|_, _| {}),
+        ),
+        Notification::new_actor_with_actions(
+            "notif-6",
+            "as-cii sent you a contact request.",
+            DateTime::parse_from_rfc3339("2022-10-25T12:09:07Z")
+                .unwrap()
+                .naive_local(),
+            PublicActor::new("as-cii", "http://github.com/as-cii.png?s=50"),
+            [
+                NotificationAction::new(
+                    Button::new("Decline"),
+                    "Decline Request",
+                    (Some(Icon::XCircle), "Declined"),
+                ),
+                NotificationAction::new(
+                    Button::new("Accept").variant(crate::ButtonVariant::Filled),
+                    "Accept Request",
+                    (Some(Icon::Check), "Accepted"),
+                ),
+            ],
+        ),
+        Notification::new_icon_message(
+            "notif-7",
+            "You were mentioned in a note.",
+            DateTime::parse_from_rfc3339("2022-10-14T12:09:07Z")
+                .unwrap()
+                .naive_local(),
+            Icon::AtSign,
+            Arc::new(|_, _| {}),
+        ),
+        Notification::new_actor_with_actions(
+            "notif-8",
+            "as-cii sent you a contact request.",
+            DateTime::parse_from_rfc3339("2021-10-12T12:09:07Z")
+                .unwrap()
+                .naive_local(),
+            PublicActor::new("as-cii", "http://github.com/as-cii.png?s=50"),
+            [
+                NotificationAction::new(
+                    Button::new("Decline"),
+                    "Decline Request",
+                    (Some(Icon::XCircle), "Declined"),
+                ),
+                NotificationAction::new(
+                    Button::new("Accept").variant(crate::ButtonVariant::Filled),
+                    "Accept Request",
+                    (Some(Icon::Check), "Accepted"),
+                ),
+            ],
+        ),
+        Notification::new_icon_message(
+            "notif-9",
+            "You were mentioned in a note.",
+            DateTime::parse_from_rfc3339("2021-02-02T12:09:07Z")
+                .unwrap()
+                .naive_local(),
+            Icon::AtSign,
+            Arc::new(|_, _| {}),
+        ),
+        Notification::new_actor_with_actions(
+            "notif-10",
+            "as-cii sent you a contact request.",
+            DateTime::parse_from_rfc3339("1969-07-20T00:00:00Z")
+                .unwrap()
+                .naive_local(),
+            PublicActor::new("as-cii", "http://github.com/as-cii.png?s=50"),
+            [
+                NotificationAction::new(
+                    Button::new("Decline"),
+                    "Decline Request",
+                    (Some(Icon::XCircle), "Declined"),
+                ),
+                NotificationAction::new(
+                    Button::new("Accept").variant(crate::ButtonVariant::Filled),
+                    "Accept Request",
+                    (Some(Icon::Check), "Accepted"),
+                ),
+            ],
+        ),
     ]
-    .into_iter()
-    .map(From::from)
-    .collect()
 }
 
-pub fn static_read_notification_items<V: 'static>() -> Vec<ListItem<V>> {
+pub fn static_new_notification_items<V: 'static>() -> Vec<ListItem<V>> {
     vec![
-        ListDetailsEntry::new("mikaylamaki added you as a contact.").actions(vec![
-            Button::new("Decline"),
-            Button::new("Accept").variant(crate::ButtonVariant::Filled),
-        ]),
-        ListDetailsEntry::new("maxdeviant invited you to a stream in #design.")
-            .seen(true)
-            .meta("This stream has ended."),
-        ListDetailsEntry::new("as-cii accepted your contact request."),
+        ListItem::Header(ListSubHeader::new("New")),
+        ListItem::Details(
+            ListDetailsEntry::new("maxdeviant invited you to join a stream in #design.")
+                .meta("4 people in stream."),
+        ),
+        ListItem::Details(ListDetailsEntry::new(
+            "nathansobo accepted your contact request.",
+        )),
+        ListItem::Header(ListSubHeader::new("Earlier")),
+        ListItem::Details(
+            ListDetailsEntry::new("mikaylamaki added you as a contact.").actions(vec![
+                Button::new("Decline"),
+                Button::new("Accept").variant(crate::ButtonVariant::Filled),
+            ]),
+        ),
+        ListItem::Details(
+            ListDetailsEntry::new("maxdeviant invited you to a stream in #design.")
+                .seen(true)
+                .meta("This stream has ended."),
+        ),
+        ListItem::Details(ListDetailsEntry::new(
+            "as-cii accepted your contact request.",
+        )),
+        ListItem::Details(
+            ListDetailsEntry::new("You were added as an admin on the #gpui2 channel.").seen(true),
+        ),
+        ListItem::Details(ListDetailsEntry::new(
+            "osiewicz accepted your contact request.",
+        )),
+        ListItem::Details(ListDetailsEntry::new(
+            "ConradIrwin accepted your contact request.",
+        )),
+        ListItem::Details(
+            ListDetailsEntry::new("nathansobo invited you to a stream in #gpui2.")
+                .seen(true)
+                .meta("This stream has ended."),
+        ),
+        ListItem::Details(ListDetailsEntry::new(
+            "nathansobo accepted your contact request.",
+        )),
+        ListItem::Header(ListSubHeader::new("Earlier")),
+        ListItem::Details(
+            ListDetailsEntry::new("mikaylamaki added you as a contact.").actions(vec![
+                Button::new("Decline"),
+                Button::new("Accept").variant(crate::ButtonVariant::Filled),
+            ]),
+        ),
+        ListItem::Details(
+            ListDetailsEntry::new("maxdeviant invited you to a stream in #design.")
+                .seen(true)
+                .meta("This stream has ended."),
+        ),
+        ListItem::Details(ListDetailsEntry::new(
+            "as-cii accepted your contact request.",
+        )),
+        ListItem::Details(
+            ListDetailsEntry::new("You were added as an admin on the #gpui2 channel.").seen(true),
+        ),
+        ListItem::Details(ListDetailsEntry::new(
+            "osiewicz accepted your contact request.",
+        )),
+        ListItem::Details(ListDetailsEntry::new(
+            "ConradIrwin accepted your contact request.",
+        )),
+        ListItem::Details(
+            ListDetailsEntry::new("nathansobo invited you to a stream in #gpui2.")
+                .seen(true)
+                .meta("This stream has ended."),
+        ),
     ]
     .into_iter()
     .map(From::from)

crates/ui2/src/utils/format_distance.rs 🔗

@@ -0,0 +1,173 @@
+use chrono::NaiveDateTime;
+
+fn distance_in_seconds(date: NaiveDateTime, base_date: NaiveDateTime) -> i64 {
+    let duration = date.signed_duration_since(base_date);
+    -duration.num_seconds()
+}
+
+fn distance_string(distance: i64, include_seconds: bool, add_suffix: bool) -> String {
+    let suffix = if distance < 0 { " from now" } else { " ago" };
+
+    let d = distance.abs();
+
+    let minutes = d / 60;
+    let hours = d / 3600;
+    let days = d / 86400;
+    let months = d / 2592000;
+    let years = d / 31536000;
+
+    let string = if d < 5 && include_seconds {
+        "less than 5 seconds".to_string()
+    } else if d < 10 && include_seconds {
+        "less than 10 seconds".to_string()
+    } else if d < 20 && include_seconds {
+        "less than 20 seconds".to_string()
+    } else if d < 40 && include_seconds {
+        "half a minute".to_string()
+    } else if d < 60 && include_seconds {
+        "less than a minute".to_string()
+    } else if d < 90 && include_seconds {
+        "1 minute".to_string()
+    } else if d < 30 {
+        "less than a minute".to_string()
+    } else if d < 90 {
+        "1 minute".to_string()
+    } else if d < 2700 {
+        format!("{} minutes", minutes)
+    } else if d < 5400 {
+        "about 1 hour".to_string()
+    } else if d < 86400 {
+        format!("about {} hours", hours)
+    } else if d < 172800 {
+        "1 day".to_string()
+    } else if d < 2592000 {
+        format!("{} days", days)
+    } else if d < 5184000 {
+        "about 1 month".to_string()
+    } else if d < 7776000 {
+        "about 2 months".to_string()
+    } else if d < 31540000 {
+        format!("{} months", months)
+    } else if d < 39425000 {
+        "about 1 year".to_string()
+    } else if d < 55195000 {
+        "over 1 year".to_string()
+    } else if d < 63080000 {
+        "almost 2 years".to_string()
+    } else {
+        let years = d / 31536000;
+        let remaining_months = (d % 31536000) / 2592000;
+
+        if remaining_months < 3 {
+            format!("about {} years", years)
+        } else if remaining_months < 9 {
+            format!("over {} years", years)
+        } else {
+            format!("almost {} years", years + 1)
+        }
+    };
+
+    if add_suffix {
+        return format!("{}{}", string, suffix);
+    } else {
+        string
+    }
+}
+
+pub fn naive_format_distance(
+    date: NaiveDateTime,
+    base_date: NaiveDateTime,
+    include_seconds: bool,
+    add_suffix: bool,
+) -> String {
+    let distance = distance_in_seconds(date, base_date);
+
+    distance_string(distance, include_seconds, add_suffix)
+}
+
+pub fn naive_format_distance_from_now(
+    datetime: NaiveDateTime,
+    include_seconds: bool,
+    add_suffix: bool,
+) -> String {
+    let now = chrono::offset::Local::now().naive_local();
+
+    naive_format_distance(datetime, now, include_seconds, add_suffix)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use chrono::NaiveDateTime;
+
+    #[test]
+    fn test_naive_format_distance() {
+        let date =
+            NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date");
+        let base_date =
+            NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date");
+
+        assert_eq!(
+            "about 2 hours",
+            naive_format_distance(date, base_date, false, false)
+        );
+    }
+
+    #[test]
+    fn test_naive_format_distance_with_suffix() {
+        let date =
+            NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date");
+        let base_date =
+            NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date");
+
+        assert_eq!(
+            "about 2 hours from now",
+            naive_format_distance(date, base_date, false, true)
+        );
+    }
+
+    #[test]
+    fn test_naive_format_distance_from_now() {
+        let date = NaiveDateTime::parse_from_str("1969-07-20T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ")
+            .expect("Invalid NaiveDateTime for date");
+
+        assert_eq!(
+            "over 54 years ago",
+            naive_format_distance_from_now(date, false, true)
+        );
+    }
+
+    #[test]
+    fn test_naive_format_distance_string() {
+        assert_eq!(distance_string(3, false, false), "less than a minute");
+        assert_eq!(distance_string(7, false, false), "less than a minute");
+        assert_eq!(distance_string(13, false, false), "less than a minute");
+        assert_eq!(distance_string(21, false, false), "less than a minute");
+        assert_eq!(distance_string(45, false, false), "1 minute");
+        assert_eq!(distance_string(61, false, false), "1 minute");
+        assert_eq!(distance_string(1920, false, false), "32 minutes");
+        assert_eq!(distance_string(3902, false, false), "about 1 hour");
+        assert_eq!(distance_string(18002, false, false), "about 5 hours");
+        assert_eq!(distance_string(86470, false, false), "1 day");
+        assert_eq!(distance_string(345880, false, false), "4 days");
+        assert_eq!(distance_string(2764800, false, false), "about 1 month");
+        assert_eq!(distance_string(5184000, false, false), "about 2 months");
+        assert_eq!(distance_string(10368000, false, false), "4 months");
+        assert_eq!(distance_string(34694000, false, false), "about 1 year");
+        assert_eq!(distance_string(47310000, false, false), "over 1 year");
+        assert_eq!(distance_string(61503000, false, false), "almost 2 years");
+        assert_eq!(distance_string(160854000, false, false), "about 5 years");
+        assert_eq!(distance_string(236550000, false, false), "over 7 years");
+        assert_eq!(distance_string(249166000, false, false), "almost 8 years");
+    }
+
+    #[test]
+    fn test_naive_format_distance_string_include_seconds() {
+        assert_eq!(distance_string(3, true, false), "less than 5 seconds");
+        assert_eq!(distance_string(7, true, false), "less than 10 seconds");
+        assert_eq!(distance_string(13, true, false), "less than 20 seconds");
+        assert_eq!(distance_string(21, true, false), "half a minute");
+        assert_eq!(distance_string(45, true, false), "less than a minute");
+        assert_eq!(distance_string(61, true, false), "1 minute");
+    }
+}